diff options
Diffstat (limited to 'spec')
998 files changed, 30935 insertions, 18308 deletions
diff --git a/spec/bin/sidekiq_cluster_spec.rb b/spec/bin/sidekiq_cluster_spec.rb index 1bba048a27c..eb014c511e3 100644 --- a/spec/bin/sidekiq_cluster_spec.rb +++ b/spec/bin/sidekiq_cluster_spec.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true -require 'spec_helper' +require 'fast_spec_helper' require 'shellwords' +require 'rspec-parameterized' -RSpec.describe 'bin/sidekiq-cluster' do +RSpec.describe 'bin/sidekiq-cluster', :aggregate_failures do using RSpec::Parameterized::TableSyntax + let(:root) { File.expand_path('../..', __dir__) } + context 'when selecting some queues and excluding others' do where(:args, :included, :excluded) do %w[--negate cronjob] | '-qdefault,1' | '-qcronjob,1' @@ -13,10 +16,10 @@ RSpec.describe 'bin/sidekiq-cluster' do end with_them do - it 'runs successfully', :aggregate_failures do + it 'runs successfully' do cmd = %w[bin/sidekiq-cluster --dryrun] + args - output, status = Gitlab::Popen.popen(cmd, Rails.root.to_s) + output, status = Gitlab::Popen.popen(cmd, root) expect(status).to be(0) expect(output).to include('bundle exec sidekiq') @@ -31,10 +34,10 @@ RSpec.describe 'bin/sidekiq-cluster' do %w[*], %w[--queue-selector *] ].each do |args| - it "runs successfully with `#{args}`", :aggregate_failures do + it "runs successfully with `#{args}`" do cmd = %w[bin/sidekiq-cluster --dryrun] + args - output, status = Gitlab::Popen.popen(cmd, Rails.root.to_s) + output, status = Gitlab::Popen.popen(cmd, root) expect(status).to be(0) expect(output).to include('bundle exec sidekiq') @@ -43,4 +46,20 @@ RSpec.describe 'bin/sidekiq-cluster' do end end end + + context 'when arguments contain newlines' do + it 'raises an error' do + [ + ["default\n"], + ["defaul\nt"] + ].each do |args| + cmd = %w[bin/sidekiq-cluster --dryrun] + args + + output, status = Gitlab::Popen.popen(cmd, root) + + expect(status).to be(1) + expect(output).to include('cannot contain newlines') + end + end + end end diff --git a/spec/config/grape_entity_patch_spec.rb b/spec/config/grape_entity_patch_spec.rb new file mode 100644 index 00000000000..7334f270ca1 --- /dev/null +++ b/spec/config/grape_entity_patch_spec.rb @@ -0,0 +1,21 @@ +# 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/integrations_controller_spec.rb b/spec/controllers/admin/integrations_controller_spec.rb index 64ae2a95b4e..1793b3a86d1 100644 --- a/spec/controllers/admin/integrations_controller_spec.rb +++ b/spec/controllers/admin/integrations_controller_spec.rb @@ -9,6 +9,14 @@ RSpec.describe Admin::IntegrationsController do sign_in(admin) end + it_behaves_like IntegrationsActions do + let(:integration_attributes) { { instance: true, project: nil } } + + let(:routing_params) do + { id: integration.to_param } + end + end + describe '#edit' do Integration.available_integration_names.each do |integration_name| context "#{integration_name}" do diff --git a/spec/controllers/admin/runners_controller_spec.rb b/spec/controllers/admin/runners_controller_spec.rb index 8e57b4f03a7..996964fdcf0 100644 --- a/spec/controllers/admin/runners_controller_spec.rb +++ b/spec/controllers/admin/runners_controller_spec.rb @@ -23,10 +23,6 @@ RSpec.describe Admin::RunnersController do describe '#show' do render_views - before do - stub_feature_flags(runner_detailed_view_vue_ui: false) - end - let_it_be(:project) { create(:project) } let_it_be(:project_two) { create(:project) } @@ -61,30 +57,6 @@ RSpec.describe Admin::RunnersController do expect(response).to have_gitlab_http_status(:ok) end - - describe 'Cost factors values' do - context 'when it is Gitlab.com' do - before do - expect(Gitlab).to receive(:com?).at_least(:once) { true } - end - - it 'renders cost factors fields' do - get :show, params: { id: runner.id } - - expect(response.body).to match /Private projects Minutes cost factor/ - expect(response.body).to match /Public projects Minutes cost factor/ - end - end - - context 'when it is not Gitlab.com' do - it 'does not show cost factor fields' do - get :show, params: { id: runner.id } - - expect(response.body).not_to match /Private projects Minutes cost factor/ - expect(response.body).not_to match /Public projects Minutes cost factor/ - end - end - end end describe '#update' do diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 6e172f53257..015c36c9335 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -146,7 +146,7 @@ RSpec.describe Admin::UsersController do it 'sends the user a rejection email' do expect_next_instance_of(NotificationService) do |notification| - allow(notification).to receive(:user_admin_rejection).with(user.name, user.notification_email) + allow(notification).to receive(:user_admin_rejection).with(user.name, user.notification_email_or_default) end subject @@ -165,7 +165,7 @@ RSpec.describe Admin::UsersController do it 'displays the error' do subject - expect(flash[:alert]).to eq('This user does not have a pending request') + expect(flash[:alert]).to eq('User does not have a pending request') end it 'does not email the user' do diff --git a/spec/controllers/boards/issues_controller_spec.rb b/spec/controllers/boards/issues_controller_spec.rb index 48000284264..cc60ab16d2e 100644 --- a/spec/controllers/boards/issues_controller_spec.rb +++ b/spec/controllers/boards/issues_controller_spec.rb @@ -428,17 +428,21 @@ RSpec.describe Boards::IssuesController do describe 'POST create' do context 'with valid params' do - it 'returns a successful 200 response' do + before do create_issue user: user, board: board, list: list1, title: 'New issue' + end + it 'returns a successful 200 response' do expect(response).to have_gitlab_http_status(:ok) end it 'returns the created issue' do - create_issue user: user, board: board, list: list1, title: 'New issue' - expect(response).to match_response_schema('entities/issue_board') end + + it 'sets the default work_item_type' do + expect(Issue.last.work_item_type.base_type).to eq('issue') + end end context 'with invalid params' do diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb index a2b62aa49d2..2297198878d 100644 --- a/spec/controllers/explore/projects_controller_spec.rb +++ b/spec/controllers/explore/projects_controller_spec.rb @@ -200,6 +200,24 @@ RSpec.describe Explore::ProjectsController do let(:sorting_param) { 'created_asc' } end end + + describe 'GET #index' do + let(:controller_action) { :index } + let(:params_with_name) { { name: 'some project' } } + + context 'when disable_anonymous_project_search is enabled' do + before do + stub_feature_flags(disable_anonymous_project_search: true) + end + + it 'does not show a flash message' do + sign_in(create(:user)) + get controller_action, params: params_with_name + + expect(flash.now[:notice]).to be_nil + end + end + end end context 'when user is not signed in' do @@ -229,5 +247,50 @@ RSpec.describe Explore::ProjectsController do expect(response).to redirect_to new_user_session_path end end + + describe 'GET #index' do + let(:controller_action) { :index } + let(:params_with_name) { { name: 'some project' } } + + context 'when disable_anonymous_project_search is enabled' do + before do + stub_feature_flags(disable_anonymous_project_search: true) + end + + it 'shows a flash message' do + get controller_action, params: params_with_name + + expect(flash.now[:notice]).to eq('You must sign in to search for specific projects.') + end + + context 'when search param is not given' do + it 'does not show a flash message' do + get controller_action + + expect(flash.now[:notice]).to be_nil + end + end + + context 'when format is not HTML' do + it 'does not show a flash message' do + get controller_action, params: params_with_name.merge(format: :atom) + + expect(flash.now[:notice]).to be_nil + end + end + end + + context 'when disable_anonymous_project_search is disabled' do + before do + stub_feature_flags(disable_anonymous_project_search: false) + end + + it 'does not show a flash message' do + get controller_action, params: params_with_name + + expect(flash.now[:notice]).to be_nil + end + end + end end end diff --git a/spec/controllers/groups/children_controller_spec.rb b/spec/controllers/groups/children_controller_spec.rb index e97fe50c468..04cf7785f1e 100644 --- a/spec/controllers/groups/children_controller_spec.rb +++ b/spec/controllers/groups/children_controller_spec.rb @@ -227,8 +227,8 @@ RSpec.describe Groups::ChildrenController do context 'when rendering hierarchies' do # When loading hierarchies we load the all the ancestors for matched projects - # in 1 separate query - let(:extra_queries_for_hierarchies) { 1 } + # in 2 separate queries + let(:extra_queries_for_hierarchies) { 2 } def get_filtered_list get :index, params: { group_id: group.to_param, filter: 'filter' }, format: :json diff --git a/spec/controllers/groups/runners_controller_spec.rb b/spec/controllers/groups/runners_controller_spec.rb index 1808969cd60..a8830efe653 100644 --- a/spec/controllers/groups/runners_controller_spec.rb +++ b/spec/controllers/groups/runners_controller_spec.rb @@ -3,11 +3,13 @@ require 'spec_helper' RSpec.describe Groups::RunnersController do - let(:user) { create(:user) } - let(:group) { create(:group) } - let(:runner) { create(:ci_runner, :group, groups: [group]) } - let(:project) { create(:project, group: group) } - let(:runner_project) { create(:ci_runner, :project, projects: [project]) } + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + + let!(:runner) { create(:ci_runner, :group, groups: [group]) } + let!(:runner_project) { create(:ci_runner, :project, projects: [project]) } + let(:params_runner_project) { { group_id: group, id: runner_project } } let(:params) { { group_id: group, id: runner } } @@ -26,6 +28,7 @@ RSpec.describe Groups::RunnersController do expect(response).to have_gitlab_http_status(:ok) expect(response).to render_template(:index) + expect(assigns(:group_runners_limited_count)).to be(2) end end diff --git a/spec/controllers/groups/settings/integrations_controller_spec.rb b/spec/controllers/groups/settings/integrations_controller_spec.rb index 931e726850a..31d1946652d 100644 --- a/spec/controllers/groups/settings/integrations_controller_spec.rb +++ b/spec/controllers/groups/settings/integrations_controller_spec.rb @@ -10,6 +10,21 @@ RSpec.describe Groups::Settings::IntegrationsController do sign_in(user) end + it_behaves_like IntegrationsActions do + let(:integration_attributes) { { group: group, project: nil } } + + let(:routing_params) do + { + group_id: group, + id: integration.to_param + } + end + + before do + group.add_owner(user) + end + end + describe '#index' do context 'when user is not owner' do it 'renders not_found' do diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 91b11cd46c5..a7625e65603 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -370,6 +370,57 @@ RSpec.describe GroupsController, factory_default: :keep do end end end + + context 'when creating a group with the `role` attribute present' do + it 'changes the users role' do + sign_in(user) + + expect do + post :create, params: { group: { name: 'new_group', path: 'new_group' }, user: { role: 'devops_engineer' } } + end.to change { user.reload.role }.to('devops_engineer') + end + end + + context 'when creating a group with the `setup_for_company` attribute present' do + before do + sign_in(user) + end + + subject do + post :create, params: { group: { name: 'new_group', path: 'new_group', setup_for_company: 'false' } } + end + + it 'sets the groups `setup_for_company` value' do + subject + expect(Group.last.setup_for_company).to be(false) + end + + context 'when the user already has a value for `setup_for_company`' do + before do + user.update_attribute(:setup_for_company, true) + end + + it 'does not change the users `setup_for_company` value' do + expect(Users::UpdateService).not_to receive(:new) + expect { subject }.not_to change { user.reload.setup_for_company }.from(true) + end + end + + context 'when the user has no value for `setup_for_company`' do + it 'changes the users `setup_for_company` value' do + expect(Users::UpdateService).to receive(:new).and_call_original + expect { subject }.to change { user.reload.setup_for_company }.to(false) + end + end + end + + context 'when creating a group with the `jobs_to_be_done` attribute present' do + it 'sets the groups `jobs_to_be_done` value' do + sign_in(user) + post :create, params: { group: { name: 'new_group', path: 'new_group', jobs_to_be_done: 'other' } } + expect(Group.last.jobs_to_be_done).to eq('other') + end + end end describe 'GET #index' do diff --git a/spec/controllers/import/manifest_controller_spec.rb b/spec/controllers/import/manifest_controller_spec.rb index d5a498e80d9..0111ad9501f 100644 --- a/spec/controllers/import/manifest_controller_spec.rb +++ b/spec/controllers/import/manifest_controller_spec.rb @@ -75,16 +75,6 @@ RSpec.describe Import::ManifestController, :clean_gitlab_redis_shared_state do expect(json_response.dig("provider_repos", 0, "id")).to eq(repo1[:id]) expect(json_response.dig("provider_repos", 1, "id")).to eq(repo2[:id]) end - - it "does not show already added project" do - project = create(:project, import_type: 'manifest', namespace: user.namespace, import_status: :finished, import_url: repo1[:url]) - - get :status, format: :json - - expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id) - expect(json_response.dig("provider_repos").length).to eq(1) - expect(json_response.dig("provider_repos", 0, "id")).not_to eq(repo1[:id]) - end end context 'when the data is stored via Gitlab::ManifestImport::Metadata' do diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb index dc1fb0454df..d4091461062 100644 --- a/spec/controllers/invites_controller_spec.rb +++ b/spec/controllers/invites_controller_spec.rb @@ -120,6 +120,29 @@ RSpec.describe InvitesController do end end + context 'when it is part of the invite_email_from experiment' do + let(:extra_params) { { invite_type: 'initial_email', experiment_name: 'invite_email_from' } } + + it 'tracks the initial join click from email' do + experiment = double(track: true) + allow(controller).to receive(:experiment).with(:invite_email_from, actor: member).and_return(experiment) + + request + + expect(experiment).to have_received(:track).with(:join_clicked) + end + + context 'when member does not exist' do + let(:raw_invite_token) { '_bogus_token_' } + + it 'does not track the experiment' do + expect(controller).not_to receive(:experiment).with(:invite_email_from, actor: member) + + request + end + end + end + context 'when member does not exist' do let(:raw_invite_token) { '_bogus_token_' } @@ -147,8 +170,9 @@ RSpec.describe InvitesController do end context 'when it is not part of our invite email experiment' do - it 'does not track via experiment' do + it 'does not track via experiment', :aggregate_failures do expect(controller).not_to receive(:experiment).with(:invite_email_preview_text, actor: member) + expect(controller).not_to receive(:experiment).with(:invite_email_from, actor: member) request end diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb index 9a142559fca..8c8de2f79a3 100644 --- a/spec/controllers/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/omniauth_callbacks_controller_spec.rb @@ -317,7 +317,7 @@ RSpec.describe OmniauthCallbacksController, type: :controller do it 'denies sign-in if sign-up is enabled, but block_auto_created_users is set' do post :atlassian_oauth2 - expect(flash[:alert]).to start_with 'Your account has been blocked.' + expect(flash[:alert]).to start_with 'Your account is pending approval' end it 'accepts sign-in if sign-up is enabled' do @@ -399,7 +399,7 @@ RSpec.describe OmniauthCallbacksController, type: :controller do it 'denies login if sign up is enabled, but block_auto_created_users is set' do post :saml, params: { SAMLResponse: mock_saml_response } - expect(flash[:alert]).to start_with 'Your account has been blocked.' + expect(flash[:alert]).to start_with 'Your account is pending approval' end it 'accepts login if sign up is enabled' do diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb index 818bf2a4ae6..073180cbafd 100644 --- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb +++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb @@ -10,8 +10,33 @@ RSpec.describe Profiles::TwoFactorAuthsController do allow(subject).to receive(:current_user).and_return(user) end + shared_examples 'user must first verify their primary email address' do + before do + allow(user).to receive(:primary_email_verified?).and_return(false) + end + + it 'redirects to profile_emails_path' do + go + + expect(response).to redirect_to(profile_emails_path) + end + + it 'displays a notice' do + go + + 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 + describe 'GET show' do - let(:user) { create(:user) } + let_it_be_with_reload(:user) { create(:user) } it 'generates otp_secret for user' do expect(User).to receive(:generate_otp_secret).with(32).and_call_original.once @@ -34,11 +59,16 @@ RSpec.describe Profiles::TwoFactorAuthsController do get :show end end + + it_behaves_like 'user must first verify their primary email address' do + let(:go) { get :show } + end end describe 'POST create' do - let(:user) { create(:user) } - let(:pin) { 'pin-code' } + let_it_be_with_reload(:user) { create(:user) } + + let(:pin) { 'pin-code' } def go post :create, params: { pin_code: pin } @@ -70,8 +100,8 @@ RSpec.describe Profiles::TwoFactorAuthsController do go end - it 'dismisses the `ACCOUNT_RECOVERY_REGULAR_CHECK` callout' do - expect(controller.helpers).to receive(:dismiss_account_recovery_regular_check) + it 'dismisses the `TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK` callout' do + expect(controller.helpers).to receive(:dismiss_two_factor_auth_recovery_settings_check) go end @@ -105,10 +135,12 @@ RSpec.describe Profiles::TwoFactorAuthsController do expect(response).to render_template(:show) end end + + it_behaves_like 'user must first verify their primary email address' end describe 'POST codes' do - let(:user) { create(:user, :two_factor) } + let_it_be_with_reload(:user) { create(:user, :two_factor) } it 'presents plaintext codes for the user to save' do expect(user).to receive(:generate_otp_backup_codes!).and_return(%w(a b c)) @@ -124,8 +156,8 @@ RSpec.describe Profiles::TwoFactorAuthsController do expect(user.otp_backup_codes).not_to be_empty end - it 'dismisses the `ACCOUNT_RECOVERY_REGULAR_CHECK` callout' do - expect(controller.helpers).to receive(:dismiss_account_recovery_regular_check) + it 'dismisses the `TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK` callout' do + expect(controller.helpers).to receive(:dismiss_two_factor_auth_recovery_settings_check) post :codes end @@ -135,7 +167,7 @@ RSpec.describe Profiles::TwoFactorAuthsController do subject { delete :destroy } context 'for a user that has 2FA enabled' do - let(:user) { create(:user, :two_factor) } + let_it_be_with_reload(:user) { create(:user, :two_factor) } it 'disables two factor' do subject @@ -158,7 +190,7 @@ RSpec.describe Profiles::TwoFactorAuthsController do end context 'for a user that does not have 2FA enabled' do - let(:user) { create(:user) } + let_it_be_with_reload(:user) { create(:user) } it 'redirects to profile_account_path' do subject diff --git a/spec/controllers/projects/analytics/cycle_analytics/summary_controller_spec.rb b/spec/controllers/projects/analytics/cycle_analytics/summary_controller_spec.rb index 1832b84ab6e..a366b2583d4 100644 --- a/spec/controllers/projects/analytics/cycle_analytics/summary_controller_spec.rb +++ b/spec/controllers/projects/analytics/cycle_analytics/summary_controller_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project) } - let(:params) { { namespace_id: project.namespace.to_param, project_id: project.to_param, created_after: '2010-01-01', created_before: '2010-01-02' } } + let(:params) { { namespace_id: project.namespace.to_param, project_id: project.to_param, created_after: '2010-01-01', created_before: '2010-02-01' } } before do sign_in(user) @@ -42,5 +42,39 @@ RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController do expect(response).to have_gitlab_http_status(:not_found) end end + + context 'when filters are applied' do + let_it_be(:author) { create(:user) } + let_it_be(:milestone) { create(:milestone, title: 'milestone 1', project: project) } + let_it_be(:issue_with_author) { create(:issue, project: project, author: author, created_at: Date.new(2010, 1, 15)) } + let_it_be(:issue_with_other_author) { create(:issue, project: project, author: user, created_at: Date.new(2010, 1, 15)) } + let_it_be(:issue_with_milestone) { create(:issue, project: project, milestone: milestone, created_at: Date.new(2010, 1, 15)) } + + before do + project.add_reporter(user) + end + + it 'filters by author username' do + params[:author_username] = author.username + + subject + + expect(response).to be_successful + + issue_count = json_response.first + expect(issue_count['value']).to eq('1') + end + + it 'filters by milestone title' do + params[:milestone_title] = milestone.title + + subject + + expect(response).to be_successful + + issue_count = json_response.first + expect(issue_count['value']).to eq('1') + end + end end end diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 7103d7df5c5..0fcdeb2edde 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -222,6 +222,16 @@ RSpec.describe Projects::EnvironmentsController do expect(response).to have_gitlab_http_status(:bad_request) end end + + context 'when name is passed' do + let(:params) { environment_params.merge(environment: { name: "new name" }) } + + it 'ignores name' do + expect do + subject + end.not_to change { environment.reload.name } + end + end end describe 'PATCH #stop' do diff --git a/spec/controllers/projects/feature_flags_controller_spec.rb b/spec/controllers/projects/feature_flags_controller_spec.rb index e038b247eff..fd95aa44568 100644 --- a/spec/controllers/projects/feature_flags_controller_spec.rb +++ b/spec/controllers/projects/feature_flags_controller_spec.rb @@ -94,20 +94,6 @@ RSpec.describe Projects::FeatureFlagsController do is_expected.to match_response_schema('feature_flags') end - it 'returns false for active when the feature flag is inactive even if it has an active scope' do - create(:operations_feature_flag_scope, - feature_flag: feature_flag_inactive, - environment_scope: 'production', - active: true) - - subject - - expect(response).to have_gitlab_http_status(:ok) - feature_flag_json = json_response['feature_flags'].second - - expect(feature_flag_json['active']).to eq(false) - end - it 'returns the feature flag iid' do subject @@ -181,7 +167,7 @@ RSpec.describe Projects::FeatureFlagsController do subject { get(:show, params: params, format: :json) } let!(:feature_flag) do - create(:operations_feature_flag, :legacy_flag, project: project) + create(:operations_feature_flag, project: project) end let(:params) do @@ -197,7 +183,7 @@ RSpec.describe Projects::FeatureFlagsController do expect(json_response['name']).to eq(feature_flag.name) expect(json_response['active']).to eq(feature_flag.active) - expect(json_response['version']).to eq('legacy_flag') + expect(json_response['version']).to eq('new_version_flag') end it 'matches json schema' do @@ -245,46 +231,6 @@ RSpec.describe Projects::FeatureFlagsController do end end - context 'when feature flags have additional scopes' do - context 'when there is at least one active scope' do - let!(:feature_flag) do - create(:operations_feature_flag, project: project, active: false) - end - - let!(:feature_flag_scope_production) do - create(:operations_feature_flag_scope, - feature_flag: feature_flag, - environment_scope: 'review/*', - active: true) - end - - it 'returns false for active' do - subject - - expect(json_response['active']).to eq(false) - end - end - - context 'when all scopes are inactive' do - let!(:feature_flag) do - create(:operations_feature_flag, project: project, active: false) - end - - let!(:feature_flag_scope_production) do - create(:operations_feature_flag_scope, - feature_flag: feature_flag, - environment_scope: 'production', - active: false) - end - - it 'recognizes the feature flag as inactive' do - subject - - expect(json_response['active']).to be_falsy - end - end - end - context 'with a version 2 feature flag' do let!(:new_version_feature_flag) do create(:operations_feature_flag, :new_version_flag, project: project) @@ -320,22 +266,6 @@ RSpec.describe Projects::FeatureFlagsController do describe 'GET edit' do subject { get(:edit, params: params) } - context 'with legacy flags' do - let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, project: project) } - - let(:params) do - { - namespace_id: project.namespace, - project_id: project, - iid: feature_flag.iid - } - end - - it 'returns not found' do - is_expected.to have_gitlab_http_status(:not_found) - end - end - context 'with new version flags' do let!(:feature_flag) { create(:operations_feature_flag, project: project) } @@ -378,14 +308,6 @@ RSpec.describe Projects::FeatureFlagsController do expect(json_response['active']).to be_truthy end - it 'creates a default scope' do - subject - - expect(json_response['scopes'].count).to eq(1) - expect(json_response['scopes'].first['environment_scope']).to eq('*') - expect(json_response['scopes'].first['active']).to be_truthy - end - it 'matches json schema' do is_expected.to match_response_schema('feature_flag') end @@ -435,119 +357,6 @@ RSpec.describe Projects::FeatureFlagsController do end end - context 'when creates additional scope' do - let(:params) do - view_params.merge({ - operations_feature_flag: { - name: 'my_feature_flag', - active: true, - scopes_attributes: [{ environment_scope: '*', active: true }, - { environment_scope: 'production', active: false }] - } - }) - end - - it 'creates feature flag scopes successfully' do - expect { subject }.to change { Operations::FeatureFlagScope.count }.by(2) - - expect(response).to have_gitlab_http_status(:ok) - end - - it 'creates feature flag scopes in a correct order' do - subject - - expect(json_response['scopes'].first['environment_scope']).to eq('*') - expect(json_response['scopes'].second['environment_scope']).to eq('production') - end - - context 'when default scope is not placed first' do - let(:params) do - view_params.merge({ - operations_feature_flag: { - name: 'my_feature_flag', - active: true, - scopes_attributes: [{ environment_scope: 'production', active: false }, - { environment_scope: '*', active: true }] - } - }) - end - - it 'returns 400' do - subject - - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']) - .to include('Default scope has to be the first element') - end - end - end - - context 'when creates additional scope with a percentage rollout' do - it 'creates a strategy for the scope' do - params = view_params.merge({ - operations_feature_flag: { - name: 'my_feature_flag', - active: true, - scopes_attributes: [{ environment_scope: '*', active: true }, - { environment_scope: 'production', active: false, - strategies: [{ name: 'gradualRolloutUserId', - parameters: { groupId: 'default', percentage: '42' } }] }] - } - }) - - post(:create, params: params, format: :json) - - expect(response).to have_gitlab_http_status(:ok) - production_strategies_json = json_response['scopes'].second['strategies'] - expect(production_strategies_json).to eq([{ - 'name' => 'gradualRolloutUserId', - 'parameters' => { "groupId" => "default", "percentage" => "42" } - }]) - end - end - - context 'when creates additional scope with a userWithId strategy' do - it 'creates a strategy for the scope' do - params = view_params.merge({ - operations_feature_flag: { - name: 'my_feature_flag', - active: true, - scopes_attributes: [{ environment_scope: '*', active: true }, - { environment_scope: 'production', active: false, - strategies: [{ name: 'userWithId', - parameters: { userIds: '123,4,6722' } }] }] - } - }) - - post(:create, params: params, format: :json) - - expect(response).to have_gitlab_http_status(:ok) - production_strategies_json = json_response['scopes'].second['strategies'] - expect(production_strategies_json).to eq([{ - 'name' => 'userWithId', - 'parameters' => { "userIds" => "123,4,6722" } - }]) - end - end - - context 'when creates an additional scope without a strategy' do - it 'creates a default strategy' do - params = view_params.merge({ - operations_feature_flag: { - name: 'my_feature_flag', - active: true, - scopes_attributes: [{ environment_scope: '*', active: true }] - } - }) - - post(:create, params: params, format: :json) - - expect(response).to have_gitlab_http_status(:ok) - default_strategies_json = json_response['scopes'].first['strategies'] - expect(default_strategies_json).to eq([{ "name" => "default", "parameters" => {} }]) - end - end - context 'when creating a version 2 feature flag' do let(:params) do { @@ -744,7 +553,7 @@ RSpec.describe Projects::FeatureFlagsController do describe 'DELETE destroy.json' do subject { delete(:destroy, params: params, format: :json) } - let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, project: project) } + let!(:feature_flag) { create(:operations_feature_flag, project: project) } let(:params) do { @@ -762,10 +571,6 @@ RSpec.describe Projects::FeatureFlagsController do expect { subject }.to change { Operations::FeatureFlag.count }.by(-1) end - it 'destroys the default scope' do - expect { subject }.to change { Operations::FeatureFlagScope.count }.by(-1) - end - it 'matches json schema' do is_expected.to match_response_schema('feature_flag') end @@ -792,14 +597,6 @@ RSpec.describe Projects::FeatureFlagsController do end end - context 'when there is an additional scope' do - let!(:scope) { create_scope(feature_flag, 'production', false) } - - it 'destroys the default scope and production scope' do - expect { subject }.to change { Operations::FeatureFlagScope.count }.by(-2) - end - end - context 'with a version 2 flag' do let!(:new_version_flag) { create(:operations_feature_flag, :new_version_flag, project: project) } let(:params) do @@ -828,70 +625,9 @@ RSpec.describe Projects::FeatureFlagsController do put(:update, params: params, format: :json, as: :json) end - context 'with a legacy feature flag' do - subject { put(:update, params: params, format: :json) } - - let!(:feature_flag) do - create(:operations_feature_flag, - :legacy_flag, - name: 'ci_live_trace', - active: true, - project: project) - end - - let(:params) do - { - namespace_id: project.namespace, - project_id: project, - iid: feature_flag.iid, - operations_feature_flag: { - name: 'ci_new_live_trace' - } - } - end - - context 'when user is reporter' do - let(:user) { reporter } - - it 'returns 404' do - is_expected.to have_gitlab_http_status(:not_found) - end - end - - context "when changing default scope's spec" do - let(:params) do - { - namespace_id: project.namespace, - project_id: project, - iid: feature_flag.iid, - operations_feature_flag: { - scopes_attributes: [ - { - id: feature_flag.default_scope.id, - environment_scope: 'review/*' - } - ] - } - } - end - - it 'returns 400' do - is_expected.to have_gitlab_http_status(:bad_request) - end - end - - it 'does not update a legacy feature flag' do - put_request(feature_flag, name: 'ci_new_live_trace') - - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']).to eq(["Legacy feature flags are read-only"]) - end - end - context 'with a version 2 feature flag' do let!(:new_version_flag) do create(:operations_feature_flag, - :new_version_flag, name: 'new-feature', active: true, project: project) diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 0c29280316a..977879b453c 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -109,6 +109,14 @@ RSpec.describe Projects::IssuesController do end end + it_behaves_like 'issuable list with anonymous search disabled' do + let(:params) { { namespace_id: project.namespace, project_id: project } } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + end + end + it_behaves_like 'paginated collection' do let!(:issue_list) { create_list(:issue, 2, project: project) } let(:collection) { project.issues } @@ -301,6 +309,8 @@ RSpec.describe Projects::IssuesController do it 'fills in an issue for a discussion' do note = create(:note_on_merge_request, project: project) + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter).to receive(:track_resolve_thread_in_issue_action).with(user: user) + get :new, params: { namespace_id: project.namespace.path, project_id: project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id } expect(assigns(:issue).title).not_to be_empty @@ -1176,12 +1186,22 @@ RSpec.describe Projects::IssuesController do project.issues.first end + context 'when creating an incident' do + it 'sets the correct issue_type' do + issue = post_new_issue(issue_type: 'incident') + + expect(issue.issue_type).to eq('incident') + expect(issue.work_item_type.base_type).to eq('incident') + end + end + it 'creates the issue successfully', :aggregate_failures do issue = post_new_issue expect(issue).to be_a(Issue) expect(issue.persisted?).to eq(true) expect(issue.issue_type).to eq('issue') + expect(issue.work_item_type.base_type).to eq('issue') end context 'resolving discussions in MergeRequest' do diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index e9e7c3c3bb3..06c29e767ad 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -755,23 +755,52 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do before do project.add_developer(user) sign_in(user) - - post_retry end context 'when job is retryable' do let(:job) { create(:ci_build, :retryable, pipeline: pipeline) } it 'redirects to the retried job page' do + post_retry + expect(response).to have_gitlab_http_status(:found) expect(response).to redirect_to(namespace_project_job_path(id: Ci::Build.last.id)) end + + shared_examples_for 'retried job has the same attributes' do + it 'creates a new build has the same attributes from the previous build' do + expect { post_retry }.to change { Ci::Build.count }.by(1) + + retried_build = Ci::Build.last + + Ci::RetryBuildService.clone_accessors.each do |accessor| + expect(job.read_attribute(accessor)) + .to eq(retried_build.read_attribute(accessor)), + "Mismatched attribute on \"#{accessor}\". " \ + "It was \"#{job.read_attribute(accessor)}\" but changed to \"#{retried_build.read_attribute(accessor)}\"" + end + end + end + + context 'with branch pipeline' do + let!(:job) { create(:ci_build, :retryable, tag: true, when: 'on_success', pipeline: pipeline) } + + it_behaves_like 'retried job has the same attributes' + end + + context 'with tag pipeline' do + let!(:job) { create(:ci_build, :retryable, tag: false, when: 'on_success', pipeline: pipeline) } + + it_behaves_like 'retried job has the same attributes' + end end context 'when job is not retryable' do let(:job) { create(:ci_build, pipeline: pipeline) } it 'renders unprocessable_entity' do + post_retry + expect(response).to have_gitlab_http_status(:unprocessable_entity) end end diff --git a/spec/controllers/projects/learn_gitlab_controller_spec.rb b/spec/controllers/projects/learn_gitlab_controller_spec.rb index f633f7aa246..620982f73be 100644 --- a/spec/controllers/projects/learn_gitlab_controller_spec.rb +++ b/spec/controllers/projects/learn_gitlab_controller_spec.rb @@ -7,13 +7,13 @@ RSpec.describe Projects::LearnGitlabController do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project, namespace: user.namespace) } - let(:learn_gitlab_experiment_enabled) { true } + let(:learn_gitlab_enabled) { true } let(:params) { { namespace_id: project.namespace.to_param, project_id: project } } subject { get :index, params: params } before do - allow(controller.helpers).to receive(:learn_gitlab_experiment_enabled?).and_return(learn_gitlab_experiment_enabled) + allow(controller.helpers).to receive(:learn_gitlab_enabled?).and_return(learn_gitlab_enabled) end context 'unauthenticated user' do @@ -27,15 +27,8 @@ RSpec.describe Projects::LearnGitlabController do it { is_expected.to render_template(:index) } - it 'pushes experiment to frontend' do - expect(controller).to receive(:push_frontend_experiment).with(:learn_gitlab_a, subject: user) - expect(controller).to receive(:push_frontend_experiment).with(:learn_gitlab_b, subject: user) - - subject - end - context 'learn_gitlab experiment not enabled' do - let(:learn_gitlab_experiment_enabled) { false } + let(:learn_gitlab_enabled) { false } it { is_expected.to have_gitlab_http_status(:not_found) } end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 7b5a58fe2e5..0da8a30611c 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -349,6 +349,15 @@ RSpec.describe Projects::MergeRequestsController do end end end + + it_behaves_like 'issuable list with anonymous search disabled' do + let(:params) { { namespace_id: project.namespace, project_id: project } } + + before do + sign_out(user) + project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + end + end end describe 'PUT update' do diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 65a563fac7c..1354e894872 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -311,23 +311,42 @@ RSpec.describe Projects::PipelinesController do let_it_be(:pipeline) { create(:ci_pipeline, project: project) } - def create_build_with_artifacts(stage, stage_idx, name) - create(:ci_build, :artifacts, :tags, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name) + def create_build_with_artifacts(stage, stage_idx, name, status) + create(:ci_build, :artifacts, :tags, status, user: user, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name) + end + + def create_bridge(stage, stage_idx, name, status) + create(:ci_bridge, status, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name) end before do - create_build_with_artifacts('build', 0, 'job1') - create_build_with_artifacts('build', 0, 'job2') + create_build_with_artifacts('build', 0, 'job1', :failed) + create_build_with_artifacts('build', 0, 'job2', :running) + create_build_with_artifacts('build', 0, 'job3', :pending) + create_bridge('deploy', 1, 'deploy-a', :failed) + create_bridge('deploy', 1, 'deploy-b', :created) end - it 'avoids N+1 database queries', :request_store do - control_count = ActiveRecord::QueryRecorder.new { get_pipeline_html }.count + it 'avoids N+1 database queries', :request_store, :use_sql_query_cache do + # warm up + get_pipeline_html expect(response).to have_gitlab_http_status(:ok) - create_build_with_artifacts('build', 0, 'job3') + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get_pipeline_html + expect(response).to have_gitlab_http_status(:ok) + end - expect { get_pipeline_html }.not_to exceed_query_limit(control_count) - expect(response).to have_gitlab_http_status(:ok) + create_build_with_artifacts('build', 0, 'job4', :failed) + create_build_with_artifacts('build', 0, 'job5', :running) + create_build_with_artifacts('build', 0, 'job6', :pending) + create_bridge('deploy', 1, 'deploy-c', :failed) + create_bridge('deploy', 1, 'deploy-d', :created) + + expect do + get_pipeline_html + expect(response).to have_gitlab_http_status(:ok) + end.not_to exceed_all_query_limit(control) end end @@ -1273,6 +1292,38 @@ RSpec.describe Projects::PipelinesController do end end + context 'when project uses external project ci config' do + let(:other_project) { create(:project) } + let(:sha) { 'master' } + let(:service) { ::Ci::ListConfigVariablesService.new(other_project, user) } + + let(:ci_config) do + { + variables: { + KEY1: { value: 'val 1', description: 'description 1' } + }, + test: { + stage: 'test', + script: 'echo' + } + } + end + + before do + project.update!(ci_config_path: ".gitlab-ci.yml@#{other_project.full_path}") + synchronous_reactive_cache(service) + end + + it 'returns other project config variables' do + expect(::Ci::ListConfigVariablesService).to receive(:new).with(other_project, anything).and_return(service) + + get_config_variables + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['KEY1']).to eq({ 'value' => 'val 1', 'description' => 'description 1' }) + end + end + private def stub_gitlab_ci_yml_for_sha(sha, result) diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb index 419b5c7e101..482ba552f8f 100644 --- a/spec/controllers/projects/services_controller_spec.rb +++ b/spec/controllers/projects/services_controller_spec.rb @@ -18,6 +18,18 @@ RSpec.describe Projects::ServicesController do project.add_maintainer(user) end + it_behaves_like IntegrationsActions do + let(:integration_attributes) { { project: project } } + + let(:routing_params) do + { + namespace_id: project.namespace, + project_id: project, + id: integration.to_param + } + end + end + describe '#test' do context 'when the integration is not testable' do it 'renders 404' do diff --git a/spec/controllers/registrations/experience_levels_controller_spec.rb b/spec/controllers/registrations/experience_levels_controller_spec.rb deleted file mode 100644 index ad145264bb8..00000000000 --- a/spec/controllers/registrations/experience_levels_controller_spec.rb +++ /dev/null @@ -1,159 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Registrations::ExperienceLevelsController do - include AfterNextHelpers - - let_it_be(:namespace) { create(:group, path: 'group-path' ) } - let_it_be(:user) { create(:user) } - - let(:params) { { namespace_path: namespace.to_param } } - - describe 'GET #show' do - subject { get :show, params: params } - - context 'with an unauthenticated user' do - it { is_expected.to have_gitlab_http_status(:redirect) } - it { is_expected.to redirect_to(new_user_session_path) } - end - - context 'with an authenticated user' do - before do - sign_in(user) - end - - it { is_expected.to have_gitlab_http_status(:ok) } - it { is_expected.to render_template('layouts/minimal') } - it { is_expected.to render_template(:show) } - end - end - - describe 'PUT/PATCH #update' do - subject { patch :update, params: params } - - context 'with an unauthenticated user' do - it { is_expected.to have_gitlab_http_status(:redirect) } - it { is_expected.to redirect_to(new_user_session_path) } - end - - context 'with an authenticated user' do - let_it_be(:project) { build(:project, namespace: namespace, creator: user, path: 'project-path') } - let_it_be(:issues_board) { build(:board, id: 123, project: project) } - - before do - sign_in(user) - end - - context 'when user is successfully updated' do - context 'when no experience_level is sent' do - before do - user.user_preference.update_attribute(:experience_level, :novice) - end - - it 'will unset the user’s experience level' do - expect { subject }.to change { user.reload.experience_level }.to(nil) - end - end - - context 'when an expected experience level is sent' do - let(:params) { super().merge(experience_level: :novice) } - - it 'sets the user’s experience level' do - expect { subject }.to change { user.reload.experience_level }.from(nil).to('novice') - end - end - - context 'when an unexpected experience level is sent' do - let(:params) { super().merge(experience_level: :nonexistent) } - - it 'raises an exception' do - expect { subject }.to raise_error(ArgumentError, "'nonexistent' is not a valid experience_level") - end - end - - context 'when "Learn GitLab" project exists' do - let(:learn_gitlab_available?) { true } - - before do - allow_next_instance_of(LearnGitlab::Project) do |learn_gitlab| - allow(learn_gitlab).to receive(:available?).and_return(learn_gitlab_available?) - allow(learn_gitlab).to receive(:project).and_return(project) - allow(learn_gitlab).to receive(:board).and_return(issues_board) - allow(learn_gitlab).to receive(:label).and_return(double(id: 1)) - end - end - - context 'redirection' do - context 'when namespace_path param is missing' do - let(:params) { super().merge(namespace_path: nil) } - - where( - learn_gitlab_available?: [true, false] - ) - - with_them do - it { is_expected.to redirect_to('/') } - end - end - - context 'when we have a namespace_path param' do - using RSpec::Parameterized::TableSyntax - - where(:learn_gitlab_available?, :path) do - true | '/group-path/project-path/-/boards/123' - false | '/group-path' - end - - with_them do - it { is_expected.to redirect_to(path) } - end - end - end - - context 'when novice' do - let(:params) { super().merge(experience_level: :novice) } - - it 'adds a BoardLabel' do - expect_next(Boards::UpdateService).to receive(:execute) - - subject - end - end - - context 'when experienced' do - let(:params) { super().merge(experience_level: :experienced) } - - it 'does not add a BoardLabel' do - expect(Boards::UpdateService).not_to receive(:new) - - subject - end - end - end - - context 'when no "Learn GitLab" project exists' do - let(:params) { super().merge(experience_level: :novice) } - - before do - allow_next(LearnGitlab::Project).to receive(:available?).and_return(false) - end - - it 'does not add a BoardLabel' do - expect(Boards::UpdateService).not_to receive(:new) - - subject - end - end - end - - context 'when user update fails' do - before do - allow_any_instance_of(User).to receive(:save).and_return(false) - end - - it { is_expected.to render_template(:show) } - end - end - end -end diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index 301c60e89c8..5edd60ebc79 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -227,6 +227,40 @@ RSpec.describe RegistrationsController do end end end + + context 'with the invite_email_preview_text experiment', :experiment do + let(:extra_session_params) { { invite_email_experiment_name: 'invite_email_from' } } + + context 'when member and invite_email_experiment_name exists from the session key value' do + it 'tracks the invite acceptance' do + expect(experiment(:invite_email_from)).to track(:accepted) + .with_context(actor: member) + .on_next_instance + + subject + end + end + + context 'when member does not exist from the session key value' do + let(:originating_member_id) { -1 } + + it 'does not track invite acceptance' do + expect(experiment(:invite_email_from)).not_to track(:accepted) + + subject + end + end + + context 'when invite_email_experiment_name does not exist from the session key value' do + let(:extra_session_params) { {} } + + it 'does not track invite acceptance' do + expect(experiment(:invite_email_from)).not_to track(:accepted) + + subject + end + end + end end context 'when invite email matches email used on registration' do @@ -249,6 +283,26 @@ RSpec.describe RegistrationsController do end end + context 'when the registration fails' do + let_it_be(:member) { create(:project_member, :invited) } + let_it_be(:missing_user_params) do + { username: '', email: member.invite_email, password: 'Any_password' } + end + + let_it_be(:user_params) { { user: missing_user_params } } + + let(:session_params) { { invite_email: member.invite_email } } + + subject { post(:create, params: user_params, session: session_params) } + + it 'does not delete the invitation or register the new user' do + subject + + expect(member.invite_token).not_to be_nil + expect(controller.current_user).to be_nil + end + end + context 'when soft email confirmation is enabled' do before do stub_feature_flags(soft_email_confirmation: true) diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index e0870e17d99..4e87a9fc1ba 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -182,6 +182,37 @@ RSpec.describe SearchController do end end end + + context 'tab feature flags' do + subject { get :show, params: { scope: scope, search: 'term' }, format: :html } + + where(:feature_flag, :scope) do + :global_search_code_tab | 'blobs' + :global_search_issues_tab | 'issues' + :global_search_merge_requests_tab | 'merge_requests' + :global_search_wiki_tab | 'wiki_blobs' + :global_search_commits_tab | 'commits' + end + + with_them do + it 'returns 200 if flag is enabled' do + stub_feature_flags(feature_flag => true) + + subject + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'redirects with alert if flag is disabled' do + stub_feature_flags(feature_flag => false) + + subject + + expect(response).to redirect_to search_path + expect(controller).to set_flash[:alert].to(/Global Search is disabled for this scope/) + end + end + end end it 'finds issue comments' do diff --git a/spec/controllers/user_callouts_controller_spec.rb b/spec/controllers/user_callouts_controller_spec.rb index 279f825e40f..3bb8d78a6b0 100644 --- a/spec/controllers/user_callouts_controller_spec.rb +++ b/spec/controllers/user_callouts_controller_spec.rb @@ -3,14 +3,16 @@ require 'spec_helper' RSpec.describe UserCalloutsController do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } before do sign_in(user) end describe "POST #create" do - subject { post :create, params: { feature_name: feature_name }, format: :json } + let(:params) { { feature_name: feature_name } } + + subject { post :create, params: params, format: :json } context 'with valid feature name' do let(:feature_name) { UserCallout.feature_names.each_key.first } @@ -30,9 +32,8 @@ RSpec.describe UserCalloutsController do context 'when callout entry already exists' do let!(:callout) { create(:user_callout, feature_name: UserCallout.feature_names.each_key.first, user: user) } - it 'returns success' do - subject - + it 'returns success', :aggregate_failures do + expect { subject }.not_to change { UserCallout.count } expect(response).to have_gitlab_http_status(:ok) end end diff --git a/spec/db/development/create_base_work_item_types_spec.rb b/spec/db/development/create_base_work_item_types_spec.rb new file mode 100644 index 00000000000..914b84d8668 --- /dev/null +++ b/spec/db/development/create_base_work_item_types_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Create base work item types in development' do + subject { load Rails.root.join('db', 'fixtures', 'development', '001_create_base_work_item_types.rb') } + + it_behaves_like 'work item base types importer' +end diff --git a/spec/db/production/create_base_work_item_types_spec.rb b/spec/db/production/create_base_work_item_types_spec.rb new file mode 100644 index 00000000000..81d80104bb4 --- /dev/null +++ b/spec/db/production/create_base_work_item_types_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Create base work item types in production' do + subject { load Rails.root.join('db', 'fixtures', 'production', '003_create_base_work_item_types.rb') } + + it_behaves_like 'work item base types importer' +end diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index 7e4b8c53885..c7739e2ff5f 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -18,6 +18,8 @@ RSpec.describe 'Database schema' do approvals: %w[user_id], approver_groups: %w[target_id], approvers: %w[target_id user_id], + analytics_cycle_analytics_merge_request_stage_events: %w[author_id group_id merge_request_id milestone_id project_id stage_event_hash_id], + analytics_cycle_analytics_issue_stage_events: %w[author_id group_id issue_id milestone_id project_id stage_event_hash_id], audit_events: %w[author_id entity_id target_id], award_emoji: %w[awardable_id user_id], aws_roles: %w[role_external_id], @@ -33,6 +35,7 @@ RSpec.describe 'Database schema' do cluster_providers_gcp: %w[gcp_project_id operation_id], compliance_management_frameworks: %w[group_id], commit_user_mentions: %w[commit_id], + dep_ci_build_trace_sections: %w[build_id], deploy_keys_projects: %w[deploy_key_id], deployments: %w[deployable_id user_id], draft_notes: %w[discussion_id commit_id], diff --git a/spec/deprecation_toolkit_env.rb b/spec/deprecation_toolkit_env.rb index f4db2c30094..5e7ff34463c 100644 --- a/spec/deprecation_toolkit_env.rb +++ b/spec/deprecation_toolkit_env.rb @@ -60,13 +60,12 @@ module DeprecationToolkitEnv # - ruby/lib/grpc/generic/interceptors.rb: https://gitlab.com/gitlab-org/gitlab/-/issues/339305 def self.allowed_kwarg_warning_paths %w[ - actionpack-6.1.3.2/lib/action_dispatch/routing/route_set.rb - ruby/lib/grpc/generic/interceptors.rb - ] + ruby/lib/grpc/generic/interceptors.rb + ] end def self.configure! - # Enable ruby deprecations for keywords, it's suppressed by default in Ruby 2.7.2 + # Enable ruby deprecations for keywords, it's suppressed by default in Ruby 2.7 Warning[:deprecated] = true DeprecationToolkit::Configuration.test_runner = :rspec diff --git a/spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb b/spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb new file mode 100644 index 00000000000..4328ff12d42 --- /dev/null +++ b/spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SecurityReportsMrWidgetPromptExperiment do + it "defines a control and candidate" do + expect(subject.behaviors.keys).to match_array(%w[control candidate]) + end + + it "publishes to the database" do + expect(subject).to receive(:publish_to_database) + + subject.publish + end +end diff --git a/spec/factories/ci/build_trace_metadata.rb b/spec/factories/ci/build_trace_metadata.rb new file mode 100644 index 00000000000..e5f8ae40cc5 --- /dev/null +++ b/spec/factories/ci/build_trace_metadata.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ci_build_trace_metadata, class: 'Ci::BuildTraceMetadata' do + build factory: :ci_build + end +end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index f3500301e22..1108c606df3 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -534,6 +534,14 @@ FactoryBot.define do end end + trait :coverage_fuzzing do + options do + { + artifacts: { reports: { coverage_fuzzing: 'gl-coverage-fuzzing-report.json' } } + } + end + end + trait :license_scanning do options do { diff --git a/spec/factories/ci/pending_builds.rb b/spec/factories/ci/pending_builds.rb index fbd76e07d8e..31e42e1bc9e 100644 --- a/spec/factories/ci/pending_builds.rb +++ b/spec/factories/ci/pending_builds.rb @@ -8,5 +8,6 @@ FactoryBot.define do instance_runners_enabled { true } namespace { project.namespace } minutes_exceeded { false } + tag_ids { build.tags_ids } end end diff --git a/spec/factories/ci/reports/security/flags.rb b/spec/factories/ci/reports/security/flags.rb new file mode 100644 index 00000000000..7efe72276c9 --- /dev/null +++ b/spec/factories/ci/reports/security/flags.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ci_reports_security_flag, class: '::Gitlab::Ci::Reports::Security::Flag' do + type { 'flagged-as-likely-false-positive' } + origin { 'post analyzer X' } + description { 'static string to sink' } + + skip_create + + initialize_with do + ::Gitlab::Ci::Reports::Security::Flag.new(**attributes) + end + end +end diff --git a/spec/factories/clusters/agents.rb b/spec/factories/clusters/agents.rb index 334671f69f0..4dc82f91bab 100644 --- a/spec/factories/clusters/agents.rb +++ b/spec/factories/clusters/agents.rb @@ -3,6 +3,7 @@ FactoryBot.define do factory :cluster_agent, class: 'Clusters::Agent' do project + association :created_by_user, factory: :user sequence(:name) { |n| "agent-#{n}" } end diff --git a/spec/factories/clusters/agents/group_authorizations.rb b/spec/factories/clusters/agents/group_authorizations.rb new file mode 100644 index 00000000000..6ea3668dc66 --- /dev/null +++ b/spec/factories/clusters/agents/group_authorizations.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :agent_group_authorization, class: 'Clusters::Agents::GroupAuthorization' do + association :agent, factory: :cluster_agent + group + + config { { default_namespace: 'production' } } + end +end diff --git a/spec/factories/clusters/agents/project_authorizations.rb b/spec/factories/clusters/agents/project_authorizations.rb new file mode 100644 index 00000000000..176ecc3b517 --- /dev/null +++ b/spec/factories/clusters/agents/project_authorizations.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :agent_project_authorization, class: 'Clusters::Agents::ProjectAuthorization' do + association :agent, factory: :cluster_agent + project + + config { { default_namespace: 'production' } } + end +end diff --git a/spec/factories/compares.rb b/spec/factories/compares.rb new file mode 100644 index 00000000000..4dd94b93049 --- /dev/null +++ b/spec/factories/compares.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :compare do + skip_create # No persistence + + start_project { association(:project, :repository) } + target_project { start_project } + + start_ref { 'master' } + target_ref { 'feature' } + + base_sha { nil } + straight { false } + + initialize_with do + CompareService + .new(start_project, start_ref) + .execute(target_project, target_ref, base_sha: base_sha, straight: straight) + end + end +end diff --git a/spec/factories/customer_relations/contacts.rb b/spec/factories/customer_relations/contacts.rb new file mode 100644 index 00000000000..437f8feea48 --- /dev/null +++ b/spec/factories/customer_relations/contacts.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :contact, class: 'CustomerRelations::Contact' do + group + + first_name { generate(:name) } + last_name { generate(:name) } + + trait :with_organization do + organization + end + end +end diff --git a/spec/factories/dependency_proxy.rb b/spec/factories/dependency_proxy.rb index 94a7986a8fa..c2873ce9b5e 100644 --- a/spec/factories/dependency_proxy.rb +++ b/spec/factories/dependency_proxy.rb @@ -3,12 +3,14 @@ FactoryBot.define do factory :dependency_proxy_blob, class: 'DependencyProxy::Blob' do group + size { 1234 } file { fixture_file_upload('spec/fixtures/dependency_proxy/a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz') } file_name { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz' } end factory :dependency_proxy_manifest, class: 'DependencyProxy::Manifest' do group + size { 1234 } file { fixture_file_upload('spec/fixtures/dependency_proxy/manifest') } digest { 'sha256:d0710affa17fad5f466a70159cc458227bd25d4afb39514ef662ead3e6c99515' } file_name { 'alpine:latest.json' } diff --git a/spec/factories/dependency_proxy/image_ttl_group_policies.rb b/spec/factories/dependency_proxy/image_ttl_group_policies.rb new file mode 100644 index 00000000000..21e5dd44cf5 --- /dev/null +++ b/spec/factories/dependency_proxy/image_ttl_group_policies.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :image_ttl_group_policy, class: 'DependencyProxy::ImageTtlGroupPolicy' do + group + + enabled { true } + ttl { 90 } + end +end diff --git a/spec/factories/integration_data.rb b/spec/factories/integration_data.rb index a7406794437..4d0892556f8 100644 --- a/spec/factories/integration_data.rb +++ b/spec/factories/integration_data.rb @@ -7,13 +7,21 @@ FactoryBot.define do integration factory: :jira_integration end + factory :zentao_tracker_data, class: 'Integrations::ZentaoTrackerData' do + integration factory: :zentao_integration + url { 'https://jihudemo.zentao.net' } + api_url { '' } + api_token { 'ZENTAO_TOKEN' } + zentao_product_xid { '3' } + end + 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'} + url { 'http://openproject.example.com' } token { 'supersecret' } project_identifier_code { 'PRJ-1' } closed_status_id { '15' } diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index a5a17ca4058..cb1c94c25c1 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -12,6 +12,12 @@ FactoryBot.define do issue_tracker end + factory :datadog_integration, class: 'Integrations::Datadog' do + project + active { true } + api_key { 'secret' } + end + factory :emails_on_push_integration, class: 'Integrations::EmailsOnPush' do project type { 'EmailsOnPushService' } @@ -79,6 +85,32 @@ FactoryBot.define do end end + factory :zentao_integration, class: 'Integrations::Zentao' do + project + active { true } + type { 'ZentaoService' } + + transient do + create_data { true } + url { 'https://jihudemo.zentao.net' } + api_url { '' } + api_token { 'ZENTAO_TOKEN' } + zentao_product_xid { '3' } + end + + after(:build) do |integration, evaluator| + if evaluator.create_data + integration.zentao_tracker_data = build(:zentao_tracker_data, + integration: integration, + url: evaluator.url, + api_url: evaluator.api_url, + api_token: evaluator.api_token, + zentao_product_xid: evaluator.zentao_product_xid + ) + end + end + end + factory :confluence_integration, class: 'Integrations::Confluence' do project active { true } diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb index 2d52747dece..8b53732a3c1 100644 --- a/spec/factories/issues.rb +++ b/spec/factories/issues.rb @@ -8,6 +8,7 @@ FactoryBot.define do updated_by { author } relative_position { RelativePositioning::START_POSITION } issue_type { :issue } + association :work_item_type, :default trait :confidential do confidential { true } @@ -59,6 +60,7 @@ FactoryBot.define do factory :incident do issue_type { :incident } + association :work_item_type, :default, :incident end end end diff --git a/spec/factories/namespaces/project_namespaces.rb b/spec/factories/namespaces/project_namespaces.rb new file mode 100644 index 00000000000..10b86f48090 --- /dev/null +++ b/spec/factories/namespaces/project_namespaces.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :project_namespace, class: 'Namespaces::ProjectNamespace' do + project + name { project.name } + path { project.path } + type { Namespaces::ProjectNamespace.sti_name } + owner { nil } + parent factory: :group + end +end diff --git a/spec/factories/operations/feature_flag_scopes.rb b/spec/factories/operations/feature_flag_scopes.rb deleted file mode 100644 index 4ca9b53f320..00000000000 --- a/spec/factories/operations/feature_flag_scopes.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -FactoryBot.define do - factory :operations_feature_flag_scope, class: 'Operations::FeatureFlagScope' do - association :feature_flag, factory: [:operations_feature_flag, :legacy_flag] - active { true } - strategies { [{ name: "default", parameters: {} }] } - sequence(:environment_scope) { |n| "review/patch-#{n}" } - end -end diff --git a/spec/factories/operations/feature_flags.rb b/spec/factories/operations/feature_flags.rb index 32e5ec9fb26..33c13a62445 100644 --- a/spec/factories/operations/feature_flags.rb +++ b/spec/factories/operations/feature_flags.rb @@ -6,13 +6,5 @@ FactoryBot.define do project active { true } version { :new_version_flag } - - trait :legacy_flag do - version { Operations::FeatureFlag.versions['legacy_flag'] } - end - - trait :new_version_flag do - version { Operations::FeatureFlag.versions['new_version_flag'] } - end end end diff --git a/spec/factories/packages.rb b/spec/factories/packages.rb index cd9c8a8bfbb..b04b7e691fe 100644 --- a/spec/factories/packages.rb +++ b/spec/factories/packages.rb @@ -112,7 +112,7 @@ FactoryBot.define do factory :npm_package do sequence(:name) { |n| "@#{project.root_namespace.path}/package-#{n}"} - version { '1.0.0' } + sequence(:version) { |n| "1.0.#{n}" } package_type { :npm } after :create do |package| @@ -354,4 +354,12 @@ FactoryBot.define 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/helm/file_metadatum.rb b/spec/factories/packages/helm/file_metadatum.rb index cbc7e114ef6..3f599b5d5c0 100644 --- a/spec/factories/packages/helm/file_metadatum.rb +++ b/spec/factories/packages/helm/file_metadatum.rb @@ -2,8 +2,16 @@ FactoryBot.define do factory :helm_file_metadatum, class: 'Packages::Helm::FileMetadatum' do + transient do + description { nil } + end + package_file { association(:helm_package_file, without_loaded_metadatum: true) } sequence(:channel) { |n| "#{FFaker::Lorem.word}-#{n}" } - metadata { { 'name': package_file.package.name, 'version': package_file.package.version, 'apiVersion': 'v2' } } + metadata do + { 'name': package_file.package.name, 'version': package_file.package.version, 'apiVersion': 'v2' }.tap do |defaults| + defaults['description'] = description if description + end + end end end diff --git a/spec/factories/packages/package_file.rb b/spec/factories/packages/package_file.rb index ac121da432c..d9afbac1048 100644 --- a/spec/factories/packages/package_file.rb +++ b/spec/factories/packages/package_file.rb @@ -212,11 +212,12 @@ FactoryBot.define do package_name { package&.name || 'foo' } sequence(:package_version) { |n| package&.version || "v#{n}" } channel { 'stable' } + description { nil } end after :create do |package_file, evaluator| unless evaluator.without_loaded_metadatum - create :helm_file_metadatum, package_file: package_file, channel: evaluator.channel + create :helm_file_metadatum, package_file: package_file, channel: evaluator.channel, description: evaluator.description end end end diff --git a/spec/factories/plan_limits.rb b/spec/factories/plan_limits.rb index ae892307193..b5921c1b311 100644 --- a/spec/factories/plan_limits.rb +++ b/spec/factories/plan_limits.rb @@ -4,6 +4,8 @@ FactoryBot.define do factory :plan_limits do plan + dast_profile_schedules { 50 } + trait :default_plan do plan factory: :default_plan end diff --git a/spec/factories/project_topics.rb b/spec/factories/project_topics.rb new file mode 100644 index 00000000000..60f5357d129 --- /dev/null +++ b/spec/factories/project_topics.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :project_topic, class: 'Projects::ProjectTopic' do + association :project, factory: :project + association :topic, factory: :topic + end +end diff --git a/spec/factories/topics.rb b/spec/factories/topics.rb new file mode 100644 index 00000000000..e77441d9eae --- /dev/null +++ b/spec/factories/topics.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :topic, class: 'Projects::Topic' do + name { generate(:name) } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 476c57f2d80..04bacbe14e7 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -11,10 +11,6 @@ FactoryBot.define do confirmation_token { nil } can_create_group { true } - after(:stub) do |user| - user.notification_email = user.email - end - trait :admin do admin { true } end diff --git a/spec/factories/users/group_user_callouts.rb b/spec/factories/users/group_user_callouts.rb new file mode 100644 index 00000000000..de8a6d3ee77 --- /dev/null +++ b/spec/factories/users/group_user_callouts.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :group_callout, class: 'Users::GroupCallout' do + feature_name { :invite_members_banner } + + user + group + end +end diff --git a/spec/factories/work_item/work_item_types.rb b/spec/factories/work_item/work_item_types.rb index 07d6d685c57..1c586aab59b 100644 --- a/spec/factories/work_item/work_item_types.rb +++ b/spec/factories/work_item/work_item_types.rb @@ -8,6 +8,17 @@ FactoryBot.define do base_type { WorkItem::Type.base_types[:issue] } icon_name { 'issue-type-issue' } + initialize_with do + type_base_attributes = attributes.with_indifferent_access.slice(:base_type, :namespace, :namespace_id) + + # Expect base_types to exist on the DB + if type_base_attributes.slice(:namespace, :namespace_id).compact.empty? + WorkItem::Type.find_or_initialize_by(type_base_attributes).tap { |type| type.assign_attributes(attributes) } + else + WorkItem::Type.new(attributes) + end + end + trait :default do namespace { nil } end diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb index 2b308c9080e..6c7c3776c4a 100644 --- a/spec/factories_spec.rb +++ b/spec/factories_spec.rb @@ -74,6 +74,7 @@ RSpec.describe 'factories' do milestone_release namespace project_broken_repo + project_namespace project_repository prometheus_alert prometheus_alert_event diff --git a/spec/fast_spec_helper.rb b/spec/fast_spec_helper.rb index b06ebba3f6c..1485edcd97d 100644 --- a/spec/fast_spec_helper.rb +++ b/spec/fast_spec_helper.rb @@ -7,7 +7,7 @@ if $".include?(File.expand_path('spec_helper.rb', __dir__)) return end -require 'bundler/setup' +require_relative '../config/bundler_setup' ENV['GITLAB_ENV'] = 'test' ENV['IN_MEMORY_APPLICATION_SETTINGS'] = 'true' diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index 54c07985a21..8053be89ffc 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -356,6 +356,7 @@ RSpec.describe "Admin Runners" do assigned_project = page.find('[data-testid="assigned-projects"]') + expect(page).to have_content('Runner assigned to project.') expect(assigned_project).to have_content(@project2.path) end end @@ -399,13 +400,14 @@ RSpec.describe "Admin Runners" do visit admin_runner_path(runner) end - it 'enables specific runner for project' do + it 'removed specific runner from project' do within '[data-testid="assigned-projects"]' do click_on 'Disable' end new_runner_project = page.find('[data-testid="unassigned-projects"]') + expect(page).to have_content('Runner unassigned from project.') expect(new_runner_project).to have_content(@project1.path) end end diff --git a/spec/features/admin/admin_sees_background_migrations_spec.rb b/spec/features/admin/admin_sees_background_migrations_spec.rb index 11823195310..94fb3a0314f 100644 --- a/spec/features/admin/admin_sees_background_migrations_spec.rb +++ b/spec/features/admin/admin_sees_background_migrations_spec.rb @@ -10,7 +10,7 @@ RSpec.describe "Admin > Admin sees background migrations" do let_it_be(:finished_migration) { create(:batched_background_migration, table_name: 'finished', status: :finished) } before_all do - create(:batched_background_migration_job, batched_migration: failed_migration, batch_size: 30, status: :succeeded) + create(:batched_background_migration_job, batched_migration: failed_migration, batch_size: 10, min_value: 6, max_value: 15, status: :failed, attempts: 3) end before do @@ -53,22 +53,35 @@ RSpec.describe "Admin > Admin sees background migrations" do end end - it 'can view failed migrations' do - visit admin_background_migrations_path + context 'when there are failed migrations' do + before do + allow_next_instance_of(Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchingStrategy) do |batch_class| + allow(batch_class).to receive(:next_batch).with(anything, anything, batch_min_value: 6, batch_size: 5).and_return([6, 10]) + end + end - within '#content-body' do - tab = find_link 'Failed' - tab.click + it 'can view and retry them' do + visit admin_background_migrations_path - expect(page).to have_current_path(admin_background_migrations_path(tab: 'failed')) - expect(tab[:class]).to include('gl-tab-nav-item-active', 'gl-tab-nav-item-active-indigo') + within '#content-body' do + tab = find_link 'Failed' + tab.click - expect(page).to have_selector('tbody tr', count: 1) + expect(page).to have_current_path(admin_background_migrations_path(tab: 'failed')) + expect(tab[:class]).to include('gl-tab-nav-item-active', 'gl-tab-nav-item-active-indigo') + + expect(page).to have_selector('tbody tr', count: 1) + + expect(page).to have_content(failed_migration.job_class_name) + expect(page).to have_content(failed_migration.table_name) + expect(page).to have_content('0.00%') + expect(page).to have_content(failed_migration.status.humanize) - expect(page).to have_content(failed_migration.job_class_name) - expect(page).to have_content(failed_migration.table_name) - expect(page).to have_content('30.00%') - expect(page).to have_content(failed_migration.status.humanize) + click_button('Retry') + expect(page).not_to have_content(failed_migration.job_class_name) + expect(page).not_to have_content(failed_migration.table_name) + expect(page).not_to have_content('0.00%') + end end end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index 4a0f7ccbb0a..b25fc9f257a 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -269,10 +269,7 @@ RSpec.describe 'Admin updates settings' do end context 'Integrations page' do - let(:mailgun_events_receiver_enabled) { true } - before do - stub_feature_flags(mailgun_events_receiver: mailgun_events_receiver_enabled) visit general_admin_application_settings_path end @@ -286,26 +283,16 @@ RSpec.describe 'Admin updates settings' do expect(current_settings.hide_third_party_offers).to be true end - context 'when mailgun_events_receiver feature flag is enabled' do - it 'enabling Mailgun events', :aggregate_failures do - page.within('.as-mailgun') do - check 'Enable Mailgun event receiver' - fill_in 'Mailgun HTTP webhook signing key', with: 'MAILGUN_SIGNING_KEY' - click_button 'Save changes' - end - - expect(page).to have_content 'Application settings saved successfully' - expect(current_settings.mailgun_events_enabled).to be true - expect(current_settings.mailgun_signing_key).to eq 'MAILGUN_SIGNING_KEY' + it 'enabling Mailgun events', :aggregate_failures do + page.within('.as-mailgun') do + check 'Enable Mailgun event receiver' + fill_in 'Mailgun HTTP webhook signing key', with: 'MAILGUN_SIGNING_KEY' + click_button 'Save changes' end - end - - context 'when mailgun_events_receiver feature flag is disabled' do - let(:mailgun_events_receiver_enabled) { false } - it 'does not have mailgun' do - expect(page).not_to have_selector('.as-mailgun') - end + expect(page).to have_content 'Application settings saved successfully' + expect(current_settings.mailgun_events_enabled).to be true + expect(current_settings.mailgun_signing_key).to eq 'MAILGUN_SIGNING_KEY' end end @@ -559,6 +546,50 @@ RSpec.describe 'Admin updates settings' do expect(current_settings.dns_rebinding_protection_enabled).to be false end + it 'changes User and IP Rate Limits settings' do + visit network_admin_application_settings_path + + page.within('.as-ip-limits') do + check 'Enable unauthenticated API request rate limit' + fill_in 'Maximum unauthenticated API requests per rate limit period per IP', with: 100 + fill_in 'Unauthenticated API rate limit period in seconds', with: 200 + + check 'Enable unauthenticated web request rate limit' + fill_in 'Maximum unauthenticated web requests per rate limit period per IP', with: 300 + fill_in 'Unauthenticated web rate limit period in seconds', with: 400 + + check 'Enable authenticated API request rate limit' + fill_in 'Maximum authenticated API requests per rate limit period per user', with: 500 + fill_in 'Authenticated API rate limit period in seconds', with: 600 + + check 'Enable authenticated web request rate limit' + fill_in 'Maximum authenticated web requests per rate limit period per user', with: 700 + fill_in 'Authenticated web rate limit period in seconds', with: 800 + + fill_in 'Plain-text response to send to clients that hit a rate limit', with: 'Custom message' + + click_button 'Save changes' + end + + expect(page).to have_content "Application settings saved successfully" + + expect(current_settings).to have_attributes( + throttle_unauthenticated_api_enabled: true, + throttle_unauthenticated_api_requests_per_period: 100, + throttle_unauthenticated_api_period_in_seconds: 200, + throttle_unauthenticated_enabled: true, + throttle_unauthenticated_requests_per_period: 300, + throttle_unauthenticated_period_in_seconds: 400, + throttle_authenticated_api_enabled: true, + throttle_authenticated_api_requests_per_period: 500, + throttle_authenticated_api_period_in_seconds: 600, + throttle_authenticated_web_enabled: true, + throttle_authenticated_web_requests_per_period: 700, + throttle_authenticated_web_period_in_seconds: 800, + rate_limiting_response_text: 'Custom message' + ) + end + it 'changes Issues rate limits settings' do visit network_admin_application_settings_path @@ -570,6 +601,20 @@ RSpec.describe 'Admin updates settings' do expect(page).to have_content "Application settings saved successfully" expect(current_settings.issues_create_limit).to eq(0) end + + it 'changes Files API rate limits 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' + 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) + end end context 'Preferences page' do diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb index 7466150addf..0966032ff37 100644 --- a/spec/features/admin/admin_users_impersonation_tokens_spec.rb +++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb @@ -42,7 +42,7 @@ RSpec.describe 'Admin > Users > Impersonation Tokens', :js do click_on "Create impersonation token" expect(active_impersonation_tokens).to have_text(name) - expect(active_impersonation_tokens).to have_text('In') + expect(active_impersonation_tokens).to have_text('in') expect(active_impersonation_tokens).to have_text('api') expect(active_impersonation_tokens).to have_text('read_user') expect(PersonalAccessTokensFinder.new(impersonation: true).execute.count).to equal(1) @@ -59,6 +59,14 @@ RSpec.describe 'Admin > Users > Impersonation Tokens', :js do expect(active_impersonation_tokens).to have_text(impersonation_token.name) expect(active_impersonation_tokens).not_to have_text(personal_access_token.name) + expect(active_impersonation_tokens).to have_text('in') + end + + it 'shows absolute times' do + admin.update!(time_display_relative: false) + visit admin_user_impersonation_tokens_path(user_id: user.username) + + expect(active_impersonation_tokens).to have_text(personal_access_token.expires_at.strftime('%b %d')) end end diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb index 511cdcc2940..855c91f70d7 100644 --- a/spec/features/atom/dashboard_issues_spec.rb +++ b/spec/features/atom/dashboard_issues_spec.rb @@ -4,8 +4,20 @@ require 'spec_helper' RSpec.describe "Dashboard Issues Feed" do describe "GET /issues" do - let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') } - let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } + let!(:user) do + user = create(:user, email: 'private1@example.com') + public_email = create(:email, :confirmed, user: user, email: 'public1@example.com') + user.update!(public_email: public_email.email) + user + end + + let!(:assignee) do + user = create(:user, email: 'private2@example.com') + public_email = create(:email, :confirmed, user: user, email: 'public2@example.com') + user.update!(public_email: public_email.email) + user + end + let!(:project1) { create(:project) } let!(:project2) { create(:project) } diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb index 13798a94fe9..913f5a7bcf3 100644 --- a/spec/features/atom/issues_spec.rb +++ b/spec/features/atom/issues_spec.rb @@ -4,61 +4,87 @@ require 'spec_helper' RSpec.describe 'Issues Feed' do describe 'GET /issues' do - let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') } - let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } - let!(:group) { create(:group) } - let!(:project) { create(:project) } - let!(:issue) { create(:issue, author: user, assignees: [assignee], project: project) } + let_it_be_with_reload(:user) do + user = create(:user, email: 'private1@example.com') + public_email = create(:email, :confirmed, user: user, email: 'public1@example.com') + user.update!(public_email: public_email.email) + user + end + + let_it_be(:assignee) do + user = create(:user, email: 'private2@example.com') + public_email = create(:email, :confirmed, user: user, email: 'public2@example.com') + user.update!(public_email: public_email.email) + user + end - before do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project) } + let_it_be(:issue) { create(:issue, author: user, assignees: [assignee], project: project, due_date: Date.today) } + let_it_be(:issuable) { issue } # "alias" for shared examples + + before_all do project.add_developer(user) group.add_developer(user) end + RSpec.shared_examples 'an authenticated issue atom feed' do + it 'renders atom feed with additional issue information' do + expect(body).to have_selector('title', text: "#{project.name} issues") + expect(body).to have_selector('due_date', text: issue.due_date) + end + end + context 'when authenticated' do - it 'renders atom feed' do + before do sign_in user visit project_issues_path(project, :atom) - - expect(response_headers['Content-Type']) - .to have_content('application/atom+xml') - expect(body).to have_selector('title', text: "#{project.name} issues") - expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) - expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) - expect(body).to have_selector('entry summary', text: issue.title) end + + it_behaves_like 'an authenticated issuable atom feed' + it_behaves_like 'an authenticated issue atom feed' end context 'when authenticated via personal access token' do - it 'renders atom feed' do + before do personal_access_token = create(:personal_access_token, user: user) visit project_issues_path(project, :atom, - private_token: personal_access_token.token) - - expect(response_headers['Content-Type']) - .to have_content('application/atom+xml') - expect(body).to have_selector('title', text: "#{project.name} issues") - expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) - expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) - expect(body).to have_selector('entry summary', text: issue.title) + private_token: personal_access_token.token) end + + it_behaves_like 'an authenticated issuable atom feed' + it_behaves_like 'an authenticated issue atom feed' end context 'when authenticated via feed token' do - it 'renders atom feed' do + before do visit project_issues_path(project, :atom, - feed_token: user.feed_token) + feed_token: user.feed_token) + end - expect(response_headers['Content-Type']) - .to have_content('application/atom+xml') - expect(body).to have_selector('title', text: "#{project.name} issues") - expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) - expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) - expect(body).to have_selector('entry summary', text: issue.title) + it_behaves_like 'an authenticated issuable atom feed' + it_behaves_like 'an authenticated issue atom feed' + end + + context 'when not authenticated' do + before do + visit project_issues_path(project, :atom) + end + + context 'and the project is private' do + it 'redirects to login page' do + expect(page).to have_current_path(new_user_session_path) + end + end + + context 'and the project is public' do + let_it_be(:project) { create(:project, :public) } + let_it_be(:issue) { create(:issue, author: user, assignees: [assignee], project: project, due_date: Date.today) } + let_it_be(:issuable) { issue } # "alias" for shared examples + + it_behaves_like 'an authenticated issuable atom feed' + it_behaves_like 'an authenticated issue atom feed' end end diff --git a/spec/features/atom/merge_requests_spec.rb b/spec/features/atom/merge_requests_spec.rb new file mode 100644 index 00000000000..48db8fcbf1e --- /dev/null +++ b/spec/features/atom/merge_requests_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Merge Requests Feed' do + describe 'GET /merge_requests' do + let_it_be_with_reload(:user) { create(:user, email: 'private1@example.com') } + let_it_be(:assignee) { create(:user, email: 'private2@example.com') } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:merge_request) { create(:merge_request, source_project: project, assignees: [assignee]) } + let_it_be(:issuable) { merge_request } # "alias" for shared examples + + before_all do + project.add_developer(user) + group.add_developer(user) + end + + RSpec.shared_examples 'an authenticated merge request atom feed' do + it 'renders atom feed with additional merge request information' do + expect(body).to have_selector('title', text: "#{project.name} merge requests") + end + end + + context 'when authenticated' do + before do + sign_in user + visit project_merge_requests_path(project, :atom) + end + + it_behaves_like 'an authenticated issuable atom feed' + it_behaves_like 'an authenticated merge request atom feed' + + context 'but the use can not see the project' do + let_it_be(:other_project) { create(:project) } + + it 'renders 404 page' do + visit project_issues_path(other_project, :atom) + + expect(page).to have_gitlab_http_status(:not_found) + end + end + end + + context 'when authenticated via personal access token' do + before do + personal_access_token = create(:personal_access_token, user: user) + + visit project_merge_requests_path(project, :atom, + private_token: personal_access_token.token) + end + + it_behaves_like 'an authenticated issuable atom feed' + it_behaves_like 'an authenticated merge request atom feed' + end + + context 'when authenticated via feed token' do + before do + visit project_merge_requests_path(project, :atom, + feed_token: user.feed_token) + end + + it_behaves_like 'an authenticated issuable atom feed' + it_behaves_like 'an authenticated merge request atom feed' + end + + context 'when not authenticated' do + before do + visit project_merge_requests_path(project, :atom) + end + + context 'and the project is private' do + it 'redirects to login page' do + expect(page).to have_current_path(new_user_session_path) + end + end + + context 'and the project is public' do + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:merge_request) { create(:merge_request, source_project: project, assignees: [assignee]) } + let_it_be(:issuable) { merge_request } # "alias" for shared examples + + it_behaves_like 'an authenticated issuable atom feed' + it_behaves_like 'an authenticated merge request atom feed' + end + end + end +end diff --git a/spec/features/boards/multi_select_spec.rb b/spec/features/boards/multi_select_spec.rb index 057464326fa..9148fb23214 100644 --- a/spec/features/boards/multi_select_spec.rb +++ b/spec/features/boards/multi_select_spec.rb @@ -43,12 +43,12 @@ RSpec.describe 'Multi Select Issue', :js do # Multi select drag&drop support is temporarily disabled # https://gitlab.com/gitlab-org/gitlab/-/issues/289797 - stub_feature_flags(graphql_board_lists: false, board_multi_select: project) + stub_feature_flags(board_multi_select: project) sign_in(user) end - context 'with lists' do + xcontext 'with lists' do let(:label1) { create(:label, project: project, name: 'Label 1', description: 'Test') } let(:label2) { create(:label, project: project, name: 'Label 2', description: 'Test') } let!(:list1) { create(:list, board: board, label: label1, position: 0) } diff --git a/spec/features/boards/sidebar_labels_spec.rb b/spec/features/boards/sidebar_labels_spec.rb index 2f0230c61d8..fa16f47f69a 100644 --- a/spec/features/boards/sidebar_labels_spec.rb +++ b/spec/features/boards/sidebar_labels_spec.rb @@ -5,8 +5,9 @@ require 'spec_helper' RSpec.describe 'Project issue boards sidebar labels', :js do include BoardHelpers + let_it_be(:group) { create(:group, :public) } let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project, :public) } + let_it_be(:project) { create(:project, :public, namespace: group) } let_it_be(:development) { create(:label, project: project, name: 'Development') } let_it_be(:bug) { create(:label, project: project, name: 'Bug') } let_it_be(:regression) { create(:label, project: project, name: 'Regression') } diff --git a/spec/features/boards/user_adds_lists_to_board_spec.rb b/spec/features/boards/user_adds_lists_to_board_spec.rb index 5128fc4004e..26c310a6f56 100644 --- a/spec/features/boards/user_adds_lists_to_board_spec.rb +++ b/spec/features/boards/user_adds_lists_to_board_spec.rb @@ -3,8 +3,6 @@ require 'spec_helper' RSpec.describe 'User adds lists', :js do - using RSpec::Parameterized::TableSyntax - let_it_be(:group) { create(:group, :nested) } let_it_be(:project) { create(:project, :public, namespace: group) } let_it_be(:group_board) { create(:board, group: group) } @@ -17,6 +15,8 @@ RSpec.describe 'User adds lists', :js do let_it_be(:project_label) { create(:label, project: project) } let_it_be(:group_backlog_list) { create(:backlog_list, board: group_board) } let_it_be(:project_backlog_list) { create(:backlog_list, board: project_board) } + let_it_be(:backlog) { create(:group_label, group: group, name: 'Backlog') } + let_it_be(:closed) { create(:group_label, group: group, name: 'Closed') } let_it_be(:issue) { create(:labeled_issue, project: project, labels: [group_label, project_label]) } @@ -25,15 +25,8 @@ RSpec.describe 'User adds lists', :js do group.add_owner(user) end - where(:board_type, :graphql_board_lists_enabled, :board_new_list_enabled) do - :project | true | true - :project | false | true - :project | true | false - :project | false | false - :group | true | true - :group | false | true - :group | true | false - :group | false | false + where(:board_type) do + [[:project], [:group]] end with_them do @@ -42,11 +35,6 @@ RSpec.describe 'User adds lists', :js do set_cookie('sidebar_collapsed', 'true') - stub_feature_flags( - graphql_board_lists: graphql_board_lists_enabled, - board_new_list: board_new_list_enabled - ) - if board_type == :project visit project_board_path(project, project_board) elsif board_type == :group @@ -56,40 +44,43 @@ RSpec.describe 'User adds lists', :js do wait_for_all_requests end - it 'creates new column for label containing labeled issue' do - click_button button_text(board_new_list_enabled) + it 'creates new column for label containing labeled issue', :aggregate_failures do + click_button 'Create list' wait_for_all_requests - select_label(board_new_list_enabled, group_label) - - wait_for_all_requests + select_label(group_label) expect(page).to have_selector('.board', text: group_label.title) expect(find('.board:nth-child(2) .board-card')).to have_content(issue.title) end - end - def select_label(board_new_list_enabled, label) - if board_new_list_enabled - click_button 'Select a label' + it 'creates new list for Backlog and closed labels' do + click_button 'Create list' + wait_for_requests - find('label', text: label.title).click + select_label(backlog) - click_button 'Add to board' + click_button 'Create list' + wait_for_requests - wait_for_all_requests - else - page.within('.dropdown-menu-issues-board-new') do - click_link label.title - end + select_label(closed) + + wait_for_requests + + expect(page).to have_selector('.board', text: closed.title) + expect(find('.board:nth-child(2) .board-header')).to have_content(backlog.title) + expect(find('.board:nth-child(3) .board-header')).to have_content(closed.title) + expect(find('.board:nth-child(4) .board-header')).to have_content('Closed') end end - def button_text(board_new_list_enabled) - if board_new_list_enabled - 'Create list' - else - 'Add list' - end + def select_label(label) + click_button 'Select a label' + + find('label', text: label.title).click + + click_button 'Add to board' + + wait_for_all_requests end end diff --git a/spec/features/clusters/cluster_health_dashboard_spec.rb b/spec/features/clusters/cluster_health_dashboard_spec.rb index e4a36f654e5..88d6976c2be 100644 --- a/spec/features/clusters/cluster_health_dashboard_spec.rb +++ b/spec/features/clusters/cluster_health_dashboard_spec.rb @@ -80,8 +80,8 @@ RSpec.describe 'Cluster Health board', :js, :kubeclient, :use_clean_rails_memory expect(page).to have_content('Avg') end - it 'focuses the single panel on toggle', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338341' do - click_button('More actions') + it 'focuses the single panel on toggle' do + click_button('More actions', match: :first) click_button('Expand panel') expect(page).to have_css('.prometheus-graph', count: 1) diff --git a/spec/features/commit_spec.rb b/spec/features/commit_spec.rb index 80a30ab01b2..3fd613ce393 100644 --- a/spec/features/commit_spec.rb +++ b/spec/features/commit_spec.rb @@ -42,7 +42,7 @@ RSpec.describe 'Commit' do visit project_commit_path(project, commit) end - it "shows an adjusted count for changed files on this page" do + it "shows an adjusted count for changed files on this page", :js do expect(page).to have_content("Showing 1 changed file") end diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index de6cb53fdfa..bec474f6cfe 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -5,35 +5,40 @@ require 'spec_helper' RSpec.describe 'Value Stream Analytics', :js do let_it_be(:user) { create(:user) } let_it_be(:guest) { create(:user) } - let_it_be(:project) { create(:project, :repository) } 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(: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(:project) { create(:project, :repository) } let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } let(:milestone) { create(:milestone, project: project) } let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") } let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) } + def metrics_values + page.find(metrics_selector).all(metric_value_selector).collect(&:text) + end + + def set_daterange(from_date, to_date) + page.find(".js-daterange-picker-from input").set(from_date) + page.find(".js-daterange-picker-to input").set(to_date) + wait_for_all_requests + end + context 'as an allowed user' do context 'when project is new' do - before(:all) do - project.add_maintainer(user) - end - before do + project.add_maintainer(user) sign_in(user) visit project_cycle_analytics_path(project) wait_for_requests end - it 'displays metrics' do - aggregate_failures 'with relevant values' do - expect(new_issues_counter).to have_content('-') - expect(commits_counter).to have_content('-') - expect(deploys_counter).to have_content('-') - expect(deployment_frequency_counter).to have_content('-') - end + it 'displays metrics with relevant values' do + expect(metrics_values).to eq(['-'] * 4) end it 'shows active stage with empty message' do @@ -43,24 +48,37 @@ RSpec.describe 'Value Stream Analytics', :js do end context "when there's value stream analytics data" do + # NOTE: in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68595 travel back + # 5 days in time before we create data for these specs, to mitigate some flakiness + # 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") + + around do |example| + travel_to(5.days.ago) { example.run } + end + before do project.add_maintainer(user) + create_list(:issue, 2, project: project, created_at: 2.weeks.ago, milestone: milestone) - @build = create_cycle(user, project, issue, mr, milestone, pipeline) + create_cycle(user, project, issue, mr, milestone, pipeline) deploy_master(user, project) issue.metrics.update!(first_mentioned_in_commit_at: issue.metrics.first_associated_with_milestone_at + 1.hour) merge_request = issue.merge_requests_closing_issues.first.merge_request merge_request.update!(created_at: issue.metrics.first_associated_with_milestone_at + 1.hour) merge_request.metrics.update!( - latest_build_started_at: 4.hours.ago, - latest_build_finished_at: 3.hours.ago, - merged_at: merge_request.created_at + 1.hour, - first_deployed_to_production_at: merge_request.created_at + 2.hours + latest_build_started_at: merge_request.created_at + 3.hours, + latest_build_finished_at: merge_request.created_at + 4.hours, + merged_at: merge_request.created_at + 4.hours, + first_deployed_to_production_at: merge_request.created_at + 5.hours ) sign_in(user) visit project_cycle_analytics_path(project) + + wait_for_requests end it 'displays metrics' do @@ -93,18 +111,20 @@ RSpec.describe 'Value Stream Analytics', :js do expect_merge_request_to_be_present end - context "when I change the time period observed" do - before do - _two_weeks_old_issue = create(:issue, project: project, created_at: 2.weeks.ago) + it 'can filter the issues by date' do + expect(stage_table.all(stage_table_event_selector).length).to eq(3) - click_button('Last 30 days') - click_link('Last 7 days') - wait_for_requests - end + set_daterange(from, to) - it 'shows only relevant data' do - expect(new_issue_counter).to have_content('1') - end + expect(stage_table.all(stage_table_event_selector).length).to eq(0) + end + + it 'can filter the metrics by date' do + expect(metrics_values).to eq(["3.0", "2.0", "1.0", "0.0"]) + + set_daterange(from, to) + + expect(metrics_values).to eq(['-'] * 4) end end end @@ -137,31 +157,6 @@ RSpec.describe 'Value Stream Analytics', :js do end end - def find_metric_tile(sel) - page.find("#{metrics_selector} #{sel}") - end - - # When now use proper pluralization for the metric names, which affects the id - def new_issue_counter - find_metric_tile("#new-issue") - end - - def new_issues_counter - find_metric_tile("#new-issues") - end - - def commits_counter - find_metric_tile("#commits") - end - - def deploys_counter - find_metric_tile("#deploys") - end - - def deployment_frequency_counter - find_metric_tile("#deployment-frequency") - end - def expect_issue_to_be_present expect(find(stage_table_selector)).to have_content(issue.title) expect(find(stage_table_selector)).to have_content(issue.author.name) diff --git a/spec/features/global_search_spec.rb b/spec/features/global_search_spec.rb index 19fb8e5f52c..a380edff3a4 100644 --- a/spec/features/global_search_spec.rb +++ b/spec/features/global_search_spec.rb @@ -11,40 +11,64 @@ RSpec.describe 'Global search' do before do project.add_maintainer(user) sign_in(user) - - visit dashboard_projects_path end - it 'increases usage ping searches counter' do - expect(Gitlab::UsageDataCounters::SearchCounter).to receive(:count).with(:navbar_searches) - expect(Gitlab::UsageDataCounters::SearchCounter).to receive(:count).with(:all_searches) + describe 'when new_header_search feature is disabled' do + before do + # TODO: Remove this along with feature flag #339348 + stub_feature_flags(new_header_search: false) + visit dashboard_projects_path + end - submit_search('foobar') - end + it 'increases usage ping searches counter' do + expect(Gitlab::UsageDataCounters::SearchCounter).to receive(:count).with(:navbar_searches) + expect(Gitlab::UsageDataCounters::SearchCounter).to receive(:count).with(:all_searches) - describe 'I search through the issues and I see pagination' do - before do - allow_next(SearchService).to receive(:per_page).and_return(1) - create_list(:issue, 2, project: project, title: 'initial') + submit_search('foobar') end - it "has a pagination" do - submit_search('initial') - select_search_scope('Issues') + describe 'I search through the issues and I see pagination' do + before do + allow_next(SearchService).to receive(:per_page).and_return(1) + create_list(:issue, 2, project: project, title: 'initial') + end + + it "has a pagination" do + submit_search('initial') + select_search_scope('Issues') - expect(page).to have_selector('.gl-pagination .next') + expect(page).to have_selector('.gl-pagination .next') + end end - end - it 'closes the dropdown on blur', :js do - find('#search').click - fill_in 'search', with: "a" + it 'closes the dropdown on blur', :js do + find('#search').click + fill_in 'search', with: "a" + + expect(page).to have_selector("div[data-testid='dashboard-search-options'].show") - expect(page).to have_selector("div[data-testid='dashboard-search-options'].show") + find('#search').send_keys(:backspace) + find('body').click - find('#search').send_keys(:backspace) - find('body').click + expect(page).to have_no_selector("div[data-testid='dashboard-search-options'].show") + end + + it 'renders legacy search bar' do + expect(page).to have_selector('.search-form') + expect(page).to have_no_selector('#js-header-search') + end + end - expect(page).to have_no_selector("div[data-testid='dashboard-search-options'].show") + describe 'when new_header_search feature is enabled' do + before do + # TODO: Remove this along with feature flag #339348 + stub_feature_flags(new_header_search: true) + visit dashboard_projects_path + end + + it 'renders updated search bar' do + expect(page).to have_no_selector('.search-form') + expect(page).to have_selector('#js-header-search') + end end end diff --git a/spec/features/groups/board_sidebar_spec.rb b/spec/features/groups/board_sidebar_spec.rb index e2dd2fecab7..69a6788e438 100644 --- a/spec/features/groups/board_sidebar_spec.rb +++ b/spec/features/groups/board_sidebar_spec.rb @@ -42,30 +42,4 @@ RSpec.describe 'Group Issue Boards', :js do end end end - - context 'when graphql_board_lists FF disabled' do - before do - stub_feature_flags(graphql_board_lists: false) - sign_in(user) - - visit group_board_path(group, board) - wait_for_requests - end - - it 'only shows valid labels for the issue project and group' do - click_card(card) - - page.within('.labels') do - click_link 'Edit' - - wait_for_requests - - page.within('.selectbox') do - expect(page).to have_content(project_1_label.title) - expect(page).to have_content(group_label.title) - expect(page).not_to have_content(project_2_label.title) - end - end - end - end end diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index 21b39d2da46..489beb70ab3 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -33,7 +33,7 @@ RSpec.describe 'Group issues page' do # However,`:js` option forces Capybara to use Selenium that doesn't support`:has` context "it has an RSS button with current_user's feed token" do it "shows the RSS button with current_user's feed token" do - expect(find('[data-testid="rss-feed-link"]')['href']).to have_content(user.feed_token) + expect(page).to have_link 'Subscribe to RSS feed', href: /feed_token=#{user.feed_token}/ end end end @@ -46,7 +46,7 @@ RSpec.describe 'Group issues page' do # Note: please see the above context "it has an RSS button without a feed token" do it "shows the RSS button without a feed token" do - expect(find('[data-testid="rss-feed-link"]')['href']).not_to have_content('feed_token') + expect(page).not_to have_link 'Subscribe to RSS feed', href: /feed_token/ end end end @@ -94,6 +94,41 @@ RSpec.describe 'Group issues page' do expect(page).not_to have_content issue.title[0..80] end end + + context 'when cached issues state count is enabled', :clean_gitlab_redis_cache do + before do + stub_feature_flags(cached_issues_state_count: true) + end + + it 'truncates issue counts if over the threshold' do + allow(Rails.cache).to receive(:read).and_call_original + allow(Rails.cache).to receive(:read).with( + ['group', group.id, 'issues'], + { expires_in: Gitlab::IssuablesCountForState::CACHE_EXPIRES_IN } + ).and_return({ opened: 1050, closed: 500, all: 1550 }) + + visit issues_group_path(group) + + expect(page).to have_text('Open 1.1k Closed 500 All 1.6k') + end + end + + context 'when cached issues state count is disabled', :clean_gitlab_redis_cache do + before do + stub_feature_flags(cached_issues_state_count: false) + end + + it 'does not truncate counts if they are over the threshold' do + allow_next_instance_of(IssuesFinder) do |finder| + allow(finder).to receive(:count_by_state).and_return(true) + .and_return({ opened: 1050, closed: 500, all: 1550 }) + end + + visit issues_group_path(group) + + expect(page).to have_text('Open 1,050 Closed 500 All 1,550') + end + end end context 'projects with issues disabled' do diff --git a/spec/features/groups/members/request_access_spec.rb b/spec/features/groups/members/request_access_spec.rb index 827962fee61..f806c7d3704 100644 --- a/spec/features/groups/members/request_access_spec.rb +++ b/spec/features/groups/members/request_access_spec.rb @@ -24,7 +24,7 @@ RSpec.describe 'Groups > Members > Request access' do it 'user can request access to a group' do perform_enqueued_jobs { click_link 'Request Access' } - expect(ActionMailer::Base.deliveries.last.to).to eq [owner.notification_email] + expect(ActionMailer::Base.deliveries.last.to).to eq [owner.notification_email_or_default] expect(ActionMailer::Base.deliveries.last.subject).to match "Request to join the #{group.name} group" expect(group.requesters.exists?(user_id: user)).to be_truthy diff --git a/spec/features/groups/packages_spec.rb b/spec/features/groups/packages_spec.rb index 9a7950266a5..3c2ade6b274 100644 --- a/spec/features/groups/packages_spec.rb +++ b/spec/features/groups/packages_spec.rb @@ -44,14 +44,6 @@ RSpec.describe 'Group Packages' do it_behaves_like 'packages list', check_project_name: true - context 'when package_details_apollo feature flag is off' do - before do - stub_feature_flags(package_details_apollo: false) - end - - it_behaves_like 'package details link' - end - it_behaves_like 'package details link' it 'allows you to navigate to the project page' do diff --git a/spec/features/groups/settings/packages_and_registries_spec.rb b/spec/features/groups/settings/packages_and_registries_spec.rb index 835555480dd..d3141da9160 100644 --- a/spec/features/groups/settings/packages_and_registries_spec.rb +++ b/spec/features/groups/settings/packages_and_registries_spec.rb @@ -90,9 +90,10 @@ RSpec.describe 'Group Packages & Registries settings' do expect(page).to have_content('Do not allow duplicates') fill_in 'Exceptions', with: ')' + + # simulate blur event + find('#maven-duplicated-settings-regex-input').native.send_keys(:tab) end - # simulate blur event - find('body').click expect(page).to have_content('is an invalid regexp') end diff --git a/spec/features/groups/settings/repository_spec.rb b/spec/features/groups/settings/repository_spec.rb index 7082b2b20bd..d95eaf3c92c 100644 --- a/spec/features/groups/settings/repository_spec.rb +++ b/spec/features/groups/settings/repository_spec.rb @@ -18,11 +18,11 @@ RSpec.describe 'Group Repository settings' do before do stub_container_registry_config(enabled: true) - visit group_settings_repository_path(group) end it_behaves_like 'a deploy token in settings' do let(:entity_type) { 'group' } + let(:page_path) { group_settings_repository_path(group) } end end diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb index 79226facad4..eb62b6fa8ee 100644 --- a/spec/features/groups/show_spec.rb +++ b/spec/features/groups/show_spec.rb @@ -3,25 +3,74 @@ require 'spec_helper' RSpec.describe 'Group show page' do - let(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let(:path) { group_path(group) } context 'when signed in' do - let(:user) do - create(:group_member, :developer, user: create(:user), group: group ).user - end + context 'with non-admin group concerns' do + before do + group.add_developer(user) + sign_in(user) + visit path + end - before do - sign_in(user) - visit path + it_behaves_like "an autodiscoverable RSS feed with current_user's feed token" + + context 'when group does not exist' do + let(:path) { group_path('not-exist') } + + it { expect(status_code).to eq(404) } + end end - it_behaves_like "an autodiscoverable RSS feed with current_user's feed token" + context 'when user is an owner' do + before do + group.add_owner(user) + sign_in(user) + end + + it 'shows the invite banner and persists dismissal', :js do + visit path + + expect(page).to have_content('Collaborate with your team') - context 'when group does not exist' do - let(:path) { group_path('not-exist') } + page.within(find('[data-testid="invite-members-banner"]')) do + find('[data-testid="close-icon"]').click + end + + expect(page).not_to have_content('Collaborate with your team') + + visit path + + expect(page).not_to have_content('Collaborate with your team') + end + + context 'when group has a project with emoji in description', :js do + let!(:project) { create(:project, description: ':smile:', namespace: group) } + + it 'shows the project info', :aggregate_failures do + visit path + + expect(page).to have_content(project.title) + expect(page).to have_emoji('smile') + end + end - it { expect(status_code).to eq(404) } + context 'when group has projects' do + it 'allows users to sorts projects by most stars', :js do + project1 = create(:project, namespace: group, star_count: 2) + project2 = create(:project, namespace: group, star_count: 3) + project3 = create(:project, namespace: group, star_count: 0) + + visit group_path(group, sort: :stars_desc) + + expect(find('.group-row:nth-child(1) .namespace-title > a')).to have_content(project2.title) + expect(find('.group-row:nth-child(2) .namespace-title > a')).to have_content(project1.title) + expect(find('.group-row:nth-child(3) .namespace-title > a')).to have_content(project3.title) + end + end end end @@ -37,7 +86,7 @@ RSpec.describe 'Group show page' do context 'when group has a public project', :js do let!(:project) { create(:project, :public, namespace: group) } - it 'renders public project' do + it 'renders public project', :aggregate_failures do visit path expect(page).to have_link group.name @@ -48,7 +97,7 @@ RSpec.describe 'Group show page' do context 'when group has a private project', :js do let!(:project) { create(:project, :private, namespace: group) } - it 'does not render private project' do + it 'does not render private project', :aggregate_failures do visit path expect(page).to have_link group.name @@ -58,28 +107,19 @@ RSpec.describe 'Group show page' do end context 'subgroup support' do - let(:restricted_group) do + let_it_be(:restricted_group) do create(:group, subgroup_creation_level: ::Gitlab::Access::OWNER_SUBGROUP_ACCESS) end - let(:relaxed_group) do - create(:group, subgroup_creation_level: ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS) - end - - let(:owner) { create(:user) } - let(:maintainer) { create(:user) } - context 'for owners' do - let(:path) { group_path(restricted_group) } - before do - restricted_group.add_owner(owner) - sign_in(owner) + restricted_group.add_owner(user) + sign_in(user) end context 'when subgroups are supported' do it 'allows creating subgroups' do - visit path + visit group_path(restricted_group) expect(page).to have_link('New subgroup') end @@ -88,18 +128,21 @@ RSpec.describe 'Group show page' do context 'for maintainers' do before do - sign_in(maintainer) + sign_in(user) end context 'when subgroups are supported' do context 'when subgroup_creation_level is set to maintainers' do + let(:relaxed_group) do + create(:group, subgroup_creation_level: ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS) + end + before do - relaxed_group.add_maintainer(maintainer) + relaxed_group.add_maintainer(user) end it 'allows creating subgroups' do - path = group_path(relaxed_group) - visit path + visit group_path(relaxed_group) expect(page).to have_link('New subgroup') end @@ -107,12 +150,11 @@ RSpec.describe 'Group show page' do context 'when subgroup_creation_level is set to owners' do before do - restricted_group.add_maintainer(maintainer) + restricted_group.add_maintainer(user) end it 'does not allow creating subgroups' do - path = group_path(restricted_group) - visit path + visit group_path(restricted_group) expect(page).not_to have_link('New subgroup') end @@ -121,50 +163,10 @@ RSpec.describe 'Group show page' do end end - context 'group has a project with emoji in description', :js do - let(:user) { create(:user) } - let!(:project) { create(:project, description: ':smile:', namespace: group) } - - before do - group.add_owner(user) - sign_in(user) - visit path - end - - it 'shows the project info' do - expect(page).to have_content(project.title) - expect(page).to have_emoji('smile') - end - end - - context 'where group has projects' do - let(:user) { create(:user) } - - before do - group.add_owner(user) - sign_in(user) - end - - it 'allows users to sorts projects by most stars', :js do - project1 = create(:project, namespace: group, star_count: 2) - project2 = create(:project, namespace: group, star_count: 3) - project3 = create(:project, namespace: group, star_count: 0) - - visit group_path(group, sort: :stars_desc) - - expect(find('.group-row:nth-child(1) .namespace-title > a')).to have_content(project2.title) - expect(find('.group-row:nth-child(2) .namespace-title > a')).to have_content(project1.title) - expect(find('.group-row:nth-child(3) .namespace-title > a')).to have_content(project3.title) - end - end - context 'notification button', :js do - let(:maintainer) { create(:user) } - let!(:project) { create(:project, namespace: group) } - before do - group.add_maintainer(maintainer) - sign_in(maintainer) + group.add_maintainer(user) + sign_in(user) end it 'is enabled by default' do @@ -174,7 +176,8 @@ RSpec.describe 'Group show page' do end it 'is disabled if emails are disabled' do - group.update_attribute(:emails_disabled, true) + group.update!(emails_disabled: true) + visit path expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled') @@ -182,12 +185,10 @@ RSpec.describe 'Group show page' do end context 'page og:description' do - let(:group) { create(:group, description: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') } - let(:maintainer) { create(:user) } - before do - group.add_maintainer(maintainer) - sign_in(maintainer) + group.update!(description: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') + group.add_maintainer(user) + sign_in(user) visit path end @@ -237,7 +238,7 @@ RSpec.describe 'Group show page' do end end - it 'does not include structured markup in shared projects tab', :js do + it 'does not include structured markup in shared projects tab', :aggregate_failures, :js do other_project = create(:project, :public) other_project.project_group_links.create!(group: group) @@ -248,7 +249,7 @@ RSpec.describe 'Group show page' do expect(page).not_to have_selector('[itemprop="owns"][itemtype="https://schema.org/SoftwareSourceCode"]') end - it 'does not include structured markup in archived projects tab', :js do + it 'does not include structured markup in archived projects tab', :aggregate_failures, :js do project.update!(archived: true) visit group_archived_path(group) diff --git a/spec/features/ics/dashboard_issues_spec.rb b/spec/features/ics/dashboard_issues_spec.rb index 4a93a4b490a..1d0ea495757 100644 --- a/spec/features/ics/dashboard_issues_spec.rb +++ b/spec/features/ics/dashboard_issues_spec.rb @@ -4,8 +4,20 @@ require 'spec_helper' RSpec.describe 'Dashboard Issues Calendar Feed' do describe 'GET /issues' do - let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') } - let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } + let!(:user) do + user = create(:user, email: 'private1@example.com') + public_email = create(:email, :confirmed, user: user, email: 'public1@example.com') + user.update!(public_email: public_email.email) + user + end + + let!(:assignee) do + user = create(:user, email: 'private2@example.com') + public_email = create(:email, :confirmed, user: user, email: 'public2@example.com') + user.update!(public_email: public_email.email) + user + end + let!(:project) { create(:project) } let(:milestone) { create(:milestone, project_id: project.id, title: 'v1.0') } diff --git a/spec/features/ics/group_issues_spec.rb b/spec/features/ics/group_issues_spec.rb index 05caca4b5a8..f29c39ad4ef 100644 --- a/spec/features/ics/group_issues_spec.rb +++ b/spec/features/ics/group_issues_spec.rb @@ -4,8 +4,20 @@ require 'spec_helper' RSpec.describe 'Group Issues Calendar Feed' do describe 'GET /issues' do - let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') } - let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } + let!(:user) do + user = create(:user, email: 'private1@example.com') + public_email = create(:email, :confirmed, user: user, email: 'public1@example.com') + user.update!(public_email: public_email.email) + user + end + + let!(:assignee) do + user = create(:user, email: 'private2@example.com') + public_email = create(:email, :confirmed, user: user, email: 'public2@example.com') + user.update!(public_email: public_email.email) + user + end + let!(:group) { create(:group) } let!(:project) { create(:project, group: group) } diff --git a/spec/features/ics/project_issues_spec.rb b/spec/features/ics/project_issues_spec.rb index 58a1a32eac2..771748060bb 100644 --- a/spec/features/ics/project_issues_spec.rb +++ b/spec/features/ics/project_issues_spec.rb @@ -4,8 +4,20 @@ require 'spec_helper' RSpec.describe 'Project Issues Calendar Feed' do describe 'GET /issues' do - let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') } - let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } + let!(:user) do + user = create(:user, email: 'private1@example.com') + public_email = create(:email, :confirmed, user: user, email: 'public1@example.com') + user.update!(public_email: public_email.email) + user + end + + let!(:assignee) do + user = create(:user, email: 'private2@example.com') + public_email = create(:email, :confirmed, user: user, email: 'public2@example.com') + user.update!(public_email: public_email.email) + user + end + let!(:project) { create(:project) } let!(:issue) { create(:issue, author: user, assignees: [assignee], project: project) } diff --git a/spec/features/incidents/user_views_incident_spec.rb b/spec/features/incidents/user_views_incident_spec.rb index b94ce3cd06f..244b66f7a9a 100644 --- a/spec/features/incidents/user_views_incident_spec.rb +++ b/spec/features/incidents/user_views_incident_spec.rb @@ -25,7 +25,7 @@ RSpec.describe "User views incident" do it 'shows the merge request and incident actions', :js, :aggregate_failures do click_button 'Incident actions' - expect(page).to have_link('New incident', href: new_project_issue_path(project, { issuable_template: 'incident', issue: { issue_type: 'incident' } })) + expect(page).to have_link('New incident', href: new_project_issue_path(project, { issuable_template: 'incident', issue: { issue_type: 'incident', description: "Related to \##{incident.iid}.\n\n" } })) expect(page).to have_button('Create merge request') expect(page).to have_button('Close incident') end diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb index d56bedd4852..87fb8955dcc 100644 --- a/spec/features/invites_spec.rb +++ b/spec/features/invites_spec.rb @@ -189,6 +189,16 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do end context 'email confirmation enabled' do + context 'when user is not valid in sign up form' do + let(:new_user) { build_stubbed(:user, first_name: '', last_name: '') } + + it 'fails sign up and redirects back to sign up', :aggregate_failures do + expect { fill_in_sign_up_form(new_user) }.not_to change { User.count } + expect(page).to have_content('prohibited this user from being saved') + expect(current_path).to eq(user_registration_path) + end + end + context 'with invite email acceptance', :snowplow do it 'tracks the accepted invite' do fill_in_sign_up_form(new_user) @@ -216,6 +226,20 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do end end + context 'with invite email acceptance for the invite_email_from experiment', :experiment do + let(:extra_params) do + { invite_type: Emails::Members::INITIAL_INVITE, experiment_name: 'invite_email_from' } + end + + it 'tracks the accepted invite' do + expect(experiment(:invite_email_from)).to track(:accepted) + .with_context(actor: group_invite) + .on_next_instance + + fill_in_sign_up_form(new_user) + end + end + it 'signs up and redirects to the group activity page with all the project/groups invitation automatically accepted' do fill_in_sign_up_form(new_user) fill_in_welcome_form diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb index 077c363f78b..507d427bf0b 100644 --- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb @@ -27,7 +27,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j it 'shows a button to resolve all threads by creating a new issue' do within('.line-resolve-all-container') do - expect(page).to have_selector resolve_all_discussions_link_selector( title: "Resolve all threads in new issue" ) + expect(page).to have_selector resolve_all_discussions_link_selector( title: "Create issue to resolve all threads" ) end end @@ -38,7 +38,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j it 'hides the link for creating a new issue' do expect(page).not_to have_selector resolve_all_discussions_link_selector - expect(page).not_to have_content "Resolve all threads in new issue" + expect(page).not_to have_content "Create issue to resolve all threads" end end @@ -62,7 +62,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j end it 'does not show a link to create a new issue' do - expect(page).not_to have_link 'Resolve all threads in new issue' + expect(page).not_to have_link 'Create issue to resolve all threads' end end @@ -77,14 +77,14 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j it 'has a link to resolve all threads by creating an issue' do page.within '.mr-widget-body' do - expect(page).to have_link 'Resolve all threads in new issue', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid) + expect(page).to have_link 'Create issue to resolve all threads', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid) end end context 'creating an issue for threads' do before do page.within '.mr-widget-body' do - page.click_link 'Resolve all threads in new issue', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid) + page.click_link 'Create issue to resolve all threads', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid) wait_for_all_requests end diff --git a/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb index 3ff8fc5ecca..0de15d3d304 100644 --- a/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'Resolve an open thread in a merge request by creating an issue', let!(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion } def resolve_discussion_selector - title = 'Resolve this thread in a new issue' + title = 'Create issue to resolve thread' url = new_project_issue_path(project, discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid) "a[title=\"#{title}\"][href=\"#{url}\"]" end diff --git a/spec/features/issues/csv_spec.rb b/spec/features/issues/csv_spec.rb index 51e0d54ca5e..b4c737495b4 100644 --- a/spec/features/issues/csv_spec.rb +++ b/spec/features/issues/csv_spec.rb @@ -44,7 +44,7 @@ RSpec.describe 'Issues csv', :js do request_csv expect(page).to have_content 'CSV export has started' - expect(page).to have_content "emailed to #{user.notification_email}" + expect(page).to have_content "emailed to #{user.notification_email_or_default}" end it 'includes a csv attachment', :sidekiq_might_not_need_inline do diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 88a7b890daa..edf3df7c16e 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -565,21 +565,18 @@ RSpec.describe 'Filter issues', :js do end it 'maintains filter' do - # Closed - find('.issues-state-filters [data-state="closed"]').click + click_link 'Closed' wait_for_requests expect(page).to have_selector('.issues-list .issue', count: 1) expect(page).to have_link(closed_issue.title) - # Opened - find('.issues-state-filters [data-state="opened"]').click + click_link 'Open' wait_for_requests expect(page).to have_selector('.issues-list .issue', count: 4) - # All - find('.issues-state-filters [data-state="all"]').click + click_link 'All' wait_for_requests expect(page).to have_selector('.issues-list .issue', count: 5) diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb index 1efcc329e32..60963d95ae5 100644 --- a/spec/features/issues/filtered_search/search_bar_spec.rb +++ b/spec/features/issues/filtered_search/search_bar_spec.rb @@ -89,7 +89,7 @@ RSpec.describe 'Search bar', :js do expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: original_size) end - it 'resets the dropdown filters', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/9985' do + it 'resets the dropdown filters' do filtered_search.click hint_offset = get_left_style(find('#js-dropdown-hint')['style']) @@ -103,7 +103,7 @@ RSpec.describe 'Search bar', :js do find('.filtered-search-box .clear-search').click filtered_search.click - expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 6) + expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', minimum: 6) expect(get_left_style(find('#js-dropdown-hint')['style'])).to eq(hint_offset) end end diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb index 644d7cc4611..2d8587d886f 100644 --- a/spec/features/issues/filtered_search/visual_tokens_spec.rb +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -16,9 +16,6 @@ RSpec.describe 'Visual tokens', :js do let(:filtered_search) { find('.filtered-search') } let(:filter_author_dropdown) { find("#js-dropdown-author .filter-dropdown") } - let(:filter_assignee_dropdown) { find("#js-dropdown-assignee .filter-dropdown") } - let(:filter_milestone_dropdown) { find("#js-dropdown-milestone .filter-dropdown") } - let(:filter_label_dropdown) { find("#js-dropdown-label .filter-dropdown") } def is_input_focused page.evaluate_script("document.activeElement.classList.contains('filtered-search')") diff --git a/spec/features/issues/issue_detail_spec.rb b/spec/features/issues/issue_detail_spec.rb index a942a1a44f6..531c3634b5e 100644 --- a/spec/features/issues/issue_detail_spec.rb +++ b/spec/features/issues/issue_detail_spec.rb @@ -32,6 +32,21 @@ RSpec.describe 'Issue Detail', :js do end end + context 'when issue description has emojis' do + let(:issue) { create(:issue, project: project, author: user, description: 'hello world :100:') } + + before do + sign_in(user) + visit project_issue_path(project, issue) + end + + it 'renders gl-emoji tag' do + page.within('.description') do + expect(page).to have_selector('gl-emoji', count: 1) + end + end + end + context 'when issue description has xss snippet' do before do issue.update!(description: '![xss" onload=alert(1);//](a)') diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index e198d9d4ebb..bd4be755a92 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -117,7 +117,7 @@ RSpec.describe 'Issue Sidebar' do page.within '.dropdown-menu-user' do expect(page).to have_link('Invite members') - expect(page).to have_selector('[data-track-event="click_invite_members"]') + expect(page).to have_selector('[data-track-action="click_invite_members"]') expect(page).to have_selector('[data-track-label="edit_assignee"]') end diff --git a/spec/features/issues/resource_label_events_spec.rb b/spec/features/issues/resource_label_events_spec.rb index 33edf2f0b63..e08410efc0b 100644 --- a/spec/features/issues/resource_label_events_spec.rb +++ b/spec/features/issues/resource_label_events_spec.rb @@ -38,7 +38,7 @@ RSpec.describe 'List issue resource label events', :js do click_on 'Edit' wait_for_requests - labels.each { |label| click_link label } + labels.each { |label| click_on label } send_keys(:escape) wait_for_requests diff --git a/spec/features/issues/rss_spec.rb b/spec/features/issues/rss_spec.rb index 6c4498ea711..b20502ecc25 100644 --- a/spec/features/issues/rss_spec.rb +++ b/spec/features/issues/rss_spec.rb @@ -3,21 +3,24 @@ require 'spec_helper' RSpec.describe 'Project Issues RSS' do - let!(:user) { create(:user) } - let(:group) { create(:group) } - let(:project) { create(:project, group: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } - let(:path) { project_issues_path(project) } + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let_it_be(:path) { project_issues_path(project) } + let_it_be(:issue) { create(:issue, project: project, assignees: [user]) } - before do - create(:issue, project: project, assignees: [user]) + before_all do group.add_developer(user) end context 'when signed in' do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } - before do + before_all do project.add_developer(user) + end + + before do sign_in(user) visit path end @@ -36,26 +39,6 @@ RSpec.describe 'Project Issues RSS' do end describe 'feeds' do - shared_examples 'updates atom feed link' do |type| - it "for #{type}" do - sign_in(user) - visit path - - link = find_link('Subscribe to RSS feed') - params = CGI.parse(URI.parse(link[:href]).query) - auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) - auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) - - expected = { - 'feed_token' => [user.feed_token], - 'assignee_id' => [user.id.to_s] - } - - expect(params).to include(expected) - expect(auto_discovery_params).to include(expected) - end - end - it_behaves_like 'updates atom feed link', :project do let(:path) { project_issues_path(project, assignee_id: user.id) } end diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb index e4bba706453..63c36a20adc 100644 --- a/spec/features/issues/user_edits_issue_spec.rb +++ b/spec/features/issues/user_edits_issue_spec.rb @@ -15,6 +15,7 @@ RSpec.describe "Issues > User edits issue", :js do context 'with authorized user' do before do + stub_feature_flags(labels_widget: false) project.add_developer(user) project_with_milestones.add_developer(user) sign_in(user) diff --git a/spec/features/issues/user_views_issue_spec.rb b/spec/features/issues/user_views_issue_spec.rb index 8792d76981f..31bf7649470 100644 --- a/spec/features/issues/user_views_issue_spec.rb +++ b/spec/features/issues/user_views_issue_spec.rb @@ -25,7 +25,7 @@ RSpec.describe "User views issue" do it 'shows the merge request and issue actions', :js, :aggregate_failures do click_button 'Issue actions' - expect(page).to have_link('New issue', href: new_project_issue_path(project)) + expect(page).to have_link('New issue', href: new_project_issue_path(project, { issue: { description: "Related to \##{issue.iid}.\n\n" } })) expect(page).to have_button('Create merge request') expect(page).to have_button('Close issue') end diff --git a/spec/features/issues/user_views_issues_spec.rb b/spec/features/issues/user_views_issues_spec.rb index 165f4b10cff..56afa7eb6ba 100644 --- a/spec/features/issues/user_views_issues_spec.rb +++ b/spec/features/issues/user_views_issues_spec.rb @@ -34,7 +34,7 @@ RSpec.describe "User views issues" do .and have_content(open_issue2.title) .and have_no_content(closed_issue.title) .and have_content(moved_open_issue.title) - .and have_no_selector(".js-new-board-list") + .and have_no_content('Create list') end it "opens issues by label" do @@ -65,7 +65,7 @@ RSpec.describe "User views issues" do .and have_no_content(open_issue1.title) .and have_no_content(open_issue2.title) .and have_no_content(moved_open_issue.title) - .and have_no_selector(".js-new-board-list") + .and have_no_content('Create list') end include_examples "opens issue from list" do @@ -87,7 +87,7 @@ RSpec.describe "User views issues" do .and have_content(open_issue2.title) .and have_content(moved_open_issue.title) .and have_no_content('CLOSED (MOVED)') - .and have_no_selector(".js-new-board-list") + .and have_no_content('Create list') end include_examples "opens issue from list" do diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb index fca5e946d0c..25c315f2d16 100644 --- a/spec/features/labels_hierarchy_spec.rb +++ b/spec/features/labels_hierarchy_spec.rb @@ -17,7 +17,7 @@ RSpec.describe 'Labels Hierarchy', :js do let!(:project_label_1) { create(:label, project: project_1, title: 'Label_4') } before do - stub_feature_flags(board_new_list: false) + stub_feature_flags(labels_widget: false) grandparent.add_owner(user) sign_in(user) @@ -215,44 +215,6 @@ RSpec.describe 'Labels Hierarchy', :js do end end - context 'issuable sidebar when graphql_board_lists FF disabled' do - let!(:issue) { create(:issue, project: project_1) } - - before do - stub_feature_flags(graphql_board_lists: false) - end - - context 'on project board issue sidebar' do - before do - project_1.add_developer(user) - board = create(:board, project: project_1) - - visit project_board_path(project_1, board) - - wait_for_requests - - find('.board-card').click - end - - it_behaves_like 'assigning labels from sidebar' - end - - context 'on group board issue sidebar' do - before do - parent.add_developer(user) - board = create(:board, group: parent) - - visit group_board_path(parent, board) - - wait_for_requests - - find('.board-card').click - end - - it_behaves_like 'assigning labels from sidebar' - end - end - context 'issuable filtering' do let!(:labeled_issue) { create(:labeled_issue, project: project_1, labels: [grandparent_group_label, parent_group_label, project_label_1]) } let!(:issue) { create(:issue, project: project_1) } @@ -307,88 +269,4 @@ RSpec.describe 'Labels Hierarchy', :js do it_behaves_like 'filtering by ancestor labels for groups', true end end - - context 'creating boards lists' do - before do - stub_feature_flags(board_new_list: false) - end - - context 'on project boards' do - let(:board) { create(:board, project: project_1) } - - before do - project_1.add_developer(user) - visit project_board_path(project_1, board) - find('.js-new-board-list').click - wait_for_requests - end - - it 'creates lists from all ancestor labels' do - [grandparent_group_label, parent_group_label, project_label_1].each do |label| - find('a', text: label.title).click - end - - wait_for_requests - - expect(page).to have_selector('.board-title-text', text: grandparent_group_label.title) - expect(page).to have_selector('.board-title-text', text: parent_group_label.title) - expect(page).to have_selector('.board-title-text', text: project_label_1.title) - end - end - - context 'on group boards' do - let(:board) { create(:board, group: parent) } - - before do - parent.add_developer(user) - visit group_board_path(parent, board) - find('.js-new-board-list').click - wait_for_requests - end - - context 'when graphql_board_lists FF enabled' do - it 'creates lists from all ancestor group labels' do - [grandparent_group_label, parent_group_label].each do |label| - find('a', text: label.title).click - end - - wait_for_requests - - expect(page).to have_selector('.board-title-text', text: grandparent_group_label.title) - expect(page).to have_selector('.board-title-text', text: parent_group_label.title) - end - - it 'does not create lists from descendant groups' do - expect(page).not_to have_selector('a', text: child_group_label.title) - end - end - end - - context 'when graphql_board_lists FF disabled' do - let(:board) { create(:board, group: parent) } - - before do - stub_feature_flags(graphql_board_lists: false) - parent.add_developer(user) - visit group_board_path(parent, board) - find('.js-new-board-list').click - wait_for_requests - end - - it 'creates lists from all ancestor group labels' do - [grandparent_group_label, parent_group_label].each do |label| - find('a', text: label.title).click - end - - wait_for_requests - - expect(page).to have_selector('.board-title-text', text: grandparent_group_label.title) - expect(page).to have_selector('.board-title-text', text: parent_group_label.title) - end - - it 'does not create lists from descendant groups' do - expect(page).not_to have_selector('a', text: child_group_label.title) - end - end - end end diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb index c646698219b..f695b225915 100644 --- a/spec/features/merge_request/batch_comments_spec.rb +++ b/spec/features/merge_request/batch_comments_spec.rb @@ -25,10 +25,6 @@ RSpec.describe 'Merge request > Batch comments', :js do visit_diffs end - it 'has review bar' do - expect(page).to have_selector('[data-testid="review_bar_component"]', visible: false) - end - it 'adds draft note' do write_diff_comment diff --git a/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb b/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb index 45ee914de9d..caf0c609f64 100644 --- a/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb +++ b/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb @@ -26,7 +26,7 @@ RSpec.describe 'Merge request > User edits reviewers sidebar', :js do page.within '.dropdown-menu-user' do expect(page).to have_link('Invite Members') - expect(page).to have_selector('[data-track-event="click_invite_members"]') + expect(page).to have_selector('[data-track-action="click_invite_members"]') expect(page).to have_selector('[data-track-label="edit_reviewer"]') end diff --git a/spec/features/merge_requests/rss_spec.rb b/spec/features/merge_requests/rss_spec.rb new file mode 100644 index 00000000000..9fc3d3d6ae1 --- /dev/null +++ b/spec/features/merge_requests/rss_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Project Merge Requests RSS' do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository, group: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let_it_be(:merge_request) { create(:merge_request, source_project: project, assignees: [user]) } + let_it_be(:path) { project_merge_requests_path(project) } + + before_all do + group.add_developer(user) + end + + context 'when signed in' do + let_it_be(:user) { create(:user) } + + before_all do + project.add_developer(user) + end + + before do + sign_in(user) + visit path + end + + it_behaves_like "it has an RSS button with current_user's feed token" + it_behaves_like "an autodiscoverable RSS feed with current_user's feed token" + end + + context 'when signed out' do + before do + visit path + end + + it_behaves_like "it has an RSS button without a feed token" + it_behaves_like "an autodiscoverable RSS feed without a feed token" + end + + describe 'feeds' do + it_behaves_like 'updates atom feed link', :project do + let(:path) { project_merge_requests_path(project, assignee_id: user.id) } + end + end +end diff --git a/spec/features/merge_requests/user_sees_empty_state_spec.rb b/spec/features/merge_requests/user_sees_empty_state_spec.rb index ac07b31731d..056da53c47b 100644 --- a/spec/features/merge_requests/user_sees_empty_state_spec.rb +++ b/spec/features/merge_requests/user_sees_empty_state_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe 'Merge request > User sees empty state' do + include ProjectForksHelper + let(:project) { create(:project, :public, :repository) } let(:user) { project.creator } @@ -37,4 +39,23 @@ RSpec.describe 'Merge request > User sees empty state' do expect(page).to have_content('To widen your search, change or remove filters above') end end + + context 'as member of a fork' do + let(:fork_user) { create(:user) } + let(:forked_project) { fork_project(project, fork_user, namespace: fork_user.namespace, repository: true) } + + before do + forked_project.add_maintainer(fork_user) + sign_in(fork_user) + end + + it 'shows an empty state and a "New merge request" button' do + visit project_merge_requests_path(project, search: 'foo') + + expect(page).to have_selector('.empty-state') + within('.empty-state') do + expect(page).to have_link 'New merge request', href: project_new_merge_request_path(forked_project) + end + end + end end diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index de511e99182..8025db9f86d 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -56,7 +56,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do click_on "Create personal access token" expect(active_personal_access_tokens).to have_text(name) - expect(active_personal_access_tokens).to have_text('In') + expect(active_personal_access_tokens).to have_text('in') expect(active_personal_access_tokens).to have_text('api') expect(active_personal_access_tokens).to have_text('read_user') expect(created_personal_access_token).not_to be_empty @@ -85,6 +85,18 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do expect(active_personal_access_tokens).to have_text(personal_access_token.name) expect(active_personal_access_tokens).not_to have_text(impersonation_token.name) end + + context 'when User#time_display_relative is false' do + before do + user.update!(time_display_relative: false) + end + + it 'shows absolute times for expires_at' do + visit profile_personal_access_tokens_path + + expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %d')) + end + end end describe "inactive tokens" do diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index d941988d12f..af085b63155 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -32,7 +32,7 @@ RSpec.describe 'User edit profile' do fill_in 'user_skype', with: 'testskype' fill_in 'user_linkedin', with: 'testlinkedin' fill_in 'user_twitter', with: 'testtwitter' - fill_in 'user_website_url', with: 'testurl' + fill_in 'user_website_url', with: 'http://testurl.com' fill_in 'user_location', with: 'Ukraine' fill_in 'user_bio', with: 'I <3 GitLab :tada:' fill_in 'user_job_title', with: 'Frontend Engineer' @@ -43,9 +43,8 @@ RSpec.describe 'User edit profile' do skype: 'testskype', linkedin: 'testlinkedin', twitter: 'testtwitter', - website_url: 'testurl', + website_url: 'http://testurl.com', bio: 'I <3 GitLab :tada:', - bio_html: '<p data-sourcepos="1:1-1:18" dir="auto">I <3 GitLab <gl-emoji title="party popper" data-name="tada" data-unicode-version="6.0">🎉</gl-emoji></p>', job_title: 'Frontend Engineer', organization: 'GitLab' ) @@ -54,6 +53,19 @@ RSpec.describe 'User edit profile' do expect(page).to have_content('Profile was successfully updated') end + it 'does not set secondary emails without user input' do + fill_in 'user_organization', with: 'GitLab' + submit_settings + + user.reload + expect(page).to have_field('user_commit_email', with: '') + expect(page).to have_field('user_public_email', with: '') + + User::SECONDARY_EMAIL_ATTRIBUTES.each do |attribute| + expect(user.read_attribute(attribute)).to be_blank + end + end + it 'shows an error if the full name contains an emoji', :js do simulate_input('#user_name', 'Martin 😀') submit_settings @@ -65,6 +77,17 @@ RSpec.describe 'User edit profile' do end end + it 'shows an error if the website url is not valid' do + fill_in 'user_website_url', with: 'admin@gitlab.com' + submit_settings + + expect(user.reload).to have_attributes( + website_url: '' + ) + + expect(page).to have_content('Website url is not a valid URL') + end + describe 'when I change my email' do before do user.send_reset_password_instructions diff --git a/spec/features/projects/ci/editor_spec.rb b/spec/features/projects/ci/editor_spec.rb index 192bccd6f6e..7fe1c63f490 100644 --- a/spec/features/projects/ci/editor_spec.rb +++ b/spec/features/projects/ci/editor_spec.rb @@ -27,10 +27,6 @@ RSpec.describe 'Pipeline Editor', :js do end context 'branch switcher' do - before do - stub_feature_flags(pipeline_editor_branch_switcher: true) - end - def switch_to_branch(branch) find('[data-testid="branch-selector"]').click diff --git a/spec/features/projects/commits/user_browses_commits_spec.rb b/spec/features/projects/commits/user_browses_commits_spec.rb index 76162fb800a..863fdbdadaa 100644 --- a/spec/features/projects/commits/user_browses_commits_spec.rb +++ b/spec/features/projects/commits/user_browses_commits_spec.rb @@ -12,7 +12,7 @@ RSpec.describe 'User browses commits' do sign_in(user) end - it 'renders commit' do + it 'renders commit', :js do visit project_commit_path(project, sample_commit.id) expect(page).to have_content(sample_commit.message.gsub(/\s+/, ' ')) @@ -103,7 +103,7 @@ RSpec.describe 'User browses commits' do context 'when the blob does not exist' do let(:commit) { create(:commit, project: project) } - it 'renders successfully' do + it 'renders successfully', :js do allow_next_instance_of(Gitlab::Diff::File) do |instance| allow(instance).to receive(:blob).and_return(nil) end @@ -113,7 +113,9 @@ RSpec.describe 'User browses commits' do visit(project_commit_path(project, commit)) - expect(find('.diff-file-changes', visible: false)).to have_content('files/ruby/popen.rb') + click_button '2 changed files' + + expect(find('[data-testid="diff-stats-dropdown"]')).to have_content('files/ruby/popen.rb') end end diff --git a/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb b/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb index f6330491886..71c9d89fbde 100644 --- a/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb +++ b/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb @@ -66,20 +66,4 @@ RSpec.describe 'User updates feature flag', :js do end end end - - context 'with a legacy feature flag' do - let!(:feature_flag) do - create_flag(project, 'ci_live_trace', true, - description: 'For live trace feature', - version: :legacy_flag) - end - - let!(:scope) { create_scope(feature_flag, 'review/*', true) } - - it 'shows not found error' do - visit(edit_project_feature_flag_path(project, feature_flag)) - - expect(page).to have_text 'Page Not Found' - end - end end diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 302187917b7..00e85a215b8 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -10,6 +10,7 @@ RSpec.describe 'Import/Export - project import integration test', :js do let(:export_path) { "#{Dir.tmpdir}/import_file_spec" } before do + stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false) stub_uploads_object_storage(FileUploader) allow_next_instance_of(Gitlab::ImportExport) do |instance| allow(instance).to receive(:storage_path).and_return(export_path) diff --git a/spec/features/projects/jobs/permissions_spec.rb b/spec/features/projects/jobs/permissions_spec.rb index 140d5dee270..a904ba770dd 100644 --- a/spec/features/projects/jobs/permissions_spec.rb +++ b/spec/features/projects/jobs/permissions_spec.rb @@ -90,7 +90,7 @@ RSpec.describe 'Project Jobs Permissions' do it_behaves_like 'recent job page details responds with status', 200 do it 'renders job details', :js do - expect(page).to have_content "Job ##{job.id}" + expect(page).to have_content "Job #{job.name}" expect(page).to have_css '.log-line' end end diff --git a/spec/features/projects/jobs/user_browses_job_spec.rb b/spec/features/projects/jobs/user_browses_job_spec.rb index 9b199157d79..060b7ffbfc9 100644 --- a/spec/features/projects/jobs/user_browses_job_spec.rb +++ b/spec/features/projects/jobs/user_browses_job_spec.rb @@ -21,7 +21,7 @@ RSpec.describe 'User browses a job', :js do it 'erases the job log', :js do wait_for_requests - expect(page).to have_content("Job ##{build.id}") + expect(page).to have_content("Job #{build.name}") expect(page).to have_css('.job-log') # scroll to the top of the page first diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index 94543290050..113ba692497 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -23,7 +23,7 @@ RSpec.describe 'Projects > Members > User requests access', :js do it 'user can request access to a project' do perform_enqueued_jobs { click_link 'Request Access' } - expect(ActionMailer::Base.deliveries.last.to).to eq [maintainer.notification_email] + expect(ActionMailer::Base.deliveries.last.to).to eq [maintainer.notification_email_or_default] expect(ActionMailer::Base.deliveries.last.subject).to eq "Request to join the #{project.full_name} project" expect(project.requesters.exists?(user_id: user)).to be_truthy diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 0b293970703..39f9d3b331b 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -6,6 +6,10 @@ RSpec.describe 'New project', :js do include Select2Helper include Spec::Support::Helpers::Features::TopNavSpecHelpers + before do + stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false) + end + context 'as a user' do let(:user) { create(:user) } diff --git a/spec/features/projects/package_files_spec.rb b/spec/features/projects/package_files_spec.rb index c5c03396d71..6dc0294bb9e 100644 --- a/spec/features/projects/package_files_spec.rb +++ b/spec/features/projects/package_files_spec.rb @@ -23,20 +23,6 @@ RSpec.describe 'PackageFiles' do expect(status_code).to eq(200) end - context 'when package_details_apollo feature flag is off' do - before do - stub_feature_flags(package_details_apollo: false) - end - - it 'renders the download link with the correct url', :js do - visit project_package_path(project, package) - - download_url = download_project_package_file_path(project, package_file) - - expect(page).to have_link(package_file.file_name, href: download_url) - end - end - it 'does not allow download of package belonging to different project' do another_package = create(:maven_package) another_file = another_package.package_files.first diff --git a/spec/features/projects/packages_spec.rb b/spec/features/projects/packages_spec.rb index 30298f79312..7fcc8200b1c 100644 --- a/spec/features/projects/packages_spec.rb +++ b/spec/features/projects/packages_spec.rb @@ -37,14 +37,6 @@ RSpec.describe 'Packages' do it_behaves_like 'packages list' - context 'when package_details_apollo feature flag is off' do - before do - stub_feature_flags(package_details_apollo: false) - end - - it_behaves_like 'package details link' - end - it_behaves_like 'package details link' context 'deleting a package' do diff --git a/spec/features/projects/services/user_activates_slack_slash_command_spec.rb b/spec/features/projects/services/user_activates_slack_slash_command_spec.rb index 4dfd4416eeb..bc84ccaa432 100644 --- a/spec/features/projects/services/user_activates_slack_slash_command_spec.rb +++ b/spec/features/projects/services/user_activates_slack_slash_command_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'Slack slash commands', :js do end it 'shows a help message' do - expect(page).to have_content('This service allows users to perform common') + expect(page).to have_content('Perform common operations in this project') end it 'redirects to the integrations page after saving but not activating' do @@ -42,6 +42,6 @@ RSpec.describe 'Slack slash commands', :js do end it 'shows help content' do - expect(page).to have_content('This service allows users to perform common operations on this project by entering slash commands in Slack.') + expect(page).to have_content('Perform common operations in this project by entering slash commands in Slack.') end end diff --git a/spec/features/projects/settings/access_tokens_spec.rb b/spec/features/projects/settings/access_tokens_spec.rb index 33e2623522e..deeab084c5f 100644 --- a/spec/features/projects/settings/access_tokens_spec.rb +++ b/spec/features/projects/settings/access_tokens_spec.rb @@ -65,7 +65,7 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do click_on 'Create project access token' expect(active_project_access_tokens).to have_text(name) - expect(active_project_access_tokens).to have_text('In') + expect(active_project_access_tokens).to have_text('in') expect(active_project_access_tokens).to have_text('api') expect(active_project_access_tokens).to have_text('read_api') expect(active_project_access_tokens).to have_text('Maintainer') @@ -156,6 +156,18 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do expect(active_project_access_tokens).to have_text(project_access_token.name) end + + context 'when User#time_display_relative is false' do + before do + user.update!(time_display_relative: false) + end + + it 'shows absolute times for expires_at' do + visit project_settings_access_tokens_path(project) + + expect(active_project_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %d')) + end + end end describe 'inactive tokens' do diff --git a/spec/features/projects/settings/monitor_settings_spec.rb b/spec/features/projects/settings/monitor_settings_spec.rb index 2d8c418b7d0..e3d75c30e5e 100644 --- a/spec/features/projects/settings/monitor_settings_spec.rb +++ b/spec/features/projects/settings/monitor_settings_spec.rb @@ -150,6 +150,33 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do assert_text('Connection failed. Check Auth Token and try again.') end end + + context 'integrated error tracking backend' do + it 'successfully fills and submits the form' do + visit project_settings_operations_path(project) + + wait_for_requests + + within '.js-error-tracking-settings' do + click_button('Expand') + end + + expect(page).to have_content('Error tracking backend') + + within '.js-error-tracking-settings' do + check('Active') + choose('GitLab') + end + + expect(page).not_to have_content('Sentry API URL') + + click_button('Save changes') + + wait_for_requests + + assert_text('Your changes have been saved') + end + end end context 'grafana integration settings form' do diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb index f420a8a76b9..4e1b55d3d70 100644 --- a/spec/features/projects/settings/repository_settings_spec.rb +++ b/spec/features/projects/settings/repository_settings_spec.rb @@ -31,11 +31,11 @@ RSpec.describe 'Projects > Settings > Repository settings' do before do stub_container_registry_config(enabled: true) stub_feature_flags(ajax_new_deploy_token: project) - visit project_settings_repository_path(project) end it_behaves_like 'a deploy token in settings' do let(:entity_type) { 'project' } + let(:page_path) { project_settings_repository_path(project) } end end diff --git a/spec/features/projects/settings/service_desk_setting_spec.rb b/spec/features/projects/settings/service_desk_setting_spec.rb index 91355d8f625..0924f8320e1 100644 --- a/spec/features/projects/settings/service_desk_setting_spec.rb +++ b/spec/features/projects/settings/service_desk_setting_spec.rb @@ -38,7 +38,6 @@ RSpec.describe 'Service Desk Setting', :js, :clean_gitlab_redis_cache do expect(project.service_desk_enabled).to be_truthy expect(project.service_desk_address).to be_present expect(find('[data-testid="incoming-email"]').value).to eq(project.service_desk_incoming_address) - expect(page).not_to have_selector('#service-desk-project-suffix') end end diff --git a/spec/features/projects/settings/user_manages_project_members_spec.rb b/spec/features/projects/settings/user_manages_project_members_spec.rb index be4b6d6b82d..02a634a0fcc 100644 --- a/spec/features/projects/settings/user_manages_project_members_spec.rb +++ b/spec/features/projects/settings/user_manages_project_members_spec.rb @@ -43,10 +43,15 @@ RSpec.describe 'Projects > Settings > User manages project members' do visit(project_project_members_path(project)) - click_link('Import a project') + click_on 'Import from a project' + click_on 'Select a project' + wait_for_requests - select2(project2.id, from: '#source_project_id') - click_button('Import project members') + click_button project2.name + click_button 'Import project members' + wait_for_requests + + page.refresh expect(find_member_row(user_mike)).to have_content('Reporter') end diff --git a/spec/features/projects/user_creates_project_spec.rb b/spec/features/projects/user_creates_project_spec.rb index 2dc2f168896..9f08759603e 100644 --- a/spec/features/projects/user_creates_project_spec.rb +++ b/spec/features/projects/user_creates_project_spec.rb @@ -6,6 +6,7 @@ RSpec.describe 'User creates a project', :js do let(:user) { create(:user) } before do + stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false) sign_in(user) create(:personal_key, user: user) end diff --git a/spec/features/registrations/experience_level_spec.rb b/spec/features/registrations/experience_level_spec.rb deleted file mode 100644 index f432215d4a8..00000000000 --- a/spec/features/registrations/experience_level_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Experience level screen' do - let_it_be(:user) { create(:user, :unconfirmed) } - let_it_be(:group) { create(:group) } - - before do - group.add_owner(user) - gitlab_sign_in(user) - visit users_sign_up_experience_level_path(namespace_path: group.to_param) - end - - subject { page } - - it 'shows the intro content' do - is_expected.to have_content('Hello there') - is_expected.to have_content('Welcome to the guided GitLab tour') - is_expected.to have_content('What describes you best?') - end - - it 'shows the option for novice' do - is_expected.to have_content('Novice') - is_expected.to have_content('I’m not familiar with the basics of DevOps') - is_expected.to have_content('Show me the basics') - end - - it 'shows the option for experienced' do - is_expected.to have_content('Experienced') - is_expected.to have_content('I’m familiar with the basics of DevOps') - is_expected.to have_content('Show me advanced features') - end - - it 'does not display any flash messages' do - is_expected.not_to have_selector('.flash-container') - is_expected.not_to have_content("Please check your email (#{user.email}) to verify that you own this address and unlock the power of CI/CD") - end - - it 'does not include the footer links' do - is_expected.not_to have_link('Help') - is_expected.not_to have_link('About GitLab') - end -end diff --git a/spec/features/users/anonymous_sessions_spec.rb b/spec/features/users/anonymous_sessions_spec.rb index 273d3aa346f..6b21412ae3d 100644 --- a/spec/features/users/anonymous_sessions_spec.rb +++ b/spec/features/users/anonymous_sessions_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe 'Session TTLs', :clean_gitlab_redis_shared_state do + include SessionHelpers + it 'creates a session with a short TTL when login fails' do visit new_user_session_path # The session key only gets created after a post @@ -12,7 +14,7 @@ RSpec.describe 'Session TTLs', :clean_gitlab_redis_shared_state do expect(page).to have_content('Invalid login or password') - expect_single_session_with_expiration(Settings.gitlab['unauthenticated_session_expire_delay']) + expect_single_session_with_short_ttl end it 'increases the TTL when the login succeeds' do @@ -21,21 +23,17 @@ RSpec.describe 'Session TTLs', :clean_gitlab_redis_shared_state do expect(page).to have_content(user.name) - expect_single_session_with_expiration(Settings.gitlab['session_expire_delay'] * 60) + expect_single_session_with_authenticated_ttl end - def expect_single_session_with_expiration(expiration) - session_keys = get_session_keys - - expect(session_keys.size).to eq(1) - expect(get_ttl(session_keys.first)).to eq expiration - end + context 'with an unauthorized project' do + let_it_be(:project) { create(:project, :repository) } - def get_session_keys - Gitlab::Redis::SharedState.with { |redis| redis.scan_each(match: 'session:gitlab:*').to_a } - end + it 'creates a session with a short TTL' do + visit project_raw_path(project, 'master/README.md') - def get_ttl(key) - Gitlab::Redis::SharedState.with { |redis| redis.ttl(key) } + expect_single_session_with_short_ttl + expect(page).to have_current_path(new_user_session_path) + end end end diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index 6c38d5d8b24..afd750d02eb 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -2,9 +2,10 @@ require 'spec_helper' -RSpec.describe 'Login' do +RSpec.describe 'Login', :clean_gitlab_redis_shared_state do include TermsHelper include UserLoginHelper + include SessionHelpers before do stub_authentication_activity_metrics(debug: true) @@ -59,6 +60,7 @@ RSpec.describe 'Login' do fill_in 'user_password', with: 'password' click_button 'Sign in' + expect_single_session_with_authenticated_ttl expect(current_path).to eq root_path end @@ -192,6 +194,7 @@ RSpec.describe 'Login' do enter_code(user.current_otp) expect(page).not_to have_content(I18n.t('devise.failure.already_authenticated')) + expect_single_session_with_authenticated_ttl end it 'does not allow sign-in if the user password is updated before entering a one-time code' do @@ -210,6 +213,7 @@ RSpec.describe 'Login' do enter_code(user.current_otp) + expect_single_session_with_authenticated_ttl expect(current_path).to eq root_path end @@ -237,6 +241,8 @@ RSpec.describe 'Login' do expect(page).to have_content('Invalid two-factor code') enter_code(user.current_otp) + + expect_single_session_with_authenticated_ttl expect(current_path).to eq root_path end @@ -353,6 +359,7 @@ RSpec.describe 'Login' do sign_in_using_saml! + expect_single_session_with_authenticated_ttl expect(page).not_to have_content('Two-Factor Authentication') expect(current_path).to eq root_path end @@ -371,6 +378,7 @@ RSpec.describe 'Login' do enter_code(user.current_otp) + expect_single_session_with_authenticated_ttl expect(current_path).to eq root_path end end @@ -391,6 +399,7 @@ RSpec.describe 'Login' do gitlab_sign_in(user) + expect_single_session_with_authenticated_ttl expect(current_path).to eq root_path expect(page).not_to have_content(I18n.t('devise.failure.already_authenticated')) end @@ -402,6 +411,7 @@ RSpec.describe 'Login' do gitlab_sign_in(user) visit new_user_session_path + expect_single_session_with_authenticated_ttl expect(page).not_to have_content(I18n.t('devise.failure.already_authenticated')) end @@ -443,6 +453,7 @@ RSpec.describe 'Login' do gitlab_sign_in(user) + expect_single_session_with_short_ttl expect(page).to have_content('Invalid login or password.') end end diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb index fb2873f1c96..e629d329033 100644 --- a/spec/features/users/show_spec.rb +++ b/spec/features/users/show_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'User page' do include ExternalAuthorizationServiceHelpers - let_it_be(:user) { create(:user, bio: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') } + let_it_be(:user) { create(:user, bio: '<b>Lorem</b> <i>ipsum</i> dolor sit <a href="https://example.com">amet</a>') } subject(:visit_profile) { visit(user_path(user)) } @@ -186,7 +186,17 @@ RSpec.describe 'User page' do end context 'with blocked profile' do - let_it_be(:user) { create(:user, state: :blocked) } + let_it_be(:user) do + create( + :user, + state: :blocked, + organization: 'GitLab - work info test', + job_title: 'Frontend Engineer', + pronunciation: 'pruh-nuhn-see-ay-shn' + ) + end + + let_it_be(:status) { create(:user_status, user: user, message: "Working hard!") } it 'shows no tab' do subject @@ -211,7 +221,10 @@ RSpec.describe 'User page' do subject expect(page).not_to have_css(".profile-user-bio") - expect(page).not_to have_css(".profile-link-holder") + expect(page).not_to have_content('GitLab - work info test') + expect(page).not_to have_content('Frontend Engineer') + expect(page).not_to have_content('Working hard!') + expect(page).not_to have_content("Pronounced as: pruh-nuhn-see-ay-shn") end it 'shows username' do @@ -222,7 +235,17 @@ RSpec.describe 'User page' do end context 'with unconfirmed user' do - let_it_be(:user) { create(:user, :unconfirmed) } + let_it_be(:user) do + create( + :user, + :unconfirmed, + organization: 'GitLab - work info test', + job_title: 'Frontend Engineer', + pronunciation: 'pruh-nuhn-see-ay-shn' + ) + end + + let_it_be(:status) { create(:user_status, user: user, message: "Working hard!") } shared_examples 'unconfirmed user profile' do before do @@ -240,7 +263,10 @@ RSpec.describe 'User page' do it 'shows no additional fields' do expect(page).not_to have_css(".profile-user-bio") - expect(page).not_to have_css(".profile-link-holder") + expect(page).not_to have_content('GitLab - work info test') + expect(page).not_to have_content('Frontend Engineer') + expect(page).not_to have_content('Working hard!') + expect(page).not_to have_content("Pronounced as: pruh-nuhn-see-ay-shn") end it 'shows private profile message' do @@ -403,4 +429,27 @@ RSpec.describe 'User page' do end end end + + context 'GPG keys' do + context 'when user has verified GPG keys' do + let_it_be(:user) { create(:user, email: GpgHelpers::User1.emails.first) } + let_it_be(:gpg_key) { create(:gpg_key, user: user, key: GpgHelpers::User1.public_key) } + let_it_be(:gpg_key2) { create(:gpg_key, user: user, key: GpgHelpers::User1.public_key2) } + + it 'shows link to public GPG keys' do + subject + + expect(page).to have_link('View public GPG keys', href: user_gpg_keys_path(user)) + end + end + + context 'when user does not have verified GPG keys' do + it 'does not show link to public GPG keys' do + subject + + expect(page).not_to have_link('View public GPG key', href: user_gpg_keys_path(user)) + expect(page).not_to have_link('View public GPG keys', href: user_gpg_keys_path(user)) + end + end + end end diff --git a/spec/finders/branches_finder_spec.rb b/spec/finders/branches_finder_spec.rb index a62dd3842db..f9d525c33a4 100644 --- a/spec/finders/branches_finder_spec.rb +++ b/spec/finders/branches_finder_spec.rb @@ -259,4 +259,11 @@ RSpec.describe BranchesFinder do end end end + + describe '#total' do + subject { branch_finder.total } + + it { is_expected.to be_an(Integer) } + it { is_expected.to eq(repository.branch_count) } + end end diff --git a/spec/finders/ci/pipelines_finder_spec.rb b/spec/finders/ci/pipelines_finder_spec.rb index c7bd52576e8..908210e0296 100644 --- a/spec/finders/ci/pipelines_finder_spec.rb +++ b/spec/finders/ci/pipelines_finder_spec.rb @@ -113,27 +113,6 @@ RSpec.describe Ci::PipelinesFinder do end end - context 'when name is specified' do - let(:user) { create(:user) } - let!(:pipeline) { create(:ci_pipeline, project: project, user: user) } - - context 'when name exists' do - let(:params) { { name: user.name } } - - it 'returns matched pipelines' do - is_expected.to eq([pipeline]) - end - end - - context 'when name does not exist' do - let(:params) { { name: 'invalid-name' } } - - it 'returns empty' do - is_expected.to be_empty - end - end - end - context 'when username is specified' do let(:user) { create(:user) } let!(:pipeline) { create(:ci_pipeline, project: project, user: user) } @@ -258,20 +237,8 @@ RSpec.describe Ci::PipelinesFinder do let!(:push_pipeline) { create(:ci_pipeline, project: project, source: 'push') } let!(:api_pipeline) { create(:ci_pipeline, project: project, source: 'api') } - context 'when `pipeline_source_filter` feature flag is disabled' do - before do - stub_feature_flags(pipeline_source_filter: false) - end - - it 'returns all the pipelines' do - is_expected.to contain_exactly(web_pipeline, push_pipeline, api_pipeline) - end - end - - context 'when `pipeline_source_filter` feature flag is enabled' do - it 'returns only the matched pipeline' do - is_expected.to eq([web_pipeline]) - end + it 'returns only the matched pipeline' do + is_expected.to eq([web_pipeline]) end end diff --git a/spec/finders/ci/runners_finder_spec.rb b/spec/finders/ci/runners_finder_spec.rb index 599b4ffb804..10d3f641e02 100644 --- a/spec/finders/ci/runners_finder_spec.rb +++ b/spec/finders/ci/runners_finder_spec.rb @@ -18,6 +18,13 @@ RSpec.describe Ci::RunnersFinder do end end + context 'with nil group' do + it 'returns all runners' do + expect(Ci::Runner).to receive(:with_tags).and_call_original + expect(described_class.new(current_user: admin, params: { group: nil }).execute).to match_array [runner1, runner2] + end + end + context 'with preload param set to :tag_name true' do it 'requests tags' do expect(Ci::Runner).to receive(:with_tags).and_call_original @@ -158,6 +165,7 @@ RSpec.describe Ci::RunnersFinder do let_it_be(:project_4) { create(:project, group: sub_group_2) } let_it_be(:project_5) { create(:project, group: sub_group_3) } let_it_be(:project_6) { create(:project, group: sub_group_4) } + let_it_be(:runner_instance) { create(:ci_runner, :instance, contacted_at: 13.minutes.ago) } let_it_be(:runner_group) { create(:ci_runner, :group, contacted_at: 12.minutes.ago) } let_it_be(:runner_sub_group_1) { create(:ci_runner, :group, active: false, contacted_at: 11.minutes.ago) } let_it_be(:runner_sub_group_2) { create(:ci_runner, :group, contacted_at: 10.minutes.ago) } @@ -171,7 +179,10 @@ RSpec.describe Ci::RunnersFinder do let_it_be(:runner_project_6) { create(:ci_runner, :project, contacted_at: 2.minutes.ago, projects: [project_5])} let_it_be(:runner_project_7) { create(:ci_runner, :project, contacted_at: 1.minute.ago, projects: [project_6])} - let(:params) { {} } + let(:target_group) { nil } + let(:membership) { nil } + let(:extra_params) { {} } + let(:params) { { group: target_group, membership: membership }.merge(extra_params).reject { |_, v| v.nil? } } before do group.runners << runner_group @@ -182,65 +193,104 @@ RSpec.describe Ci::RunnersFinder do end describe '#execute' do - subject { described_class.new(current_user: user, group: group, params: params).execute } + subject { described_class.new(current_user: user, params: params).execute } + + shared_examples 'membership equal to :descendants' do + it 'returns all descendant runners' do + expect(subject).to eq([runner_project_7, runner_project_6, runner_project_5, + runner_project_4, runner_project_3, runner_project_2, + runner_project_1, runner_sub_group_4, runner_sub_group_3, + runner_sub_group_2, runner_sub_group_1, runner_group]) + end + end context 'with user as group owner' do before do group.add_owner(user) end - context 'passing no params' do - it 'returns all descendant runners' do - expect(subject).to eq([runner_project_7, runner_project_6, runner_project_5, - runner_project_4, runner_project_3, runner_project_2, - runner_project_1, runner_sub_group_4, runner_sub_group_3, - runner_sub_group_2, runner_sub_group_1, runner_group]) + context 'with :group as target group' do + let(:target_group) { group } + + context 'passing no params' do + it_behaves_like 'membership equal to :descendants' end - end - context 'with sort param' do - let(:params) { { sort: 'contacted_asc' } } + context 'with :descendants membership' do + let(:membership) { :descendants } - it 'sorts by specified attribute' do - expect(subject).to eq([runner_group, runner_sub_group_1, runner_sub_group_2, - runner_sub_group_3, runner_sub_group_4, runner_project_1, - runner_project_2, runner_project_3, runner_project_4, - runner_project_5, runner_project_6, runner_project_7]) + it_behaves_like 'membership equal to :descendants' end - end - context 'filtering' do - context 'by search term' do - let(:params) { { search: 'runner_project_search' } } + context 'with :direct membership' do + let(:membership) { :direct } + + it 'returns runners belonging to group' do + expect(subject).to eq([runner_group]) + end + end + + context 'with unknown membership' do + let(:membership) { :unsupported } - it 'returns correct runner' do - expect(subject).to eq([runner_project_3]) + it 'raises an error' do + expect { subject }.to raise_error(ArgumentError, 'Invalid membership filter') end end - context 'by status' do - let(:params) { { status_status: 'paused' } } + context 'with nil group' do + let(:target_group) { nil } - it 'returns correct runner' do - expect(subject).to eq([runner_sub_group_1]) + it 'returns no runners' do + # Query should run against all runners, however since user is not admin, query returns no results + expect(subject).to eq([]) end end - context 'by tag_name' do - let(:params) { { tag_name: %w[runner_tag] } } + context 'with sort param' do + let(:extra_params) { { sort: 'contacted_asc' } } - it 'returns correct runner' do - expect(subject).to eq([runner_project_5]) + it 'sorts by specified attribute' do + expect(subject).to eq([runner_group, runner_sub_group_1, runner_sub_group_2, + runner_sub_group_3, runner_sub_group_4, runner_project_1, + runner_project_2, runner_project_3, runner_project_4, + runner_project_5, runner_project_6, runner_project_7]) end end - context 'by runner type' do - let(:params) { { type_type: 'project_type' } } + context 'filtering' do + context 'by search term' do + let(:extra_params) { { search: 'runner_project_search' } } + + it 'returns correct runner' do + expect(subject).to eq([runner_project_3]) + end + end + + context 'by status' do + let(:extra_params) { { status_status: 'paused' } } + + it 'returns correct runner' do + expect(subject).to eq([runner_sub_group_1]) + end + end + + context 'by tag_name' do + let(:extra_params) { { tag_name: %w[runner_tag] } } + + it 'returns correct runner' do + expect(subject).to eq([runner_project_5]) + end + end + + context 'by runner type' do + let(:extra_params) { { type_type: 'project_type' } } - it 'returns correct runners' do - expect(subject).to eq([runner_project_7, runner_project_6, - runner_project_5, runner_project_4, - runner_project_3, runner_project_2, runner_project_1]) + it 'returns correct runners' do + expect(subject).to eq([runner_project_7, runner_project_6, + runner_project_5, runner_project_4, + runner_project_3, runner_project_2, runner_project_1]) + end end end end @@ -278,7 +328,7 @@ RSpec.describe Ci::RunnersFinder do end describe '#sort_key' do - subject { described_class.new(current_user: user, group: group, params: params).sort_key } + subject { described_class.new(current_user: user, params: params.merge(group: group)).sort_key } context 'without params' do it 'returns created_at_desc' do @@ -287,7 +337,7 @@ RSpec.describe Ci::RunnersFinder do end context 'with params' do - let(:params) { { sort: 'contacted_asc' } } + let(:extra_params) { { sort: 'contacted_asc' } } it 'returns contacted_asc' do expect(subject).to eq('contacted_asc') diff --git a/spec/finders/error_tracking/errors_finder_spec.rb b/spec/finders/error_tracking/errors_finder_spec.rb index 2df5f1653e0..29053054f9d 100644 --- a/spec/finders/error_tracking/errors_finder_spec.rb +++ b/spec/finders/error_tracking/errors_finder_spec.rb @@ -6,7 +6,8 @@ RSpec.describe ErrorTracking::ErrorsFinder do let_it_be(:project) { create(:project) } let_it_be(:user) { project.creator } let_it_be(:error) { create(:error_tracking_error, project: project) } - let_it_be(:error_resolved) { create(:error_tracking_error, :resolved, project: project) } + let_it_be(:error_resolved) { create(:error_tracking_error, :resolved, project: project, first_seen_at: 2.hours.ago) } + let_it_be(:error_yesterday) { create(:error_tracking_error, project: project, first_seen_at: Time.zone.now.yesterday) } before do project.add_maintainer(user) @@ -17,12 +18,25 @@ RSpec.describe ErrorTracking::ErrorsFinder do subject { described_class.new(user, project, params).execute } - it { is_expected.to contain_exactly(error, error_resolved) } + it { is_expected.to contain_exactly(error, error_resolved, error_yesterday) } context 'with status parameter' do let(:params) { { status: 'resolved' } } it { is_expected.to contain_exactly(error_resolved) } end + + context 'with sort parameter' do + let(:params) { { status: 'unresolved', sort: 'first_seen' } } + + it { is_expected.to eq([error, error_yesterday]) } + end + + context 'with limit parameter' 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) } + end end end diff --git a/spec/finders/feature_flags_finder_spec.rb b/spec/finders/feature_flags_finder_spec.rb index 4faa6a62a1f..1b3c71b143f 100644 --- a/spec/finders/feature_flags_finder_spec.rb +++ b/spec/finders/feature_flags_finder_spec.rb @@ -72,13 +72,5 @@ RSpec.describe FeatureFlagsFinder do subject end end - - context 'with a legacy flag' do - let!(:feature_flag_3) { create(:operations_feature_flag, :legacy_flag, name: 'flag-c', project: project) } - - it 'returns new flags' do - is_expected.to eq([feature_flag_1, feature_flag_2]) - end - end end end diff --git a/spec/finders/groups/user_groups_finder_spec.rb b/spec/finders/groups/user_groups_finder_spec.rb new file mode 100644 index 00000000000..4cce3ab72eb --- /dev/null +++ b/spec/finders/groups/user_groups_finder_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::UserGroupsFinder do + describe '#execute' do + let_it_be(:user) { create(:user) } + let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') } + let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer') } + let_it_be(:public_developer_group) { create(:group, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') } + let_it_be(:public_maintainer_group) { create(:group, name: 'a public maintainer', path: 'a-public-maintainer') } + + subject { described_class.new(current_user, target_user, arguments).execute } + + let(:arguments) { {} } + let(:current_user) { user } + let(:target_user) { user } + + before_all do + guest_group.add_guest(user) + private_maintainer_group.add_maintainer(user) + public_developer_group.add_developer(user) + public_maintainer_group.add_maintainer(user) + end + + it 'returns all groups where the user is a direct member' do + is_expected.to match( + [ + public_maintainer_group, + private_maintainer_group, + public_developer_group, + guest_group + ] + ) + end + + context 'when target_user is nil' do + let(:target_user) { nil } + + it { is_expected.to be_empty } + end + + context 'when current_user is nil' do + let(:current_user) { nil } + + it { is_expected.to be_empty } + end + + context 'when permission is :create_projects' do + let(:arguments) { { permission_scope: :create_projects } } + + specify do + is_expected.to match( + [ + public_maintainer_group, + private_maintainer_group, + public_developer_group + ] + ) + end + + context 'when paginatable_namespace_drop_down_for_project_creation feature flag is disabled' do + before do + stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false) + end + + it 'ignores project creation scope and returns all groups where the user is a direct member' do + is_expected.to match( + [ + public_maintainer_group, + private_maintainer_group, + public_developer_group, + guest_group + ] + ) + end + end + + context 'when search is provided' do + let(:arguments) { { permission_scope: :create_projects, search: 'maintainer' } } + + specify do + is_expected.to match( + [ + public_maintainer_group, + private_maintainer_group + ] + ) + end + end + end + + context 'when search is provided' do + let(:arguments) { { search: 'maintainer' } } + + specify do + is_expected.to match( + [ + public_maintainer_group, + private_maintainer_group + ] + ) + end + end + end +end diff --git a/spec/finders/issues_finder/params_spec.rb b/spec/finders/issues_finder/params_spec.rb new file mode 100644 index 00000000000..879ecc364a2 --- /dev/null +++ b/spec/finders/issues_finder/params_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe IssuesFinder::Params do + describe '#include_hidden' do + subject { described_class.new(params, user, IssuesFinder) } + + context 'when param is not set' do + let(:params) { {} } + + context 'with an admin', :enable_admin_mode do + let(:user) { create(:user, :admin) } + + it 'returns true' do + expect(subject.include_hidden?).to be_truthy + end + end + + context 'with a regular user' do + let(:user) { create(:user) } + + it 'returns false' do + expect(subject.include_hidden?).to be_falsey + end + end + end + + context 'when param is set' do + let(:params) { { include_hidden: true } } + + context 'with an admin', :enable_admin_mode do + let(:user) { create(:user, :admin) } + + it 'returns true' do + expect(subject.include_hidden?).to be_truthy + end + end + + context 'with a regular user' do + let(:user) { create(:user) } + + it 'returns false' do + expect(subject.include_hidden?).to be_falsey + end + end + end + end +end diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 0cb73f3da6d..ed35d75720c 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -12,8 +12,52 @@ RSpec.describe IssuesFinder do context 'scope: all' do let(:scope) { 'all' } - it 'returns all issues' do - expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5) + context 'include_hidden and public_only params' do + let_it_be(:banned_user) { create(:user, :banned) } + let_it_be(:hidden_issue) { create(:issue, project: project1, author: banned_user) } + let_it_be(:confidential_issue) { create(:issue, project: project1, confidential: true) } + + context 'when user is an admin', :enable_admin_mode do + let(:user) { create(:user, :admin) } + + it 'returns all issues' do + expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, hidden_issue, confidential_issue) + end + end + + context 'when user is not an admin' do + context 'when public_only is true' do + let(:params) { { public_only: true } } + + it 'returns public issues' do + expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5) + end + end + + context 'when public_only is false' do + let(:params) { { public_only: false } } + + it 'returns public and confidential issues' do + expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, confidential_issue) + end + end + + context 'when public_only is not set' do + it 'returns public and confidential issue' do + expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, confidential_issue) + end + end + + context 'when ban_user_feature_flag is false' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it 'returns all issues' do + expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, hidden_issue, confidential_issue) + end + end + end end context 'user does not have read permissions' do @@ -426,139 +470,121 @@ RSpec.describe IssuesFinder do end end - shared_examples ':label_name parameter' do - context 'filtering by label' do - let(:params) { { label_name: label.title } } + context 'filtering by label' do + let(:params) { { label_name: label.title } } - it 'returns issues with that label' do - expect(issues).to contain_exactly(issue2) - end + it 'returns issues with that label' do + expect(issues).to contain_exactly(issue2) + end - context 'using NOT' do - let(:params) { { not: { label_name: label.title } } } + context 'using NOT' do + let(:params) { { not: { label_name: label.title } } } - it 'returns issues that do not have that label' do - expect(issues).to contain_exactly(issue1, issue3, issue4, issue5) - end + it 'returns issues that do not have that label' do + expect(issues).to contain_exactly(issue1, issue3, issue4, issue5) + end - # IssuableFinder first filters using the outer params (the ones not inside the `not` key.) - # Afterwards, it applies the `not` params to that resultset. This means that things inside the `not` param - # do not take precedence over the outer params with the same name. - context 'shadowing the same outside param' do - let(:params) { { label_name: label2.title, not: { label_name: label.title } } } + # IssuableFinder first filters using the outer params (the ones not inside the `not` key.) + # Afterwards, it applies the `not` params to that resultset. This means that things inside the `not` param + # do not take precedence over the outer params with the same name. + context 'shadowing the same outside param' do + let(:params) { { label_name: label2.title, not: { label_name: label.title } } } - it 'does not take precedence over labels outside NOT' do - expect(issues).to contain_exactly(issue3) - end + it 'does not take precedence over labels outside NOT' do + expect(issues).to contain_exactly(issue3) end + end - context 'further filtering outside params' do - let(:params) { { label_name: label2.title, not: { assignee_username: user2.username } } } + context 'further filtering outside params' do + let(:params) { { label_name: label2.title, not: { assignee_username: user2.username } } } - it 'further filters on the returned resultset' do - expect(issues).to be_empty - end + it 'further filters on the returned resultset' do + expect(issues).to be_empty end end end + end - context 'filtering by multiple labels' do - let(:params) { { label_name: [label.title, label2.title].join(',') } } - let(:label2) { create(:label, project: project2) } - - before do - create(:label_link, label: label2, target: issue2) - end + context 'filtering by multiple labels' do + let(:params) { { label_name: [label.title, label2.title].join(',') } } + let(:label2) { create(:label, project: project2) } - it 'returns the unique issues with all those labels' do - expect(issues).to contain_exactly(issue2) - end - - context 'using NOT' do - let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } } + before do + create(:label_link, label: label2, target: issue2) + end - it 'returns issues that do not have any of the labels provided' do - expect(issues).to contain_exactly(issue1, issue4, issue5) - end - end + it 'returns the unique issues with all those labels' do + expect(issues).to contain_exactly(issue2) end - context 'filtering by a label that includes any or none in the title' do - let(:params) { { label_name: [label.title, label2.title].join(',') } } - let(:label) { create(:label, title: 'any foo', project: project2) } - let(:label2) { create(:label, title: 'bar none', project: project2) } + context 'using NOT' do + let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } } - before do - create(:label_link, label: label2, target: issue2) + it 'returns issues that do not have any of the labels provided' do + expect(issues).to contain_exactly(issue1, issue4, issue5) end + end + end - it 'returns the unique issues with all those labels' do - expect(issues).to contain_exactly(issue2) - end + context 'filtering by a label that includes any or none in the title' do + let(:params) { { label_name: [label.title, label2.title].join(',') } } + let(:label) { create(:label, title: 'any foo', project: project2) } + let(:label2) { create(:label, title: 'bar none', project: project2) } - context 'using NOT' do - let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } } + before do + create(:label_link, label: label2, target: issue2) + end - it 'returns issues that do not have ANY ONE of the labels provided' do - expect(issues).to contain_exactly(issue1, issue4, issue5) - end - end + it 'returns the unique issues with all those labels' do + expect(issues).to contain_exactly(issue2) end - context 'filtering by no label' do - let(:params) { { label_name: described_class::Params::FILTER_NONE } } + context 'using NOT' do + let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } } - it 'returns issues with no labels' do + it 'returns issues that do not have ANY ONE of the labels provided' do expect(issues).to contain_exactly(issue1, issue4, issue5) end end + end - context 'filtering by any label' do - let(:params) { { label_name: described_class::Params::FILTER_ANY } } - - it 'returns issues that have one or more label' do - create_list(:label_link, 2, label: create(:label, project: project2), target: issue3) + context 'filtering by no label' do + let(:params) { { label_name: described_class::Params::FILTER_NONE } } - expect(issues).to contain_exactly(issue2, issue3) - end + it 'returns issues with no labels' do + expect(issues).to contain_exactly(issue1, issue4, issue5) end + end - context 'when the same label exists on project and group levels' do - let(:issue1) { create(:issue, project: project1) } - let(:issue2) { create(:issue, project: project1) } - - # Skipping validation to reproduce a "real-word" scenario. - # We still have legacy labels on PRD that have the same title on the group and project levels, example: `bug` - let(:project_label) { build(:label, title: 'somelabel', project: project1).tap { |r| r.save!(validate: false) } } - let(:group_label) { create(:group_label, title: 'somelabel', group: project1.group) } - - let(:params) { { label_name: 'somelabel' } } + context 'filtering by any label' do + let(:params) { { label_name: described_class::Params::FILTER_ANY } } - before do - create(:label_link, label: group_label, target: issue1) - create(:label_link, label: project_label, target: issue2) - end + it 'returns issues that have one or more label' do + create_list(:label_link, 2, label: create(:label, project: project2), target: issue3) - it 'finds both issue records' do - expect(issues).to contain_exactly(issue1, issue2) - end + expect(issues).to contain_exactly(issue2, issue3) end end - context 'when `optimized_issuable_label_filter` feature flag is off' do - before do - stub_feature_flags(optimized_issuable_label_filter: false) - end + context 'when the same label exists on project and group levels' do + let(:issue1) { create(:issue, project: project1) } + let(:issue2) { create(:issue, project: project1) } - it_behaves_like ':label_name parameter' - end + # Skipping validation to reproduce a "real-word" scenario. + # We still have legacy labels on PRD that have the same title on the group and project levels, example: `bug` + let(:project_label) { build(:label, title: 'somelabel', project: project1).tap { |r| r.save!(validate: false) } } + let(:group_label) { create(:group_label, title: 'somelabel', group: project1.group) } + + let(:params) { { label_name: 'somelabel' } } - context 'when `optimized_issuable_label_filter` feature flag is on' do before do - stub_feature_flags(optimized_issuable_label_filter: true) + create(:label_link, label: group_label, target: issue1) + create(:label_link, label: project_label, target: issue2) end - it_behaves_like ':label_name parameter' + it 'finds both issue records' do + expect(issues).to contain_exactly(issue1, issue2) + end end context 'filtering by issue term' do @@ -567,6 +593,35 @@ RSpec.describe IssuesFinder do it 'returns issues with title and description match for search term' do expect(issues).to contain_exactly(issue1, issue2) end + + context 'with anonymous user' do + let_it_be(:public_project) { create(:project, :public, group: subgroup) } + let_it_be(:issue6) { create(:issue, project: public_project, title: 'tanuki') } + let_it_be(:issue7) { create(:issue, project: public_project, title: 'ikunat') } + + let(:search_user) { nil } + let(:params) { { search: 'tanuki' } } + + context 'with disable_anonymous_search feature flag enabled' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it 'does not perform search' do + expect(issues).to contain_exactly(issue6, issue7) + end + end + + context 'with disable_anonymous_search feature flag disabled' do + before do + stub_feature_flags(disable_anonymous_search: false) + end + + it 'finds one public issue' do + expect(issues).to contain_exactly(issue6) + end + end + end end context 'filtering by issue term in title' do @@ -1001,132 +1056,64 @@ RSpec.describe IssuesFinder do end describe '#with_confidentiality_access_check' do - let(:guest) { create(:user) } + let(:user) { create(:user) } let_it_be(:authorized_user) { create(:user) } - let_it_be(:banned_user) { create(:user, :banned) } let_it_be(:project) { create(:project, namespace: authorized_user.namespace) } let_it_be(:public_issue) { create(:issue, project: project) } let_it_be(:confidential_issue) { create(:issue, project: project, confidential: true) } - let_it_be(:hidden_issue) { create(:issue, project: project, author: banned_user) } - shared_examples 'returns public, does not return hidden or confidential' do + shared_examples 'returns public, does not return confidential' do it 'returns only public issues' do expect(subject).to include(public_issue) - expect(subject).not_to include(confidential_issue, hidden_issue) + expect(subject).not_to include(confidential_issue) end end - shared_examples 'returns public and confidential, does not return hidden' do - it 'returns only public and confidential issues' do + shared_examples 'returns public and confidential' do + it 'returns public and confidential issues' do expect(subject).to include(public_issue, confidential_issue) - expect(subject).not_to include(hidden_issue) - end - end - - shared_examples 'returns public and hidden, does not return confidential' do - it 'returns only public and hidden issues' do - expect(subject).to include(public_issue, hidden_issue) - expect(subject).not_to include(confidential_issue) end end - shared_examples 'returns public, confidential, and hidden' do - it 'returns all issues' do - expect(subject).to include(public_issue, confidential_issue, hidden_issue) - end - end + subject { described_class.new(user, params).with_confidentiality_access_check } context 'when no project filter is given' do let(:params) { {} } context 'for an anonymous user' do - subject { described_class.new(nil, params).with_confidentiality_access_check } - - it_behaves_like 'returns public, does not return hidden or confidential' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it_behaves_like 'returns public and hidden, does not return confidential' - end + it_behaves_like 'returns public, does not return confidential' end context 'for a user without project membership' do - subject { described_class.new(user, params).with_confidentiality_access_check } - - it_behaves_like 'returns public, does not return hidden or confidential' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it_behaves_like 'returns public and hidden, does not return confidential' - end + it_behaves_like 'returns public, does not return confidential' end context 'for a guest user' do - subject { described_class.new(guest, params).with_confidentiality_access_check } - before do - project.add_guest(guest) + project.add_guest(user) end - it_behaves_like 'returns public, does not return hidden or confidential' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it_behaves_like 'returns public and hidden, does not return confidential' - end + it_behaves_like 'returns public, does not return confidential' end context 'for a project member with access to view confidential issues' do - subject { described_class.new(authorized_user, params).with_confidentiality_access_check } - - it_behaves_like 'returns public and confidential, does not return hidden' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it_behaves_like 'returns public, confidential, and hidden' + before do + project.add_reporter(user) end + + it_behaves_like 'returns public and confidential' end context 'for an admin' do - let(:admin_user) { create(:user, :admin) } - - subject { described_class.new(admin_user, params).with_confidentiality_access_check } + let(:user) { create(:user, :admin) } context 'when admin mode is enabled', :enable_admin_mode do - it_behaves_like 'returns public, confidential, and hidden' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it_behaves_like 'returns public, confidential, and hidden' - end + it_behaves_like 'returns public and confidential' end context 'when admin mode is disabled' do - it_behaves_like 'returns public, does not return hidden or confidential' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it_behaves_like 'returns public and hidden, does not return confidential' - end + it_behaves_like 'returns public, does not return confidential' end end end @@ -1135,17 +1122,9 @@ RSpec.describe IssuesFinder do let(:params) { { project_id: project.id } } context 'for an anonymous user' do - subject { described_class.new(nil, params).with_confidentiality_access_check } - - it_behaves_like 'returns public, does not return hidden or confidential' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end + let(:user) { nil } - it_behaves_like 'returns public and hidden, does not return confidential' - end + it_behaves_like 'returns public, does not return confidential' it 'does not filter by confidentiality' do expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything) @@ -1154,17 +1133,7 @@ RSpec.describe IssuesFinder do end context 'for a user without project membership' do - subject { described_class.new(user, params).with_confidentiality_access_check } - - it_behaves_like 'returns public, does not return hidden or confidential' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it_behaves_like 'returns public and hidden, does not return confidential' - end + it_behaves_like 'returns public, does not return confidential' it 'filters by confidentiality' do expect(subject.to_sql).to match("issues.confidential") @@ -1172,21 +1141,11 @@ RSpec.describe IssuesFinder do end context 'for a guest user' do - subject { described_class.new(guest, params).with_confidentiality_access_check } - before do - project.add_guest(guest) + project.add_guest(user) end - it_behaves_like 'returns public, does not return hidden or confidential' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it_behaves_like 'returns public and hidden, does not return confidential' - end + it_behaves_like 'returns public, does not return confidential' it 'filters by confidentiality' do expect(subject.to_sql).to match("issues.confidential") @@ -1194,40 +1153,18 @@ RSpec.describe IssuesFinder do end context 'for a project member with access to view confidential issues' do - subject { described_class.new(authorized_user, params).with_confidentiality_access_check } - - it_behaves_like 'returns public and confidential, does not return hidden' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it_behaves_like 'returns public, confidential, and hidden' + before do + project.add_reporter(user) end - it 'does not filter by confidentiality' do - expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything) - - subject - end + it_behaves_like 'returns public and confidential' end context 'for an admin' do - let(:admin_user) { create(:user, :admin) } - - subject { described_class.new(admin_user, params).with_confidentiality_access_check } + let(:user) { create(:user, :admin) } context 'when admin mode is enabled', :enable_admin_mode do - it_behaves_like 'returns public, confidential, and hidden' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it_behaves_like 'returns public, confidential, and hidden' - end + it_behaves_like 'returns public and confidential' it 'does not filter by confidentiality' do expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything) @@ -1237,19 +1174,7 @@ RSpec.describe IssuesFinder do end context 'when admin mode is disabled' do - it_behaves_like 'returns public, does not return hidden or confidential' - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it_behaves_like 'returns public and hidden, does not return confidential' - end - - it 'filters by confidentiality' do - expect(subject.to_sql).to match("issues.confidential") - end + it_behaves_like 'returns public, does not return confidential' end end end diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index 49b29cefb9b..42197a6b103 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -227,56 +227,38 @@ RSpec.describe MergeRequestsFinder do end end - shared_examples ':label_name parameter' do - describe ':label_name parameter' do - let(:common_labels) { create_list(:label, 3) } - let(:distinct_labels) { create_list(:label, 3) } - let(:merge_requests) do - common_attrs = { - source_project: project1, target_project: project1, author: user - } - distinct_labels.map do |label| - labels = [label, *common_labels] - create(:labeled_merge_request, :closed, labels: labels, **common_attrs) - end - end - - def find(label_name) - described_class.new(user, label_name: label_name).execute - end - - it 'accepts a single label' do - found = find(distinct_labels.first.title) - common = find(common_labels.first.title) - - expect(found).to contain_exactly(merge_requests.first) - expect(common).to match_array(merge_requests) - end - - it 'accepts an array of labels, all of which must match' do - all_distinct = find(distinct_labels.pluck(:title)) - all_common = find(common_labels.pluck(:title)) - - expect(all_distinct).to be_empty - expect(all_common).to match_array(merge_requests) + describe ':label_name parameter' do + let(:common_labels) { create_list(:label, 3) } + let(:distinct_labels) { create_list(:label, 3) } + let(:merge_requests) do + common_attrs = { + source_project: project1, target_project: project1, author: user + } + distinct_labels.map do |label| + labels = [label, *common_labels] + create(:labeled_merge_request, :closed, labels: labels, **common_attrs) end end - end - context 'when `optimized_issuable_label_filter` feature flag is off' do - before do - stub_feature_flags(optimized_issuable_label_filter: false) + def find(label_name) + described_class.new(user, label_name: label_name).execute end - it_behaves_like ':label_name parameter' - end + it 'accepts a single label' do + found = find(distinct_labels.first.title) + common = find(common_labels.first.title) - context 'when `optimized_issuable_label_filter` feature flag is on' do - before do - stub_feature_flags(optimized_issuable_label_filter: true) + expect(found).to contain_exactly(merge_requests.first) + expect(common).to match_array(merge_requests) end - it_behaves_like ':label_name parameter' + it 'accepts an array of labels, all of which must match' do + all_distinct = find(distinct_labels.pluck(:title)) + all_common = find(common_labels.pluck(:title)) + + expect(all_distinct).to be_empty + expect(all_common).to match_array(merge_requests) + end end it 'filters by source project id' do @@ -729,6 +711,36 @@ RSpec.describe MergeRequestsFinder do merge_requests = described_class.new(user, params).execute expect { merge_requests.load }.not_to raise_error end + + context 'filtering by search text' do + let!(:merge_request6) { create(:merge_request, source_project: project1, target_project: project1, source_branch: 'tanuki-branch', title: 'tanuki') } + + let(:params) { { project_id: project1.id, search: 'tanuki' } } + + context 'with anonymous user' do + let(:merge_requests) { described_class.new(nil, params).execute } + + context 'with disable_anonymous_search feature flag enabled' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it 'does not perform search' do + expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request6) + end + end + + context 'with disable_anonymous_search feature flag disabled' do + before do + stub_feature_flags(disable_anonymous_search: false) + end + + it 'returns matching merge requests' do + expect(merge_requests).to contain_exactly(merge_request6) + end + end + end + end end describe '#row_count', :request_store do diff --git a/spec/finders/packages/helm/package_files_finder_spec.rb b/spec/finders/packages/helm/package_files_finder_spec.rb index 2b84fd2b2d2..5f1378f837d 100644 --- a/spec/finders/packages/helm/package_files_finder_spec.rb +++ b/spec/finders/packages/helm/package_files_finder_spec.rb @@ -6,42 +6,51 @@ RSpec.describe ::Packages::Helm::PackageFilesFinder do let_it_be(:project1) { create(:project) } let_it_be(:project2) { create(:project) } let_it_be(:helm_package) { create(:helm_package, project: project1) } - let_it_be(:helm_package_file) { helm_package.package_files.first } + let_it_be(:helm_package_file1) { helm_package.package_files.first } + let_it_be(:helm_package_file2) { create(:helm_package_file, package: helm_package) } let_it_be(:debian_package) { create(:debian_package, project: project1) } - describe '#execute' do - let(:project) { project1 } - let(:channel) { 'stable' } - let(:params) { {} } + let(:project) { project1 } + let(:channel) { 'stable' } + let(:params) { {} } + + let(:service) { described_class.new(project, channel, params) } - subject { described_class.new(project, channel, params).execute } + describe '#execute' do + subject { service.execute } context 'with empty params' do - it { is_expected.to match_array([helm_package_file]) } + it { is_expected.to eq([helm_package_file2, helm_package_file1]) } end context 'with another project' do let(:project) { project2 } - it { is_expected.to match_array([]) } + it { is_expected.to eq([]) } end context 'with another channel' do let(:channel) { 'staging' } - it { is_expected.to match_array([]) } + it { is_expected.to eq([]) } end - context 'with file_name' do - let(:params) { { file_name: helm_package_file.file_name } } + context 'with matching file_name' do + let(:params) { { file_name: helm_package_file1.file_name } } - it { is_expected.to match_array([helm_package_file]) } + it { is_expected.to eq([helm_package_file2, helm_package_file1]) } end context 'with another file_name' do let(:params) { { file_name: 'foobar.tgz' } } - it { is_expected.to match_array([]) } + it { is_expected.to eq([]) } end end + + describe '#most_recent!' do + subject { service.most_recent! } + + it { is_expected.to eq(helm_package_file2) } + end end diff --git a/spec/finders/packages/helm/packages_finder_spec.rb b/spec/finders/packages/helm/packages_finder_spec.rb new file mode 100644 index 00000000000..5037a9e6205 --- /dev/null +++ b/spec/finders/packages/helm/packages_finder_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Helm::PackagesFinder do + let_it_be(:project1) { create(:project) } + let_it_be(:project2) { create(:project) } + let_it_be(:helm_package) { create(:helm_package, project: project1) } + let_it_be(:npm_package) { create(:npm_package, project: project1) } + let_it_be(:npm_package) { create(:npm_package, project: project2) } + + let(:project) { project1 } + let(:channel) { 'stable' } + let(:finder) { described_class.new(project, channel) } + + describe '#execute' do + subject { finder.execute } + + context 'with project' do + context 'with channel' do + it { is_expected.to eq([helm_package]) } + + context 'ignores duplicate package files' do + let_it_be(:package_file1) { create(:helm_package_file, package: helm_package) } + let_it_be(:package_file2) { create(:helm_package_file, package: helm_package) } + + it { is_expected.to eq([helm_package]) } + + context 'let clients use select id' do + subject { finder.execute.pluck_primary_key } + + it { is_expected.to eq([helm_package.id]) } + end + end + end + + context 'with not existing channel' do + let(:channel) { 'alpha' } + + it { is_expected.to be_empty } + end + + context 'with no channel' do + let(:channel) { nil } + + it { is_expected.to be_empty } + end + + context 'with no helm packages' do + let(:project) { project2 } + + it { is_expected.to be_empty } + end + end + + context 'with no project' do + let(:project) { nil } + + it { is_expected.to be_empty } + end + + context 'when the limit is hit' do + let_it_be(:helm_package2) { create(:helm_package, project: project1) } + let_it_be(:helm_package3) { create(:helm_package, project: project1) } + let_it_be(:helm_package4) { create(:helm_package, project: project1) } + + before do + stub_const("#{described_class}::MAX_PACKAGES_COUNT", 2) + end + + it { is_expected.to eq([helm_package4, helm_package3]) } + end + end +end diff --git a/spec/finders/packages/npm/package_finder_spec.rb b/spec/finders/packages/npm/package_finder_spec.rb index a995f3b96c4..230d267e508 100644 --- a/spec/finders/packages/npm/package_finder_spec.rb +++ b/spec/finders/packages/npm/package_finder_spec.rb @@ -7,6 +7,7 @@ RSpec.describe ::Packages::Npm::PackageFinder do let(:project) { package.project } let(:package_name) { package.name } + let(:last_of_each_version) { true } shared_examples 'accepting a namespace for' do |example_name| before do @@ -38,6 +39,8 @@ RSpec.describe ::Packages::Npm::PackageFinder do end describe '#execute' do + subject { finder.execute } + shared_examples 'finding packages by name' do it { is_expected.to eq([package]) } @@ -56,13 +59,27 @@ RSpec.describe ::Packages::Npm::PackageFinder do end end - subject { finder.execute } + shared_examples 'handling last_of_each_version' do + include_context 'last_of_each_version setup context' + + context 'disabled' do + let(:last_of_each_version) { false } + + it { is_expected.to contain_exactly(package1, package2) } + end + + context 'enabled' do + it { is_expected.to contain_exactly(package2) } + end + end context 'with a project' do - let(:finder) { described_class.new(package_name, project: project) } + let(:finder) { described_class.new(package_name, project: project, last_of_each_version: last_of_each_version) } it_behaves_like 'finding packages by name' + it_behaves_like 'handling last_of_each_version' + context 'set to nil' do let(:project) { nil } @@ -71,10 +88,12 @@ RSpec.describe ::Packages::Npm::PackageFinder do end context 'with a namespace' do - let(:finder) { described_class.new(package_name, namespace: namespace) } + let(:finder) { described_class.new(package_name, namespace: namespace, last_of_each_version: last_of_each_version) } it_behaves_like 'accepting a namespace for', 'finding packages by name' + it_behaves_like 'accepting a namespace for', 'handling last_of_each_version' + context 'set to nil' do let_it_be(:namespace) { nil } @@ -98,16 +117,28 @@ RSpec.describe ::Packages::Npm::PackageFinder do end end + shared_examples 'handling last_of_each_version' do + include_context 'last_of_each_version setup context' + + context 'enabled' do + it { is_expected.to eq(package2) } + end + end + context 'with a project' do - let(:finder) { described_class.new(package_name, project: project) } + let(:finder) { described_class.new(package_name, project: project, last_of_each_version: last_of_each_version) } it_behaves_like 'finding packages by version' + + it_behaves_like 'handling last_of_each_version' end context 'with a namespace' do - let(:finder) { described_class.new(package_name, namespace: namespace) } + let(:finder) { described_class.new(package_name, namespace: namespace, last_of_each_version: last_of_each_version) } it_behaves_like 'accepting a namespace for', 'finding packages by version' + + it_behaves_like 'accepting a namespace for', 'handling last_of_each_version' end end @@ -118,10 +149,26 @@ RSpec.describe ::Packages::Npm::PackageFinder do it { is_expected.to eq(package) } end + shared_examples 'handling last_of_each_version' do + include_context 'last_of_each_version setup context' + + context 'disabled' do + let(:last_of_each_version) { false } + + it { is_expected.to eq(package2) } + end + + context 'enabled' do + it { is_expected.to eq(package2) } + end + end + context 'with a project' do - let(:finder) { described_class.new(package_name, project: project) } + let(:finder) { described_class.new(package_name, project: project, last_of_each_version: last_of_each_version) } it_behaves_like 'finding package by last' + + it_behaves_like 'handling last_of_each_version' end context 'with a namespace' do @@ -129,6 +176,8 @@ RSpec.describe ::Packages::Npm::PackageFinder do it_behaves_like 'accepting a namespace for', 'finding package by last' + it_behaves_like 'accepting a namespace for', 'handling last_of_each_version' + context 'with duplicate packages' do let_it_be(:namespace) { create(:group) } let_it_be(:subgroup1) { create(:group, parent: namespace) } diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index 21b5b2f6130..d26180bbf94 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -135,6 +135,7 @@ RSpec.describe ProjectsFinder do describe 'filter by tags (deprecated)' do before do + public_project.reload public_project.topic_list = 'foo' public_project.save! end @@ -146,6 +147,7 @@ RSpec.describe ProjectsFinder do describe 'filter by topics' do before do + public_project.reload public_project.topic_list = 'foo, bar' public_project.save! end @@ -188,6 +190,32 @@ RSpec.describe ProjectsFinder do it { is_expected.to eq([public_project]) } end + context 'with anonymous user' do + let(:public_project_2) { create(:project, :public, group: group, name: 'E', path: 'E') } + let(:current_user) { nil } + let(:params) { { search: 'C' } } + + context 'with disable_anonymous_project_search feature flag enabled' do + before do + stub_feature_flags(disable_anonymous_project_search: true) + end + + it 'does not perform search' do + is_expected.to eq([public_project_2, public_project]) + end + end + + context 'with disable_anonymous_project_search feature flag disabled' do + before do + stub_feature_flags(disable_anonymous_project_search: false) + end + + it 'finds one public project' do + is_expected.to eq([public_project]) + end + end + end + describe 'filter by name for backward compatibility' do let(:params) { { name: 'C' } } diff --git a/spec/finders/repositories/tree_finder_spec.rb b/spec/finders/repositories/tree_finder_spec.rb new file mode 100644 index 00000000000..0d70d5f92d3 --- /dev/null +++ b/spec/finders/repositories/tree_finder_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Repositories::TreeFinder do + include RepoHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository, creator: user) } + + let(:repository) { project.repository } + let(:tree_finder) { described_class.new(project, params) } + let(:params) { {} } + let(:first_page_ids) { tree_finder.execute.map(&:id) } + let(:second_page_token) { first_page_ids.last } + + describe "#execute" do + subject { tree_finder.execute(gitaly_pagination: true) } + + it "returns an array" do + is_expected.to be_an(Array) + end + + it "includes 20 items by default" do + expect(subject.size).to eq(20) + end + + it "accepts a gitaly_pagination argument" do + expect(repository).to receive(:tree).with(anything, anything, recursive: nil, pagination_params: { limit: 20, page_token: nil }).and_call_original + expect(tree_finder.execute(gitaly_pagination: true)).to be_an(Array) + + expect(repository).to receive(:tree).with(anything, anything, recursive: nil).and_call_original + expect(tree_finder.execute(gitaly_pagination: false)).to be_an(Array) + end + + context "commit doesn't exist" do + let(:params) do + { ref: "nonesuchref" } + end + + it "raises an error" do + expect { subject }.to raise_error(described_class::CommitMissingError) + end + end + + describe "pagination_params" do + let(:params) do + { per_page: 5, page_token: nil } + end + + it "has the per_page number of items" do + expect(subject.size).to eq(5) + end + + it "doesn't include any of the first page records" do + first_page_ids = subject.map(&:id) + second_page = described_class.new(project, { per_page: 5, page_token: first_page_ids.last }).execute(gitaly_pagination: true) + + expect(second_page.map(&:id)).not_to include(*first_page_ids) + end + end + end + + describe "#total", :use_clean_rails_memory_store_caching do + subject { tree_finder.total } + + it { is_expected.to be_an(Integer) } + + it "only calculates the total once" do + expect(repository).to receive(:tree).once.and_call_original + + 2.times { tree_finder.total } + end + end + + describe "#commit_exists?" do + subject { tree_finder.commit_exists? } + + context "ref exists" do + let(:params) do + { ref: project.default_branch } + end + + it { is_expected.to be(true) } + end + + context "ref is missing" do + let(:params) do + { ref: "nonesuchref" } + end + + it { is_expected.to be(false) } + end + end +end diff --git a/spec/fixtures/api/schemas/feature_flag.json b/spec/fixtures/api/schemas/feature_flag.json index 45b704e4b84..47f86a9f92b 100644 --- a/spec/fixtures/api/schemas/feature_flag.json +++ b/spec/fixtures/api/schemas/feature_flag.json @@ -1,10 +1,7 @@ { "type": "object", - "required" : [ - "id", - "name" - ], - "properties" : { + "required": ["id", "name"], + "properties": { "id": { "type": "integer" }, "iid": { "type": ["integer", "null"] }, "version": { "type": "string" }, diff --git a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric.yml b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric.yml index 8495d983d10..16ca71f24ae 100644 --- a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric.yml +++ b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric.yml @@ -7,7 +7,7 @@ product_stage: product_group: product_category: value_type: number -status: implemented +status: active milestone: "13.9" introduced_by_url: time_frame: 7d diff --git a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml index 82e9af5b04f..060ab7baccf 100644 --- a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml +++ b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml @@ -7,7 +7,7 @@ product_stage: product_group: product_category: value_type: number -status: implemented +status: active milestone: "13.9" introduced_by_url: time_frame: 7d diff --git a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml index aad7dc76290..e373d6a9e45 100644 --- a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml +++ b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml @@ -8,7 +8,7 @@ product_stage: product_group: product_category: value_type: number -status: implemented +status: active milestone: "13.9" introduced_by_url: time_frame: 7d diff --git a/spec/frontend/__helpers__/emoji.js b/spec/frontend/__helpers__/emoji.js index 9f9134f6f63..a64135601ae 100644 --- a/spec/frontend/__helpers__/emoji.js +++ b/spec/frontend/__helpers__/emoji.js @@ -49,6 +49,11 @@ export const emojiFixtureMap = { unicodeVersion: '5.1', description: 'white medium star', }, + xss: { + moji: '<img src=x onerror=prompt(1)>', + unicodeVersion: '5.1', + description: 'xss', + }, }; export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => { diff --git a/spec/frontend/__helpers__/local_storage_helper.js b/spec/frontend/__helpers__/local_storage_helper.js index 21749fd8070..cf75b0b53fe 100644 --- a/spec/frontend/__helpers__/local_storage_helper.js +++ b/spec/frontend/__helpers__/local_storage_helper.js @@ -2,9 +2,7 @@ * Manage the instance of a custom `window.localStorage` * * This only encapsulates the setup / teardown logic so that it can easily be - * reused with different implementations (i.e. a spy or a [fake][1]) - * - * [1]: https://stackoverflow.com/a/41434763/1708147 + * reused with different implementations (i.e. a spy or a fake) * * @param {() => any} fn Function that returns the object to use for localStorage */ diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js index 3755778e5c1..14082857053 100644 --- a/spec/frontend/__helpers__/mock_window_location_helper.js +++ b/spec/frontend/__helpers__/mock_window_location_helper.js @@ -2,9 +2,7 @@ * Manage the instance of a custom `window.location` * * This only encapsulates the setup / teardown logic so that it can easily be - * reused with different implementations (i.e. a spy or a [fake][1]) - * - * [1]: https://stackoverflow.com/a/41434763/1708147 + * reused with different implementations (i.e. a spy or a fake) * * @param {() => any} fn Function that returns the object to use for window.location */ diff --git a/spec/frontend/__helpers__/test_apollo_link.js b/spec/frontend/__helpers__/test_apollo_link.js new file mode 100644 index 00000000000..dde3a4e99bb --- /dev/null +++ b/spec/frontend/__helpers__/test_apollo_link.js @@ -0,0 +1,46 @@ +import { InMemoryCache } from 'apollo-cache-inmemory'; +import { ApolloClient } from 'apollo-client'; +import { ApolloLink } from 'apollo-link'; +import gql from 'graphql-tag'; + +const FOO_QUERY = gql` + query { + foo + } +`; + +/** + * This function returns a promise that resolves to the final operation after + * running an ApolloClient query with the given ApolloLink + * + * @typedef {Object} TestApolloLinkOptions + * @property {Object} context the default context object sent along the ApolloLink chain + * + * @param {ApolloLink} subjectLink the ApolloLink which is under test + * @param {TestApolloLinkOptions} options contains options to send a long with the query + * + * @returns Promise resolving to the resulting operation after running the subjectLink + */ +export const testApolloLink = (subjectLink, options = {}) => + new Promise((resolve) => { + const { context = {} } = options; + + // Use the terminating link to capture the final operation and resolve with this. + const terminatingLink = new ApolloLink((operation) => { + resolve(operation); + + return null; + }); + + const client = new ApolloClient({ + link: ApolloLink.from([subjectLink, terminatingLink]), + // cache is a required option + cache: new InMemoryCache(), + }); + + // Trigger a query so the ApolloLink chain will be executed. + client.query({ + context, + query: FOO_QUERY, + }); + }); 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 3a374084dbc..ddb188edb10 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 @@ -51,10 +51,14 @@ exports[`Alert integration settings form default state should match the default <gl-dropdown-stub block="true" category="primary" + clearalltext="Clear all" 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/api/projects_api_spec.js b/spec/frontend/api/projects_api_spec.js new file mode 100644 index 00000000000..8f40b557e1f --- /dev/null +++ b/spec/frontend/api/projects_api_spec.js @@ -0,0 +1,62 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as projectsApi from '~/api/projects_api'; +import axios from '~/lib/utils/axios_utils'; + +describe('~/api/projects_api.js', () => { + let mock; + let originalGon; + + const projectId = 1; + + beforeEach(() => { + mock = new MockAdapter(axios); + + originalGon = window.gon; + window.gon = { api_version: 'v7' }; + }); + + afterEach(() => { + mock.restore(); + window.gon = originalGon; + }); + + describe('getProjects', () => { + beforeEach(() => { + jest.spyOn(axios, 'get'); + }); + + it('retrieves projects from the correct URL and returns them in the response data', () => { + const expectedUrl = '/api/v7/projects.json'; + const expectedParams = { params: { per_page: 20, search: '', simple: true } }; + const expectedProjects = [{ name: 'project 1' }]; + const query = ''; + const options = {}; + + mock.onGet(expectedUrl).reply(200, { data: expectedProjects }); + + return projectsApi.getProjects(query, options).then(({ data }) => { + expect(axios.get).toHaveBeenCalledWith(expectedUrl, expectedParams); + expect(data.data).toEqual(expectedProjects); + }); + }); + }); + + describe('importProjectMembers', () => { + beforeEach(() => { + jest.spyOn(axios, 'post'); + }); + + it('posts to the correct URL and returns the response message', () => { + const targetId = 2; + const expectedUrl = '/api/v7/projects/1/import_project_members/2'; + const expectedMessage = 'Successfully imported'; + + mock.onPost(expectedUrl).replyOnce(200, expectedMessage); + + return projectsApi.importProjectMembers(projectId, targetId).then(({ data }) => { + expect(axios.post).toHaveBeenCalledWith(expectedUrl); + expect(data).toEqual(expectedMessage); + }); + }); + }); +}); diff --git a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js index b77def195b6..2dcc537809f 100644 --- a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js +++ b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js @@ -78,7 +78,7 @@ describe('RecoveryCodes', () => { it('fires Snowplow event', () => { expect(findProceedButton().attributes()).toMatchObject({ - 'data-track-event': 'click_button', + 'data-track-action': 'click_button', 'data-track-label': '2fa_recovery_codes_proceed_button', }); }); diff --git a/spec/frontend/autosave_spec.js b/spec/frontend/autosave_spec.js index bbdf3c6f91d..c881e0f9794 100644 --- a/spec/frontend/autosave_spec.js +++ b/spec/frontend/autosave_spec.js @@ -15,28 +15,28 @@ describe('Autosave', () => { describe('class constructor', () => { beforeEach(() => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); jest.spyOn(Autosave.prototype, 'restore').mockImplementation(() => {}); }); it('should set .isLocalStorageAvailable', () => { autosave = new Autosave(field, key); - expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled(); expect(autosave.isLocalStorageAvailable).toBe(true); }); it('should set .isLocalStorageAvailable if fallbackKey is passed', () => { autosave = new Autosave(field, key, fallbackKey); - expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled(); expect(autosave.isLocalStorageAvailable).toBe(true); }); it('should set .isLocalStorageAvailable if lockVersion is passed', () => { autosave = new Autosave(field, key, null, lockVersion); - expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled(); expect(autosave.isLocalStorageAvailable).toBe(true); }); }); diff --git a/spec/frontend/batch_comments/components/review_bar_spec.js b/spec/frontend/batch_comments/components/review_bar_spec.js new file mode 100644 index 00000000000..f50db6ab210 --- /dev/null +++ b/spec/frontend/batch_comments/components/review_bar_spec.js @@ -0,0 +1,42 @@ +import { shallowMount } from '@vue/test-utils'; +import ReviewBar from '~/batch_comments/components/review_bar.vue'; +import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '~/batch_comments/constants'; +import createStore from '../create_batch_comments_store'; + +describe('Batch comments review bar component', () => { + let store; + let wrapper; + + const createComponent = (propsData = {}) => { + store = createStore(); + + wrapper = shallowMount(ReviewBar, { + store, + propsData, + }); + }; + + beforeEach(() => { + document.body.className = ''; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('it adds review-bar-visible class to body when review bar is mounted', async () => { + expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(false); + + createComponent(); + + expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(true); + }); + + it('it removes review-bar-visible class to body when review bar is destroyed', async () => { + createComponent(); + + wrapper.destroy(); + + expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(false); + }); +}); diff --git a/spec/frontend/batch_comments/create_batch_comments_store.js b/spec/frontend/batch_comments/create_batch_comments_store.js new file mode 100644 index 00000000000..10dc6fe196e --- /dev/null +++ b/spec/frontend/batch_comments/create_batch_comments_store.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments'; +import notesModule from '~/notes/stores/modules'; + +Vue.use(Vuex); + +export default function createDiffsStore() { + return new Vuex.Store({ + modules: { + notes: notesModule(), + batchComments: batchCommentsModule(), + }, + }); +} diff --git a/spec/frontend/blob/notebook/notebook_viever_spec.js b/spec/frontend/blob/notebook/notebook_viever_spec.js index 604104bb31f..93406db2675 100644 --- a/spec/frontend/blob/notebook/notebook_viever_spec.js +++ b/spec/frontend/blob/notebook/notebook_viever_spec.js @@ -11,6 +11,7 @@ describe('iPython notebook renderer', () => { let mock; const endpoint = 'test'; + const relativeRawPath = ''; const mockNotebook = { cells: [ { @@ -27,7 +28,7 @@ describe('iPython notebook renderer', () => { }; const mountComponent = () => { - wrapper = shallowMount(component, { propsData: { endpoint } }); + wrapper = shallowMount(component, { propsData: { endpoint, relativeRawPath } }); }; const findLoading = () => wrapper.find(GlLoadingIcon); diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 7d3ecc773a6..e0446811f64 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -2,6 +2,7 @@ import { GlLabel, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; import { range } from 'lodash'; import Vuex from 'vuex'; import setWindowLocation from 'helpers/set_window_location_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue'; @@ -44,6 +45,7 @@ describe('Board card component', () => { const findEpicBadgeProgress = () => wrapper.findByTestId('epic-progress'); const findEpicCountablesTotalWeight = () => wrapper.findByTestId('epic-countables-total-weight'); const findEpicProgressTooltip = () => wrapper.findByTestId('epic-progress-tooltip-content'); + const findHiddenIssueIcon = () => wrapper.findByTestId('hidden-icon'); const createStore = ({ isEpicBoard = false, isProjectBoard = false } = {}) => { store = new Vuex.Store({ @@ -72,6 +74,9 @@ describe('Board card component', () => { GlLabel: true, GlLoadingIcon: true, }, + directives: { + GlTooltip: createMockDirective(), + }, mocks: { $apollo: { queries: { @@ -122,6 +127,10 @@ describe('Board card component', () => { expect(wrapper.find('.confidential-icon').exists()).toBe(false); }); + it('does not render hidden issue icon', () => { + expect(findHiddenIssueIcon().exists()).toBe(false); + }); + it('renders issue ID with #', () => { expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.iid}`); }); @@ -184,6 +193,30 @@ describe('Board card component', () => { }); }); + describe('hidden issue', () => { + beforeEach(() => { + wrapper.setProps({ + item: { + ...wrapper.props('item'), + hidden: true, + }, + }); + }); + + it('renders hidden issue icon', () => { + expect(findHiddenIssueIcon().exists()).toBe(true); + }); + + it('displays a tooltip which explains the meaning of the icon', () => { + const tooltip = getBinding(findHiddenIssueIcon().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + expect(findHiddenIssueIcon().attributes('title')).toBe( + 'This issue is hidden because its author has been banned', + ); + }); + }); + describe('with assignee', () => { describe('with avatar', () => { beforeEach(() => { diff --git a/spec/frontend/boards/board_list_deprecated_spec.js b/spec/frontend/boards/board_list_deprecated_spec.js deleted file mode 100644 index b71564f7858..00000000000 --- a/spec/frontend/boards/board_list_deprecated_spec.js +++ /dev/null @@ -1,274 +0,0 @@ -/* global List */ -/* global ListIssue */ -import MockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; -import waitForPromises from 'helpers/wait_for_promises'; -import BoardList from '~/boards/components/board_list_deprecated.vue'; -import eventHub from '~/boards/eventhub'; -import store from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import { listObj, boardsMockInterceptor } from './mock_data'; - -const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => { - const el = document.createElement('div'); - - document.body.appendChild(el); - const mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - boardsStore.create(); - - const BoardListComp = Vue.extend(BoardList); - const list = new List({ ...listObj, ...listProps }); - const issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [], - assignees: [], - ...listIssueProps, - }); - if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { - list.issuesSize = 1; - } - list.issues.push(issue); - - const component = new BoardListComp({ - el, - store, - propsData: { - disabled: false, - list, - issues: list.issues, - ...componentProps, - }, - provide: { - groupId: null, - rootPath: '/', - }, - }).$mount(); - - Vue.nextTick(() => { - done(); - }); - - return { component, mock }; -}; - -describe('Board list component', () => { - let mock; - let component; - let getIssues; - function generateIssues(compWrapper) { - for (let i = 1; i < 20; i += 1) { - const issue = { ...compWrapper.list.issues[0] }; - issue.id += i; - compWrapper.list.issues.push(issue); - } - } - - describe('When Expanded', () => { - beforeEach((done) => { - getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); - ({ mock, component } = createComponent({ done })); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - it('loads first page of issues', () => { - return waitForPromises().then(() => { - expect(getIssues).toHaveBeenCalled(); - }); - }); - - it('renders component', () => { - expect(component.$el.classList.contains('board-list-component')).toBe(true); - }); - - it('renders loading icon', () => { - component.list.loading = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); - }); - }); - - it('renders issues', () => { - expect(component.$el.querySelectorAll('.board-card').length).toBe(1); - }); - - it('sets data attribute with issue id', () => { - expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1'); - }); - - it('shows new issue form', () => { - component.toggleForm(); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); - - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - }); - }); - - it('shows new issue form after eventhub event', () => { - eventHub.$emit(`toggle-issue-form-${component.list.id}`); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); - - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - }); - }); - - it('does not show new issue form for closed list', () => { - component.list.type = 'closed'; - component.toggleForm(); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).toBeNull(); - }); - }); - - it('shows count list item', () => { - component.showCount = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count')).not.toBeNull(); - - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing all issues', - ); - }); - }); - - it('sets data attribute with invalid id', () => { - component.showCount = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe( - '-1', - ); - }); - }); - - it('shows how many more issues to load', () => { - component.showCount = true; - component.list.issuesSize = 20; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing 1 of 20 issues', - ); - }); - }); - - it('loads more issues after scrolling', () => { - jest.spyOn(component.list, 'nextPage').mockImplementation(() => {}); - generateIssues(component); - component.$refs.list.dispatchEvent(new Event('scroll')); - - return waitForPromises().then(() => { - expect(component.list.nextPage).toHaveBeenCalled(); - }); - }); - - it('does not load issues if already loading', () => { - component.list.nextPage = jest - .spyOn(component.list, 'nextPage') - .mockReturnValue(new Promise(() => {})); - - component.onScroll(); - component.onScroll(); - - return waitForPromises().then(() => { - expect(component.list.nextPage).toHaveBeenCalledTimes(1); - }); - }); - - it('shows loading more spinner', () => { - component.showCount = true; - component.list.loadingMore = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull(); - }); - }); - }); - - describe('When Collapsed', () => { - beforeEach((done) => { - getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); - ({ mock, component } = createComponent({ - done, - listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, - })); - generateIssues(component); - component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - it('does not load all issues', () => { - return waitForPromises().then(() => { - // Initial getIssues from list constructor - expect(getIssues).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('max issue count warning', () => { - beforeEach((done) => { - ({ mock, component } = createComponent({ - done, - listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, - })); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - describe('when issue count exceeds max issue count', () => { - it('sets background to bg-danger-100', () => { - component.list.issuesSize = 4; - component.list.maxIssueCount = 3; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull(); - }); - }); - }); - - describe('when list issue count does NOT exceed list max issue count', () => { - it('does not sets background to bg-danger-100', () => { - component.list.issuesSize = 2; - component.list.maxIssueCount = 3; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); - }); - }); - }); - - describe('when list max issue count is 0', () => { - it('does not sets background to bg-danger-100', () => { - component.list.maxIssueCount = 0; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); - }); - }); - }); - }); -}); diff --git a/spec/frontend/boards/board_new_issue_deprecated_spec.js b/spec/frontend/boards/board_new_issue_deprecated_spec.js deleted file mode 100644 index 3beaf870bf5..00000000000 --- a/spec/frontend/boards/board_new_issue_deprecated_spec.js +++ /dev/null @@ -1,211 +0,0 @@ -/* global List */ - -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import boardNewIssue from '~/boards/components/board_new_issue_deprecated.vue'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; - -import '~/boards/models/list'; -import { listObj, boardsMockInterceptor } from './mock_data'; - -Vue.use(Vuex); - -describe('Issue boards new issue form', () => { - let wrapper; - let vm; - let list; - let mock; - let newIssueMock; - const promiseReturn = { - data: { - iid: 100, - }, - }; - - const submitIssue = () => { - const dummySubmitEvent = { - preventDefault() {}, - }; - wrapper.vm.$refs.submitButton = wrapper.find({ ref: 'submitButton' }); - return wrapper.vm.submit(dummySubmitEvent); - }; - - beforeEach(() => { - const BoardNewIssueComp = Vue.extend(boardNewIssue); - - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - - boardsStore.create(); - - list = new List(listObj); - - newIssueMock = Promise.resolve(promiseReturn); - jest.spyOn(list, 'newIssue').mockImplementation(() => newIssueMock); - - const store = new Vuex.Store({ - getters: { isGroupBoard: () => false }, - }); - - wrapper = mount(BoardNewIssueComp, { - propsData: { - disabled: false, - list, - }, - store, - provide: { - groupId: null, - }, - }); - - vm = wrapper.vm; - - return Vue.nextTick(); - }); - - afterEach(() => { - wrapper.destroy(); - mock.restore(); - }); - - it('calls submit if submit button is clicked', () => { - jest.spyOn(wrapper.vm, 'submit').mockImplementation(); - vm.title = 'Testing Title'; - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(wrapper.vm.submit).toHaveBeenCalled(); - }); - }); - - it('disables submit button if title is empty', () => { - expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(true); - }); - - it('enables submit button if title is not empty', () => { - wrapper.setData({ title: 'Testing Title' }); - - return Vue.nextTick().then(() => { - expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title'); - expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(false); - }); - }); - - it('clears title after clicking cancel', () => { - wrapper.find({ ref: 'cancelButton' }).trigger('click'); - - return Vue.nextTick().then(() => { - expect(vm.title).toBe(''); - }); - }); - - it('does not create new issue if title is empty', () => { - return submitIssue().then(() => { - expect(list.newIssue).not.toHaveBeenCalled(); - }); - }); - - describe('submit success', () => { - it('creates new issue', () => { - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(list.newIssue).toHaveBeenCalled(); - }); - }); - - it('enables button after submit', () => { - jest.spyOn(wrapper.vm, 'submit').mockImplementation(); - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(wrapper.vm.$refs.submitButton.props().disabled).toBe(false); - }); - }); - - it('clears title after submit', () => { - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(vm.title).toBe(''); - }); - }); - - it('sets detail issue after submit', () => { - expect(boardsStore.detail.issue.title).toBe(undefined); - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.issue.title).toBe('create issue'); - }); - }); - - it('sets detail list after submit', () => { - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.list.id).toBe(list.id); - }); - }); - - it('sets detail weight after submit', () => { - boardsStore.weightFeatureAvailable = true; - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.list.weight).toBe(list.weight); - }); - }); - - it('does not set detail weight after submit', () => { - boardsStore.weightFeatureAvailable = false; - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.list.weight).toBe(list.weight); - }); - }); - }); - - describe('submit error', () => { - beforeEach(() => { - newIssueMock = Promise.reject(new Error('My hovercraft is full of eels!')); - vm.title = 'error'; - }); - - it('removes issue', () => { - const lengthBefore = list.issues.length; - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(list.issues.length).toBe(lengthBefore); - }); - }); - - it('shows error', () => { - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(vm.error).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js deleted file mode 100644 index 02881333273..00000000000 --- a/spec/frontend/boards/boards_store_spec.js +++ /dev/null @@ -1,1013 +0,0 @@ -import AxiosMockAdapter from 'axios-mock-adapter'; -import { TEST_HOST } from 'helpers/test_constants'; -import eventHub from '~/boards/eventhub'; - -import ListIssue from '~/boards/models/issue'; -import List from '~/boards/models/list'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; -import { listObj, listObjDuplicate } from './mock_data'; - -jest.mock('js-cookie'); - -const createTestIssue = () => ({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [], - assignees: [], -}); - -describe('boardsStore', () => { - const dummyResponse = "without type checking this doesn't matter"; - const boardId = 'dummy-board-id'; - const endpoints = { - boardsEndpoint: `${TEST_HOST}/boards`, - listsEndpoint: `${TEST_HOST}/lists`, - bulkUpdatePath: `${TEST_HOST}/bulk/update`, - recentBoardsEndpoint: `${TEST_HOST}/recent/boards`, - }; - - let axiosMock; - - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - boardsStore.setEndpoints({ - ...endpoints, - boardId, - }); - }); - - afterEach(() => { - axiosMock.restore(); - }); - - const setupDefaultResponses = () => { - axiosMock - .onGet(`${endpoints.listsEndpoint}/${listObj.id}/issues?id=${listObj.id}&page=1`) - .reply(200, { issues: [createTestIssue()] }); - axiosMock.onPost(endpoints.listsEndpoint).reply(200, listObj); - axiosMock.onPut(); - }; - - describe('all', () => { - it('makes a request to fetch lists', () => { - axiosMock.onGet(endpoints.listsEndpoint).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.all()).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onGet(endpoints.listsEndpoint).replyOnce(500); - - return expect(boardsStore.all()).rejects.toThrow(); - }); - }); - - describe('createList', () => { - const entityType = 'moorhen'; - const entityId = 'quack'; - const expectedRequest = expect.objectContaining({ - data: JSON.stringify({ list: { [entityType]: entityId } }), - }); - - let requestSpy; - - beforeEach(() => { - requestSpy = jest.fn(); - axiosMock.onPost(endpoints.listsEndpoint).replyOnce((config) => requestSpy(config)); - }); - - it('makes a request to create a list', () => { - requestSpy.mockReturnValue([200, dummyResponse]); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.createList(entityId, entityType)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.createList(entityId, entityType)) - .rejects.toThrow() - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - }); - - describe('updateList', () => { - const id = 'David Webb'; - const position = 'unknown'; - const collapsed = false; - const expectedRequest = expect.objectContaining({ - data: JSON.stringify({ list: { position, collapsed } }), - }); - - let requestSpy; - - beforeEach(() => { - requestSpy = jest.fn(); - axiosMock.onPut(`${endpoints.listsEndpoint}/${id}`).replyOnce((config) => requestSpy(config)); - }); - - it('makes a request to update a list position', () => { - requestSpy.mockReturnValue([200, dummyResponse]); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.updateList(id, position, collapsed)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.updateList(id, position, collapsed)) - .rejects.toThrow() - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - }); - - describe('destroyList', () => { - const id = '-42'; - - let requestSpy; - - beforeEach(() => { - requestSpy = jest.fn(); - axiosMock - .onDelete(`${endpoints.listsEndpoint}/${id}`) - .replyOnce((config) => requestSpy(config)); - }); - - it('makes a request to delete a list', () => { - requestSpy.mockReturnValue([200, dummyResponse]); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.destroyList(id)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalled(); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.destroyList(id)) - .rejects.toThrow() - .then(() => { - expect(requestSpy).toHaveBeenCalled(); - }); - }); - }); - - describe('saveList', () => { - let list; - - beforeEach(() => { - list = new List(listObj); - setupDefaultResponses(); - }); - - it('makes a request to save a list', () => { - const expectedResponse = expect.objectContaining({ issues: [createTestIssue()] }); - const expectedListValue = { - id: listObj.id, - position: listObj.position, - type: listObj.list_type, - label: listObj.label, - }; - expect(list.id).toBe(listObj.id); - expect(list.position).toBe(listObj.position); - expect(list).toMatchObject(expectedListValue); - - return expect(boardsStore.saveList(list)).resolves.toEqual(expectedResponse); - }); - }); - - describe('getListIssues', () => { - let list; - - beforeEach(() => { - list = new List(listObj); - setupDefaultResponses(); - }); - - it('makes a request to get issues', () => { - const expectedResponse = expect.objectContaining({ issues: [createTestIssue()] }); - expect(list.issues).toEqual([]); - - return expect(boardsStore.getListIssues(list, true)).resolves.toEqual(expectedResponse); - }); - }); - - describe('getIssuesForList', () => { - const id = 'TOO-MUCH'; - const url = `${endpoints.listsEndpoint}/${id}/issues?id=${id}`; - - it('makes a request to fetch list issues', () => { - axiosMock.onGet(url).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.getIssuesForList(id)).resolves.toEqual(expectedResponse); - }); - - it('makes a request to fetch list issues with filter', () => { - const filter = { algal: 'scrubber' }; - axiosMock.onGet(`${url}&algal=scrubber`).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.getIssuesForList(id, filter)).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onGet(url).replyOnce(500); - - return expect(boardsStore.getIssuesForList(id)).rejects.toThrow(); - }); - }); - - describe('moveIssue', () => { - const urlRoot = 'potato'; - const id = 'over 9000'; - const fromListId = 'left'; - const toListId = 'right'; - const moveBeforeId = 'up'; - const moveAfterId = 'down'; - const expectedRequest = expect.objectContaining({ - data: JSON.stringify({ - from_list_id: fromListId, - to_list_id: toListId, - move_before_id: moveBeforeId, - move_after_id: moveAfterId, - }), - }); - - let requestSpy; - - beforeAll(() => { - global.gon.relative_url_root = urlRoot; - }); - - afterAll(() => { - delete global.gon.relative_url_root; - }); - - beforeEach(() => { - requestSpy = jest.fn(); - axiosMock - .onPut(`${urlRoot}/-/boards/${boardId}/issues/${id}`) - .replyOnce((config) => requestSpy(config)); - }); - - it('makes a request to move an issue between lists', () => { - requestSpy.mockReturnValue([200, dummyResponse]); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId)) - .rejects.toThrow() - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - }); - - describe('newIssue', () => { - const id = 1; - const issue = { some: 'issue data' }; - const url = `${endpoints.listsEndpoint}/${id}/issues`; - const expectedRequest = expect.objectContaining({ - data: JSON.stringify({ - issue, - }), - }); - - let requestSpy; - - beforeEach(() => { - requestSpy = jest.fn(); - axiosMock.onPost(url).replyOnce((config) => requestSpy(config)); - }); - - it('makes a request to create a new issue', () => { - requestSpy.mockReturnValue([200, dummyResponse]); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.newIssue(id, issue)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.newIssue(id, issue)) - .rejects.toThrow() - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - }); - - describe('getBacklog', () => { - const urlRoot = 'deep'; - const url = `${urlRoot}/-/boards/${boardId}/issues.json?not=relevant`; - const requestParams = { - not: 'relevant', - }; - - beforeAll(() => { - global.gon.relative_url_root = urlRoot; - }); - - afterAll(() => { - delete global.gon.relative_url_root; - }); - - it('makes a request to fetch backlog', () => { - axiosMock.onGet(url).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.getBacklog(requestParams)).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onGet(url).replyOnce(500); - - return expect(boardsStore.getBacklog(requestParams)).rejects.toThrow(); - }); - }); - - describe('bulkUpdate', () => { - const issueIds = [1, 2, 3]; - const extraData = { moar: 'data' }; - const expectedRequest = expect.objectContaining({ - data: JSON.stringify({ - update: { - ...extraData, - issuable_ids: '1,2,3', - }, - }), - }); - - let requestSpy; - - beforeEach(() => { - requestSpy = jest.fn(); - axiosMock.onPost(endpoints.bulkUpdatePath).replyOnce((config) => requestSpy(config)); - }); - - it('makes a request to create a list', () => { - requestSpy.mockReturnValue([200, dummyResponse]); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.bulkUpdate(issueIds, extraData)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.bulkUpdate(issueIds, extraData)) - .rejects.toThrow() - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - }); - - describe('getIssueInfo', () => { - const dummyEndpoint = `${TEST_HOST}/some/where`; - - it('makes a request to the given endpoint', () => { - axiosMock.onGet(dummyEndpoint).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.getIssueInfo(dummyEndpoint)).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onGet(dummyEndpoint).replyOnce(500); - - return expect(boardsStore.getIssueInfo(dummyEndpoint)).rejects.toThrow(); - }); - }); - - describe('toggleIssueSubscription', () => { - const dummyEndpoint = `${TEST_HOST}/some/where`; - - it('makes a request to the given endpoint', () => { - axiosMock.onPost(dummyEndpoint).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.toggleIssueSubscription(dummyEndpoint)).resolves.toEqual( - expectedResponse, - ); - }); - - it('fails for error response', () => { - axiosMock.onPost(dummyEndpoint).replyOnce(500); - - return expect(boardsStore.toggleIssueSubscription(dummyEndpoint)).rejects.toThrow(); - }); - }); - - describe('recentBoards', () => { - const url = `${endpoints.recentBoardsEndpoint}.json`; - - it('makes a request to fetch all boards', () => { - axiosMock.onGet(url).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.recentBoards()).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onGet(url).replyOnce(500); - - return expect(boardsStore.recentBoards()).rejects.toThrow(); - }); - }); - - describe('when created', () => { - beforeEach(() => { - setupDefaultResponses(); - - jest.spyOn(boardsStore, 'moveIssue').mockReturnValue(Promise.resolve()); - jest.spyOn(boardsStore, 'moveMultipleIssues').mockReturnValue(Promise.resolve()); - - boardsStore.create(); - }); - - it('starts with a blank state', () => { - expect(boardsStore.state.lists.length).toBe(0); - }); - - describe('addList', () => { - it('sorts by position', () => { - boardsStore.addList({ position: 2 }); - boardsStore.addList({ position: 1 }); - - expect(boardsStore.state.lists.map(({ position }) => position)).toEqual([1, 2]); - }); - }); - - describe('toggleFilter', () => { - const dummyFilter = 'x=42'; - let updateTokensSpy; - - beforeEach(() => { - updateTokensSpy = jest.fn(); - eventHub.$once('updateTokens', updateTokensSpy); - - // prevent using window.history - jest.spyOn(boardsStore, 'updateFiltersUrl').mockReturnValue(); - }); - - it('adds the filter if it is not present', () => { - boardsStore.filter.path = 'something'; - - boardsStore.toggleFilter(dummyFilter); - - expect(boardsStore.filter.path).toEqual(`something&${dummyFilter}`); - expect(updateTokensSpy).toHaveBeenCalled(); - expect(boardsStore.updateFiltersUrl).toHaveBeenCalled(); - }); - - it('removes the filter if it is present', () => { - boardsStore.filter.path = `something&${dummyFilter}`; - - boardsStore.toggleFilter(dummyFilter); - - expect(boardsStore.filter.path).toEqual('something'); - expect(updateTokensSpy).toHaveBeenCalled(); - expect(boardsStore.updateFiltersUrl).toHaveBeenCalled(); - }); - }); - - describe('lists', () => { - it('creates new list without persisting to DB', () => { - expect(boardsStore.state.lists.length).toBe(0); - - boardsStore.addList(listObj); - - expect(boardsStore.state.lists.length).toBe(1); - }); - - it('finds list by ID', () => { - boardsStore.addList(listObj); - const list = boardsStore.findList('id', listObj.id); - - expect(list.id).toBe(listObj.id); - }); - - it('finds list by type', () => { - boardsStore.addList(listObj); - const list = boardsStore.findList('type', 'label'); - - expect(list).toBeDefined(); - }); - - it('finds list by label ID', () => { - boardsStore.addList(listObj); - const list = boardsStore.findListByLabelId(listObj.label.id); - - expect(list.id).toBe(listObj.id); - }); - - it('gets issue when new list added', () => { - boardsStore.addList(listObj); - const list = boardsStore.findList('id', listObj.id); - - expect(boardsStore.state.lists.length).toBe(1); - - return axios.waitForAll().then(() => { - expect(list.issues.length).toBe(1); - expect(list.issues[0].id).toBe(1); - }); - }); - - it('persists new list', () => { - boardsStore.new({ - title: 'Test', - list_type: 'label', - label: { - id: 1, - title: 'Testing', - color: 'red', - description: 'testing;', - }, - }); - - expect(boardsStore.state.lists.length).toBe(1); - - return axios.waitForAll().then(() => { - const list = boardsStore.findList('id', listObj.id); - - expect(list).toEqual( - expect.objectContaining({ - id: listObj.id, - position: 0, - }), - ); - }); - }); - - it('removes list from state', () => { - boardsStore.addList(listObj); - - expect(boardsStore.state.lists.length).toBe(1); - - boardsStore.removeList(listObj.id); - - expect(boardsStore.state.lists.length).toBe(0); - }); - - it('moves the position of lists', () => { - const listOne = boardsStore.addList(listObj); - boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - boardsStore.moveList(listOne, [listObjDuplicate.id, listObj.id]); - - expect(listOne.position).toBe(1); - }); - - it('moves an issue from one list to another', () => { - const listOne = boardsStore.addList(listObj); - const listTwo = boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - return axios.waitForAll().then(() => { - expect(listOne.issues.length).toBe(1); - expect(listTwo.issues.length).toBe(1); - - boardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(1)); - - expect(listOne.issues.length).toBe(0); - expect(listTwo.issues.length).toBe(1); - }); - }); - - it('moves an issue from backlog to a list', () => { - const backlog = boardsStore.addList({ - ...listObj, - list_type: 'backlog', - }); - const listTwo = boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - return axios.waitForAll().then(() => { - expect(backlog.issues.length).toBe(1); - expect(listTwo.issues.length).toBe(1); - - boardsStore.moveIssueToList(backlog, listTwo, backlog.findIssue(1)); - - expect(backlog.issues.length).toBe(0); - expect(listTwo.issues.length).toBe(1); - }); - }); - - it('moves issue to top of another list', () => { - const listOne = boardsStore.addList(listObj); - const listTwo = boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - return axios.waitForAll().then(() => { - listOne.issues[0].id = 2; - - expect(listOne.issues.length).toBe(1); - expect(listTwo.issues.length).toBe(1); - - boardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(2), 0); - - expect(listOne.issues.length).toBe(0); - expect(listTwo.issues.length).toBe(2); - expect(listTwo.issues[0].id).toBe(2); - expect(boardsStore.moveIssue).toHaveBeenCalledWith(2, listOne.id, listTwo.id, null, 1); - }); - }); - - it('moves issue to bottom of another list', () => { - const listOne = boardsStore.addList(listObj); - const listTwo = boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - return axios.waitForAll().then(() => { - listOne.issues[0].id = 2; - - expect(listOne.issues.length).toBe(1); - expect(listTwo.issues.length).toBe(1); - - boardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(2), 1); - - expect(listOne.issues.length).toBe(0); - expect(listTwo.issues.length).toBe(2); - expect(listTwo.issues[1].id).toBe(2); - expect(boardsStore.moveIssue).toHaveBeenCalledWith(2, listOne.id, listTwo.id, 1, null); - }); - }); - - it('moves issue in list', () => { - const issue = new ListIssue({ - title: 'Testing', - id: 2, - iid: 2, - confidential: false, - labels: [], - assignees: [], - }); - const list = boardsStore.addList(listObj); - - return axios.waitForAll().then(() => { - list.addIssue(issue); - - expect(list.issues.length).toBe(2); - - boardsStore.moveIssueInList(list, issue, 0, 1, [1, 2]); - - expect(list.issues[0].id).toBe(2); - expect(boardsStore.moveIssue).toHaveBeenCalledWith(2, null, null, 1, null); - }); - }); - }); - - describe('setListDetail', () => { - it('sets the list detail', () => { - boardsStore.detail.list = 'not a list'; - - const dummyValue = 'new list'; - boardsStore.setListDetail(dummyValue); - - expect(boardsStore.detail.list).toEqual(dummyValue); - }); - }); - - describe('clearDetailIssue', () => { - it('resets issue details', () => { - boardsStore.detail.issue = 'something'; - - boardsStore.clearDetailIssue(); - - expect(boardsStore.detail.issue).toEqual({}); - }); - }); - - describe('setIssueDetail', () => { - it('sets issue details', () => { - boardsStore.detail.issue = 'some details'; - - const dummyValue = 'new details'; - boardsStore.setIssueDetail(dummyValue); - - expect(boardsStore.detail.issue).toEqual(dummyValue); - }); - }); - - describe('startMoving', () => { - it('stores list and issue', () => { - const dummyIssue = 'some issue'; - const dummyList = 'some list'; - - boardsStore.startMoving(dummyList, dummyIssue); - - expect(boardsStore.moving.issue).toEqual(dummyIssue); - expect(boardsStore.moving.list).toEqual(dummyList); - }); - }); - - describe('setTimeTrackingLimitToHours', () => { - it('sets the timeTracking.LimitToHours option', () => { - boardsStore.timeTracking.limitToHours = false; - - boardsStore.setTimeTrackingLimitToHours('true'); - - expect(boardsStore.timeTracking.limitToHours).toEqual(true); - }); - }); - - describe('setCurrentBoard', () => { - const dummyBoard = 'hoverboard'; - - it('sets the current board', () => { - const { state } = boardsStore; - state.currentBoard = null; - - boardsStore.setCurrentBoard(dummyBoard); - - expect(state.currentBoard).toEqual(dummyBoard); - }); - }); - - describe('toggleMultiSelect', () => { - let basicIssueObj; - - beforeAll(() => { - basicIssueObj = { id: 987654 }; - }); - - afterEach(() => { - boardsStore.clearMultiSelect(); - }); - - it('adds issue when not present', () => { - boardsStore.toggleMultiSelect(basicIssueObj); - - const selectedIds = boardsStore.multiSelect.list.map(({ id }) => id); - - expect(selectedIds.includes(basicIssueObj.id)).toEqual(true); - }); - - it('removes issue when issue is present', () => { - boardsStore.toggleMultiSelect(basicIssueObj); - let selectedIds = boardsStore.multiSelect.list.map(({ id }) => id); - - expect(selectedIds.includes(basicIssueObj.id)).toEqual(true); - - boardsStore.toggleMultiSelect(basicIssueObj); - selectedIds = boardsStore.multiSelect.list.map(({ id }) => id); - - expect(selectedIds.includes(basicIssueObj.id)).toEqual(false); - }); - }); - - describe('clearMultiSelect', () => { - it('clears all the multi selected issues', () => { - const issue1 = { id: 12345 }; - const issue2 = { id: 12346 }; - - boardsStore.toggleMultiSelect(issue1); - boardsStore.toggleMultiSelect(issue2); - - expect(boardsStore.multiSelect.list.length).toEqual(2); - - boardsStore.clearMultiSelect(); - - expect(boardsStore.multiSelect.list.length).toEqual(0); - }); - }); - - describe('moveMultipleIssuesToList', () => { - it('move issues on the new index', () => { - const listOne = boardsStore.addList(listObj); - const listTwo = boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - return axios.waitForAll().then(() => { - expect(listOne.issues.length).toBe(1); - expect(listTwo.issues.length).toBe(1); - - boardsStore.moveMultipleIssuesToList({ - listFrom: listOne, - listTo: listTwo, - issues: listOne.issues, - newIndex: 0, - }); - - expect(listTwo.issues.length).toBe(1); - }); - }); - }); - - describe('moveMultipleIssuesInList', () => { - it('moves multiple issues in list', () => { - const issueObj = { - title: 'Issue #1', - id: 12345, - iid: 2, - confidential: false, - labels: [], - assignees: [], - }; - const issue1 = new ListIssue(issueObj); - const issue2 = new ListIssue({ - ...issueObj, - title: 'Issue #2', - id: 12346, - }); - - const list = boardsStore.addList(listObj); - - return axios.waitForAll().then(() => { - list.addIssue(issue1); - list.addIssue(issue2); - - expect(list.issues.length).toBe(3); - expect(list.issues[0].id).not.toBe(issue2.id); - - boardsStore.moveMultipleIssuesInList({ - list, - issues: [issue1, issue2], - oldIndicies: [0], - newIndex: 1, - idArray: [1, 12345, 12346], - }); - - expect(list.issues[0].id).toBe(issue1.id); - - expect(boardsStore.moveMultipleIssues).toHaveBeenCalledWith({ - ids: [issue1.id, issue2.id], - fromListId: null, - toListId: null, - moveBeforeId: 1, - moveAfterId: null, - }); - }); - }); - }); - - describe('addListIssue', () => { - let list; - const issue1 = new ListIssue({ - title: 'Testing', - id: 2, - iid: 2, - confidential: false, - labels: [ - { - color: '#ff0000', - description: 'testing;', - id: 5000, - priority: undefined, - textColor: 'white', - title: 'Test', - }, - ], - assignees: [], - }); - const issue2 = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [ - { - id: 1, - title: 'test', - color: 'red', - description: 'testing', - }, - ], - assignees: [ - { - id: 1, - name: 'name', - username: 'username', - avatar_url: 'http://avatar_url', - }, - ], - real_path: 'path/to/issue', - }); - - beforeEach(() => { - list = new List(listObj); - list.addIssue(issue1); - setupDefaultResponses(); - }); - - it('adds issues that are not already on the list', () => { - expect(list.findIssue(issue2.id)).toBe(undefined); - expect(list.issues).toEqual([issue1]); - - boardsStore.addListIssue(list, issue2); - expect(list.findIssue(issue2.id)).toBe(issue2); - expect(list.issues.length).toBe(2); - expect(list.issues).toEqual([issue1, issue2]); - }); - }); - - describe('updateIssue', () => { - let issue; - let patchSpy; - - beforeEach(() => { - issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [ - { - id: 1, - title: 'test', - color: 'red', - description: 'testing', - }, - ], - assignees: [ - { - id: 1, - name: 'name', - username: 'username', - avatar_url: 'http://avatar_url', - }, - ], - real_path: 'path/to/issue', - }); - - patchSpy = jest.fn().mockReturnValue([200, { labels: [] }]); - axiosMock.onPatch(`path/to/issue.json`).reply(({ data }) => patchSpy(JSON.parse(data))); - }); - - it('passes assignee ids when there are assignees', () => { - boardsStore.updateIssue(issue); - return boardsStore.updateIssue(issue).then(() => { - expect(patchSpy).toHaveBeenCalledWith({ - issue: { - milestone_id: null, - assignee_ids: [1], - label_ids: [1], - }, - }); - }); - }); - - it('passes assignee ids of [0] when there are no assignees', () => { - issue.removeAllAssignees(); - - return boardsStore.updateIssue(issue).then(() => { - expect(patchSpy).toHaveBeenCalledWith({ - issue: { - milestone_id: null, - assignee_ids: [0], - label_ids: [1], - }, - }); - }); - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_add_new_column_spec.js b/spec/frontend/boards/components/board_add_new_column_spec.js index 61f210f566b..5fae1c4359f 100644 --- a/spec/frontend/boards/components/board_add_new_column_spec.js +++ b/spec/frontend/boards/components/board_add_new_column_spec.js @@ -48,7 +48,6 @@ describe('Board card layout', () => { ...actions, }, getters: { - shouldUseGraphQL: () => true, getListByLabelId: () => getListByLabelId, }, state: { diff --git a/spec/frontend/boards/components/board_app_spec.js b/spec/frontend/boards/components/board_app_spec.js new file mode 100644 index 00000000000..dee097bfb08 --- /dev/null +++ b/spec/frontend/boards/components/board_app_spec.js @@ -0,0 +1,54 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; + +import BoardApp from '~/boards/components/board_app.vue'; + +describe('BoardApp', () => { + let wrapper; + let store; + + Vue.use(Vuex); + + const createStore = ({ mockGetters = {} } = {}) => { + store = new Vuex.Store({ + state: {}, + actions: { + performSearch: jest.fn(), + }, + getters: { + isSidebarOpen: () => true, + ...mockGetters, + }, + }); + }; + + const createComponent = ({ provide = { disabled: true } } = {}) => { + wrapper = shallowMount(BoardApp, { + store, + provide: { + ...provide, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + store = null; + }); + + it("should have 'is-compact' class when sidebar is open", () => { + createStore(); + createComponent(); + + expect(wrapper.classes()).toContain('is-compact'); + }); + + it("should not have 'is-compact' class when sidebar is closed", () => { + createStore({ mockGetters: { isSidebarOpen: () => false } }); + createComponent(); + + expect(wrapper.classes()).not.toContain('is-compact'); + }); +}); diff --git a/spec/frontend/boards/components/board_card_deprecated_spec.js b/spec/frontend/boards/components/board_card_deprecated_spec.js deleted file mode 100644 index 266cbc7106d..00000000000 --- a/spec/frontend/boards/components/board_card_deprecated_spec.js +++ /dev/null @@ -1,219 +0,0 @@ -/* global List */ -/* global ListAssignee */ -/* global ListLabel */ - -import { mount } from '@vue/test-utils'; - -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import BoardCardDeprecated from '~/boards/components/board_card_deprecated.vue'; -import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; -import eventHub from '~/boards/eventhub'; -import store from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; - -import sidebarEventHub from '~/sidebar/event_hub'; -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/list'; -import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; - -describe('BoardCard', () => { - let wrapper; - let mock; - let list; - - const findIssueCardInner = () => wrapper.find(issueCardInner); - const findUserAvatarLink = () => wrapper.find(userAvatarLink); - - // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = (propsData) => { - wrapper = mount(BoardCardDeprecated, { - stubs: { - issueCardInner, - }, - store, - propsData: { - list, - issue: list.issues[0], - disabled: false, - index: 0, - ...propsData, - }, - provide: { - groupId: null, - rootPath: '/', - scopedLabelsAvailable: false, - }, - }); - }; - - const setupData = async () => { - list = new List(listObj); - boardsStore.create(); - boardsStore.detail.issue = {}; - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: '#000cff', - text_color: 'white', - description: 'test', - }); - await waitForPromises(); - - list.issues[0].labels.push(label1); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - setMockEndpoints(); - return setupData(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - list = null; - mock.restore(); - }); - - it('when details issue is empty does not show the element', () => { - mountComponent(); - expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active'); - }); - - it('when detailIssue is equal to card issue shows the element', () => { - [boardsStore.detail.issue] = list.issues; - mountComponent(); - - expect(wrapper.classes()).toContain('is-active'); - }); - - it('when multiSelect does not contain issue removes multi select class', () => { - mountComponent(); - expect(wrapper.classes()).not.toContain('multi-select'); - }); - - it('when multiSelect contain issue add multi select class', () => { - boardsStore.multiSelect.list = [list.issues[0]]; - mountComponent(); - - expect(wrapper.classes()).toContain('multi-select'); - }); - - it('adds user-can-drag class if not disabled', () => { - mountComponent(); - expect(wrapper.classes()).toContain('user-can-drag'); - }); - - it('does not add user-can-drag class disabled', () => { - mountComponent({ disabled: true }); - - expect(wrapper.classes()).not.toContain('user-can-drag'); - }); - - it('does not add disabled class', () => { - mountComponent(); - expect(wrapper.classes()).not.toContain('is-disabled'); - }); - - it('adds disabled class is disabled is true', () => { - mountComponent({ disabled: true }); - - expect(wrapper.classes()).toContain('is-disabled'); - }); - - describe('mouse events', () => { - it('does not set detail issue if showDetail is false', () => { - mountComponent(); - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if link is clicked', () => { - mountComponent(); - findIssueCardInner().find('a').trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if img is clicked', () => { - mountComponent({ - issue: { - ...list.issues[0], - assignees: [ - new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - avatar: 'test_image', - }), - ], - }, - }); - - findUserAvatarLink().trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if showDetail is false after mouseup', () => { - mountComponent(); - wrapper.trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('sets detail issue to card issue on mouse up', () => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - - mountComponent(); - - wrapper.trigger('mousedown'); - wrapper.trigger('mouseup'); - - expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false); - expect(boardsStore.detail.list).toEqual(wrapper.vm.list); - }); - - it('resets detail issue to empty if already set', () => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - const [issue] = list.issues; - boardsStore.detail.issue = issue; - mountComponent(); - - wrapper.trigger('mousedown'); - wrapper.trigger('mouseup'); - - expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false); - }); - }); - - describe('sidebarHub events', () => { - it('closes all sidebars before showing an issue if no issues are opened', () => { - jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); - boardsStore.detail.issue = {}; - mountComponent(); - - // sets conditional so that event is emitted. - wrapper.trigger('mousedown'); - - wrapper.trigger('mouseup'); - - expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll'); - }); - - it('it does not closes all sidebars before showing an issue if an issue is opened', () => { - jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); - const [issue] = list.issues; - boardsStore.detail.issue = issue; - mountComponent(); - - wrapper.trigger('mousedown'); - - expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll'); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js b/spec/frontend/boards/components/board_card_layout_deprecated_spec.js deleted file mode 100644 index 9853c9f434f..00000000000 --- a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js +++ /dev/null @@ -1,158 +0,0 @@ -/* global List */ -/* global ListLabel */ - -import { createLocalVue, shallowMount } from '@vue/test-utils'; - -import MockAdapter from 'axios-mock-adapter'; -import Vuex from 'vuex'; -import waitForPromises from 'helpers/wait_for_promises'; - -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/list'; -import BoardCardLayout from '~/boards/components/board_card_layout_deprecated.vue'; -import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; -import { ISSUABLE } from '~/boards/constants'; -import boardsVuexStore from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; -import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; - -describe('Board card layout', () => { - let wrapper; - let mock; - let list; - let store; - - const localVue = createLocalVue(); - localVue.use(Vuex); - - const createStore = ({ getters = {}, actions = {} } = {}) => { - store = new Vuex.Store({ - ...boardsVuexStore, - actions, - getters, - }); - }; - - // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = ({ propsData = {}, provide = {} } = {}) => { - wrapper = shallowMount(BoardCardLayout, { - localVue, - stubs: { - issueCardInner, - }, - store, - propsData: { - list, - issue: list.issues[0], - disabled: false, - index: 0, - ...propsData, - }, - provide: { - groupId: null, - rootPath: '/', - scopedLabelsAvailable: false, - ...provide, - }, - }); - }; - - const setupData = () => { - list = new List(listObj); - boardsStore.create(); - boardsStore.detail.issue = {}; - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: '#000cff', - text_color: 'white', - description: 'test', - }); - return waitForPromises().then(() => { - list.issues[0].labels.push(label1); - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - setMockEndpoints(); - return setupData(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - list = null; - mock.restore(); - }); - - describe('mouse events', () => { - it('sets showDetail to true on mousedown', async () => { - createStore(); - mountComponent(); - - wrapper.trigger('mousedown'); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.showDetail).toBe(true); - }); - - it('sets showDetail to false on mousemove', async () => { - createStore(); - mountComponent(); - wrapper.trigger('mousedown'); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.showDetail).toBe(true); - wrapper.trigger('mousemove'); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.showDetail).toBe(false); - }); - - it("calls 'setActiveId' when 'graphqlBoardLists' feature flag is turned on", async () => { - const setActiveId = jest.fn(); - createStore({ - actions: { - setActiveId, - }, - }); - mountComponent({ - provide: { - glFeatures: { graphqlBoardLists: true }, - }, - }); - - wrapper.trigger('mouseup'); - await wrapper.vm.$nextTick(); - - expect(setActiveId).toHaveBeenCalledTimes(1); - expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { - id: list.issues[0].id, - sidebarType: ISSUABLE, - }); - }); - - it("calls 'setActiveId' when epic swimlanes is active", async () => { - const setActiveId = jest.fn(); - const isSwimlanesOn = () => true; - createStore({ - getters: { isSwimlanesOn }, - actions: { - setActiveId, - }, - }); - mountComponent(); - - wrapper.trigger('mouseup'); - await wrapper.vm.$nextTick(); - - expect(setActiveId).toHaveBeenCalledTimes(1); - expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { - id: list.issues[0].id, - sidebarType: ISSUABLE, - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_column_deprecated_spec.js b/spec/frontend/boards/components/board_column_deprecated_spec.js deleted file mode 100644 index e6d65e48c3f..00000000000 --- a/spec/frontend/boards/components/board_column_deprecated_spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import Vue, { nextTick } from 'vue'; - -import { TEST_HOST } from 'helpers/test_constants'; -import { listObj } from 'jest/boards/mock_data'; -import Board from '~/boards/components/board_column_deprecated.vue'; -import { ListType } from '~/boards/constants'; -import List from '~/boards/models/list'; -import axios from '~/lib/utils/axios_utils'; - -describe('Board Column Component', () => { - let wrapper; - let axiosMock; - - beforeEach(() => { - window.gon = {}; - axiosMock = new AxiosMockAdapter(axios); - axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); - }); - - afterEach(() => { - axiosMock.restore(); - - wrapper.destroy(); - - localStorage.clear(); - }); - - const createComponent = ({ - listType = ListType.backlog, - collapsed = false, - highlighted = false, - withLocalStorage = true, - } = {}) => { - const boardId = '1'; - - const listMock = { - ...listObj, - list_type: listType, - highlighted, - collapsed, - }; - - if (listType === ListType.assignee) { - delete listMock.label; - listMock.user = {}; - } - - // Making List reactive - const list = Vue.observable(new List(listMock)); - - if (withLocalStorage) { - localStorage.setItem( - `boards.${boardId}.${list.type}.${list.id}.expanded`, - (!collapsed).toString(), - ); - } - - wrapper = shallowMount(Board, { - propsData: { - boardId, - disabled: false, - list, - }, - provide: { - boardId, - }, - }); - }; - - const isExpandable = () => wrapper.classes('is-expandable'); - const isCollapsed = () => wrapper.classes('is-collapsed'); - - describe('Given different list types', () => { - it('is expandable when List Type is `backlog`', () => { - createComponent({ listType: ListType.backlog }); - - expect(isExpandable()).toBe(true); - }); - }); - - describe('expanded / collapsed column', () => { - it('has class is-collapsed when list is collapsed', () => { - createComponent({ collapsed: false }); - - expect(wrapper.vm.list.isExpanded).toBe(true); - }); - - it('does not have class is-collapsed when list is expanded', () => { - createComponent({ collapsed: true }); - - expect(isCollapsed()).toBe(true); - }); - }); - - describe('highlighting', () => { - it('scrolls to column when highlighted', async () => { - createComponent({ highlighted: true }); - - await nextTick(); - - expect(wrapper.element.scrollIntoView).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 5a799b6388e..f535679b8a0 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -5,9 +5,10 @@ import Draggable from 'vuedraggable'; import Vuex from 'vuex'; import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; import getters from 'ee_else_ce/boards/stores/getters'; -import BoardColumnDeprecated from '~/boards/components/board_column_deprecated.vue'; +import BoardColumn from '~/boards/components/board_column.vue'; import BoardContent from '~/boards/components/board_content.vue'; -import { mockLists, mockListsWithModel } from '../mock_data'; +import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; +import { mockLists } from '../mock_data'; Vue.use(Vuex); @@ -23,6 +24,7 @@ describe('BoardContent', () => { isShowingEpicsSwimlanes: false, boardLists: mockLists, error: undefined, + issuableType: 'issue', }; const createStore = (state = defaultState) => { @@ -33,25 +35,19 @@ describe('BoardContent', () => { }); }; - const createComponent = ({ - state, - props = {}, - graphqlBoardListsEnabled = false, - canAdminList = true, - } = {}) => { + const createComponent = ({ state, props = {}, canAdminList = true } = {}) => { const store = createStore({ ...defaultState, ...state, }); wrapper = shallowMount(BoardContent, { propsData: { - lists: mockListsWithModel, + lists: mockLists, disabled: false, ...props, }, provide: { canAdminList, - glFeatures: { graphqlBoardLists: graphqlBoardListsEnabled }, }, store, }); @@ -61,53 +57,48 @@ describe('BoardContent', () => { wrapper.destroy(); }); - it('renders a BoardColumnDeprecated component per list', () => { - createComponent(); + describe('default', () => { + beforeEach(() => { + createComponent(); + }); - expect(wrapper.findAllComponents(BoardColumnDeprecated)).toHaveLength( - mockListsWithModel.length, - ); - }); + it('renders a BoardColumn component per list', () => { + expect(wrapper.findAllComponents(BoardColumn)).toHaveLength(mockLists.length); + }); - it('does not display EpicsSwimlanes component', () => { - createComponent(); + it('renders BoardContentSidebar', () => { + expect(wrapper.find(BoardContentSidebar).exists()).toBe(true); + }); - expect(wrapper.find(EpicsSwimlanes).exists()).toBe(false); - expect(wrapper.find(GlAlert).exists()).toBe(false); + it('does not display EpicsSwimlanes component', () => { + expect(wrapper.find(EpicsSwimlanes).exists()).toBe(false); + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); }); - describe('graphqlBoardLists feature flag enabled', () => { + describe('when issuableType is not issue', () => { beforeEach(() => { - createComponent({ graphqlBoardListsEnabled: true }); - gon.features = { - graphqlBoardLists: true, - }; + createComponent({ state: { issuableType: 'foo' } }); }); - describe('can admin list', () => { - beforeEach(() => { - createComponent({ graphqlBoardListsEnabled: true, canAdminList: true }); - }); - - it('renders draggable component', () => { - expect(wrapper.find(Draggable).exists()).toBe(true); - }); + it('does not render BoardContentSidebar', () => { + expect(wrapper.find(BoardContentSidebar).exists()).toBe(false); }); + }); - describe('can not admin list', () => { - beforeEach(() => { - createComponent({ graphqlBoardListsEnabled: true, canAdminList: false }); - }); + describe('can admin list', () => { + beforeEach(() => { + createComponent({ canAdminList: true }); + }); - it('does not render draggable component', () => { - expect(wrapper.find(Draggable).exists()).toBe(false); - }); + it('renders draggable component', () => { + expect(wrapper.find(Draggable).exists()).toBe(true); }); }); - describe('graphqlBoardLists feature flag disabled', () => { + describe('can not admin list', () => { beforeEach(() => { - createComponent({ graphqlBoardListsEnabled: false }); + createComponent({ canAdminList: false }); }); it('does not render draggable component', () => { diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index 50f86e92adb..dc93890f27a 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -2,7 +2,6 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; -import { createStore } from '~/boards/stores'; import * as urlUtility from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; @@ -44,6 +43,12 @@ describe('BoardFilteredSearch', () => { ]; const createComponent = ({ initialFilterParams = {} } = {}) => { + store = new Vuex.Store({ + actions: { + performSearch: jest.fn(), + }, + }); + wrapper = shallowMount(BoardFilteredSearch, { provide: { initialFilterParams, fullPath: '' }, store, @@ -55,22 +60,15 @@ describe('BoardFilteredSearch', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBarRoot); - beforeEach(() => { - // this needed for actions call for performSearch - window.gon = { features: {} }; - }); - afterEach(() => { wrapper.destroy(); }); describe('default', () => { beforeEach(() => { - store = createStore(); + createComponent(); jest.spyOn(store, 'dispatch'); - - createComponent(); }); it('renders FilteredSearch', () => { @@ -103,8 +101,6 @@ describe('BoardFilteredSearch', () => { describe('when searching', () => { beforeEach(() => { - store = createStore(); - createComponent(); jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(); @@ -133,11 +129,9 @@ describe('BoardFilteredSearch', () => { describe('when url params are already set', () => { beforeEach(() => { - store = createStore(); + createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } }); jest.spyOn(store, 'dispatch'); - - createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } }); }); it('passes the correct props to FilterSearchBar', () => { diff --git a/spec/frontend/boards/components/board_list_header_deprecated_spec.js b/spec/frontend/boards/components/board_list_header_deprecated_spec.js deleted file mode 100644 index db79e67fe78..00000000000 --- a/spec/frontend/boards/components/board_list_header_deprecated_spec.js +++ /dev/null @@ -1,174 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; - -import { TEST_HOST } from 'helpers/test_constants'; -import { listObj } from 'jest/boards/mock_data'; -import BoardListHeader from '~/boards/components/board_list_header_deprecated.vue'; -import { ListType } from '~/boards/constants'; -import List from '~/boards/models/list'; -import axios from '~/lib/utils/axios_utils'; - -describe('Board List Header Component', () => { - let wrapper; - let axiosMock; - - beforeEach(() => { - window.gon = {}; - axiosMock = new AxiosMockAdapter(axios); - axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); - }); - - afterEach(() => { - axiosMock.restore(); - - wrapper.destroy(); - - localStorage.clear(); - }); - - const createComponent = ({ - listType = ListType.backlog, - collapsed = false, - withLocalStorage = true, - currentUserId = 1, - } = {}) => { - const boardId = '1'; - - const listMock = { - ...listObj, - list_type: listType, - collapsed, - }; - - if (listType === ListType.assignee) { - delete listMock.label; - listMock.user = {}; - } - - // Making List reactive - const list = Vue.observable(new List(listMock)); - - if (withLocalStorage) { - localStorage.setItem( - `boards.${boardId}.${list.type}.${list.id}.expanded`, - (!collapsed).toString(), - ); - } - - wrapper = shallowMount(BoardListHeader, { - propsData: { - disabled: false, - list, - }, - provide: { - boardId, - currentUserId, - }, - }); - }; - - const isCollapsed = () => !wrapper.props().list.isExpanded; - const isExpanded = () => wrapper.vm.list.isExpanded; - - const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); - const findCaret = () => wrapper.find('.board-title-caret'); - - describe('Add issue button', () => { - const hasNoAddButton = [ListType.closed]; - const hasAddButton = [ - ListType.backlog, - ListType.label, - ListType.milestone, - ListType.iteration, - ListType.assignee, - ]; - - it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => { - createComponent({ listType }); - - expect(findAddIssueButton().exists()).toBe(false); - }); - - it.each(hasAddButton)('does render when List Type is `%s`', (listType) => { - createComponent({ listType }); - - expect(findAddIssueButton().exists()).toBe(true); - }); - - it('has a test for each list type', () => { - Object.values(ListType).forEach((value) => { - expect([...hasAddButton, ...hasNoAddButton]).toContain(value); - }); - }); - - it('does not render when logged out', () => { - createComponent({ - currentUserId: null, - }); - - expect(findAddIssueButton().exists()).toBe(false); - }); - }); - - describe('expanding / collapsing the column', () => { - it('does not collapse when clicking the header', () => { - createComponent(); - - expect(isCollapsed()).toBe(false); - wrapper.find('[data-testid="board-list-header"]').trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(false); - }); - }); - - it('collapses expanded Column when clicking the collapse icon', () => { - createComponent(); - - expect(isExpanded()).toBe(true); - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(true); - }); - }); - - it('expands collapsed Column when clicking the expand icon', () => { - createComponent({ collapsed: true }); - - expect(isCollapsed()).toBe(true); - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(false); - }); - }); - - it("when logged in it calls list update and doesn't set localStorage", () => { - jest.spyOn(List.prototype, 'update'); - - createComponent({ withLocalStorage: false }); - - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); - }); - }); - - it("when logged out it doesn't call list update and sets localStorage", () => { - jest.spyOn(List.prototype, 'update'); - - createComponent({ currentUserId: null }); - - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.list.update).not.toHaveBeenCalled(); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js index 20a08be6c19..46dd109ffb1 100644 --- a/spec/frontend/boards/components/board_settings_sidebar_spec.js +++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js @@ -1,38 +1,55 @@ -import '~/boards/models/list'; import { GlDrawer, GlLabel } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import { shallowMount } from '@vue/test-utils'; import { MountingPortal } from 'portal-vue'; +import Vue from 'vue'; import Vuex from 'vuex'; +import { stubComponent } from 'helpers/stub_component'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue'; import { inactiveId, LIST } from '~/boards/constants'; -import { createStore } from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; +import actions from '~/boards/stores/actions'; +import getters from '~/boards/stores/getters'; +import mutations from '~/boards/stores/mutations'; import sidebarEventHub from '~/sidebar/event_hub'; +import { mockLabelList } from '../mock_data'; -const localVue = createLocalVue(); - -localVue.use(Vuex); +Vue.use(Vuex); describe('BoardSettingsSidebar', () => { let wrapper; - let mock; - let store; - const labelTitle = 'test'; - const labelColor = '#FFFF'; - const listId = 1; + const labelTitle = mockLabelList.label.title; + const labelColor = mockLabelList.label.color; + const listId = mockLabelList.id; const findRemoveButton = () => wrapper.findByTestId('remove-list'); - const createComponent = ({ canAdminList = false } = {}) => { + const createComponent = ({ + canAdminList = false, + list = {}, + sidebarType = LIST, + activeId = inactiveId, + } = {}) => { + const boardLists = { + [listId]: list, + }; + const store = new Vuex.Store({ + state: { sidebarType, activeId, boardLists }, + getters, + mutations, + actions, + }); + wrapper = extendedWrapper( shallowMount(BoardSettingsSidebar, { store, - localVue, provide: { canAdminList, + scopedLabelsAvailable: false, + }, + stubs: { + GlDrawer: stubComponent(GlDrawer, { + template: '<div><slot name="header"></slot><slot></slot></div>', + }), }, }), ); @@ -40,16 +57,10 @@ describe('BoardSettingsSidebar', () => { const findLabel = () => wrapper.find(GlLabel); const findDrawer = () => wrapper.find(GlDrawer); - beforeEach(() => { - store = createStore(); - store.state.activeId = inactiveId; - store.state.sidebarType = LIST; - boardsStore.create(); - }); - afterEach(() => { jest.restoreAllMocks(); wrapper.destroy(); + wrapper = null; }); it('finds a MountingPortal component', () => { @@ -100,86 +111,40 @@ describe('BoardSettingsSidebar', () => { }); describe('when activeId is greater than zero', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - - boardsStore.addList({ - id: listId, - label: { title: labelTitle, color: labelColor }, - list_type: 'label', - }); - store.state.activeId = 1; - store.state.sidebarType = LIST; - }); - - afterEach(() => { - boardsStore.removeList(listId); - }); - - it('renders GlDrawer with open false', () => { - createComponent(); + it('renders GlDrawer with open true', () => { + createComponent({ list: mockLabelList, activeId: listId }); expect(findDrawer().props('open')).toBe(true); }); }); - describe('when activeId is in boardsStore', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - - boardsStore.addList({ - id: listId, - label: { title: labelTitle, color: labelColor }, - list_type: 'label', - }); - - store.state.activeId = listId; - store.state.sidebarType = LIST; - - createComponent(); - }); - - afterEach(() => { - mock.restore(); - }); - + describe('when activeId is in state', () => { it('renders label title', () => { + createComponent({ list: mockLabelList, activeId: listId }); + expect(findLabel().props('title')).toBe(labelTitle); }); it('renders label background color', () => { + createComponent({ list: mockLabelList, activeId: listId }); + expect(findLabel().props('backgroundColor')).toBe(labelColor); }); }); - describe('when activeId is not in boardsStore', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - - boardsStore.addList({ id: listId, label: { title: labelTitle, color: labelColor } }); - - store.state.activeId = inactiveId; - - createComponent(); - }); - - afterEach(() => { - mock.restore(); - }); - + describe('when activeId is not in state', () => { it('does not render GlLabel', () => { + createComponent({ list: mockLabelList }); + expect(findLabel().exists()).toBe(false); }); }); }); describe('when sidebarType is not List', () => { - beforeEach(() => { - store.state.sidebarType = ''; - createComponent(); - }); - it('does not render GlDrawer', () => { + createComponent({ sidebarType: '' }); + expect(findDrawer().exists()).toBe(false); }); }); @@ -191,20 +156,9 @@ describe('BoardSettingsSidebar', () => { }); describe('when user can admin the boards list', () => { - beforeEach(() => { - store.state.activeId = listId; - store.state.sidebarType = LIST; - - boardsStore.addList({ - id: listId, - label: { title: labelTitle, color: labelColor }, - list_type: 'label', - }); - - createComponent({ canAdminList: true }); - }); - it('renders "Remove list" button', () => { + createComponent({ canAdminList: true, activeId: listId, list: mockLabelList }); + expect(findRemoveButton().exists()).toBe(true); }); }); diff --git a/spec/frontend/boards/components/boards_selector_deprecated_spec.js b/spec/frontend/boards/components/boards_selector_deprecated_spec.js deleted file mode 100644 index cc078861d75..00000000000 --- a/spec/frontend/boards/components/boards_selector_deprecated_spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { TEST_HOST } from 'spec/test_constants'; -import BoardsSelector from '~/boards/components/boards_selector_deprecated.vue'; -import boardsStore from '~/boards/stores/boards_store'; - -const throttleDuration = 1; - -function boardGenerator(n) { - return new Array(n).fill().map((board, index) => { - const id = `${index}`; - const name = `board${id}`; - - return { - id, - name, - }; - }); -} - -describe('BoardsSelector', () => { - let wrapper; - let allBoardsResponse; - let recentBoardsResponse; - const boards = boardGenerator(20); - const recentBoards = boardGenerator(5); - - const fillSearchBox = (filterTerm) => { - const searchBox = wrapper.find({ ref: 'searchBox' }); - const searchBoxInput = searchBox.find('input'); - searchBoxInput.setValue(filterTerm); - searchBoxInput.trigger('input'); - }; - - const getDropdownItems = () => wrapper.findAll('.js-dropdown-item'); - const getDropdownHeaders = () => wrapper.findAll(GlDropdownSectionHeader); - const getLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findDropdown = () => wrapper.find(GlDropdown); - - beforeEach(() => { - const $apollo = { - queries: { - boards: { - loading: false, - }, - }, - }; - - boardsStore.setEndpoints({ - boardsEndpoint: '', - recentBoardsEndpoint: '', - listsEndpoint: '', - bulkUpdatePath: '', - boardId: '', - }); - - allBoardsResponse = Promise.resolve({ - data: { - group: { - boards: { - edges: boards.map((board) => ({ node: board })), - }, - }, - }, - }); - recentBoardsResponse = Promise.resolve({ - data: recentBoards, - }); - - boardsStore.allBoards = jest.fn(() => allBoardsResponse); - boardsStore.recentBoards = jest.fn(() => recentBoardsResponse); - - wrapper = mount(BoardsSelector, { - propsData: { - throttleDuration, - currentBoard: { - id: 1, - name: 'Development', - milestone_id: null, - weight: null, - assignee_id: null, - labels: [], - }, - boardBaseUrl: `${TEST_HOST}/board/base/url`, - hasMissingBoards: false, - canAdminBoard: true, - multipleIssueBoardsAvailable: true, - labelsPath: `${TEST_HOST}/labels/path`, - labelsWebUrl: `${TEST_HOST}/labels`, - projectId: 42, - groupId: 19, - scopedIssueBoardFeatureEnabled: true, - weights: [], - }, - mocks: { $apollo }, - attachTo: document.body, - }); - - wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => { - wrapper.setData({ - [options.loadingKey]: true, - }); - }); - - // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time - findDropdown().vm.$emit('show'); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('loading', () => { - // we are testing loading state, so don't resolve responses until after the tests - afterEach(() => { - return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick()); - }); - - it('shows loading spinner', () => { - expect(getDropdownHeaders()).toHaveLength(0); - expect(getDropdownItems()).toHaveLength(0); - expect(getLoadingIcon().exists()).toBe(true); - }); - }); - - describe('loaded', () => { - beforeEach(async () => { - await wrapper.setData({ - loadingBoards: false, - }); - return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick()); - }); - - it('hides loading spinner', () => { - expect(getLoadingIcon().exists()).toBe(false); - }); - - describe('filtering', () => { - beforeEach(() => { - wrapper.setData({ - boards, - }); - - return nextTick(); - }); - - it('shows all boards without filtering', () => { - expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length); - }); - - it('shows only matching boards when filtering', () => { - const filterTerm = 'board1'; - const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length; - - fillSearchBox(filterTerm); - - return nextTick().then(() => { - expect(getDropdownItems()).toHaveLength(expectedCount); - }); - }); - - it('shows message if there are no matching boards', () => { - fillSearchBox('does not exist'); - - return nextTick().then(() => { - expect(getDropdownItems()).toHaveLength(0); - expect(wrapper.text().includes('No matching boards found')).toBe(true); - }); - }); - }); - - describe('recent boards section', () => { - it('shows only when boards are greater than 10', () => { - wrapper.setData({ - boards, - }); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(2); - }); - }); - - it('does not show when boards are less than 10', () => { - wrapper.setData({ - boards: boards.slice(0, 5), - }); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(0); - }); - }); - - it('does not show when recentBoards api returns empty array', () => { - wrapper.setData({ - recentBoards: [], - }); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(0); - }); - }); - - it('does not show when search is active', () => { - fillSearchBox('Random string'); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(0); - }); - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js b/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js deleted file mode 100644 index fafebaf3a4e..00000000000 --- a/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js +++ /dev/null @@ -1,64 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import IssueTimeEstimate from '~/boards/components/issue_time_estimate_deprecated.vue'; -import boardsStore from '~/boards/stores/boards_store'; - -describe('Issue Time Estimate component', () => { - let wrapper; - - beforeEach(() => { - boardsStore.create(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when limitToHours is false', () => { - beforeEach(() => { - boardsStore.timeTracking.limitToHours = false; - wrapper = shallowMount(IssueTimeEstimate, { - propsData: { - estimate: 374460, - }, - }); - }); - - it('renders the correct time estimate', () => { - expect(wrapper.find('time').text().trim()).toEqual('2w 3d 1m'); - }); - - it('renders expanded time estimate in tooltip', () => { - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute'); - }); - - it('prevents tooltip xss', (done) => { - const alertSpy = jest.spyOn(window, 'alert'); - wrapper.setProps({ estimate: 'Foo <script>alert("XSS")</script>' }); - wrapper.vm.$nextTick(() => { - expect(alertSpy).not.toHaveBeenCalled(); - expect(wrapper.find('time').text().trim()).toEqual('0m'); - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m'); - done(); - }); - }); - }); - - describe('when limitToHours is true', () => { - beforeEach(() => { - boardsStore.timeTracking.limitToHours = true; - wrapper = shallowMount(IssueTimeEstimate, { - propsData: { - estimate: 374460, - }, - }); - }); - - it('renders the correct time estimate', () => { - expect(wrapper.find('time').text().trim()).toEqual('104h 1m'); - }); - - it('renders expanded time estimate in tooltip', () => { - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute'); - }); - }); -}); diff --git a/spec/frontend/boards/issue_card_deprecated_spec.js b/spec/frontend/boards/issue_card_deprecated_spec.js deleted file mode 100644 index 909be275030..00000000000 --- a/spec/frontend/boards/issue_card_deprecated_spec.js +++ /dev/null @@ -1,332 +0,0 @@ -/* global ListAssignee, ListLabel, ListIssue */ -import { GlLabel } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { range } from 'lodash'; -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import IssueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; -import store from '~/boards/stores'; -import { listObj } from './mock_data'; - -describe('Issue card component', () => { - const user = new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - avatar: 'test_image', - }); - - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: '#000CFF', - text_color: 'white', - description: 'test', - }); - - let wrapper; - let issue; - let list; - - beforeEach(() => { - list = { ...listObj, type: 'label' }; - issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [list.label], - assignees: [], - reference_path: '#1', - real_path: '/test/1', - weight: 1, - }); - wrapper = mount(IssueCardInner, { - propsData: { - list, - issue, - }, - store, - stubs: { - GlLabel: true, - }, - provide: { - groupId: null, - rootPath: '/', - }, - }); - }); - - it('renders issue title', () => { - expect(wrapper.find('.board-card-title').text()).toContain(issue.title); - }); - - it('includes issue base in link', () => { - expect(wrapper.find('.board-card-title a').attributes('href')).toContain('/test'); - }); - - it('includes issue title on link', () => { - expect(wrapper.find('.board-card-title a').attributes('title')).toBe(issue.title); - }); - - it('does not render confidential icon', () => { - expect(wrapper.find('.confidential-icon').exists()).toBe(false); - }); - - it('does not render blocked icon', () => { - expect(wrapper.find('.issue-blocked-icon').exists()).toBe(false); - }); - - it('renders confidential icon', (done) => { - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - confidential: true, - }, - }); - wrapper.vm.$nextTick(() => { - expect(wrapper.find('.confidential-icon').exists()).toBe(true); - done(); - }); - }); - - it('renders issue ID with #', () => { - expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`); - }); - - describe('assignee', () => { - it('does not render assignee', () => { - expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false); - }); - - describe('exists', () => { - beforeEach((done) => { - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - assignees: [user], - updateData(newData) { - Object.assign(this, newData); - }, - }, - }); - - wrapper.vm.$nextTick(done); - }); - - it('renders assignee', () => { - expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(true); - }); - - it('sets title', () => { - expect(wrapper.find('.js-assignee-tooltip').text()).toContain(`${user.name}`); - }); - - it('sets users path', () => { - expect(wrapper.find('.board-card-assignee a').attributes('href')).toBe('/test'); - }); - - it('renders avatar', () => { - expect(wrapper.find('.board-card-assignee img').exists()).toBe(true); - }); - - it('renders the avatar using avatar_url property', (done) => { - wrapper.props('issue').updateData({ - ...wrapper.props('issue'), - assignees: [ - { - id: '1', - name: 'test', - state: 'active', - username: 'test_name', - avatar_url: 'test_image_from_avatar_url', - }, - ], - }); - - wrapper.vm.$nextTick(() => { - expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe( - 'test_image_from_avatar_url?width=24', - ); - done(); - }); - }); - }); - - describe('assignee default avatar', () => { - beforeEach((done) => { - global.gon.default_avatar_url = 'default_avatar'; - - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - assignees: [ - new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - }), - ], - }, - }); - - wrapper.vm.$nextTick(done); - }); - - afterEach(() => { - global.gon.default_avatar_url = null; - }); - - it('displays defaults avatar if users avatar is null', () => { - expect(wrapper.find('.board-card-assignee img').exists()).toBe(true); - expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe( - 'default_avatar?width=24', - ); - }); - }); - }); - - describe('multiple assignees', () => { - beforeEach((done) => { - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - assignees: [ - new ListAssignee({ - id: 2, - name: 'user2', - username: 'user2', - avatar: 'test_image', - }), - new ListAssignee({ - id: 3, - name: 'user3', - username: 'user3', - avatar: 'test_image', - }), - new ListAssignee({ - id: 4, - name: 'user4', - username: 'user4', - avatar: 'test_image', - }), - ], - }, - }); - - wrapper.vm.$nextTick(done); - }); - - it('renders all three assignees', () => { - expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(3); - }); - - describe('more than three assignees', () => { - beforeEach((done) => { - const { assignees } = wrapper.props('issue'); - assignees.push( - new ListAssignee({ - id: 5, - name: 'user5', - username: 'user5', - avatar: 'test_image', - }), - ); - - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - assignees, - }, - }); - wrapper.vm.$nextTick(done); - }); - - it('renders more avatar counter', () => { - expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('+2'); - }); - - it('renders two assignees', () => { - expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(2); - }); - - it('renders 99+ avatar counter', (done) => { - const assignees = [ - ...wrapper.props('issue').assignees, - ...range(5, 103).map( - (i) => - new ListAssignee({ - id: i, - name: 'name', - username: 'username', - avatar: 'test_image', - }), - ), - ]; - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - assignees, - }, - }); - - wrapper.vm.$nextTick(() => { - expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('99+'); - done(); - }); - }); - }); - }); - - describe('labels', () => { - beforeEach((done) => { - issue.addLabel(label1); - wrapper.setProps({ issue: { ...issue } }); - - wrapper.vm.$nextTick(done); - }); - - it('does not render list label but renders all other labels', () => { - expect(wrapper.findAll(GlLabel).length).toBe(1); - const label = wrapper.find(GlLabel); - expect(label.props('title')).toEqual(label1.title); - expect(label.props('description')).toEqual(label1.description); - expect(label.props('backgroundColor')).toEqual(label1.color); - }); - - it('does not render label if label does not have an ID', (done) => { - issue.addLabel( - new ListLabel({ - title: 'closed', - }), - ); - wrapper.setProps({ issue: { ...issue } }); - wrapper.vm - .$nextTick() - .then(() => { - expect(wrapper.findAll(GlLabel).length).toBe(1); - expect(wrapper.text()).not.toContain('closed'); - done(); - }) - .catch(done.fail); - }); - }); - - describe('blocked', () => { - beforeEach((done) => { - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - blocked: true, - }, - }); - wrapper.vm.$nextTick(done); - }); - - it('renders blocked icon if issue is blocked', () => { - expect(wrapper.find('.issue-blocked-icon').exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/boards/issue_spec.js b/spec/frontend/boards/issue_spec.js deleted file mode 100644 index 1f354fb04db..00000000000 --- a/spec/frontend/boards/issue_spec.js +++ /dev/null @@ -1,162 +0,0 @@ -/* global ListIssue */ - -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import boardsStore from '~/boards/stores/boards_store'; -import { setMockEndpoints, mockIssue } from './mock_data'; - -describe('Issue model', () => { - let issue; - - beforeEach(() => { - setMockEndpoints(); - boardsStore.create(); - - issue = new ListIssue(mockIssue); - }); - - it('has label', () => { - expect(issue.labels.length).toBe(1); - }); - - it('add new label', () => { - issue.addLabel({ - id: 2, - title: 'bug', - color: 'blue', - description: 'bugs!', - }); - - expect(issue.labels.length).toBe(2); - }); - - it('does not add label if label id exists', () => { - issue.addLabel({ - id: 1, - title: 'test 2', - color: 'blue', - description: 'testing', - }); - - expect(issue.labels.length).toBe(1); - expect(issue.labels[0].color).toBe('#F0AD4E'); - }); - - it('adds other label with same title', () => { - issue.addLabel({ - id: 2, - title: 'test', - color: 'blue', - description: 'other test', - }); - - expect(issue.labels.length).toBe(2); - }); - - it('finds label', () => { - const label = issue.findLabel(issue.labels[0]); - - expect(label).toBeDefined(); - }); - - it('removes label', () => { - const label = issue.findLabel(issue.labels[0]); - issue.removeLabel(label); - - expect(issue.labels.length).toBe(0); - }); - - it('removes multiple labels', () => { - issue.addLabel({ - id: 2, - title: 'bug', - color: 'blue', - description: 'bugs!', - }); - - expect(issue.labels.length).toBe(2); - - issue.removeLabels([issue.labels[0], issue.labels[1]]); - - expect(issue.labels.length).toBe(0); - }); - - it('adds assignee', () => { - issue.addAssignee({ - id: 2, - name: 'Bruce Wayne', - username: 'batman', - avatar_url: 'http://batman', - }); - - expect(issue.assignees.length).toBe(2); - }); - - it('finds assignee', () => { - const assignee = issue.findAssignee(issue.assignees[0]); - - expect(assignee).toBeDefined(); - }); - - it('removes assignee', () => { - const assignee = issue.findAssignee(issue.assignees[0]); - issue.removeAssignee(assignee); - - expect(issue.assignees.length).toBe(0); - }); - - it('removes all assignees', () => { - issue.removeAllAssignees(); - - expect(issue.assignees.length).toBe(0); - }); - - it('sets position to infinity if no position is stored', () => { - expect(issue.position).toBe(Infinity); - }); - - it('sets position', () => { - const relativePositionIssue = new ListIssue({ - title: 'Testing', - iid: 1, - confidential: false, - relative_position: 1, - labels: [], - assignees: [], - }); - - expect(relativePositionIssue.position).toBe(1); - }); - - it('updates data', () => { - issue.updateData({ subscribed: true }); - - expect(issue.subscribed).toBe(true); - }); - - it('sets fetching state', () => { - expect(issue.isFetching.subscriptions).toBe(true); - - issue.setFetchingState('subscriptions', false); - - expect(issue.isFetching.subscriptions).toBe(false); - }); - - it('sets loading state', () => { - issue.setLoadingState('foo', true); - - expect(issue.isLoading.foo).toBe(true); - }); - - describe('update', () => { - it('passes update to boardsStore', () => { - jest.spyOn(boardsStore, 'updateIssue').mockImplementation(); - - issue.update(); - - expect(boardsStore.updateIssue).toHaveBeenCalledWith(issue); - }); - }); -}); diff --git a/spec/frontend/boards/list_spec.js b/spec/frontend/boards/list_spec.js deleted file mode 100644 index 4d6a82bdff0..00000000000 --- a/spec/frontend/boards/list_spec.js +++ /dev/null @@ -1,230 +0,0 @@ -/* global List */ -/* global ListAssignee */ -/* global ListIssue */ -/* global ListLabel */ -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import { ListType } from '~/boards/constants'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; -import { listObj, listObjDuplicate, boardsMockInterceptor } from './mock_data'; - -describe('List model', () => { - let list; - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - boardsStore.create(); - boardsStore.setEndpoints({ - listsEndpoint: '/test/-/boards/1/lists', - }); - - list = new List(listObj); - return waitForPromises(); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('list type', () => { - const notExpandableList = ['blank']; - - const table = Object.keys(ListType).map((k) => { - const value = ListType[k]; - return [value, !notExpandableList.includes(value)]; - }); - it.each(table)(`when list_type is %s boards isExpandable is %p`, (type, result) => { - expect(new List({ id: 1, list_type: type }).isExpandable).toBe(result); - }); - }); - - it('gets issues when created', () => { - expect(list.issues.length).toBe(1); - }); - - it('saves list and returns ID', () => { - list = new List({ - title: 'test', - label: { - id: 1, - title: 'test', - color: '#ff0000', - text_color: 'white', - }, - }); - return list.save().then(() => { - expect(list.id).toBe(listObj.id); - expect(list.type).toBe('label'); - expect(list.position).toBe(0); - expect(list.label).toEqual(listObj.label); - }); - }); - - it('destroys the list', () => { - boardsStore.addList(listObj); - list = boardsStore.findList('id', listObj.id); - - expect(boardsStore.state.lists.length).toBe(1); - list.destroy(); - - return waitForPromises().then(() => { - expect(boardsStore.state.lists.length).toBe(0); - }); - }); - - it('gets issue from list', () => { - const issue = list.findIssue(1); - - expect(issue).toBeDefined(); - }); - - it('removes issue', () => { - const issue = list.findIssue(1); - - expect(list.issues.length).toBe(1); - list.removeIssue(issue); - - expect(list.issues.length).toBe(0); - }); - - it('sends service request to update issue label', () => { - const listDup = new List(listObjDuplicate); - const issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [list.label, listDup.label], - assignees: [], - }); - - list.issues.push(issue); - listDup.issues.push(issue); - - jest.spyOn(boardsStore, 'moveIssue'); - - listDup.updateIssueLabel(issue, list); - - expect(boardsStore.moveIssue).toHaveBeenCalledWith( - issue.id, - list.id, - listDup.id, - undefined, - undefined, - ); - }); - - describe('page number', () => { - beforeEach(() => { - jest.spyOn(list, 'getIssues').mockImplementation(() => {}); - list.issues = []; - }); - - it('increase page number if current issue count is more than the page size', () => { - for (let i = 0; i < 30; i += 1) { - list.issues.push( - new ListIssue({ - title: 'Testing', - id: i, - iid: i, - confidential: false, - labels: [list.label], - assignees: [], - }), - ); - } - list.issuesSize = 50; - - expect(list.issues.length).toBe(30); - - list.nextPage(); - - expect(list.page).toBe(2); - expect(list.getIssues).toHaveBeenCalled(); - }); - - it('does not increase page number if issue count is less than the page size', () => { - list.issues.push( - new ListIssue({ - title: 'Testing', - id: 1, - confidential: false, - labels: [list.label], - assignees: [], - }), - ); - list.issuesSize = 2; - - list.nextPage(); - - expect(list.page).toBe(1); - expect(list.getIssues).toHaveBeenCalled(); - }); - }); - - describe('newIssue', () => { - beforeEach(() => { - jest.spyOn(boardsStore, 'newIssue').mockReturnValue( - Promise.resolve({ - data: { - id: 42, - subscribed: false, - assignable_labels_endpoint: '/issue/42/labels', - toggle_subscription_endpoint: '/issue/42/subscriptions', - issue_sidebar_endpoint: '/issue/42/sidebar_info', - }, - }), - ); - list.issues = []; - }); - - it('adds new issue to top of list', (done) => { - const user = new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - avatar: 'test_image', - }); - - list.issues.push( - new ListIssue({ - title: 'Testing', - id: 1, - confidential: false, - labels: [new ListLabel(list.label)], - assignees: [], - }), - ); - const dummyIssue = new ListIssue({ - title: 'new issue', - id: 2, - confidential: false, - labels: [new ListLabel(list.label)], - assignees: [user], - subscribed: false, - }); - - list - .newIssue(dummyIssue) - .then(() => { - expect(list.issues.length).toBe(2); - expect(list.issues[0]).toBe(dummyIssue); - expect(list.issues[0].subscribed).toBe(false); - expect(list.issues[0].assignableLabelsEndpoint).toBe('/issue/42/labels'); - expect(list.issues[0].toggleSubscriptionEndpoint).toBe('/issue/42/subscriptions'); - expect(list.issues[0].sidebarInfoEndpoint).toBe('/issue/42/sidebar_info'); - expect(list.issues[0].labels).toBe(dummyIssue.labels); - expect(list.issues[0].assignees).toBe(dummyIssue.assignees); - }) - .then(done) - .catch(done.fail); - }); - }); -}); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 106f7b04c4b..6a4f344bbfb 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -1,12 +1,8 @@ -/* global List */ - import { GlFilteredSearchToken } from '@gitlab/ui'; import { keyBy } from 'lodash'; -import Vue from 'vue'; -import '~/boards/models/list'; import { ListType } from '~/boards/constants'; -import boardsStore from '~/boards/stores/boards_store'; import { __ } from '~/locale'; +import { DEFAULT_MILESTONES_GRAPHQL } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -196,8 +192,7 @@ export const mockIssue = { export const mockActiveIssue = { ...mockIssue, - fullId: 'gid://gitlab/Issue/436', - id: 436, + id: 'gid://gitlab/Issue/436', iid: '27', subscribed: false, emailsDisabled: false, @@ -289,20 +284,6 @@ export const boardsMockInterceptor = (config) => { return [200, body]; }; -export const setMockEndpoints = (opts = {}) => { - const boardsEndpoint = opts.boardsEndpoint || '/test/issue-boards/-/boards.json'; - const listsEndpoint = opts.listsEndpoint || '/test/-/boards/1/lists'; - const bulkUpdatePath = opts.bulkUpdatePath || ''; - const boardId = opts.boardId || '1'; - - boardsStore.setEndpoints({ - boardsEndpoint, - listsEndpoint, - bulkUpdatePath, - boardId, - }); -}; - export const mockList = { id: 'gid://gitlab/List/1', title: 'Open', @@ -335,14 +316,26 @@ export const mockLabelList = { issuesCount: 0, }; +export const mockMilestoneList = { + id: 'gid://gitlab/List/3', + title: 'To Do', + position: 0, + listType: 'milestone', + collapsed: false, + label: null, + assignee: null, + milestone: { + webUrl: 'https://gitlab.com/h5bp/html5-boilerplate/-/milestones/1', + title: 'Backlog', + }, + loading: false, + issuesCount: 0, +}; + export const mockLists = [mockList, mockLabelList]; export const mockListsById = keyBy(mockLists, 'id'); -export const mockListsWithModel = mockLists.map((listMock) => - Vue.observable(new List({ ...listMock, doNotFetchIssues: true })), -); - export const mockIssuesByListId = { 'gid://gitlab/List/1': [mockIssue.id, mockIssue3.id, mockIssue4.id], 'gid://gitlab/List/2': mockIssues.map(({ id }) => id), @@ -547,17 +540,17 @@ export const mockMoveData = { export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [ { - icon: 'labels', - title: __('Label'), - type: 'label_name', + icon: 'user', + title: __('Assignee'), + type: 'assignee_username', operators: [ { value: '=', description: 'is' }, { value: '!=', description: 'is not' }, ], - token: LabelToken, - unique: false, - symbol: '~', - fetchLabels, + token: AuthorToken, + unique: true, + fetchAuthors, + preloadedAuthors: [], }, { icon: 'pencil', @@ -574,17 +567,27 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [ preloadedAuthors: [], }, { - icon: 'user', - title: __('Assignee'), - type: 'assignee_username', + icon: 'labels', + title: __('Label'), + type: 'label_name', operators: [ { value: '=', description: 'is' }, { value: '!=', description: 'is not' }, ], - token: AuthorToken, + token: LabelToken, + unique: false, + symbol: '~', + fetchLabels, + }, + { + icon: 'clock', + title: __('Milestone'), + symbol: '%', + type: 'milestone_title', + token: MilestoneToken, unique: true, - fetchAuthors, - preloadedAuthors: [], + defaultMilestones: DEFAULT_MILESTONES_GRAPHQL, + fetchMilestones, }, { icon: 'issues', @@ -599,16 +602,6 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [ ], }, { - icon: 'clock', - title: __('Milestone'), - symbol: '%', - type: 'milestone_title', - token: MilestoneToken, - unique: true, - defaultMilestones: [], - fetchMilestones, - }, - { icon: 'weight', title: __('Weight'), type: 'weight', diff --git a/spec/frontend/boards/project_select_deprecated_spec.js b/spec/frontend/boards/project_select_deprecated_spec.js deleted file mode 100644 index 4494de43083..00000000000 --- a/spec/frontend/boards/project_select_deprecated_spec.js +++ /dev/null @@ -1,263 +0,0 @@ -import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import axios from 'axios'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import ProjectSelect from '~/boards/components/project_select_deprecated.vue'; -import { ListType } from '~/boards/constants'; -import eventHub from '~/boards/eventhub'; -import createFlash from '~/flash'; -import httpStatus from '~/lib/utils/http_status'; -import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; - -import { listObj, mockRawGroupProjects } from './mock_data'; - -jest.mock('~/boards/eventhub'); -jest.mock('~/flash'); - -const dummyGon = { - api_version: 'v4', - relative_url_root: '/gitlab', -}; - -const mockGroupId = 1; -const mockProjectsList1 = mockRawGroupProjects.slice(0, 1); -const mockProjectsList2 = mockRawGroupProjects.slice(1); -const mockDefaultFetchOptions = { - with_issues_enabled: true, - with_shared: false, - include_subgroups: true, - order_by: 'similarity', - archived: false, -}; - -const itemsPerPage = 20; - -describe('ProjectSelect component', () => { - let wrapper; - let axiosMock; - - const findLabel = () => wrapper.find("[data-testid='header-label']"); - const findGlDropdown = () => wrapper.find(GlDropdown); - const findGlDropdownLoadingIcon = () => - findGlDropdown().find('button:first-child').find(GlLoadingIcon); - const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType); - const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem); - const findFirstGlDropdownItem = () => findGlDropdownItems().at(0); - const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']"); - const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']"); - - const mockGetRequest = (data = [], statusCode = httpStatus.OK) => { - axiosMock - .onGet(`/gitlab/api/v4/groups/${mockGroupId}/projects.json`) - .replyOnce(statusCode, data); - }; - - const searchForProject = async (keyword, waitForAll = true) => { - findGlSearchBoxByType().vm.$emit('input', keyword); - - if (waitForAll) { - await axios.waitForAll(); - } - }; - - const createWrapper = async ({ list = listObj } = {}, waitForAll = true) => { - wrapper = mount(ProjectSelect, { - propsData: { - list, - }, - provide: { - groupId: 1, - }, - }); - - if (waitForAll) { - await axios.waitForAll(); - } - }; - - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - window.gon = dummyGon; - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - axiosMock.restore(); - jest.clearAllMocks(); - }); - - it('displays a header title', async () => { - createWrapper({}); - - expect(findLabel().text()).toBe('Projects'); - }); - - it('renders a default dropdown text', async () => { - createWrapper({}); - - expect(findGlDropdown().exists()).toBe(true); - expect(findGlDropdown().text()).toContain('Select a project'); - }); - - describe('when mounted', () => { - it('displays a loading icon while projects are being fetched', async () => { - mockGetRequest([]); - - createWrapper({}, false); - - expect(findGlDropdownLoadingIcon().exists()).toBe(true); - - await axios.waitForAll(); - - expect(axiosMock.history.get[0].params).toMatchObject({ search: '' }); - expect(axiosMock.history.get[0].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); - - expect(findGlDropdownLoadingIcon().exists()).toBe(false); - }); - }); - - describe('when dropdown menu is open', () => { - describe('by default', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); - - await createWrapper(); - }); - - it('shows GlSearchBoxByType with default attributes', () => { - expect(findGlSearchBoxByType().exists()).toBe(true); - expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({ - placeholder: 'Search projects', - debounce: '250', - }); - }); - - it("displays the fetched project's name", () => { - expect(findFirstGlDropdownItem().exists()).toBe(true); - expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList1[0].name); - }); - - it("doesn't render loading icon in the menu", () => { - expect(findInMenuLoadingIcon().isVisible()).toBe(false); - }); - - it('renders empty search result message', async () => { - await createWrapper(); - - expect(findEmptySearchMessage().exists()).toBe(true); - }); - }); - - describe('when a project is selected', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); - - await createWrapper(); - - await findFirstGlDropdownItem().find('button').trigger('click'); - }); - - it('emits setSelectedProject with correct project metadata', () => { - expect(eventHub.$emit).toHaveBeenCalledWith('setSelectedProject', { - id: mockProjectsList1[0].id, - path: mockProjectsList1[0].path_with_namespace, - name: mockProjectsList1[0].name, - namespacedName: mockProjectsList1[0].name_with_namespace, - }); - }); - - it('renders the name of the selected project', () => { - expect(findGlDropdown().find('.gl-new-dropdown-button-text').text()).toBe( - mockProjectsList1[0].name, - ); - }); - }); - - describe('when user searches for a project', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); - - await createWrapper(); - }); - - it('calls API with correct parameters with default fetch options', async () => { - await searchForProject('foobar'); - - const expectedApiParams = { - search: 'foobar', - per_page: itemsPerPage, - ...mockDefaultFetchOptions, - }; - - expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); - expect(axiosMock.history.get[1].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); - }); - - describe("when list type is defined and isn't backlog", () => { - it('calls API with an additional fetch option (min_access_level)', async () => { - axiosMock.reset(); - - await createWrapper({ list: { ...listObj, type: ListType.label } }); - - await searchForProject('foobar'); - - const expectedApiParams = { - search: 'foobar', - per_page: itemsPerPage, - ...mockDefaultFetchOptions, - min_access_level: featureAccessLevel.EVERYONE, - }; - - expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); - expect(axiosMock.history.get[1].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); - }); - }); - - it('displays and hides gl-loading-icon while and after fetching data', async () => { - await searchForProject('some keyword', false); - - await wrapper.vm.$nextTick(); - - expect(findInMenuLoadingIcon().isVisible()).toBe(true); - - await axios.waitForAll(); - - expect(findInMenuLoadingIcon().isVisible()).toBe(false); - }); - - it('flashes an error message when fetching fails', async () => { - mockGetRequest([], httpStatus.INTERNAL_SERVER_ERROR); - - await searchForProject('foobar'); - - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ - message: 'Something went wrong while fetching projects', - }); - }); - - describe('with non-empty search result', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList2); - - await searchForProject('foobar'); - }); - - it('displays the retrieved list of projects', async () => { - expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList2[0].name); - }); - - it('does not render empty search result message', async () => { - expect(findEmptySearchMessage().exists()).toBe(false); - }); - }); - }); - }); -}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 1272a573d2f..62e0fa7a68a 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -26,7 +26,6 @@ import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql' import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import mutations from '~/boards/stores/mutations'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { mockLists, @@ -107,12 +106,7 @@ describe('setFilters', () => { }); describe('performSearch', () => { - it('should dispatch setFilters action', (done) => { - testAction(actions.performSearch, {}, {}, [], [{ type: 'setFilters', payload: {} }], done); - }); - - it('should dispatch setFilters, fetchLists and resetIssues action when graphqlBoardLists FF is on', (done) => { - window.gon = { features: { graphqlBoardLists: true } }; + it('should dispatch setFilters, fetchLists and resetIssues action', (done) => { testAction( actions.performSearch, {}, @@ -496,12 +490,9 @@ describe('fetchLabels', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); const commit = jest.fn(); - const getters = { - shouldUseGraphQL: () => true, - }; const state = { boardType: 'group' }; - await actions.fetchLabels({ getters, state, commit }); + await actions.fetchLabels({ state, commit }); expect(commit).toHaveBeenCalledWith(types.RECEIVE_LABELS_SUCCESS, labels); }); @@ -954,7 +945,7 @@ describe('moveIssue', () => { }); describe('moveIssueCard and undoMoveIssueCard', () => { - describe('card should move without clonning', () => { + describe('card should move without cloning', () => { let state; let params; let moveMutations; @@ -1221,8 +1212,8 @@ describe('updateMovedIssueCard', () => { describe('updateIssueOrder', () => { const issues = { - 436: mockIssue, - 437: mockIssue2, + [mockIssue.id]: mockIssue, + [mockIssue2.id]: mockIssue2, }; const state = { @@ -1231,7 +1222,7 @@ describe('updateIssueOrder', () => { }; const moveData = { - itemId: 436, + itemId: mockIssue.id, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', }; @@ -1490,7 +1481,7 @@ describe('addListNewIssue', () => { type: 'addListItem', payload: { list: fakeList, - item: formatIssue({ ...mockIssue, id: getIdFromGraphQLId(mockIssue.id) }), + item: formatIssue(mockIssue), position: 0, }, }, diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index c0774dd3ae1..b30968c45d7 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -77,12 +77,12 @@ describe('Boards - Getters', () => { }); describe('getBoardItemById', () => { - const state = { boardItems: { 1: 'issue' } }; + const state = { boardItems: { 'gid://gitlab/Issue/1': 'issue' } }; it.each` - id | expected - ${'1'} | ${'issue'} - ${''} | ${{}} + id | expected + ${'gid://gitlab/Issue/1'} | ${'issue'} + ${''} | ${{}} `('returns $expected when $id is passed to state', ({ id, expected }) => { expect(getters.getBoardItemById(state)(id)).toEqual(expected); }); @@ -90,11 +90,11 @@ describe('Boards - Getters', () => { describe('activeBoardItem', () => { it.each` - id | expected - ${'1'} | ${'issue'} - ${''} | ${{ id: '', iid: '', fullId: '' }} + id | expected + ${'gid://gitlab/Issue/1'} | ${'issue'} + ${''} | ${{ id: '', iid: '' }} `('returns $expected when $id is passed to state', ({ id, expected }) => { - const state = { boardItems: { 1: 'issue' }, activeId: id }; + const state = { boardItems: { 'gid://gitlab/Issue/1': 'issue' }, activeId: id }; expect(getters.activeBoardItem(state)).toEqual(expected); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index a2ba1e9eb5e..0e830258327 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -407,7 +407,7 @@ describe('Board Store Mutations', () => { describe('MUTATE_ISSUE_SUCCESS', () => { it('updates issue in issues state', () => { const issues = { - 436: { id: rawIssue.id }, + [rawIssue.id]: { id: rawIssue.id }, }; state = { @@ -419,7 +419,7 @@ describe('Board Store Mutations', () => { issue: rawIssue, }); - expect(state.boardItems).toEqual({ 436: { ...mockIssue, id: 436 } }); + expect(state.boardItems).toEqual({ [mockIssue.id]: mockIssue }); }); }); @@ -545,7 +545,7 @@ describe('Board Store Mutations', () => { expect(state.groupProjectsFlags.isLoading).toBe(true); }); - it('Should set isLoading in groupProjectsFlags to true in state when fetchNext is true', () => { + it('Should set isLoadingMore in groupProjectsFlags to true in state when fetchNext is true', () => { mutations[types.REQUEST_GROUP_PROJECTS](state, true); expect(state.groupProjectsFlags.isLoadingMore).toBe(true); 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 0e1fe790771..b34265b7234 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 @@ -47,8 +47,26 @@ 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" > + <!----> + <li class="gl-new-dropdown-item" role="presentation" diff --git a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap index 271c6356f7e..c2fa6556847 100644 --- a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap +++ b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap @@ -17,11 +17,15 @@ exports[`Confidential merge request project form group component renders empty s No forks are available to you. <br /> - - <gl-sprintf-stub - message="To protect this issue's confidentiality, %{forkLink} and set the fork's visibility to private." - /> - + To protect this issue's confidentiality, + <a + class="help-link" + href="https://test.com" + target="_blank" + > + fork this project + </a> + and set the fork's visibility to private. <gl-link-stub class="w-auto p-0 d-inline-block text-primary bg-transparent" href="/help" @@ -52,18 +56,16 @@ exports[`Confidential merge request project form group component renders fork dr </label> <div> - <!----> + <dropdown-stub + projects="[object Object],[object Object]" + selectedproject="[object Object]" + /> <p class="text-muted mt-1 mb-0" > - No forks are available to you. - <br /> - - <gl-sprintf-stub - message="To protect this issue's confidentiality, %{forkLink} and set the fork's visibility to private." - /> + To protect this issue's confidentiality, a private fork of this project was selected. <gl-link-stub class="w-auto p-0 d-inline-block text-primary bg-transparent" diff --git a/spec/frontend/confidential_merge_request/components/project_form_group_spec.js b/spec/frontend/confidential_merge_request/components/project_form_group_spec.js index 67f6d360f52..0e73d50fdb5 100644 --- a/spec/frontend/confidential_merge_request/components/project_form_group_spec.js +++ b/spec/frontend/confidential_merge_request/components/project_form_group_spec.js @@ -1,3 +1,4 @@ +import { GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import ProjectFormGroup from '~/confidential_merge_request/components/project_form_group.vue'; @@ -21,55 +22,52 @@ const mockData = [ }, }, ]; -let vm; +let wrapper; let mock; function factory(projects = mockData) { mock = new MockAdapter(axios); mock.onGet(/api\/(.*)\/projects\/gitlab-org%2Fgitlab-ce\/forks/).reply(200, projects); - vm = shallowMount(ProjectFormGroup, { + wrapper = shallowMount(ProjectFormGroup, { propsData: { namespacePath: 'gitlab-org', projectPath: 'gitlab-org/gitlab-ce', newForkPath: 'https://test.com', helpPagePath: '/help', }, + stubs: { GlSprintf }, }); + + return axios.waitForAll(); } describe('Confidential merge request project form group component', () => { afterEach(() => { mock.restore(); - vm.destroy(); + wrapper.destroy(); }); - it('renders fork dropdown', () => { - factory(); + it('renders fork dropdown', async () => { + await factory(); - return vm.vm.$nextTick(() => { - expect(vm.element).toMatchSnapshot(); - }); + expect(wrapper.element).toMatchSnapshot(); }); - it('sets selected project as first fork', () => { - factory(); + it('sets selected project as first fork', async () => { + await factory(); - return vm.vm.$nextTick(() => { - expect(vm.vm.selectedProject).toEqual({ - id: 1, - name: 'root / gitlab-ce', - pathWithNamespace: 'root/gitlab-ce', - namespaceFullpath: 'root', - }); + expect(wrapper.vm.selectedProject).toEqual({ + id: 1, + name: 'root / gitlab-ce', + pathWithNamespace: 'root/gitlab-ce', + namespaceFullpath: 'root', }); }); - it('renders empty state when response is empty', () => { - factory([]); + it('renders empty state when response is empty', async () => { + await factory([]); - return vm.vm.$nextTick(() => { - expect(vm.element).toMatchSnapshot(); - }); + expect(wrapper.element).toMatchSnapshot(); }); }); 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 3c88c05a4b4..8f5516545eb 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,7 +11,16 @@ 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!\\"> <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\"> <div placeholder=\\"Link URL\\"> diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index d516baf6f0f..3d1ef03083d 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -6,6 +6,7 @@ import ContentEditor from '~/content_editor/components/content_editor.vue'; import ContentEditorError from '~/content_editor/components/content_editor_error.vue'; import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; +import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import { LOADING_CONTENT_EVENT, @@ -25,6 +26,7 @@ describe('ContentEditor', () => { const findEditorElement = () => wrapper.findByTestId('content-editor'); const findEditorContent = () => wrapper.findComponent(EditorContent); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findBubbleMenu = () => wrapper.findComponent(FormattingBubbleMenu); const createWrapper = (propsData = {}) => { renderMarkdown = jest.fn(); @@ -131,6 +133,10 @@ describe('ContentEditor', () => { it('hides EditorContent component', () => { expect(findEditorContent().exists()).toBe(false); }); + + it('hides formatting bubble menu', () => { + expect(findBubbleMenu().exists()).toBe(false); + }); }); describe('when loading content succeeds', () => { @@ -171,5 +177,9 @@ describe('ContentEditor', () => { it('displays EditorContent component', () => { expect(findEditorContent().exists()).toBe(true); }); + + it('displays formatting bubble menu', () => { + expect(findBubbleMenu().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js new file mode 100644 index 00000000000..e48f59f6d9c --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js @@ -0,0 +1,193 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { selectedRect as getSelectedRect } from 'prosemirror-tables'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; +import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../../test_utils'; + +jest.mock('prosemirror-tables'); + +describe('content/components/wrappers/table_cell_base', () => { + let wrapper; + let editor; + let getPos; + + const createWrapper = async (propsData = { cellType: 'td' }) => { + wrapper = shallowMountExtended(TableCellBaseWrapper, { + propsData: { + editor, + getPos, + ...propsData, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItemWithLabel = (name) => + wrapper + .findAllComponents(GlDropdownItem) + .filter((dropdownItem) => dropdownItem.text().includes(name)) + .at(0); + const findDropdownItemWithLabelExists = (name) => + wrapper + .findAllComponents(GlDropdownItem) + .filter((dropdownItem) => dropdownItem.text().includes(name)).length > 0; + const setCurrentPositionInCell = () => { + const { $cursor } = editor.state.selection; + + getPos.mockReturnValue($cursor.pos - $cursor.parentOffset - 1); + }; + const mockDropdownHide = () => { + /* + * TODO: Replace this method with using the scoped hide function + * provided by BootstrapVue https://bootstrap-vue.org/docs/components/dropdown. + * GitLab UI is not exposing it in the default scope + */ + findDropdown().vm.hide = jest.fn(); + }; + + beforeEach(() => { + getPos = jest.fn(); + editor = createTestEditor({}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a td node-view-wrapper with relative position', () => { + createWrapper(); + expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-relative'); + expect(wrapper.findComponent(NodeViewWrapper).props().as).toBe('td'); + }); + + it('displays dropdown when selection cursor is on the cell', async () => { + setCurrentPositionInCell(); + createWrapper(); + + await nextTick(); + + expect(findDropdown().props()).toMatchObject({ + category: 'tertiary', + icon: 'chevron-down', + size: 'small', + split: false, + }); + expect(findDropdown().attributes()).toMatchObject({ + boundary: 'viewport', + 'no-caret': '', + }); + }); + + it('does not display dropdown when selection cursor is not on the cell', async () => { + createWrapper(); + + await nextTick(); + + expect(findDropdown().exists()).toBe(false); + }); + + describe('when dropdown is visible', () => { + beforeEach(async () => { + setCurrentPositionInCell(); + getSelectedRect.mockReturnValue({ + map: { + height: 1, + width: 1, + }, + }); + + createWrapper(); + await nextTick(); + + mockDropdownHide(); + }); + + it.each` + dropdownItemLabel | commandName + ${'Insert column before'} | ${'addColumnBefore'} + ${'Insert column after'} | ${'addColumnAfter'} + ${'Insert row before'} | ${'addRowBefore'} + ${'Insert row after'} | ${'addRowAfter'} + ${'Delete table'} | ${'deleteTable'} + `( + 'executes $commandName when $dropdownItemLabel button is clicked', + ({ commandName, dropdownItemLabel }) => { + const mocks = mockChainedCommands(editor, [commandName, 'run']); + + findDropdownItemWithLabel(dropdownItemLabel).vm.$emit('click'); + + expect(mocks[commandName]).toHaveBeenCalled(); + }, + ); + + it('does not allow deleting rows and columns', async () => { + expect(findDropdownItemWithLabelExists('Delete row')).toBe(false); + expect(findDropdownItemWithLabelExists('Delete column')).toBe(false); + }); + + it('allows deleting rows when there are more than 2 rows in the table', async () => { + const mocks = mockChainedCommands(editor, ['deleteRow', 'run']); + + getSelectedRect.mockReturnValue({ + map: { + height: 3, + }, + }); + + emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + + await nextTick(); + + findDropdownItemWithLabel('Delete row').vm.$emit('click'); + + expect(mocks.deleteRow).toHaveBeenCalled(); + }); + + it('allows deleting columns when there are more than 1 column in the table', async () => { + const mocks = mockChainedCommands(editor, ['deleteColumn', 'run']); + + getSelectedRect.mockReturnValue({ + map: { + width: 2, + }, + }); + + emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + + await nextTick(); + + findDropdownItemWithLabel('Delete column').vm.$emit('click'); + + expect(mocks.deleteColumn).toHaveBeenCalled(); + }); + + describe('when current row is the table’s header', () => { + beforeEach(async () => { + // Remove 2 rows condition + getSelectedRect.mockReturnValue({ + map: { + height: 3, + }, + }); + + createWrapper({ cellType: 'th' }); + + await nextTick(); + }); + + it('does not allow adding a row before the header', async () => { + expect(findDropdownItemWithLabelExists('Insert row before')).toBe(false); + }); + + it('does not allow removing the header row', async () => { + createWrapper({ cellType: 'th' }); + + await nextTick(); + + expect(findDropdownItemWithLabelExists('Delete row')).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js new file mode 100644 index 00000000000..5d26c44ba03 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js @@ -0,0 +1,37 @@ +import { shallowMount } from '@vue/test-utils'; +import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; +import TableCellBodyWrapper from '~/content_editor/components/wrappers/table_cell_body.vue'; +import { createTestEditor } from '../../test_utils'; + +describe('content/components/wrappers/table_cell_body', () => { + let wrapper; + let editor; + let getPos; + + const createWrapper = async () => { + wrapper = shallowMount(TableCellBodyWrapper, { + propsData: { + editor, + getPos, + }, + }); + }; + + beforeEach(() => { + getPos = jest.fn(); + editor = createTestEditor({}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a TableCellBase component', () => { + createWrapper(); + expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({ + editor, + getPos, + cellType: 'td', + }); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js new file mode 100644 index 00000000000..e561191418d --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js @@ -0,0 +1,37 @@ +import { shallowMount } from '@vue/test-utils'; +import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; +import TableCellHeaderWrapper from '~/content_editor/components/wrappers/table_cell_header.vue'; +import { createTestEditor } from '../../test_utils'; + +describe('content/components/wrappers/table_cell_header', () => { + let wrapper; + let editor; + let getPos; + + const createWrapper = async () => { + wrapper = shallowMount(TableCellHeaderWrapper, { + propsData: { + editor, + getPos, + }, + }); + }; + + beforeEach(() => { + getPos = jest.fn(); + editor = createTestEditor({}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a TableCellBase component', () => { + createWrapper(); + expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({ + editor, + getPos, + cellType: 'th', + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index 1334b1ddaad..d4f05a25bd6 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -1,18 +1,23 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { once } from 'lodash'; -import waitForPromises from 'helpers/wait_for_promises'; import Attachment from '~/content_editor/extensions/attachment'; import Image from '~/content_editor/extensions/image'; import Link from '~/content_editor/extensions/link'; import Loading from '~/content_editor/extensions/loading'; import httpStatus from '~/lib/utils/http_status'; -import { loadMarkdownApiResult } from '../markdown_processing_examples'; import { createTestEditor, createDocBuilder } from '../test_utils'; +const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto"> + <a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.png"> + <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"> + </a> +</p>`; +const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto"> + <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a> +</p>`; + describe('content_editor/extensions/attachment', () => { let tiptapEditor; - let eq; let doc; let p; let image; @@ -25,6 +30,24 @@ describe('content_editor/extensions/attachment', () => { const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' }); + const expectDocumentAfterTransaction = ({ number, expectedDoc, action }) => { + return new Promise((resolve) => { + let counter = 1; + const handleTransaction = () => { + if (counter === number) { + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + tiptapEditor.off('update', handleTransaction); + resolve(); + } + + counter += 1; + }; + + tiptapEditor.on('update', handleTransaction); + action(); + }); + }; + beforeEach(() => { renderMarkdown = jest.fn(); @@ -34,7 +57,6 @@ describe('content_editor/extensions/attachment', () => { ({ builders: { doc, p, image, loading, link }, - eq, } = createDocBuilder({ tiptapEditor, names: { @@ -76,9 +98,7 @@ describe('content_editor/extensions/attachment', () => { const base64EncodedFile = 'data:image/png;base64,Zm9v'; beforeEach(() => { - renderMarkdown.mockResolvedValue( - loadMarkdownApiResult('project_wiki_attachment_image').body, - ); + renderMarkdown.mockResolvedValue(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML); }); describe('when uploading succeeds', () => { @@ -92,18 +112,14 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.OK, successResponse); }); - it('inserts an image with src set to the encoded image file and uploading true', (done) => { + it('inserts an image with src set to the encoded image file and uploading true', async () => { const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile }))); - tiptapEditor.on( - 'update', - once(() => { - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); - done(); - }), - ); - - tiptapEditor.commands.uploadAttachment({ file: imageFile }); + await expectDocumentAfterTransaction({ + number: 1, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + }); }); it('updates the inserted image with canonicalSrc when upload is successful', async () => { @@ -118,11 +134,11 @@ describe('content_editor/extensions/attachment', () => { ), ); - tiptapEditor.commands.uploadAttachment({ file: imageFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + }); }); }); @@ -131,14 +147,14 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR); }); - it('resets the doc to orginal state', async () => { + it('resets the doc to original state', async () => { const expectedDoc = doc(p('')); - tiptapEditor.commands.uploadAttachment({ file: imageFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + }); }); it('emits an error event that includes an error message', (done) => { @@ -153,7 +169,7 @@ describe('content_editor/extensions/attachment', () => { }); describe('when the file has a zip (or any other attachment) mime type', () => { - const markdownApiResult = loadMarkdownApiResult('project_wiki_attachment_link').body; + const markdownApiResult = PROJECT_WIKI_ATTACHMENT_LINK_HTML; beforeEach(() => { renderMarkdown.mockResolvedValue(markdownApiResult); @@ -170,18 +186,14 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.OK, successResponse); }); - it('inserts a loading mark', (done) => { + it('inserts a loading mark', async () => { const expectedDoc = doc(p(loading({ label: 'test-file' }))); - tiptapEditor.on( - 'update', - once(() => { - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); - done(); - }), - ); - - tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); + await expectDocumentAfterTransaction({ + number: 1, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }), + }); }); it('updates the loading mark with a link with canonicalSrc and href attrs', async () => { @@ -198,11 +210,11 @@ describe('content_editor/extensions/attachment', () => { ), ); - tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }), + }); }); }); @@ -214,11 +226,11 @@ describe('content_editor/extensions/attachment', () => { it('resets the doc to orginal state', async () => { const expectedDoc = doc(p('')); - tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }), + }); }); it('emits an error event that includes an error message', (done) => { diff --git a/spec/frontend/content_editor/extensions/blockquote_spec.js b/spec/frontend/content_editor/extensions/blockquote_spec.js new file mode 100644 index 00000000000..c5b5044352d --- /dev/null +++ b/spec/frontend/content_editor/extensions/blockquote_spec.js @@ -0,0 +1,19 @@ +import { multilineInputRegex } from '~/content_editor/extensions/blockquote'; + +describe('content_editor/extensions/blockquote', () => { + describe.each` + input | matches + ${'>>> '} | ${true} + ${' >>> '} | ${true} + ${'\t>>> '} | ${true} + ${'>> '} | ${false} + ${'>>>x '} | ${false} + ${'> '} | ${false} + `('multilineInputRegex', ({ input, matches }) => { + it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => { + const match = new RegExp(multilineInputRegex).test(input); + + expect(match).toBe(matches); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index 188e6580dc6..6a0a0c76825 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -1,9 +1,15 @@ import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; -import { loadMarkdownApiResult } from '../markdown_processing_examples'; import { createTestEditor } from '../test_utils'; +const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"> + <code> + <span id="LC1" class="line" lang="javascript"> + <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">hello world</span><span class="dl">'</span><span class="p">)</span> + </span> + </code> +</pre>`; + describe('content_editor/extensions/code_block_highlight', () => { - let codeBlockHtmlFixture; let parsedCodeBlockHtmlFixture; let tiptapEditor; @@ -11,13 +17,10 @@ describe('content_editor/extensions/code_block_highlight', () => { const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); beforeEach(() => { - const { html } = loadMarkdownApiResult('code_block'); - tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); - codeBlockHtmlFixture = html; - parsedCodeBlockHtmlFixture = parseHTML(codeBlockHtmlFixture); + parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); - tiptapEditor.commands.setContent(codeBlockHtmlFixture); + tiptapEditor.commands.setContent(CODE_BLOCK_HTML); }); it('extracts language and params attributes from Markdown API output', () => { diff --git a/spec/frontend/content_editor/markdown_processing_examples.js b/spec/frontend/content_editor/markdown_processing_examples.js index 12eed00f3c6..b3aabfeb145 100644 --- a/spec/frontend/content_editor/markdown_processing_examples.js +++ b/spec/frontend/content_editor/markdown_processing_examples.js @@ -6,7 +6,8 @@ import { getJSONFixture } from 'helpers/fixtures'; export const loadMarkdownApiResult = (testName) => { const fixturePathPrefix = `api/markdown/${testName}.json`; - return getJSONFixture(fixturePathPrefix); + const fixture = getJSONFixture(fixturePathPrefix); + return fixture.body || fixture.html; }; export const loadMarkdownApiExamples = () => { @@ -16,3 +17,9 @@ export const loadMarkdownApiExamples = () => { return apiMarkdownExampleObjects.map(({ name, context, markdown }) => [name, context, markdown]); }; + +export const loadMarkdownApiExample = (testName) => { + return loadMarkdownApiExamples().find(([name, context]) => { + return (context ? `${context}_${name}` : name) === testName; + })[2]; +}; diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js index da3f6e64db8..71565768558 100644 --- a/spec/frontend/content_editor/markdown_processing_spec.js +++ b/spec/frontend/content_editor/markdown_processing_spec.js @@ -9,8 +9,9 @@ describe('markdown processing', () => { 'correctly handles %s (context: %s)', async (name, context, markdown) => { const testName = context ? `${context}_${name}` : name; - const { html, body } = loadMarkdownApiResult(testName); - const contentEditor = createContentEditor({ renderMarkdown: () => html || body }); + const contentEditor = createContentEditor({ + renderMarkdown: () => loadMarkdownApiResult(testName), + }); await contentEditor.setSerializedContent(markdown); expect(contentEditor.getSerializedContent()).toBe(markdown); diff --git a/spec/frontend/content_editor/services/mark_utils_spec.js b/spec/frontend/content_editor/services/mark_utils_spec.js new file mode 100644 index 00000000000..bbfb8f26f99 --- /dev/null +++ b/spec/frontend/content_editor/services/mark_utils_spec.js @@ -0,0 +1,38 @@ +import { + markInputRegex, + extractMarkAttributesFromMatch, +} from '~/content_editor/services/mark_utils'; + +describe('content_editor/services/mark_utils', () => { + describe.each` + tag | input | matches + ${'tag'} | ${'<tag>hello</tag>'} | ${true} + ${'tag'} | ${'<tag title="tooltip">hello</tag>'} | ${true} + ${'kbd'} | ${'Hold <kbd>Ctrl</kbd>'} | ${true} + ${'time'} | ${'Lets meet at <time title="today" datetime="20:00">20:00</time>'} | ${true} + ${'tag'} | ${'<tag width=30 height=30>attrs not quoted</tag>'} | ${false} + ${'tag'} | ${"<tag title='abc'>single quote attrs not supported</tag>"} | ${false} + ${'tag'} | ${'<tag title>attr has no value</tag>'} | ${false} + ${'tag'} | ${'<tag>tag opened but not closed'} | ${false} + ${'tag'} | ${'</tag>tag closed before opened<tag>'} | ${false} + `('inputRegex("$tag")', ({ tag, input, matches }) => { + it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => { + const match = markInputRegex(tag).test(input); + + expect(match).toBe(matches); + }); + }); + + describe.each` + tag | input | attrs + ${'kbd'} | ${'Hold <kbd>Ctrl</kbd>'} | ${{}} + ${'tag'} | ${'<tag title="tooltip">hello</tag>'} | ${{ title: 'tooltip' }} + ${'time'} | ${'Lets meet at <time title="today" datetime="20:00">20:00</time>'} | ${{ title: 'today', datetime: '20:00' }} + ${'abbr'} | ${'Sure, you can try it out but <abbr title="Your mileage may vary">YMMV</abbr>'} | ${{ title: 'Your mileage may vary' }} + `('extractAttributesFromMatch(inputRegex("$tag").exec(\'$input\'))', ({ tag, input, attrs }) => { + it(`returns: "${JSON.stringify(attrs)}"`, () => { + const matches = markInputRegex(tag).exec(input); + expect(extractMarkAttributesFromMatch(matches)).toEqual(attrs); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js new file mode 100644 index 00000000000..6f2c908c289 --- /dev/null +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -0,0 +1,1008 @@ +import Blockquote from '~/content_editor/extensions/blockquote'; +import Bold from '~/content_editor/extensions/bold'; +import BulletList from '~/content_editor/extensions/bullet_list'; +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 Division from '~/content_editor/extensions/division'; +import Emoji from '~/content_editor/extensions/emoji'; +import Figure from '~/content_editor/extensions/figure'; +import FigureCaption from '~/content_editor/extensions/figure_caption'; +import HardBreak from '~/content_editor/extensions/hard_break'; +import Heading from '~/content_editor/extensions/heading'; +import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; +import Image from '~/content_editor/extensions/image'; +import InlineDiff from '~/content_editor/extensions/inline_diff'; +import Italic from '~/content_editor/extensions/italic'; +import Link from '~/content_editor/extensions/link'; +import ListItem from '~/content_editor/extensions/list_item'; +import OrderedList from '~/content_editor/extensions/ordered_list'; +import Paragraph from '~/content_editor/extensions/paragraph'; +import Strike from '~/content_editor/extensions/strike'; +import Table from '~/content_editor/extensions/table'; +import TableCell from '~/content_editor/extensions/table_cell'; +import TableHeader from '~/content_editor/extensions/table_header'; +import TableRow from '~/content_editor/extensions/table_row'; +import TaskItem from '~/content_editor/extensions/task_item'; +import TaskList from '~/content_editor/extensions/task_list'; +import Text from '~/content_editor/extensions/text'; +import markdownSerializer from '~/content_editor/services/markdown_serializer'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +jest.mock('~/emoji'); + +jest.mock('~/content_editor/services/feature_flags', () => ({ + isBlockTablesFeatureEnabled: jest.fn().mockReturnValue(true), +})); + +const tiptapEditor = createTestEditor({ + extensions: [ + Blockquote, + Bold, + BulletList, + Code, + CodeBlockHighlight, + DescriptionItem, + DescriptionList, + Division, + Emoji, + Figure, + FigureCaption, + HardBreak, + Heading, + HorizontalRule, + Image, + InlineDiff, + Italic, + Link, + ListItem, + OrderedList, + Paragraph, + Strike, + Table, + TableCell, + TableHeader, + TableRow, + TaskItem, + TaskList, + Text, + ], +}); + +const { + builders: { + doc, + blockquote, + bold, + bulletList, + code, + codeBlock, + division, + descriptionItem, + descriptionList, + emoji, + figure, + figureCaption, + heading, + hardBreak, + horizontalRule, + image, + inlineDiff, + italic, + link, + listItem, + orderedList, + paragraph, + strike, + table, + tableCell, + tableHeader, + tableRow, + taskItem, + taskList, + }, +} = createDocBuilder({ + tiptapEditor, + names: { + blockquote: { nodeType: Blockquote.name }, + bold: { markType: Bold.name }, + bulletList: { nodeType: BulletList.name }, + code: { markType: Code.name }, + codeBlock: { nodeType: CodeBlockHighlight.name }, + division: { nodeType: Division.name }, + descriptionItem: { nodeType: DescriptionItem.name }, + descriptionList: { nodeType: DescriptionList.name }, + emoji: { markType: Emoji.name }, + figure: { nodeType: Figure.name }, + figureCaption: { nodeType: FigureCaption.name }, + hardBreak: { nodeType: HardBreak.name }, + heading: { nodeType: Heading.name }, + horizontalRule: { nodeType: HorizontalRule.name }, + image: { nodeType: Image.name }, + inlineDiff: { markType: InlineDiff.name }, + italic: { nodeType: Italic.name }, + link: { markType: Link.name }, + listItem: { nodeType: ListItem.name }, + orderedList: { nodeType: OrderedList.name }, + paragraph: { nodeType: Paragraph.name }, + strike: { markType: Strike.name }, + table: { nodeType: Table.name }, + tableCell: { nodeType: TableCell.name }, + tableHeader: { nodeType: TableHeader.name }, + tableRow: { nodeType: TableRow.name }, + taskItem: { nodeType: TaskItem.name }, + taskList: { nodeType: TaskList.name }, + }, +}); + +const serialize = (...content) => + markdownSerializer({}).serialize({ + schema: tiptapEditor.schema, + content: doc(...content).toJSON(), + }); + +describe('markdownSerializer', () => { + it('correctly serializes bold', () => { + expect(serialize(paragraph(bold('bold')))).toBe('**bold**'); + }); + + it('correctly serializes italics', () => { + expect(serialize(paragraph(italic('italics')))).toBe('_italics_'); + }); + + it('correctly serializes inline diff', () => { + expect( + serialize( + paragraph( + inlineDiff({ type: 'addition' }, '+30 lines'), + inlineDiff({ type: 'deletion' }, '-10 lines'), + ), + ), + ).toBe('{++30 lines+}{--10 lines-}'); + }); + + it('correctly serializes a line break', () => { + expect(serialize(paragraph('hello', hardBreak(), 'world'))).toBe('hello\\\nworld'); + }); + + it('correctly serializes a link', () => { + expect(serialize(paragraph(link({ href: 'https://example.com' }, 'example url')))).toBe( + '[example url](https://example.com)', + ); + }); + + it('correctly serializes a plain URL link', () => { + expect(serialize(paragraph(link({ href: 'https://example.com' }, 'https://example.com')))).toBe( + '<https://example.com>', + ); + }); + + it('correctly serializes a link with a title', () => { + expect( + serialize( + paragraph(link({ href: 'https://example.com', title: 'click this link' }, 'example url')), + ), + ).toBe('[example url](https://example.com "click this link")'); + }); + + it('correctly serializes a plain URL link with a title', () => { + expect( + serialize( + paragraph( + link({ href: 'https://example.com', title: 'link title' }, 'https://example.com'), + ), + ), + ).toBe('[https://example.com](https://example.com "link title")'); + }); + + it('correctly serializes a link with a canonicalSrc', () => { + expect( + serialize( + paragraph( + link( + { + href: '/uploads/abcde/file.zip', + canonicalSrc: 'file.zip', + title: 'click here to download', + }, + 'download file', + ), + ), + ), + ).toBe('[download file](file.zip "click here to download")'); + }); + + it('correctly serializes strikethrough', () => { + expect(serialize(paragraph(strike('deleted content')))).toBe('~~deleted content~~'); + }); + + it('correctly serializes blockquotes with hard breaks', () => { + expect(serialize(blockquote('some text', hardBreak(), hardBreak(), 'new line'))).toBe( + ` +> some text\\ +> \\ +> new line + `.trim(), + ); + }); + + it('correctly serializes blockquote with multiple block nodes', () => { + expect(serialize(blockquote(paragraph('some paragraph'), codeBlock('var x = 10;')))).toBe( + ` +> some paragraph +> +> \`\`\` +> var x = 10; +> \`\`\` + `.trim(), + ); + }); + + it('correctly serializes a multiline blockquote', () => { + expect( + serialize( + blockquote( + { multiline: true }, + paragraph('some paragraph with ', bold('bold')), + codeBlock('var y = 10;'), + ), + ), + ).toBe( + ` +>>> +some paragraph with **bold** + +\`\`\` +var y = 10; +\`\`\` + +>>> + `.trim(), + ); + }); + + it('correctly serializes a code block with language', () => { + expect( + serialize( + codeBlock( + { language: 'json' }, + 'this is not really json but just trying out whether this case works or not', + ), + ), + ).toBe( + ` +\`\`\`json +this is not really json but just trying out whether this case works or not +\`\`\` + `.trim(), + ); + }); + + it('correctly serializes emoji', () => { + expect(serialize(paragraph(emoji({ name: 'dog' })))).toBe(':dog:'); + }); + + it('correctly serializes headings', () => { + expect( + serialize( + heading({ level: 1 }, 'Heading 1'), + heading({ level: 2 }, 'Heading 2'), + heading({ level: 3 }, 'Heading 3'), + heading({ level: 4 }, 'Heading 4'), + heading({ level: 5 }, 'Heading 5'), + heading({ level: 6 }, 'Heading 6'), + ), + ).toBe( + ` +# Heading 1 + +## Heading 2 + +### Heading 3 + +#### Heading 4 + +##### Heading 5 + +###### Heading 6 + `.trim(), + ); + }); + + it('correctly serializes horizontal rule', () => { + expect(serialize(horizontalRule(), horizontalRule(), horizontalRule())).toBe( + ` +--- + +--- + +--- + `.trim(), + ); + }); + + it('correctly serializes an image', () => { + expect(serialize(paragraph(image({ src: 'img.jpg', alt: 'foo bar' })))).toBe( + '![foo bar](img.jpg)', + ); + }); + + it('correctly serializes an image with a title', () => { + expect(serialize(paragraph(image({ src: 'img.jpg', title: 'baz', alt: 'foo bar' })))).toBe( + '![foo bar](img.jpg "baz")', + ); + }); + + it('correctly serializes an image with a canonicalSrc', () => { + expect( + serialize( + paragraph( + image({ + src: '/uploads/abcde/file.png', + alt: 'this is an image', + canonicalSrc: 'file.png', + title: 'foo bar baz', + }), + ), + ), + ).toBe('![this is an image](file.png "foo bar baz")'); + }); + + it('correctly serializes bullet list', () => { + expect( + serialize( + bulletList( + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +* list item 1 +* list item 2 +* list item 3 + `.trim(), + ); + }); + + it('correctly serializes bullet list with different bullet styles', () => { + expect( + serialize( + bulletList( + { bullet: '+' }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem( + paragraph('list item 3'), + bulletList( + { bullet: '-' }, + listItem(paragraph('sub-list item 1')), + listItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + ` ++ list item 1 ++ list item 2 ++ list item 3 + - sub-list item 1 + - sub-list item 2 + `.trim(), + ); + }); + + it('correctly serializes a numeric list', () => { + expect( + serialize( + orderedList( + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +1. list item 1 +2. list item 2 +3. list item 3 + `.trim(), + ); + }); + + it('correctly serializes a numeric list with parens', () => { + expect( + serialize( + orderedList( + { parens: true }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +1) list item 1 +2) list item 2 +3) list item 3 + `.trim(), + ); + }); + + it('correctly serializes a numeric list with a different start order', () => { + expect( + serialize( + orderedList( + { start: 17 }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +17. list item 1 +18. list item 2 +19. list item 3 + `.trim(), + ); + }); + + it('correctly serializes a numeric list with an invalid start order', () => { + expect( + serialize( + orderedList( + { start: NaN }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +1. list item 1 +2. list item 2 +3. list item 3 + `.trim(), + ); + }); + + it('correctly serializes a bullet list inside an ordered list', () => { + expect( + serialize( + orderedList( + { start: 17 }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem( + paragraph('list item 3'), + bulletList( + listItem(paragraph('sub-list item 1')), + listItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + // notice that 4 space indent works fine in this case, + // when it usually wouldn't + ` +17. list item 1 +18. list item 2 +19. list item 3 + * sub-list item 1 + * sub-list item 2 + `.trim(), + ); + }); + + it('correctly serializes a task list', () => { + expect( + serialize( + taskList( + taskItem({ checked: true }, paragraph('list item 1')), + taskItem(paragraph('list item 2')), + taskItem( + paragraph('list item 3'), + taskList( + taskItem({ checked: true }, paragraph('sub-list item 1')), + taskItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + ` +* [x] list item 1 +* [ ] list item 2 +* [ ] list item 3 + * [x] sub-list item 1 + * [ ] sub-list item 2 + `.trim(), + ); + }); + + it('correctly serializes a numeric task list + with start order', () => { + expect( + serialize( + taskList( + { numeric: true }, + taskItem({ checked: true }, paragraph('list item 1')), + taskItem(paragraph('list item 2')), + taskItem( + paragraph('list item 3'), + taskList( + { numeric: true, start: 1351, parens: true }, + taskItem({ checked: true }, paragraph('sub-list item 1')), + taskItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + ` +1. [x] list item 1 +2. [ ] list item 2 +3. [ ] list item 3 + 1351) [x] sub-list item 1 + 1352) [ ] sub-list item 2 + `.trim(), + ); + }); + + it('correctly renders a description list', () => { + expect( + serialize( + descriptionList( + descriptionItem(paragraph('Beast of Bodmin')), + descriptionItem({ isTerm: false }, paragraph('A large feline inhabiting Bodmin Moor.')), + + descriptionItem(paragraph('Morgawr')), + descriptionItem({ isTerm: false }, paragraph('A sea serpent.')), + + descriptionItem(paragraph('Owlman')), + descriptionItem( + { isTerm: false }, + paragraph('A giant ', italic('owl-like'), ' creature.'), + ), + ), + ), + ).toBe( + ` +<dl> +<dt>Beast of Bodmin</dt> +<dd>A large feline inhabiting Bodmin Moor.</dd> +<dt>Morgawr</dt> +<dd>A sea serpent.</dd> +<dt>Owlman</dt> +<dd> + +A giant _owl-like_ creature. + +</dd> +</dl> + `.trim(), + ); + }); + + it('correctly renders div', () => { + expect( + serialize( + division(paragraph('just a paragraph in a div')), + division(paragraph('just some ', bold('styled'), ' ', italic('content'), ' in a div')), + ), + ).toBe( + '<div>just a paragraph in a div</div>\n<div>\n\njust some **styled** _content_ in a div\n\n</div>', + ); + }); + + it('correctly renders figure', () => { + expect( + serialize( + figure( + paragraph(image({ src: 'elephant.jpg', alt: 'An elephant at sunset' })), + figureCaption('An elephant at sunset'), + ), + ), + ).toBe( + ` +<figure> + +![An elephant at sunset](elephant.jpg) + +<figcaption>An elephant at sunset</figcaption> +</figure> + `.trim(), + ); + }); + + it('correctly renders figure with styled caption', () => { + expect( + serialize( + figure( + paragraph(image({ src: 'elephant.jpg', alt: 'An elephant at sunset' })), + figureCaption(italic('An elephant at sunset')), + ), + ), + ).toBe( + ` +<figure> + +![An elephant at sunset](elephant.jpg) + +<figcaption> + +_An elephant at sunset_ + +</figcaption> +</figure> + `.trim(), + ); + }); + + it('correctly serializes a table with inline content', () => { + expect( + serialize( + table( + // each table cell must contain at least one paragraph + tableRow( + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + ), + tableRow( + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + ), + tableRow( + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + ), + ), + ).trim(), + ).toBe( + ` +| header | header | header | +|--------|--------|--------| +| cell | cell | cell | +| cell | cell | cell | + `.trim(), + ); + }); + + it('correctly serializes a table with line breaks', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))), + tableRow( + tableCell(paragraph('cell with', hardBreak(), 'line', hardBreak(), 'breaks')), + tableCell(paragraph('cell')), + ), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +| header | header | +|--------|--------| +| cell with<br>line<br>breaks | cell | +| cell | cell | + `.trim(), + ); + }); + + it('correctly serializes two consecutive tables', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + table( + tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +| header | header | +|--------|--------| +| cell | cell | +| cell | cell | + +| header | header | +|--------|--------| +| cell | cell | +| cell | cell | + `.trim(), + ); + }); + + it('correctly serializes a table with block content', () => { + expect( + serialize( + table( + tableRow( + tableHeader(paragraph('examples of')), + tableHeader(paragraph('block content')), + tableHeader(paragraph('in tables')), + tableHeader(paragraph('in content editor')), + ), + tableRow( + tableCell(heading({ level: 1 }, 'heading 1')), + tableCell(heading({ level: 2 }, 'heading 2')), + tableCell(paragraph(bold('just bold'))), + tableCell(paragraph(bold('bold'), ' ', italic('italic'), ' ', code('code'))), + ), + tableRow( + tableCell( + paragraph('all marks in three paragraphs:'), + paragraph('the ', bold('quick'), ' ', italic('brown'), ' ', code('fox')), + paragraph( + link({ href: '/home' }, 'jumps'), + ' over the ', + strike('lazy'), + ' ', + emoji({ name: 'dog' }), + ), + ), + tableCell( + paragraph(image({ src: 'img.jpg', alt: 'some image' }), hardBreak(), 'image content'), + ), + tableCell( + blockquote('some text', hardBreak(), hardBreak(), 'in a multiline blockquote'), + ), + tableCell( + codeBlock( + { language: 'javascript' }, + 'var a = 2;\nvar b = 3;\nvar c = a + d;\n\nconsole.log(c);', + ), + ), + ), + tableRow( + tableCell(bulletList(listItem('item 1'), listItem('item 2'), listItem('item 2'))), + tableCell(orderedList(listItem('item 1'), listItem('item 2'), listItem('item 2'))), + tableCell( + paragraph('paragraphs separated by'), + horizontalRule(), + paragraph('a horizontal rule'), + ), + tableCell( + table( + tableRow(tableHeader(paragraph('table')), tableHeader(paragraph('inside'))), + tableRow(tableCell(paragraph('another')), tableCell(paragraph('table'))), + ), + ), + ), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>examples of</th> +<th>block content</th> +<th>in tables</th> +<th>in content editor</th> +</tr> +<tr> +<td> + +# heading 1 +</td> +<td> + +## heading 2 +</td> +<td> + +**just bold** +</td> +<td> + +**bold** _italic_ \`code\` +</td> +</tr> +<tr> +<td> + +all marks in three paragraphs: + +the **quick** _brown_ \`fox\` + +[jumps](/home) over the ~~lazy~~ :dog: +</td> +<td> + +![some image](img.jpg)<br>image content +</td> +<td> + +> some text\\ +> \\ +> in a multiline blockquote +</td> +<td> + +\`\`\`javascript +var a = 2; +var b = 3; +var c = a + d; + +console.log(c); +\`\`\` +</td> +</tr> +<tr> +<td> + +* item 1 +* item 2 +* item 2 +</td> +<td> + +1. item 1 +2. item 2 +3. item 2 +</td> +<td> + +paragraphs separated by + +--- + +a horizontal rule +</td> +<td> + +| table | inside | +|-------|--------| +| another | table | + +</td> +</tr> +</table> + `.trim(), + ); + }); + + it('correctly renders content after a markdown table', () => { + expect( + serialize( + table(tableRow(tableHeader(paragraph('header'))), tableRow(tableCell(paragraph('cell')))), + heading({ level: 1 }, 'this is a heading'), + ).trim(), + ).toBe( + ` +| header | +|--------| +| cell | + +# this is a heading + `.trim(), + ); + }); + + it('correctly renders content after an html table', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('header'))), + tableRow(tableCell(blockquote('hi'), paragraph('there'))), + ), + heading({ level: 1 }, 'this is a heading'), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>header</th> +</tr> +<tr> +<td> + +> hi + +there +</td> +</tr> +</table> + +# this is a heading + `.trim(), + ); + }); + + it('correctly serializes tables with misplaced header cells', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableHeader(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>cell</th> +<td>cell</td> +</tr> +<tr> +<td>cell</td> +<th>cell</th> +</tr> +</table> + `.trim(), + ); + }); + + it('correctly serializes table without any headers', () => { + expect( + serialize( + table( + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<td>cell</td> +<td>cell</td> +</tr> +<tr> +<td>cell</td> +<td>cell</td> +</tr> +</table> + `.trim(), + ); + }); + + it('correctly serializes table with rowspan and colspan', () => { + expect( + serialize( + table( + tableRow( + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + ), + tableRow( + tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2')), + tableCell({ rowspan: 2 }, paragraph('cell')), + ), + tableRow(tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2'))), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>header</th> +<th>header</th> +<th>header</th> +</tr> +<tr> +<td colspan="2">cell with rowspan: 2</td> +<td rowspan="2">cell</td> +</tr> +<tr> +<td colspan="2">cell with rowspan: 2</td> +</tr> +</table> + `.trim(), + ); + }); +}); diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js new file mode 100644 index 00000000000..6f908f468f6 --- /dev/null +++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js @@ -0,0 +1,81 @@ +import { Extension } from '@tiptap/core'; +import BulletList from '~/content_editor/extensions/bullet_list'; +import ListItem from '~/content_editor/extensions/list_item'; +import Paragraph from '~/content_editor/extensions/paragraph'; +import markdownSerializer from '~/content_editor/services/markdown_serializer'; +import { getMarkdownSource } from '~/content_editor/services/markdown_sourcemap'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +const BULLET_LIST_MARKDOWN = `+ list item 1 ++ list item 2 + - embedded list item 3`; +const BULLET_LIST_HTML = `<ul data-sourcepos="1:1-3:24" dir="auto"> + <li data-sourcepos="1:1-1:13">list item 1</li> + <li data-sourcepos="2:1-3:24">list item 2 + <ul data-sourcepos="3:3-3:24"> + <li data-sourcepos="3:3-3:24">embedded list item 3</li> + </ul> + </li> +</ul>`; + +const SourcemapExtension = Extension.create({ + // lets add `source` attribute to every element using `getMarkdownSource` + addGlobalAttributes() { + return [ + { + types: [Paragraph.name, BulletList.name, ListItem.name], + attributes: { + source: { + parseHTML: (element) => { + const source = getMarkdownSource(element); + return source; + }, + }, + }, + }, + ]; + }, +}); + +const tiptapEditor = createTestEditor({ + extensions: [BulletList, ListItem, SourcemapExtension], +}); + +const { + builders: { doc, bulletList, listItem, paragraph }, +} = createDocBuilder({ + tiptapEditor, + names: { + bulletList: { nodeType: BulletList.name }, + listItem: { nodeType: ListItem.name }, + }, +}); + +describe('content_editor/services/markdown_sourcemap', () => { + it('gets markdown source for a rendered HTML element', async () => { + const deserialized = await markdownSerializer({ + render: () => BULLET_LIST_HTML, + serializerConfig: {}, + }).deserialize({ + schema: tiptapEditor.schema, + content: BULLET_LIST_MARKDOWN, + }); + + const expected = doc( + bulletList( + { bullet: '+', source: '+ list item 1\n+ list item 2' }, + listItem({ source: '+ list item 1' }, paragraph('list item 1')), + listItem( + { source: '+ list item 2' }, + paragraph('list item 2'), + bulletList( + { bullet: '-', source: '- embedded list item 3' }, + listItem({ source: '- embedded list item 3' }, paragraph('embedded list item 3')), + ), + ), + ), + ); + + expect(deserialized).toEqual(expected.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index b5a2abc2389..cf5aa3f2938 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -98,9 +98,7 @@ export const createTestContentEditorExtension = ({ commands = [] } = {}) => { return { labelName: { default: null, - parseHTML: (element) => { - return { labelName: element.dataset.labelName }; - }, + parseHTML: (element) => element.dataset.labelName, }, }; }, diff --git a/spec/frontend/cycle_analytics/banner_spec.js b/spec/frontend/cycle_analytics/banner_spec.js deleted file mode 100644 index ef7998c5ff5..00000000000 --- a/spec/frontend/cycle_analytics/banner_spec.js +++ /dev/null @@ -1,47 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Banner from '~/cycle_analytics/components/banner.vue'; - -describe('Value Stream Analytics banner', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMount(Banner, { - propsData: { - documentationLink: 'path', - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render value stream analytics information', () => { - expect(wrapper.find('h4').text().trim()).toBe('Introducing Value Stream Analytics'); - - expect( - wrapper - .find('p') - .text() - .trim() - .replace(/[\r\n]+/g, ' '), - ).toContain( - 'Value Stream Analytics gives an overview of how much time it takes to go from idea to production in your project.', - ); - - expect(wrapper.find('a').text().trim()).toBe('Read more'); - expect(wrapper.find('a').attributes('href')).toBe('path'); - }); - - it('should emit an event when close button is clicked', async () => { - jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {}); - - await wrapper.find('.js-ca-dismiss-button').trigger('click'); - - expect(wrapper.vm.$emit).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js index 71830eed3ef..5d3361bfa35 100644 --- a/spec/frontend/cycle_analytics/base_spec.js +++ b/spec/frontend/cycle_analytics/base_spec.js @@ -6,6 +6,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import BaseComponent from '~/cycle_analytics/components/base.vue'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import StageTable from '~/cycle_analytics/components/stage_table.vue'; +import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue'; import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue'; import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants'; import initState from '~/cycle_analytics/store/state'; @@ -30,13 +31,14 @@ Vue.use(Vuex); let wrapper; +const { id: groupId, path: groupPath } = currentGroup; const defaultState = { permissions, currentGroup, createdBefore, createdAfter, stageCounts, - endpoints: { fullPath }, + endpoints: { fullPath, groupId, groupPath }, }; function createStore({ initialState = {}, initialGetters = {} }) { @@ -74,6 +76,7 @@ function createComponent({ initialState, initialGetters } = {}) { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findPathNavigation = () => wrapper.findComponent(PathNavigation); +const findFilters = () => wrapper.findComponent(ValueStreamFilters); const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics); const findStageTable = () => wrapper.findComponent(StageTable); const findStageEvents = () => findStageTable().props('stageEvents'); @@ -123,6 +126,29 @@ describe('Value stream analytics component', () => { expect(findStageEvents()).toEqual(selectedStageEvents); }); + it('renders the filters', () => { + expect(findFilters().exists()).toBe(true); + }); + + it('displays the date range selector and hides the project selector', () => { + expect(findFilters().props()).toMatchObject({ + hasProjectFilter: false, + hasDateRangeFilter: true, + }); + }); + + it('passes the paths to the filter bar', () => { + expect(findFilters().props()).toEqual({ + groupId, + groupPath, + endDate: createdBefore, + hasDateRangeFilter: true, + hasProjectFilter: false, + selectedProjects: [], + startDate: createdAfter, + }); + }); + it('does not render the loading icon', () => { expect(findLoadingIcon().exists()).toBe(false); }); diff --git a/spec/frontend/cycle_analytics/stage_table_spec.js b/spec/frontend/cycle_analytics/stage_table_spec.js index 47a2ce4444b..3158446c37d 100644 --- a/spec/frontend/cycle_analytics/stage_table_spec.js +++ b/spec/frontend/cycle_analytics/stage_table_spec.js @@ -22,6 +22,7 @@ const findStageEvents = () => wrapper.findAllByTestId('vsa-stage-event'); const findPagination = () => wrapper.findByTestId('vsa-stage-pagination'); const findTable = () => wrapper.findComponent(GlTable); const findTableHead = () => wrapper.find('thead'); +const findTableHeadColumns = () => findTableHead().findAll('th'); const findStageEventTitle = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-title'); const findStageTime = () => wrapper.findByTestId('vsa-stage-event-time'); const findIcon = (name) => wrapper.findByTestId(`${name}-icon`); @@ -244,6 +245,12 @@ describe('StageTable', () => { wrapper.destroy(); }); + it('can sort the table by each column', () => { + findTableHeadColumns().wrappers.forEach((w) => { + expect(w.attributes('aria-sort')).toBe('none'); + }); + }); + it('clicking a table column will send tracking information', () => { triggerTableSort(); @@ -275,5 +282,17 @@ describe('StageTable', () => { }, ]); }); + + describe('with sortable=false', () => { + beforeEach(() => { + wrapper = createComponent({ sortable: false }); + }); + + it('cannot sort the table', () => { + findTableHeadColumns().wrappers.forEach((w) => { + expect(w.attributes('aria-sort')).toBeUndefined(); + }); + }); + }); }); }); diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js index 915a828ff19..97b5bd03e18 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -4,21 +4,41 @@ import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/cycle_analytics/store/actions'; import * as getters from '~/cycle_analytics/store/getters'; import httpStatusCodes from '~/lib/utils/http_status'; -import { allowedStages, selectedStage, selectedValueStream } from '../mock_data'; - +import { + allowedStages, + selectedStage, + selectedValueStream, + currentGroup, + createdAfter, + createdBefore, +} from '../mock_data'; + +const { id: groupId, path: groupPath } = currentGroup; +const mockMilestonesPath = 'mock-milestones.json'; +const mockLabelsPath = 'mock-labels.json'; const mockRequestPath = 'some/cool/path'; const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams'; -const mockStartDate = 30; -const mockEndpoints = { fullPath: mockFullPath, requestPath: mockRequestPath }; -const mockSetDateActionCommit = { payload: { startDate: mockStartDate }, type: 'SET_DATE_RANGE' }; - -const defaultState = { ...getters, selectedValueStream }; +const mockEndpoints = { + fullPath: mockFullPath, + requestPath: mockRequestPath, + labelsPath: mockLabelsPath, + milestonesPath: mockMilestonesPath, + groupId, + groupPath, +}; +const mockSetDateActionCommit = { + payload: { createdAfter, createdBefore }, + type: 'SET_DATE_RANGE', +}; + +const defaultState = { ...getters, selectedValueStream, createdAfter, createdBefore }; describe('Project Value Stream Analytics actions', () => { let state; let mock; beforeEach(() => { + state = { ...defaultState }; mock = new MockAdapter(axios); }); @@ -34,16 +54,17 @@ describe('Project Value Stream Analytics actions', () => { { type: 'fetchCycleAnalyticsData' }, { type: 'fetchStageData' }, { type: 'fetchStageMedians' }, + { type: 'fetchStageCountValues' }, { type: 'setLoading', payload: false }, ]; describe.each` - action | payload | expectedActions | expectedMutations - ${'setLoading'} | ${true} | ${[]} | ${[{ type: 'SET_LOADING', payload: true }]} - ${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockFetchStageDataActions} | ${[mockSetDateActionCommit]} - ${'setFilters'} | ${[]} | ${mockFetchStageDataActions} | ${[]} - ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'fetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} - ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} + action | payload | expectedActions | expectedMutations + ${'setLoading'} | ${true} | ${[]} | ${[{ type: 'SET_LOADING', payload: true }]} + ${'setDateRange'} | ${{ createdAfter, createdBefore }} | ${mockFetchStageDataActions} | ${[mockSetDateActionCommit]} + ${'setFilters'} | ${[]} | ${mockFetchStageDataActions} | ${[]} + ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'fetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} + ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} `('$action', ({ action, payload, expectedActions, expectedMutations }) => { const types = mutationTypes(expectedMutations); it(`will dispatch ${expectedActions} and commit ${types}`, () => @@ -60,6 +81,12 @@ describe('Project Value Stream Analytics actions', () => { let mockDispatch; let mockCommit; const payload = { endpoints: mockEndpoints }; + const mockFilterEndpoints = { + groupEndpoint: 'foo', + labelsEndpoint: mockLabelsPath, + milestonesEndpoint: mockMilestonesPath, + projectEndpoint: '/namespace/-/analytics/value_stream_analytics/value_streams', + }; beforeEach(() => { mockDispatch = jest.fn(() => Promise.resolve()); @@ -76,6 +103,9 @@ describe('Project Value Stream Analytics actions', () => { payload, ); expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_VSA', { endpoints: mockEndpoints }); + + expect(mockDispatch).toHaveBeenCalledTimes(4); + expect(mockDispatch).toHaveBeenCalledWith('filters/setEndpoints', mockFilterEndpoints); expect(mockDispatch).toHaveBeenCalledWith('setLoading', true); expect(mockDispatch).toHaveBeenCalledWith('fetchValueStreams'); expect(mockDispatch).toHaveBeenCalledWith('setLoading', false); @@ -84,7 +114,7 @@ describe('Project Value Stream Analytics actions', () => { describe('fetchCycleAnalyticsData', () => { beforeEach(() => { - state = { endpoints: mockEndpoints }; + state = { ...defaultState, endpoints: mockEndpoints }; mock = new MockAdapter(axios); mock.onGet(mockRequestPath).reply(httpStatusCodes.OK); }); @@ -129,7 +159,6 @@ describe('Project Value Stream Analytics actions', () => { state = { ...defaultState, endpoints: mockEndpoints, - startDate: mockStartDate, selectedStage, }; mock = new MockAdapter(axios); @@ -152,7 +181,6 @@ describe('Project Value Stream Analytics actions', () => { state = { ...defaultState, endpoints: mockEndpoints, - startDate: mockStartDate, selectedStage, }; mock = new MockAdapter(axios); @@ -177,7 +205,6 @@ describe('Project Value Stream Analytics actions', () => { state = { ...defaultState, endpoints: mockEndpoints, - startDate: mockStartDate, selectedStage, }; mock = new MockAdapter(axios); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js index 7fcfef98547..628e2a4e7ae 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -1,5 +1,4 @@ import { useFakeDate } from 'helpers/fake_date'; -import { DEFAULT_DAYS_TO_DISPLAY } from '~/cycle_analytics/constants'; import * as types from '~/cycle_analytics/store/mutation_types'; import mutations from '~/cycle_analytics/store/mutations'; import { @@ -65,15 +64,16 @@ describe('Project Value Stream Analytics mutations', () => { expect(state).toMatchObject({ [stateKey]: value }); }); + const mockSetDatePayload = { createdAfter: mockCreatedAfter, createdBefore: mockCreatedBefore }; const mockInitialPayload = { endpoints: { requestPath: mockRequestPath }, currentGroup: { title: 'cool-group' }, id: 1337, + ...mockSetDatePayload, }; const mockInitializedObj = { endpoints: { requestPath: mockRequestPath }, - createdAfter: mockCreatedAfter, - createdBefore: mockCreatedBefore, + ...mockSetDatePayload, }; it.each` @@ -89,9 +89,8 @@ describe('Project Value Stream Analytics mutations', () => { it.each` mutation | payload | stateKey | value - ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY} - ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter} - ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore} + ${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} diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js index 168ddcfeacc..403d0dce3fc 100644 --- a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js @@ -1,3 +1,4 @@ +import { GlModal } from '@gitlab/ui'; import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue'; @@ -29,6 +30,8 @@ describe('Deploy freeze table', () => { const findAddDeployFreezeButton = () => wrapper.find('[data-testid="add-deploy-freeze"]'); const findEditDeployFreezeButton = () => wrapper.find('[data-testid="edit-deploy-freeze"]'); const findDeployFreezeTable = () => wrapper.find('[data-testid="deploy-freeze-table"]'); + const findDeleteDeployFreezeButton = () => wrapper.find('[data-testid="delete-deploy-freeze"]'); + const findDeleteDeployFreezeModal = () => wrapper.findComponent(GlModal); beforeEach(() => { createComponent(); @@ -73,6 +76,29 @@ describe('Deploy freeze table', () => { store.state.freezePeriods[0], ); }); + + it('displays delete deploy freeze button', () => { + expect(findDeleteDeployFreezeButton().exists()).toBe(true); + }); + + it('confirms a user wants to delete a deploy freeze', async () => { + const [{ freezeStart, freezeEnd, cronTimezone }] = store.state.freezePeriods; + await findDeleteDeployFreezeButton().trigger('click'); + const modal = findDeleteDeployFreezeModal(); + expect(modal.text()).toContain( + `Deploy freeze from ${freezeStart} to ${freezeEnd} in ${cronTimezone.formattedTimezone} will be removed.`, + ); + }); + + it('deletes the freeze period on confirmation', async () => { + await findDeleteDeployFreezeButton().trigger('click'); + const modal = findDeleteDeployFreezeModal(); + modal.vm.$emit('primary'); + expect(store.dispatch).toHaveBeenCalledWith( + 'deleteFreezePeriod', + store.state.freezePeriods[0], + ); + }); }); }); diff --git a/spec/frontend/deploy_freeze/helpers.js b/spec/frontend/deploy_freeze/helpers.js index bfb84142662..598f14d45f6 100644 --- a/spec/frontend/deploy_freeze/helpers.js +++ b/spec/frontend/deploy_freeze/helpers.js @@ -1,7 +1,7 @@ import { secondsToHours } from '~/lib/utils/datetime_utility'; export const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); -export const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); +export const timezoneDataFixture = getJSONFixture('/timezones/short.json'); export const findTzByName = (identifier = '') => timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase()); diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js index 6bc9c4d374c..ad67afdce75 100644 --- a/spec/frontend/deploy_freeze/store/actions_spec.js +++ b/spec/frontend/deploy_freeze/store/actions_spec.js @@ -5,6 +5,7 @@ import * as actions from '~/deploy_freeze/store/actions'; import * as types from '~/deploy_freeze/store/mutation_types'; import getInitialState from '~/deploy_freeze/store/state'; import createFlash from '~/flash'; +import * as logger from '~/lib/logger'; import axios from '~/lib/utils/axios_utils'; import { freezePeriodsFixture, timezoneDataFixture } from '../helpers'; @@ -12,6 +13,7 @@ jest.mock('~/api.js'); jest.mock('~/flash.js'); describe('deploy freeze store actions', () => { + const freezePeriodFixture = freezePeriodsFixture[0]; let mock; let state; @@ -24,6 +26,7 @@ describe('deploy freeze store actions', () => { Api.freezePeriods.mockResolvedValue({ data: freezePeriodsFixture }); Api.createFreezePeriod.mockResolvedValue(); Api.updateFreezePeriod.mockResolvedValue(); + Api.deleteFreezePeriod.mockResolvedValue(); }); afterEach(() => { @@ -195,4 +198,46 @@ describe('deploy freeze store actions', () => { ); }); }); + + describe('deleteFreezePeriod', () => { + it('dispatch correct actions on deleting a freeze period', () => { + testAction( + actions.deleteFreezePeriod, + freezePeriodFixture, + state, + [ + { type: 'REQUEST_DELETE_FREEZE_PERIOD', payload: freezePeriodFixture.id }, + { type: 'RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS', payload: freezePeriodFixture.id }, + ], + [], + () => + expect(Api.deleteFreezePeriod).toHaveBeenCalledWith( + state.projectId, + freezePeriodFixture.id, + ), + ); + }); + + it('should show flash error and set error in state on delete failure', () => { + jest.spyOn(logger, 'logError').mockImplementation(); + const error = new Error(); + Api.deleteFreezePeriod.mockRejectedValue(error); + + testAction( + actions.deleteFreezePeriod, + freezePeriodFixture, + state, + [ + { type: 'REQUEST_DELETE_FREEZE_PERIOD', payload: freezePeriodFixture.id }, + { type: 'RECEIVE_DELETE_FREEZE_PERIOD_ERROR', payload: freezePeriodFixture.id }, + ], + [], + () => { + expect(createFlash).toHaveBeenCalled(); + + expect(logger.logError).toHaveBeenCalledWith('Unable to delete deploy freeze', error); + }, + ); + }); + }); }); diff --git a/spec/frontend/deploy_freeze/store/mutations_spec.js b/spec/frontend/deploy_freeze/store/mutations_spec.js index f8683489340..878a755088c 100644 --- a/spec/frontend/deploy_freeze/store/mutations_spec.js +++ b/spec/frontend/deploy_freeze/store/mutations_spec.js @@ -28,9 +28,9 @@ describe('Deploy freeze mutations', () => { describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => { it('should set freeze periods and format timezones from identifiers to names', () => { const timezoneNames = { - 'Europe/Berlin': 'Berlin', - 'Etc/UTC': 'UTC', - 'America/New_York': 'Eastern Time (US & Canada)', + 'Europe/Berlin': '[UTC 2] Berlin', + 'Etc/UTC': '[UTC 0] UTC', + 'America/New_York': '[UTC -4] Eastern Time (US & Canada)', }; mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture); diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js index 7858f88f8c3..4a6dee31cd5 100644 --- a/spec/frontend/deprecated_jquery_dropdown_spec.js +++ b/spec/frontend/deprecated_jquery_dropdown_spec.js @@ -323,7 +323,7 @@ describe('deprecatedJQueryDropdown', () => { const li = dropdown.renderItem(item, null, 3); const link = li.querySelector('a'); - expect(link).toHaveAttr('data-track-event', 'click_text'); + expect(link).toHaveAttr('data-track-action', 'click_text'); expect(link).toHaveAttr('data-track-label', 'some_value_for_label'); expect(link).toHaveAttr('data-track-value', '3'); expect(link).toHaveAttr('data-track-property', 'suggestion-category'); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap index d9f5ba0bade..4dc8eaea174 100644 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> +"<button data-track-action=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> <!----> <!----> <span class=\\"gl-button-text\\"> Comment @@ -9,7 +9,7 @@ exports[`Design reply form component renders button text as "Comment" when creat `; exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> +"<button data-track-action=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> <!----> <!----> <span class=\\"gl-button-text\\"> Save comment diff --git a/spec/frontend/design_management/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js index 8a123b2d1e5..095c070e5e8 100644 --- a/spec/frontend/design_management/components/design_scaler_spec.js +++ b/spec/frontend/design_management/components/design_scaler_spec.js @@ -13,7 +13,11 @@ describe('Design management design scaler component', () => { const setScale = (scale) => wrapper.vm.setScale(scale); const createComponent = () => { - wrapper = shallowMount(DesignScaler); + wrapper = shallowMount(DesignScaler, { + propsData: { + maxScale: 2, + }, + }); }; beforeEach(() => { @@ -61,6 +65,18 @@ describe('Design management design scaler component', () => { expect(wrapper.emitted('scale')).toEqual([[1.2]]); }); + it('computes & increments correct stepSize based on maxScale', async () => { + wrapper.setProps({ maxScale: 11 }); + + await wrapper.vm.$nextTick(); + + getIncreaseScaleButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted().scale[0][0]).toBe(3); + }); + describe('when `scale` value is 1', () => { it('disables the "reset" button', () => { const resetButton = getResetScaleButton(); @@ -77,7 +93,7 @@ describe('Design management design scaler component', () => { }); }); - describe('when `scale` value is 2 (maximum)', () => { + describe('when `scale` value is maximum', () => { beforeEach(async () => { setScale(2); await wrapper.vm.$nextTick(); 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 637f22457c4..67e4a82787c 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 @@ -3,10 +3,14 @@ exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` <gl-dropdown-stub category="primary" + clearalltext="Clear all" headertext="" hideheaderborder="true" + highlighteditemstitle="Selected" + highlighteditemstitleclass="gl-px-5" issueiid="" projectpath="" + showhighlighteditemstitle="true" size="small" text="Showing latest version" variant="default" @@ -80,10 +84,14 @@ exports[`Design management design version dropdown component renders design vers exports[`Design management design version dropdown component renders design version list 1`] = ` <gl-dropdown-stub category="primary" + clearalltext="Clear all" 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/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index 57023c55878..3d04840b1f8 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -25,7 +25,9 @@ exports[`Design management design index page renders design index 1`] = ` <div class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center" > - <design-scaler-stub /> + <design-scaler-stub + maxscale="2" + /> </div> </div> @@ -186,7 +188,9 @@ exports[`Design management design index page with error GlAlert is rendered in c <div class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center" > - <design-scaler-stub /> + <design-scaler-stub + maxscale="2" + /> </div> </div> diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 1332e872246..6ce384b4869 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -390,28 +390,13 @@ describe('Design management design index page', () => { ); }); - describe('with usage_data_design_action enabled', () => { - it('tracks design view service ping', () => { - createComponent( - { loading: true }, - { - provide: { - glFeatures: { usageDataDesignAction: true }, - }, - }, - ); - expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); - expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith( - DESIGN_SERVICE_PING_EVENT_TYPES.DESIGN_ACTION, - ); - }); - }); + it('tracks design view service ping', () => { + createComponent({ loading: true }); - describe('with usage_data_design_action disabled', () => { - it("doesn't track design view service ping", () => { - createComponent({ loading: true }); - expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(0); - }); + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith( + DESIGN_SERVICE_PING_EVENT_TYPES.DESIGN_ACTION, + ); }); }); }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 95cb1ac943c..ce79feae2e7 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -338,6 +338,13 @@ describe('Design management index page', () => { __typename: 'DesignVersion', id: expect.anything(), sha: expect.anything(), + createdAt: '', + author: { + __typename: 'UserCore', + id: expect.anything(), + name: '', + avatarUrl: '', + }, }, }, }, @@ -623,6 +630,16 @@ describe('Design management index page', () => { expect(mockMutate).not.toHaveBeenCalled(); }); + it('does not upload designs if designs wrapper is destroyed', () => { + findDesignsWrapper().trigger('mouseenter'); + + wrapper.destroy(); + + document.dispatchEvent(event); + + expect(mockMutate).not.toHaveBeenCalled(); + }); + describe('when designs wrapper is hovered', () => { let realDateNow; const today = () => new Date('2020-12-25'); diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js index 5b7f99e9d96..dc6056badb9 100644 --- a/spec/frontend/design_management/utils/design_management_utils_spec.js +++ b/spec/frontend/design_management/utils/design_management_utils_spec.js @@ -101,7 +101,13 @@ describe('optimistic responses', () => { discussions: { __typename: 'DesignDiscussion', nodes: [] }, versions: { __typename: 'DesignVersionConnection', - nodes: { __typename: 'DesignVersion', id: -1, sha: -1 }, + nodes: { + __typename: 'DesignVersion', + id: expect.anything(), + sha: expect.anything(), + createdAt: '', + author: { __typename: 'UserCore', avatarUrl: '', name: '', id: expect.anything() }, + }, }, }, ], diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 1464dd84666..9dc82bbdc93 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -183,7 +183,7 @@ describe('diffs/components/app', () => { it('displays loading icon on batch loading', () => { createComponent({}, ({ state }) => { - state.diffs.isBatchLoading = true; + state.diffs.batchLoadingState = 'loading'; }); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); @@ -705,24 +705,4 @@ describe('diffs/components/app', () => { ); }); }); - - describe('diff file tree is aware of review bar', () => { - it('it does not have review-bar-visible class when review bar is not visible', () => { - createComponent({}, ({ state }) => { - state.diffs.diffFiles = [{ file_hash: '111', file_path: '111.js' }]; - }); - - expect(wrapper.find('.js-diff-tree-list').exists()).toBe(true); - expect(wrapper.find('.js-diff-tree-list.review-bar-visible').exists()).toBe(false); - }); - - it('it does have review-bar-visible class when review bar is visible', () => { - createComponent({}, ({ state }) => { - state.diffs.diffFiles = [{ file_hash: '111', file_path: '111.js' }]; - state.batchComments.drafts = ['draft message']; - }); - - expect(wrapper.find('.js-diff-tree-list.review-bar-visible').exists()).toBe(true); - }); - }); }); diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 3dec56f2fe3..feb7118744b 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -242,32 +242,20 @@ describe('DiffFile', () => { }); it.each` - loggedIn | featureOn | bool - ${true} | ${true} | ${true} - ${false} | ${true} | ${false} - ${true} | ${false} | ${false} - ${false} | ${false} | ${false} - `( - 'should be $bool when { userIsLoggedIn: $loggedIn, featureEnabled: $featureOn }', - ({ loggedIn, featureOn, bool }) => { - setLoggedIn(loggedIn); - - ({ wrapper } = createComponent({ - options: { - provide: { - glFeatures: { - localFileReviews: featureOn, - }, - }, - }, - props: { - file: store.state.diffs.diffFiles[0], - }, - })); + loggedIn | bool + ${true} | ${true} + ${false} | ${false} + `('should be $bool when { userIsLoggedIn: $loggedIn }', ({ loggedIn, bool }) => { + setLoggedIn(loggedIn); + + ({ wrapper } = createComponent({ + props: { + file: store.state.diffs.diffFiles[0], + }, + })); - expect(wrapper.vm.showLocalFileReviews).toBe(bool); - }, - ); + expect(wrapper.vm.showLocalFileReviews).toBe(bool); + }); }); }); diff --git a/spec/frontend/diffs/create_diffs_store.js b/spec/frontend/diffs/create_diffs_store.js index e6a8b7a72ae..307ebdaa4ac 100644 --- a/spec/frontend/diffs/create_diffs_store.js +++ b/spec/frontend/diffs/create_diffs_store.js @@ -9,6 +9,12 @@ Vue.use(Vuex); export default function createDiffsStore() { return new Vuex.Store({ modules: { + page: { + namespaced: true, + state: { + activeTab: 'notes', + }, + }, diffs: diffsModule(), notes: notesModule(), batchComments: batchCommentsModule(), diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 6d005b868a9..b35abc9da02 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -186,15 +186,16 @@ describe('DiffsStoreActions', () => { {}, { endpointBatch, diffViewType: 'inline' }, [ - { type: types.SET_BATCH_LOADING, payload: true }, + { type: types.SET_BATCH_LOADING_STATE, payload: 'loading' }, { type: types.SET_RETRIEVING_BATCHES, payload: true }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res1.diff_files } }, - { type: types.SET_BATCH_LOADING, payload: false }, + { type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' }, { type: types.VIEW_DIFF_FILE, payload: 'test' }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res2.diff_files } }, - { type: types.SET_BATCH_LOADING, payload: false }, + { type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' }, { type: types.VIEW_DIFF_FILE, payload: 'test2' }, { type: types.SET_RETRIEVING_BATCHES, payload: false }, + { type: types.SET_BATCH_LOADING_STATE, payload: 'error' }, ], [{ type: 'startRenderDiffsQueue' }, { type: 'startRenderDiffsQueue' }], done, diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index b549ca42634..fc9ba223d5a 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -31,13 +31,13 @@ describe('DiffsStoreMutations', () => { }); }); - describe('SET_BATCH_LOADING', () => { + describe('SET_BATCH_LOADING_STATE', () => { it('should set loading state', () => { const state = {}; - mutations[types.SET_BATCH_LOADING](state, false); + mutations[types.SET_BATCH_LOADING_STATE](state, false); - expect(state.isBatchLoading).toEqual(false); + expect(state.batchLoadingState).toEqual(false); }); }); diff --git a/spec/frontend/diffs/utils/preferences_spec.js b/spec/frontend/diffs/utils/preferences_spec.js deleted file mode 100644 index 2dcc71dc188..00000000000 --- a/spec/frontend/diffs/utils/preferences_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import Cookies from 'js-cookie'; -import { - DIFF_FILE_BY_FILE_COOKIE_NAME, - DIFF_VIEW_FILE_BY_FILE, - DIFF_VIEW_ALL_FILES, -} from '~/diffs/constants'; -import { fileByFile } from '~/diffs/utils/preferences'; - -describe('diffs preferences', () => { - describe('fileByFile', () => { - afterEach(() => { - Cookies.remove(DIFF_FILE_BY_FILE_COOKIE_NAME); - }); - - it.each` - result | preference | cookie - ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} - ${false} | ${true} | ${DIFF_VIEW_ALL_FILES} - ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} - ${false} | ${true} | ${DIFF_VIEW_ALL_FILES} - ${false} | ${false} | ${DIFF_VIEW_ALL_FILES} - ${true} | ${true} | ${DIFF_VIEW_FILE_BY_FILE} - `( - 'should return $result when { preference: $preference, cookie: $cookie }', - ({ result, preference, cookie }) => { - Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, cookie); - - expect(fileByFile(preference)).toBe(result); - }, - ); - }); -}); diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index 5e6ccbd7cda..acf7d0780cd 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -1,9 +1,12 @@ +import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import mock from 'xhr-mock'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table'; import dropzoneInput from '~/dropzone_input'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; const TEST_FILE = new File([], 'somefile.jpg'); TEST_FILE.upload = {}; @@ -29,6 +32,16 @@ describe('dropzone_input', () => { }); describe('handlePaste', () => { + const triggerPasteEvent = (clipboardData = {}) => { + const event = $.Event('paste'); + const origEvent = new Event('paste'); + + origEvent.clipboardData = clipboardData; + event.originalEvent = origEvent; + + $('.js-gfm-input').trigger(event); + }; + beforeEach(() => { loadFixtures('issues/new-issue.html'); @@ -38,24 +51,39 @@ describe('dropzone_input', () => { }); it('pastes Markdown tables', () => { - const event = $.Event('paste'); - const origEvent = new Event('paste'); + jest.spyOn(PasteMarkdownTable.prototype, 'isTable'); + jest.spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown'); - origEvent.clipboardData = { + triggerPasteEvent({ types: ['text/plain', 'text/html'], getData: () => '<table><tr><td>Hello World</td></tr></table>', items: [], - }; - event.originalEvent = origEvent; - - jest.spyOn(PasteMarkdownTable.prototype, 'isTable'); - jest.spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown'); - - $('.js-gfm-input').trigger(event); + }); expect(PasteMarkdownTable.prototype.isTable).toHaveBeenCalled(); expect(PasteMarkdownTable.prototype.convertToTableMarkdown).toHaveBeenCalled(); }); + + it('passes truncated long filename to post request', async () => { + const axiosMock = new MockAdapter(axios); + const longFileName = 'a'.repeat(300); + + triggerPasteEvent({ + types: ['text/plain', 'text/html', 'text/rtf', 'Files'], + getData: () => longFileName, + items: [ + { + kind: 'file', + type: 'image/png', + getAsFile: () => new Blob(), + }, + ], + }); + + axiosMock.onPost().reply(httpStatusCodes.OK, { link: { markdown: 'foo' } }); + await waitForPromises(); + expect(axiosMock.history.post[0].data.get('file').name).toHaveLength(246); + }); }); describe('shows error message', () => { diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js index 1e6f5483160..9652c513671 100644 --- a/spec/frontend/emoji/index_spec.js +++ b/spec/frontend/emoji/index_spec.js @@ -9,6 +9,7 @@ import isEmojiUnicodeSupported, { isHorceRacingSkinToneComboEmoji, isPersonZwjEmoji, } from '~/emoji/support/is_emoji_unicode_supported'; +import { sanitize } from '~/lib/dompurify'; const emptySupportMap = { personZwj: false, @@ -379,7 +380,7 @@ describe('emoji', () => { describe('searchEmoji', () => { const emojiFixture = Object.keys(mockEmojiData).reduce((acc, k) => { const { name, e, u, d } = mockEmojiData[k]; - acc[k] = { name, e, u, d }; + acc[k] = { name, e: sanitize(e), u, d }; return acc; }, {}); @@ -397,6 +398,7 @@ describe('emoji', () => { 'heart', 'custard', 'star', + 'xss', ].map((name) => { return { emoji: emojiFixture[name], @@ -620,4 +622,13 @@ describe('emoji', () => { expect(sortEmoji(scoredItems)).toEqual(expected); }); }); + + describe('sanitize emojis', () => { + it('should return sanitized emoji', () => { + expect(getEmojiInfo('xss')).toEqual({ + ...mockEmojiData.xss, + e: '<img src="x">', + }); + }); + }); }); diff --git a/spec/frontend/emoji/support/unicode_support_map_spec.js b/spec/frontend/emoji/support/unicode_support_map_spec.js index 945e804a9fa..37f74db30b5 100644 --- a/spec/frontend/emoji/support/unicode_support_map_spec.js +++ b/spec/frontend/emoji/support/unicode_support_map_spec.js @@ -8,14 +8,14 @@ describe('Unicode Support Map', () => { const stringSupportMap = 'stringSupportMap'; beforeEach(() => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockImplementation(() => {}); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockImplementation(() => {}); jest.spyOn(JSON, 'parse').mockImplementation(() => {}); jest.spyOn(JSON, 'stringify').mockReturnValue(stringSupportMap); }); describe('if isLocalStorageAvailable is `true`', () => { beforeEach(() => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); getUnicodeSupportMap(); }); @@ -38,7 +38,7 @@ describe('Unicode Support Map', () => { describe('if isLocalStorageAvailable is `false`', () => { beforeEach(() => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(false); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); getUnicodeSupportMap(); }); diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js index 3e7f5dd5ff4..2c8c054ccbd 100644 --- a/spec/frontend/environments/edit_environment_spec.js +++ b/spec/frontend/environments/edit_environment_spec.js @@ -15,15 +15,12 @@ const DEFAULT_OPTS = { projectEnvironmentsPath: '/projects/environments', updateEnvironmentPath: '/proejcts/environments/1', }, - propsData: { environment: { name: 'foo', externalUrl: 'https://foo.example.com' } }, + propsData: { environment: { id: '0', name: 'foo', external_url: 'https://foo.example.com' } }, }; describe('~/environments/components/edit.vue', () => { let wrapper; let mock; - let name; - let url; - let form; const createWrapper = (opts = {}) => mountExtended(EditEnvironment, { @@ -34,9 +31,6 @@ describe('~/environments/components/edit.vue', () => { beforeEach(() => { mock = new MockAdapter(axios); wrapper = createWrapper(); - name = wrapper.findByLabelText('Name'); - url = wrapper.findByLabelText('External URL'); - form = wrapper.findByRole('form', { name: 'Edit environment' }); }); afterEach(() => { @@ -44,19 +38,22 @@ describe('~/environments/components/edit.vue', () => { wrapper.destroy(); }); + const findNameInput = () => wrapper.findByLabelText('Name'); + const findExternalUrlInput = () => wrapper.findByLabelText('External URL'); + const findForm = () => wrapper.findByRole('form', { name: 'Edit environment' }); + const showsLoading = () => wrapper.find(GlLoadingIcon).exists(); const submitForm = async (expected, response) => { mock .onPut(DEFAULT_OPTS.provide.updateEnvironmentPath, { - name: expected.name, external_url: expected.url, + id: '0', }) .reply(...response); - await name.setValue(expected.name); - await url.setValue(expected.url); + await findExternalUrlInput().setValue(expected.url); - await form.trigger('submit'); + await findForm().trigger('submit'); await waitForPromises(); }; @@ -65,18 +62,8 @@ describe('~/environments/components/edit.vue', () => { expect(header.exists()).toBe(true); }); - it.each` - input | value - ${() => name} | ${'test'} - ${() => url} | ${'https://example.org'} - `('it changes the value of the input to $value', async ({ input, value }) => { - await input().setValue(value); - - expect(input().element.value).toBe(value); - }); - it('shows loader after form is submitted', async () => { - const expected = { name: 'test', url: 'https://google.ca' }; + const expected = { url: 'https://google.ca' }; expect(showsLoading()).toBe(false); @@ -86,7 +73,7 @@ describe('~/environments/components/edit.vue', () => { }); it('submits the updated environment on submit', async () => { - const expected = { name: 'test', url: 'https://google.ca' }; + const expected = { url: 'https://google.ca' }; await submitForm(expected, [200, { path: '/test' }]); @@ -94,11 +81,24 @@ describe('~/environments/components/edit.vue', () => { }); it('shows errors on error', async () => { - const expected = { name: 'test', url: 'https://google.ca' }; + const expected = { url: 'https://google.ca' }; - await submitForm(expected, [400, { message: ['name taken'] }]); + await submitForm(expected, [400, { message: ['uh oh!'] }]); - expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' }); + expect(createFlash).toHaveBeenCalledWith({ message: 'uh oh!' }); expect(showsLoading()).toBe(false); }); + + it('renders a disabled "Name" field', () => { + const nameInput = findNameInput(); + + expect(nameInput.attributes().disabled).toBe('disabled'); + expect(nameInput.element.value).toBe('foo'); + }); + + it('renders an "External URL" field', () => { + const urlInput = findExternalUrlInput(); + + expect(urlInput.element.value).toBe('https://foo.example.com'); + }); }); diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js index ed8fda71dab..f1af08bcf32 100644 --- a/spec/frontend/environments/environment_form_spec.js +++ b/spec/frontend/environments/environment_form_spec.js @@ -102,4 +102,52 @@ describe('~/environments/components/form.vue', () => { wrapper = createWrapper({ loading: true }); expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); + describe('when a new environment is being created', () => { + beforeEach(() => { + wrapper = createWrapper({ + environment: { + name: '', + externalUrl: '', + }, + }); + }); + + it('renders an enabled "Name" field', () => { + const nameInput = wrapper.findByLabelText('Name'); + + expect(nameInput.attributes().disabled).toBeUndefined(); + expect(nameInput.element.value).toBe(''); + }); + + it('renders an "External URL" field', () => { + const urlInput = wrapper.findByLabelText('External URL'); + + expect(urlInput.element.value).toBe(''); + }); + }); + + describe('when an existing environment is being edited', () => { + beforeEach(() => { + wrapper = createWrapper({ + environment: { + id: 1, + name: 'test', + externalUrl: 'https://example.com', + }, + }); + }); + + it('renders a disabled "Name" field', () => { + const nameInput = wrapper.findByLabelText('Name'); + + expect(nameInput.attributes().disabled).toBe('disabled'); + expect(nameInput.element.value).toBe('test'); + }); + + it('renders an "External URL" field', () => { + const urlInput = wrapper.findByLabelText('External URL'); + + expect(urlInput.element.value).toBe('https://example.com'); + }); + }); }); diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index a568a7d5396..b930259149f 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -31,7 +31,6 @@ describe('Environment item', () => { factory({ propsData: { model: environment, - canReadEnvironment: true, tableData, }, }); @@ -135,7 +134,6 @@ describe('Environment item', () => { factory({ propsData: { model: environmentWithoutDeployable, - canReadEnvironment: true, tableData, }, }); @@ -161,7 +159,6 @@ describe('Environment item', () => { factory({ propsData: { model: environmentWithoutUpcomingDeployment, - canReadEnvironment: true, tableData, }, }); @@ -177,7 +174,6 @@ describe('Environment item', () => { factory({ propsData: { model: environment, - canReadEnvironment: true, tableData, shouldShowAutoStopDate: true, }, @@ -205,7 +201,6 @@ describe('Environment item', () => { ...environment, auto_stop_at: futureDate, }, - canReadEnvironment: true, tableData, shouldShowAutoStopDate: true, }, @@ -241,7 +236,6 @@ describe('Environment item', () => { ...environment, auto_stop_at: pastDate, }, - canReadEnvironment: true, tableData, shouldShowAutoStopDate: true, }, @@ -360,7 +354,6 @@ describe('Environment item', () => { factory({ propsData: { model: folder, - canReadEnvironment: true, tableData, }, }); diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js index 71426ee5170..1851163ac68 100644 --- a/spec/frontend/environments/environment_table_spec.js +++ b/spec/frontend/environments/environment_table_spec.js @@ -28,7 +28,6 @@ describe('Environment table', () => { factory({ propsData: { environments: [folder], - canReadEnvironment: true, ...eeOnlyProps, }, }); @@ -50,7 +49,6 @@ describe('Environment table', () => { await factory({ propsData: { environments: [mockItem], - canReadEnvironment: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -78,7 +76,6 @@ describe('Environment table', () => { propsData: { environments: [mockItem], canCreateDeployment: false, - canReadEnvironment: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -114,7 +111,6 @@ describe('Environment table', () => { propsData: { environments: [mockItem], canCreateDeployment: false, - canReadEnvironment: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -151,7 +147,6 @@ describe('Environment table', () => { factory({ propsData: { environments: [mockItem], - canReadEnvironment: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -179,7 +174,6 @@ describe('Environment table', () => { propsData: { environments: [mockItem], canCreateDeployment: false, - canReadEnvironment: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -230,7 +224,6 @@ describe('Environment table', () => { factory({ propsData: { environments: mockItems, - canReadEnvironment: true, ...eeOnlyProps, }, }); @@ -296,7 +289,6 @@ describe('Environment table', () => { factory({ propsData: { environments: mockItems, - canReadEnvironment: true, ...eeOnlyProps, }, }); @@ -335,7 +327,6 @@ describe('Environment table', () => { factory({ propsData: { environments: mockItems, - canReadEnvironment: true, ...eeOnlyProps, }, }); @@ -364,7 +355,6 @@ describe('Environment table', () => { factory({ propsData: { environments: mockItems, - canReadEnvironment: true, ...eeOnlyProps, }, }); @@ -415,7 +405,6 @@ describe('Environment table', () => { factory({ propsData: { environments: mockItems, - canReadEnvironment: true, ...eeOnlyProps, }, }); diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index dc176001943..cd05ecbfb53 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -1,4 +1,4 @@ -import { GlTabs, GlAlert } from '@gitlab/ui'; +import { GlTabs } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -7,9 +7,7 @@ import DeployBoard from '~/environments/components/deploy_board.vue'; import EmptyState from '~/environments/components/empty_state.vue'; import EnableReviewAppModal from '~/environments/components/enable_review_app_modal.vue'; import EnvironmentsApp from '~/environments/components/environments_app.vue'; -import { ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME } from '~/environments/constants'; import axios from '~/lib/utils/axios_utils'; -import { setCookie, getCookie, removeCookie } from '~/lib/utils/common_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import { environment, folder } from './mock_data'; @@ -20,7 +18,6 @@ describe('Environment', () => { const mockData = { endpoint: 'environments.json', canCreateEnvironment: true, - canReadEnvironment: true, newEnvironmentPath: 'environments/new', helpPagePath: 'help', userCalloutsPath: '/callouts', @@ -50,7 +47,6 @@ describe('Environment', () => { const findNewEnvironmentButton = () => wrapper.findByTestId('new-environment'); const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available > a'); const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped > a'); - const findSurveyAlert = () => wrapper.find(GlAlert); beforeEach(() => { mock = new MockAdapter(axios); @@ -283,49 +279,4 @@ describe('Environment', () => { expect(wrapper.findComponent(GlTabs).attributes('value')).toBe('1'); }); }); - - describe('survey alert', () => { - beforeEach(async () => { - mockRequest(200, { environments: [] }); - await createWrapper(true); - }); - - afterEach(() => { - removeCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME); - }); - - describe('when the user has not dismissed the alert', () => { - it('shows the alert', () => { - expect(findSurveyAlert().exists()).toBe(true); - }); - - describe('when the user dismisses the alert', () => { - beforeEach(() => { - findSurveyAlert().vm.$emit('dismiss'); - }); - - it('hides the alert', () => { - expect(findSurveyAlert().exists()).toBe(false); - }); - - it('persists the dismisal using a cookie', () => { - const cookieValue = getCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME); - - expect(cookieValue).toBe('true'); - }); - }); - }); - - describe('when the user has previously dismissed the alert', () => { - beforeEach(async () => { - setCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME, 'true'); - - await createWrapper(true); - }); - - it('does not show the alert', () => { - expect(findSurveyAlert().exists()).toBe(false); - }); - }); - }); }); diff --git a/spec/frontend/environments/environments_detail_header_spec.js b/spec/frontend/environments/environments_detail_header_spec.js index 6334060c736..305e7385b43 100644 --- a/spec/frontend/environments/environments_detail_header_spec.js +++ b/spec/frontend/environments/environments_detail_header_spec.js @@ -44,7 +44,6 @@ describe('Environments detail header component', () => { TimeAgo, }, propsData: { - canReadEnvironment: false, canAdminEnvironment: false, canUpdateEnvironment: false, canStopEnvironment: false, @@ -60,7 +59,7 @@ describe('Environments detail header component', () => { describe('default state with minimal access', () => { beforeEach(() => { - createWrapper({ props: { environment: createEnvironment() } }); + createWrapper({ props: { environment: createEnvironment({ externalUrl: null }) } }); }); it('displays the environment name', () => { @@ -164,7 +163,6 @@ describe('Environments detail header component', () => { createWrapper({ props: { environment: createEnvironment({ hasTerminals: true, externalUrl }), - canReadEnvironment: true, }, }); }); @@ -178,8 +176,7 @@ describe('Environments detail header component', () => { beforeEach(() => { createWrapper({ props: { - environment: createEnvironment(), - canReadEnvironment: true, + environment: createEnvironment({ metricsUrl: 'my metrics url' }), metricsPath, }, }); @@ -195,7 +192,6 @@ describe('Environments detail header component', () => { createWrapper({ props: { environment: createEnvironment(), - canReadEnvironment: true, canAdminEnvironment: true, canStopEnvironment: true, canUpdateEnvironment: true, diff --git a/spec/frontend/environments/environments_folder_view_spec.js b/spec/frontend/environments/environments_folder_view_spec.js index e4661d27872..72a7449f24e 100644 --- a/spec/frontend/environments/environments_folder_view_spec.js +++ b/spec/frontend/environments/environments_folder_view_spec.js @@ -11,7 +11,6 @@ describe('Environments Folder View', () => { const mockData = { endpoint: 'environments.json', folderName: 'review', - canReadEnvironment: true, cssContainerClass: 'container', userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js index d02ed8688c6..9eb57b2682f 100644 --- a/spec/frontend/environments/folder/environments_folder_view_spec.js +++ b/spec/frontend/environments/folder/environments_folder_view_spec.js @@ -14,7 +14,6 @@ describe('Environments Folder View', () => { const mockData = { endpoint: 'environments.json', folderName: 'review', - canReadEnvironment: true, cssContainerClass: 'container', userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', diff --git a/spec/frontend/error_tracking_settings/components/app_spec.js b/spec/frontend/error_tracking_settings/components/app_spec.js index e0be81b3899..30541ba68a5 100644 --- a/spec/frontend/error_tracking_settings/components/app_spec.js +++ b/spec/frontend/error_tracking_settings/components/app_spec.js @@ -1,6 +1,9 @@ +import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import Vuex from 'vuex'; import { TEST_HOST } from 'helpers/test_constants'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ErrorTrackingSettings from '~/error_tracking_settings/components/app.vue'; import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue'; import ProjectDropdown from '~/error_tracking_settings/components/project_dropdown.vue'; @@ -14,20 +17,31 @@ describe('error tracking settings app', () => { let wrapper; function mountComponent() { - wrapper = shallowMount(ErrorTrackingSettings, { - localVue, - store, // Override the imported store - propsData: { - initialEnabled: 'true', - initialApiHost: TEST_HOST, - initialToken: 'someToken', - initialProject: null, - listProjectsEndpoint: TEST_HOST, - operationsSettingsEndpoint: TEST_HOST, - }, - }); + wrapper = extendedWrapper( + shallowMount(ErrorTrackingSettings, { + localVue, + store, // Override the imported store + propsData: { + initialEnabled: 'true', + initialIntegrated: 'false', + initialApiHost: TEST_HOST, + initialToken: 'someToken', + initialProject: null, + listProjectsEndpoint: TEST_HOST, + operationsSettingsEndpoint: TEST_HOST, + }, + }), + ); } + const findBackendSettingsSection = () => wrapper.findByTestId('tracking-backend-settings'); + const findBackendSettingsRadioGroup = () => + findBackendSettingsSection().findComponent(GlFormRadioGroup); + const findBackendSettingsRadioButtons = () => + findBackendSettingsRadioGroup().findAllComponents(GlFormRadio); + const findElementWithText = (wrappers, text) => wrappers.filter((item) => item.text() === text); + const findSentrySettings = () => wrapper.findByTestId('sentry-setting-form'); + beforeEach(() => { store = createStore(); @@ -62,4 +76,46 @@ describe('error tracking settings app', () => { }); }); }); + + describe('tracking-backend settings', () => { + it('contains a form-group with the correct label', () => { + expect(findBackendSettingsSection().attributes('label')).toBe('Error tracking backend'); + }); + + it('contains a radio group', () => { + expect(findBackendSettingsRadioGroup().exists()).toBe(true); + }); + + it('contains the correct radio buttons', () => { + expect(findBackendSettingsRadioButtons()).toHaveLength(2); + + expect(findElementWithText(findBackendSettingsRadioButtons(), 'Sentry')).toHaveLength(1); + expect(findElementWithText(findBackendSettingsRadioButtons(), 'GitLab')).toHaveLength(1); + }); + + it('toggles the sentry-settings section when sentry 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(); + + expect(findSentrySettings().exists()).toBe(false); + }); + + it.each([true, false])( + 'calls the `updateIntegrated` action when the setting changes to `%s`', + (integrated) => { + jest.spyOn(store, 'dispatch').mockImplementation(); + + expect(store.dispatch).toHaveBeenCalledTimes(0); + + findBackendSettingsRadioGroup().vm.$emit('change', integrated); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenCalledWith('updateIntegrated', integrated); + }, + ); + }); }); diff --git a/spec/frontend/error_tracking_settings/mock.js b/spec/frontend/error_tracking_settings/mock.js index e64a6d1fe14..b2d7a912518 100644 --- a/spec/frontend/error_tracking_settings/mock.js +++ b/spec/frontend/error_tracking_settings/mock.js @@ -42,6 +42,7 @@ export const sampleBackendProject = { export const sampleFrontendSettings = { apiHost: 'apiHost', enabled: false, + integrated: false, token: 'token', selectedProject: { slug: normalizedProject.slug, @@ -54,6 +55,7 @@ export const sampleFrontendSettings = { export const transformedSettings = { api_host: 'apiHost', enabled: false, + integrated: false, token: 'token', project: { slug: normalizedProject.slug, @@ -71,6 +73,7 @@ export const defaultProps = { export const initialEmptyState = { apiHost: '', enabled: false, + integrated: false, project: null, token: '', listProjectsEndpoint: TEST_HOST, @@ -80,6 +83,7 @@ export const initialEmptyState = { export const initialPopulatedState = { apiHost: 'apiHost', enabled: true, + integrated: true, project: JSON.stringify(projectList[0]), token: 'token', listProjectsEndpoint: TEST_HOST, diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js index 281db7d9686..1b9be042dd4 100644 --- a/spec/frontend/error_tracking_settings/store/actions_spec.js +++ b/spec/frontend/error_tracking_settings/store/actions_spec.js @@ -202,5 +202,11 @@ describe('error tracking settings actions', () => { done, ); }); + + it.each([true, false])('should set the `integrated` flag to `%s`', async (payload) => { + await testAction(actions.updateIntegrated, payload, state, [ + { type: types.UPDATE_INTEGRATED, payload }, + ]); + }); }); }); diff --git a/spec/frontend/error_tracking_settings/store/mutation_spec.js b/spec/frontend/error_tracking_settings/store/mutation_spec.js index 78fd56904b3..ecf1c91c08a 100644 --- a/spec/frontend/error_tracking_settings/store/mutation_spec.js +++ b/spec/frontend/error_tracking_settings/store/mutation_spec.js @@ -25,6 +25,7 @@ describe('error tracking settings mutations', () => { expect(state.apiHost).toEqual(''); expect(state.enabled).toEqual(false); + expect(state.integrated).toEqual(false); expect(state.selectedProject).toEqual(null); expect(state.token).toEqual(''); expect(state.listProjectsEndpoint).toEqual(TEST_HOST); @@ -38,6 +39,7 @@ describe('error tracking settings mutations', () => { expect(state.apiHost).toEqual('apiHost'); expect(state.enabled).toEqual(true); + expect(state.integrated).toEqual(true); expect(state.selectedProject).toEqual(projectList[0]); expect(state.token).toEqual('token'); expect(state.listProjectsEndpoint).toEqual(TEST_HOST); @@ -78,5 +80,11 @@ describe('error tracking settings mutations', () => { expect(state.connectSuccessful).toBe(false); expect(state.connectError).toBe(false); }); + + it.each([true, false])('should update `integrated` to `%s`', (integrated) => { + mutations[types.UPDATE_INTEGRATED](state, integrated); + + expect(state.integrated).toBe(integrated); + }); }); }); diff --git a/spec/frontend/error_tracking_settings/utils_spec.js b/spec/frontend/error_tracking_settings/utils_spec.js index 4b144f7daf1..61e75cdc45e 100644 --- a/spec/frontend/error_tracking_settings/utils_spec.js +++ b/spec/frontend/error_tracking_settings/utils_spec.js @@ -11,12 +11,14 @@ describe('error tracking settings utils', () => { const emptyFrontendSettingsObject = { apiHost: '', enabled: false, + integrated: false, token: '', selectedProject: null, }; const transformedEmptySettingsObject = { api_host: null, enabled: false, + integrated: false, token: null, project: null, }; diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js index 2ba8c65a252..999bed1ffbd 100644 --- a/spec/frontend/experimentation/utils_spec.js +++ b/spec/frontend/experimentation/utils_spec.js @@ -37,6 +37,50 @@ describe('experiment Utilities', () => { }); }); + describe('getAllExperimentContexts', () => { + const schema = TRACKING_CONTEXT_SCHEMA; + let origGon; + + beforeEach(() => { + origGon = window.gon; + }); + + afterEach(() => { + window.gon = origGon; + }); + + it('collects all of the experiment contexts into a single array', () => { + const experiments = [ + { experiment: 'abc', variant: 'candidate' }, + { experiment: 'def', variant: 'control' }, + { experiment: 'ghi', variant: 'blue' }, + ]; + window.gon = { + experiment: experiments.reduce((collector, { experiment, variant }) => { + return { ...collector, [experiment]: { experiment, variant } }; + }, {}), + }; + + expect(experimentUtils.getAllExperimentContexts()).toEqual( + experiments.map((data) => ({ schema, data })), + ); + }); + + it('returns an empty array if there are no experiments', () => { + window.gon.experiment = {}; + + expect(experimentUtils.getAllExperimentContexts()).toEqual([]); + }); + + it('includes all additional experiment data', () => { + const experiment = 'experimentWithCustomData'; + const data = { experiment, variant: 'control', color: 'blue', style: 'rounded' }; + window.gon.experiment[experiment] = data; + + expect(experimentUtils.getAllExperimentContexts()).toContainEqual({ schema, data }); + }); + }); + describe('isExperimentVariant', () => { describe.each` gon | input | output diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js index 6711ce03d40..dfa53652eb1 100644 --- a/spec/frontend/filtered_search/services/recent_searches_service_spec.js +++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js @@ -145,13 +145,13 @@ describe('RecentSearchesService', () => { let isAvailable; beforeEach(() => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe'); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage'); isAvailable = RecentSearchesService.isAvailable(); }); - it('should call .isLocalStorageAccessSafe', () => { - expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + it('should call .canUseLocalStorage', () => { + expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled(); }); it('should return a boolean', () => { diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml index b581aac6aee..1edb8cb3f41 100644 --- a/spec/frontend/fixtures/api_markdown.yml +++ b/spec/frontend/fixtures/api_markdown.yml @@ -12,14 +12,71 @@ markdown: |- * {-deleted-} * {+added+} -- name: subscript - markdown: H<sub>2</sub>O -- name: superscript - markdown: 2<sup>8</sup> = 256 - name: strike markdown: '~~del~~' - name: horizontal_rule markdown: '---' +- name: html_marks + markdown: |- + * Content editor is ~~great~~<ins>amazing</ins>. + * If the changes <abbr title="Looks good to merge">LGTM</abbr>, please <abbr title="Merge when pipeline succeeds">MWPS</abbr>. + * The English song <q>Oh I do like to be beside the seaside</q> looks like this in Hebrew: <span dir="rtl">אה, אני אוהב להיות ליד חוף הים</span>. In the computer's memory, this is stored as <bdo dir="ltr">אה, אני אוהב להיות ליד חוף הים</bdo>. + * <cite>The Scream</cite> by Edvard Munch. Painted in 1893. + * <dfn>HTML</dfn> is the standard markup language for creating web pages. + * Do not forget to buy <mark>milk</mark> today. + * This is a paragraph and <small>smaller text goes here</small>. + * The concert starts at <time datetime="20:00">20:00</time> and you'll be able to enjoy the band for at least <time datetime="PT2H30M">2h 30m</time>. + * Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to copy text (Windows). + * WWF's goal is to: <q>Build a future where people live in harmony with nature.</q> We hope they succeed. + * The error occured was: <samp>Keyboard not found. Press F1 to continue.</samp> + * The area of a triangle is: 1/2 x <var>b</var> x <var>h</var>, where <var>b</var> is the base, and <var>h</var> is the vertical height. + * <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> +- name: div + markdown: |- + <div>plain text</div> + <div> + + just a plain ol' div, not much to _expect_! + + </div> +- name: figure + markdown: |- + <figure> + + ![Elephant at sunset](elephant-sunset.jpg) + + <figcaption>An elephant at sunset</figcaption> + </figure> + <figure> + + ![A crocodile wearing crocs](croc-crocs.jpg) + + <figcaption> + + A crocodile wearing _crocs_! + + </figcaption> + </figure> +- name: description_list + markdown: |- + <dl> + <dt>Frog</dt> + <dd>Wet green thing</dd> + <dt>Rabbit</dt> + <dd>Warm fluffy thing</dd> + <dt>Punt</dt> + <dd>Kick a ball</dd> + <dd>Take a bet</dd> + <dt>Color</dt> + <dt>Colour</dt> + <dd> + + Any hue except _white_ or **black** + + </dd> + </dl> - name: link markdown: '[GitLab](https://gitlab.com)' - name: attachment_link @@ -66,16 +123,31 @@ - name: thematic_break markdown: |- --- -- name: bullet_list +- name: bullet_list_style_1 markdown: |- * list item 1 * list item 2 * embedded list item 3 +- name: bullet_list_style_2 + markdown: |- + - list item 1 + - list item 2 + * embedded list item 3 +- name: bullet_list_style_3 + markdown: |- + + list item 1 + + list item 2 + - embedded list item 3 - name: ordered_list markdown: |- 1. list item 1 2. list item 2 3. list item 3 +- name: ordered_list_with_start_order + markdown: |- + 134. list item 1 + 135. list item 2 + 136. list item 3 - name: task_list markdown: |- * [x] hello @@ -92,6 +164,11 @@ 1. [ ] of nested 1. [x] task list 2. [ ] items +- name: ordered_task_list_with_order + markdown: |- + 4893. [x] hello + 4894. [x] world + 4895. [ ] example - name: image markdown: '![alt text](https://gitlab.com/logo.png)' - name: hard_break @@ -102,17 +179,28 @@ markdown: |- | header | header | |--------|--------| - | cell | cell | - | cell | cell | -- name: table_with_alignment - markdown: |- - | header | : header : | header : | - |--------|------------|----------| - | cell | cell | cell | - | cell | cell | cell | + | `code` | cell with **bold** | + | ~~strike~~ | cell with _italic_ | + + # content after table - name: emoji markdown: ':sparkles: :heart: :100:' - name: reference context: project_wiki markdown: |- Hi @gitlab - thank you for reporting this ~bug (#1) we hope to fix it in %1.1 as part of !1 +- name: audio + markdown: '![Sample Audio](https://gitlab.com/gitlab.mp3)' +- name: video + markdown: '![Sample Video](https://gitlab.com/gitlab.mp4)' +- name: audio_and_video_in_lists + markdown: |- + * ![Sample Audio](https://gitlab.com/1.mp3) + * ![Sample Video](https://gitlab.com/2.mp4) + + 1. ![Sample Video](https://gitlab.com/1.mp4) + 2. ![Sample Audio](https://gitlab.com/2.mp3) + + * [x] ![Sample Audio](https://gitlab.com/1.mp3) + * [x] ![Sample Audio](https://gitlab.com/2.mp3) + * [x] ![Sample Video](https://gitlab.com/3.mp4) diff --git a/spec/frontend/fixtures/freeze_period.rb b/spec/frontend/fixtures/freeze_period.rb index 09e4f969e1d..42762fa56f9 100644 --- a/spec/frontend/fixtures/freeze_period.rb +++ b/spec/frontend/fixtures/freeze_period.rb @@ -39,13 +39,4 @@ RSpec.describe 'Freeze Periods (JavaScript fixtures)' do expect(response).to be_successful end end - - describe TimeZoneHelper, '(JavaScript fixtures)' do - let(:response) { timezone_data.to_json } - - it 'api/freeze-periods/timezone_data.json' do - # Looks empty but does things - # More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38525/diffs#note_391048415 - end - end end diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index e29a58f43b9..d5d6f534def 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -14,6 +14,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do let_it_be(:instance_runner) { create(:ci_runner, :instance, version: '1.0.0', revision: '123', description: 'Instance runner', ip_address: '127.0.0.1') } let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner', ip_address: '127.0.0.1') } + let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner 2', ip_address: '127.0.0.1') } let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') } query_path = 'runner/graphql/' @@ -27,14 +28,14 @@ RSpec.describe 'Runner (JavaScript fixtures)' do remove_repository(project) end - before do - sign_in(admin) - enable_admin_mode!(admin) - end - describe GraphQL::Query, type: :request do get_runners_query_name = 'get_runners.query.graphql' + before do + sign_in(admin) + enable_admin_mode!(admin) + end + let_it_be(:query) do get_graphql_query_as_string("#{query_path}#{get_runners_query_name}") end @@ -55,6 +56,11 @@ RSpec.describe 'Runner (JavaScript fixtures)' do describe GraphQL::Query, type: :request do get_runner_query_name = 'get_runner.query.graphql' + before do + sign_in(admin) + enable_admin_mode!(admin) + end + let_it_be(:query) do get_graphql_query_as_string("#{query_path}#{get_runner_query_name}") end @@ -67,4 +73,35 @@ RSpec.describe 'Runner (JavaScript fixtures)' do expect_graphql_errors_to_be_empty end end + + describe GraphQL::Query, type: :request do + get_group_runners_query_name = 'get_group_runners.query.graphql' + + let_it_be(:group_owner) { create(:user) } + + before do + group.add_owner(group_owner) + end + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_group_runners_query_name}") + end + + it "#{fixtures_path}#{get_group_runners_query_name}.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path + }) + + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path, + first: 1 + }) + + expect_graphql_errors_to_be_empty + end + end end diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb index be2ead756cf..1bd99f5cd7f 100644 --- a/spec/frontend/fixtures/startup_css.rb +++ b/spec/frontend/fixtures/startup_css.rb @@ -40,6 +40,21 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do expect(response).to be_successful end + + # This Feature Flag is off by default + # This ensures that the correct css is generated + # When the feature flag is off, the general startup will capture it + # This will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/339348 + it "startup_css/project-#{type}-search-ff-on.html" do + stub_feature_flags(new_header_search: true) + + get :show, params: { + namespace_id: project.namespace.to_param, + id: project + } + + expect(response).to be_successful + end end describe ProjectsController, '(Startup CSS fixtures)', type: :controller do diff --git a/spec/frontend/fixtures/static/pipeline_graph.html b/spec/frontend/fixtures/static/pipeline_graph.html deleted file mode 100644 index d2c30ff9211..00000000000 --- a/spec/frontend/fixtures/static/pipeline_graph.html +++ /dev/null @@ -1,24 +0,0 @@ -<div class="pipeline-visualization js-pipeline-graph"> -<ul class="stage-column-list"> -<li class="stage-column"> -<div class="stage-name"> -<a href="/"> -Test -<div class="builds-container"> -<ul> -<li class="build"> -<div class="curve"></div> -<a> -<svg></svg> -<div> -stop_review -</div> -</a> -</li> -</ul> -</div> -</a> -</div> -</li> -</ul> -</div> diff --git a/spec/frontend/fixtures/timezones.rb b/spec/frontend/fixtures/timezones.rb new file mode 100644 index 00000000000..261dcf5e116 --- /dev/null +++ b/spec/frontend/fixtures/timezones.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TimeZoneHelper, '(JavaScript fixtures)' do + include JavaScriptFixturesHelpers + include TimeZoneHelper + + 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 + + it 'timezones/full.json' do + @timezones = timezone_data(format: :full) + end +end diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js index dacfc7ce707..fb0321545c2 100644 --- a/spec/frontend/frequent_items/store/actions_spec.js +++ b/spec/frontend/frequent_items/store/actions_spec.js @@ -109,7 +109,7 @@ describe('Frequent Items Dropdown Store Actions', () => { }); it('should dispatch `receiveFrequentItemsError`', (done) => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(false); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); mockedState.namespace = mockNamespace; mockedState.storageKey = mockStorageKey; diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index da0ff2a64ec..bc8c6460cf4 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -182,7 +182,12 @@ describe('AppComponent', () => { jest.spyOn(window.history, 'replaceState').mockImplementation(() => {}); jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); - const fetchPagePromise = vm.fetchPage(2, null, null, true); + const fetchPagePromise = vm.fetchPage({ + page: 2, + filterGroupsBy: null, + sortBy: null, + archived: true, + }); expect(vm.isLoading).toBe(true); expect(vm.fetchGroups).toHaveBeenCalledWith({ diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index dc1a10639fc..0ec1ef5a49e 100644 --- a/spec/frontend/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -41,13 +41,12 @@ describe('GroupsComponent', () => { vm.change(2); - expect(eventHub.$emit).toHaveBeenCalledWith( - 'fetchPage', - 2, - expect.any(Object), - expect.any(Object), - expect.any(Object), - ); + expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', { + page: 2, + archived: null, + filterGroupsBy: null, + sortBy: null, + }); }); }); }); diff --git a/spec/frontend/groups/components/invite_members_banner_spec.js b/spec/frontend/groups/components/invite_members_banner_spec.js index 0da2f84f2a1..c81edad499c 100644 --- a/spec/frontend/groups/components/invite_members_banner_spec.js +++ b/spec/frontend/groups/components/invite_members_banner_spec.js @@ -1,29 +1,29 @@ -import { GlBanner, GlButton } from '@gitlab/ui'; +import { GlBanner } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import InviteMembersBanner from '~/groups/components/invite_members_banner.vue'; import eventHub from '~/invite_members/event_hub'; -import { setCookie, parseBoolean } from '~/lib/utils/common_utils'; +import axios from '~/lib/utils/axios_utils'; jest.mock('~/lib/utils/common_utils'); -const isDismissedKey = 'invite_99_1'; const title = 'Collaborate with your team'; const body = "We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge"; -const svgPath = '/illustrations/background'; -const inviteMembersPath = 'groups/members'; const buttonText = 'Invite your colleagues'; -const trackLabel = 'invite_members_banner'; +const provide = { + svgPath: '/illustrations/background', + inviteMembersPath: 'groups/members', + trackLabel: 'invite_members_banner', + calloutsPath: 'call/out/path', + calloutsFeatureId: 'some-feature-id', + groupId: '1', +}; const createComponent = (stubs = {}) => { return shallowMount(InviteMembersBanner, { - provide: { - svgPath, - inviteMembersPath, - isDismissedKey, - trackLabel, - }, + provide, stubs, }); }; @@ -31,8 +31,10 @@ const createComponent = (stubs = {}) => { describe('InviteMembersBanner', () => { let wrapper; let trackingSpy; + let mockAxios; beforeEach(() => { + mockAxios = new MockAdapter(axios); document.body.dataset.page = 'any:page'; trackingSpy = mockTracking('_category_', undefined, jest.spyOn); }); @@ -40,22 +42,28 @@ describe('InviteMembersBanner', () => { afterEach(() => { wrapper.destroy(); wrapper = null; + mockAxios.restore(); unmockTracking(); }); describe('tracking', () => { + const mockTrackingOnWrapper = () => { + unmockTracking(); + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + }; + beforeEach(() => { wrapper = createComponent({ GlBanner }); }); const trackCategory = undefined; - const displayEvent = 'invite_members_banner_displayed'; const buttonClickEvent = 'invite_members_banner_button_clicked'; - const dismissEvent = 'invite_members_banner_dismissed'; it('sends the displayEvent when the banner is displayed', () => { + const displayEvent = 'invite_members_banner_displayed'; + expect(trackingSpy).toHaveBeenCalledWith(trackCategory, displayEvent, { - label: trackLabel, + label: provide.trackLabel, }); }); @@ -74,16 +82,20 @@ describe('InviteMembersBanner', () => { it('sends the buttonClickEvent with correct trackCategory and trackLabel', () => { expect(trackingSpy).toHaveBeenCalledWith(trackCategory, buttonClickEvent, { - label: trackLabel, + label: provide.trackLabel, }); }); }); it('sends the dismissEvent when the banner is dismissed', () => { + mockTrackingOnWrapper(); + mockAxios.onPost(provide.calloutsPath).replyOnce(200); + const dismissEvent = 'invite_members_banner_dismissed'; + wrapper.find(GlBanner).vm.$emit('close'); expect(trackingSpy).toHaveBeenCalledWith(trackCategory, dismissEvent, { - label: trackLabel, + label: provide.trackLabel, }); }); }); @@ -98,7 +110,7 @@ describe('InviteMembersBanner', () => { }); it('uses the svgPath for the banner svgpath', () => { - expect(findBanner().attributes('svgpath')).toBe(svgPath); + expect(findBanner().attributes('svgpath')).toBe(provide.svgPath); }); it('uses the title from options for title', () => { @@ -115,35 +127,20 @@ describe('InviteMembersBanner', () => { }); describe('dismissing', () => { - const findButton = () => wrapper.findAll(GlButton).at(1); - beforeEach(() => { wrapper = createComponent({ GlBanner }); - - findButton().vm.$emit('click'); }); - it('sets iDismissed to true', () => { - expect(wrapper.vm.isDismissed).toBe(true); + it('should render the banner when not dismissed', () => { + expect(wrapper.find(GlBanner).exists()).toBe(true); }); - it('sets the cookie with the isDismissedKey', () => { - expect(setCookie).toHaveBeenCalledWith(isDismissedKey, true); - }); - }); - - describe('when a dismiss cookie exists', () => { - beforeEach(() => { - parseBoolean.mockReturnValue(true); - - wrapper = createComponent({ GlBanner }); - }); - - it('sets isDismissed to true', () => { - expect(wrapper.vm.isDismissed).toBe(true); - }); + it('should close the banner when dismiss is clicked', async () => { + mockAxios.onPost(provide.calloutsPath).replyOnce(200); + expect(wrapper.find(GlBanner).exists()).toBe(true); + wrapper.find(GlBanner).vm.$emit('close'); - it('does not render the banner', () => { + await wrapper.vm.$nextTick(); expect(wrapper.find(GlBanner).exists()).toBe(false); }); }); diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js index f350012ebed..49f3f5da43c 100644 --- a/spec/frontend/groups/components/item_stats_spec.js +++ b/spec/frontend/groups/components/item_stats_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ItemStats from '~/groups/components/item_stats.vue'; import ItemStatsValue from '~/groups/components/item_stats_value.vue'; @@ -12,7 +12,7 @@ describe('ItemStats', () => { }; const createComponent = (props = {}) => { - wrapper = shallowMount(ItemStats, { + wrapper = shallowMountExtended(ItemStats, { propsData: { ...defaultProps, ...props }, }); }; @@ -46,5 +46,31 @@ describe('ItemStats', () => { expect(findItemStatsValue().props('cssClass')).toBe('project-stars'); expect(wrapper.find('.last-updated').exists()).toBe(true); }); + + describe('group specific rendering', () => { + describe.each` + provided | state | data + ${true} | ${'displays'} | ${null} + ${false} | ${'does not display'} | ${{ subgroupCount: undefined, projectCount: undefined }} + `('when provided = $provided', ({ provided, state, data }) => { + beforeEach(() => { + const item = { + ...mockParentGroupItem, + ...data, + type: ITEM_TYPE.GROUP, + }; + + createComponent({ item }); + }); + + it.each` + entity | testId + ${'subgroups'} | ${'subgroups-count'} + ${'projects'} | ${'projects-count'} + `(`${state} $entity count`, ({ testId }) => { + expect(wrapper.findByTestId(testId).exists()).toBe(provided); + }); + }); + }); }); }); diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js new file mode 100644 index 00000000000..2cbcb73ce5b --- /dev/null +++ b/spec/frontend/header_search/components/app_spec.js @@ -0,0 +1,159 @@ +import { GlSearchBoxByType } from '@gitlab/ui'; +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 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'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { MOCK_SEARCH, MOCK_SEARCH_QUERY, MOCK_USERNAME } from '../mock_data'; + +Vue.use(Vuex); + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), +})); + +describe('HeaderSearchApp', () => { + let wrapper; + + const actionSpies = { + setSearch: jest.fn(), + }; + + const createComponent = (initialState) => { + const store = new Vuex.Store({ + state: { + ...initialState, + }, + actions: actionSpies, + getters: { + searchQuery: () => MOCK_SEARCH_QUERY, + }, + }); + + wrapper = shallowMountExtended(HeaderSearchApp, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType); + const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu'); + const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems); + const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); + + describe('template', () => { + it('always renders Header Search Input', () => { + createComponent(); + expect(findHeaderSearchInput().exists()).toBe(true); + }); + + describe.each` + showDropdown | username | showSearchDropdown + ${false} | ${null} | ${false} + ${false} | ${MOCK_USERNAME} | ${false} + ${true} | ${null} | ${false} + ${true} | ${MOCK_USERNAME} | ${true} + `('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => { + describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => { + beforeEach(() => { + createComponent(); + window.gon.current_username = username; + wrapper.setData({ showDropdown }); + }); + + it(`should${showSearchDropdown ? '' : ' not'} render`, () => { + expect(findHeaderSearchDropdown().exists()).toBe(showSearchDropdown); + }); + }); + }); + + describe.each` + search | showDefault | showScoped + ${null} | ${true} | ${false} + ${''} | ${true} | ${false} + ${MOCK_SEARCH} | ${false} | ${true} + `('Header Search Dropdown Items', ({ search, showDefault, showScoped }) => { + describe(`when search is ${search}`, () => { + beforeEach(() => { + createComponent({ search }); + window.gon.current_username = MOCK_USERNAME; + wrapper.setData({ showDropdown: true }); + }); + + it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => { + expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault); + }); + + it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { + expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); + }); + }); + }); + }); + + describe('events', () => { + beforeEach(() => { + createComponent(); + window.gon.current_username = MOCK_USERNAME; + }); + + describe('Header Search Input', () => { + describe('when dropdown is closed', () => { + it('onFocus opens dropdown', async () => { + expect(findHeaderSearchDropdown().exists()).toBe(false); + findHeaderSearchInput().vm.$emit('focus'); + + await wrapper.vm.$nextTick(); + + expect(findHeaderSearchDropdown().exists()).toBe(true); + }); + + it('onClick opens dropdown', async () => { + expect(findHeaderSearchDropdown().exists()).toBe(false); + findHeaderSearchInput().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(findHeaderSearchDropdown().exists()).toBe(true); + }); + }); + + describe('when dropdown is opened', () => { + beforeEach(() => { + wrapper.setData({ showDropdown: true }); + }); + + it('onKey-Escape closes dropdown', async () => { + expect(findHeaderSearchDropdown().exists()).toBe(true); + findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ESC_KEY })); + + await wrapper.vm.$nextTick(); + + expect(findHeaderSearchDropdown().exists()).toBe(false); + }); + }); + + it('calls setSearch when search input event is fired', async () => { + findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); + + await wrapper.vm.$nextTick(); + + expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); + }); + + it('submits a search onKey-Enter', async () => { + findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + + await wrapper.vm.$nextTick(); + + expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); + }); + }); + }); +}); diff --git a/spec/frontend/header_search/components/header_search_default_items_spec.js b/spec/frontend/header_search/components/header_search_default_items_spec.js new file mode 100644 index 00000000000..ce083d0df72 --- /dev/null +++ b/spec/frontend/header_search/components/header_search_default_items_spec.js @@ -0,0 +1,81 @@ +import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue'; +import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS } from '../mock_data'; + +Vue.use(Vuex); + +describe('HeaderSearchDefaultItems', () => { + let wrapper; + + const createComponent = (initialState) => { + const store = new Vuex.Store({ + state: { + searchContext: MOCK_SEARCH_CONTEXT, + ...initialState, + }, + getters: { + defaultSearchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, + }, + }); + + wrapper = shallowMount(HeaderSearchDefaultItems, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text()); + const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + + describe('template', () => { + describe('Dropdown items', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders item for each option in defaultSearchOptions', () => { + expect(findDropdownItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length); + }); + + it('renders titles correctly', () => { + const expectedTitles = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.title); + expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + }); + + it('renders links correctly', () => { + const expectedLinks = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.url); + expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + }); + }); + + describe.each` + group | project | dropdownTitle + ${null} | ${null} | ${'All GitLab'} + ${{ name: 'Test Group' }} | ${null} | ${'Test Group'} + ${{ name: 'Test Group' }} | ${{ name: 'Test Project' }} | ${'Test Project'} + `('Dropdown Header', ({ group, project, dropdownTitle }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createComponent({ + searchContext: { + group, + project, + }, + }); + }); + + it(`should render as ${dropdownTitle}`, () => { + expect(findDropdownHeader().text()).toBe(dropdownTitle); + }); + }); + }); + }); +}); diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js new file mode 100644 index 00000000000..f0e5e182ec4 --- /dev/null +++ b/spec/frontend/header_search/components/header_search_scoped_items_spec.js @@ -0,0 +1,61 @@ +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { trimText } from 'helpers/text_helper'; +import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; +import { MOCK_SEARCH, MOCK_SCOPED_SEARCH_OPTIONS } from '../mock_data'; + +Vue.use(Vuex); + +describe('HeaderSearchScopedItems', () => { + let wrapper; + + const createComponent = (initialState) => { + const store = new Vuex.Store({ + state: { + search: MOCK_SEARCH, + ...initialState, + }, + getters: { + scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, + }, + }); + + wrapper = shallowMount(HeaderSearchScopedItems, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text())); + const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + + describe('template', () => { + describe('Dropdown items', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders item for each option in scopedSearchOptions', () => { + expect(findDropdownItems()).toHaveLength(MOCK_SCOPED_SEARCH_OPTIONS.length); + }); + + it('renders titles correctly', () => { + const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => + trimText(`"${MOCK_SEARCH}" ${o.description} ${o.scope || ''}`), + ); + expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + }); + + it('renders links correctly', () => { + const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url); + expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + }); + }); + }); +}); diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js new file mode 100644 index 00000000000..5963ad9c279 --- /dev/null +++ b/spec/frontend/header_search/mock_data.js @@ -0,0 +1,83 @@ +import { + MSG_ISSUES_ASSIGNED_TO_ME, + MSG_ISSUES_IVE_CREATED, + MSG_MR_ASSIGNED_TO_ME, + MSG_MR_IM_REVIEWER, + MSG_MR_IVE_CREATED, + MSG_IN_PROJECT, + MSG_IN_GROUP, + MSG_IN_ALL_GITLAB, +} from '~/header_search/constants'; + +export const MOCK_USERNAME = 'anyone'; + +export const MOCK_SEARCH_PATH = '/search'; + +export const MOCK_ISSUE_PATH = '/dashboard/issues'; + +export const MOCK_MR_PATH = '/dashboard/merge_requests'; + +export const MOCK_ALL_PATH = '/'; + +export const MOCK_PROJECT = { + id: 123, + name: 'MockProject', + path: '/mock-project', +}; + +export const MOCK_GROUP = { + id: 321, + name: 'MockGroup', + path: '/mock-group', +}; + +export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test'; + +export const MOCK_SEARCH = 'test'; + +export const MOCK_SEARCH_CONTEXT = { + project: null, + project_metadata: {}, + group: null, + group_metadata: {}, +}; + +export const MOCK_DEFAULT_SEARCH_OPTIONS = [ + { + title: MSG_ISSUES_ASSIGNED_TO_ME, + url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`, + }, + { + title: MSG_ISSUES_IVE_CREATED, + url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`, + }, + { + title: MSG_MR_ASSIGNED_TO_ME, + url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`, + }, + { + title: MSG_MR_IM_REVIEWER, + url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`, + }, + { + title: MSG_MR_IVE_CREATED, + url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`, + }, +]; + +export const MOCK_SCOPED_SEARCH_OPTIONS = [ + { + scope: MOCK_PROJECT.name, + description: MSG_IN_PROJECT, + url: MOCK_PROJECT.path, + }, + { + scope: MOCK_GROUP.name, + description: MSG_IN_GROUP, + url: MOCK_GROUP.path, + }, + { + description: MSG_IN_ALL_GITLAB, + url: MOCK_ALL_PATH, + }, +]; diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js new file mode 100644 index 00000000000..4530df0d91c --- /dev/null +++ b/spec/frontend/header_search/store/actions_spec.js @@ -0,0 +1,28 @@ +import testAction from 'helpers/vuex_action_helper'; +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'; + +describe('Header Search Store Actions', () => { + let state; + + beforeEach(() => { + state = createState({}); + }); + + afterEach(() => { + state = null; + }); + + describe('setSearch', () => { + it('calls the SET_SEARCH mutation', () => { + return testAction({ + action: actions.setSearch, + payload: MOCK_SEARCH, + state, + expectedMutations: [{ type: types.SET_SEARCH, payload: MOCK_SEARCH }], + }); + }); + }); +}); diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js new file mode 100644 index 00000000000..2ad0a082f6a --- /dev/null +++ b/spec/frontend/header_search/store/getters_spec.js @@ -0,0 +1,211 @@ +import * as getters from '~/header_search/store/getters'; +import initState from '~/header_search/store/state'; +import { + MOCK_USERNAME, + MOCK_SEARCH_PATH, + MOCK_ISSUE_PATH, + MOCK_MR_PATH, + MOCK_SEARCH_CONTEXT, + MOCK_DEFAULT_SEARCH_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_PROJECT, + MOCK_GROUP, + MOCK_ALL_PATH, + MOCK_SEARCH, +} from '../mock_data'; + +describe('Header Search Store Getters', () => { + let state; + + const createState = (initialState) => { + state = initState({ + searchPath: MOCK_SEARCH_PATH, + issuesPath: MOCK_ISSUE_PATH, + mrPath: MOCK_MR_PATH, + searchContext: MOCK_SEARCH_CONTEXT, + ...initialState, + }); + }; + + afterEach(() => { + state = null; + }); + + describe.each` + group | project | expectedPath + ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=undefined&scope=issues`} + ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=${MOCK_GROUP.id}&scope=issues`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} + `('searchQuery', ({ group, project, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + project, + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.searchQuery(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'} + ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ issues_path: 'project/path' }} | ${'project/path'} + `('scopedIssuesPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + group_metadata, + project, + project_metadata, + }, + }); + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.scopedIssuesPath(state)).toBe(expectedPath); + }); + }); + }); + + describe.each` + group | group_metadata | project | project_metadata | expectedPath + ${null} | ${null} | ${null} | ${null} | ${MOCK_MR_PATH} + ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${null} | ${null} | ${'group/path'} + ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ mr_path: 'project/path' }} | ${'project/path'} + `('scopedMRPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + group_metadata, + project, + project_metadata, + }, + }); + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.scopedMRPath(state)).toBe(expectedPath); + }); + }); + }); + + describe.each` + group | project | expectedPath + ${null} | ${null} | ${null} + ${MOCK_GROUP} | ${null} | ${null} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} + `('projectUrl', ({ group, project, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + project, + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.projectUrl(state)).toBe(expectedPath); + }); + }); + }); + + describe.each` + group | project | expectedPath + ${null} | ${null} | ${null} + ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`} + `('groupUrl', ({ group, project, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + project, + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.groupUrl(state)).toBe(expectedPath); + }); + }); + }); + + describe('allUrl', () => { + const expectedPath = `${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`; + + beforeEach(() => { + createState({ + searchContext: { + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.allUrl(state)).toBe(expectedPath); + }); + }); + + describe('defaultSearchOptions', () => { + const mockGetters = { + scopedIssuesPath: MOCK_ISSUE_PATH, + scopedMRPath: MOCK_MR_PATH, + }; + + beforeEach(() => { + createState(); + window.gon.current_username = MOCK_USERNAME; + }); + + it('returns the correct array', () => { + expect(getters.defaultSearchOptions(state, mockGetters)).toStrictEqual( + MOCK_DEFAULT_SEARCH_OPTIONS, + ); + }); + }); + + describe('scopedSearchOptions', () => { + const mockGetters = { + projectUrl: MOCK_PROJECT.path, + groupUrl: MOCK_GROUP.path, + allUrl: MOCK_ALL_PATH, + }; + + beforeEach(() => { + createState({ + searchContext: { + project: MOCK_PROJECT, + group: MOCK_GROUP, + }, + }); + }); + + it('returns the correct array', () => { + expect(getters.scopedSearchOptions(state, mockGetters)).toStrictEqual( + MOCK_SCOPED_SEARCH_OPTIONS, + ); + }); + }); +}); diff --git a/spec/frontend/header_search/store/mutations_spec.js b/spec/frontend/header_search/store/mutations_spec.js new file mode 100644 index 00000000000..8196c06099d --- /dev/null +++ b/spec/frontend/header_search/store/mutations_spec.js @@ -0,0 +1,20 @@ +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'; + +describe('Header Search Store Mutations', () => { + let state; + + beforeEach(() => { + state = createState({}); + }); + + describe('SET_SEARCH', () => { + it('sets search to value', () => { + mutations[types.SET_SEARCH](state, MOCK_SEARCH); + + expect(state.search).toBe(MOCK_SEARCH); + }); + }); +}); diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 4ca6d7259bd..0d43accb7e5 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -59,8 +59,8 @@ describe('Header', () => { beforeEach(() => { setFixtures(` <li class="js-nav-user-dropdown"> - <a class="js-buy-pipeline-minutes-link" data-track-event="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a> - <a class="js-upgrade-plan-link" data-track-event="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a> + <a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a> + <a class="js-upgrade-plan-link" data-track-action="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a> </li>`); trackingSpy = mockTracking('_category_', $('.js-nav-user-dropdown').element, jest.spyOn); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 47bcfb59a5f..c2212eea849 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -8,6 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import waitUsingRealTimer from 'helpers/wait_using_real_timer'; import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants'; +import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; import SourceEditor from '~/editor/source_editor'; import RepoEditor from '~/ide/components/repo_editor.vue'; @@ -25,6 +26,7 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer import { file } from '../helpers'; const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown'; +const CURRENT_PROJECT_ID = 'gitlab-org/gitlab'; const defaultFileProps = { ...file('file.txt'), @@ -63,7 +65,7 @@ const prepareStore = (state, activeFile) => { const localState = { openFiles: [activeFile], projects: { - 'gitlab-org/gitlab': { + [CURRENT_PROJECT_ID]: { branches: { main: { name: 'main', @@ -74,7 +76,7 @@ const prepareStore = (state, activeFile) => { }, }, }, - currentProjectId: 'gitlab-org/gitlab', + currentProjectId: CURRENT_PROJECT_ID, currentBranchId: 'main', entries: { [activeFile.path]: activeFile, @@ -98,6 +100,7 @@ describe('RepoEditor', () => { let createInstanceSpy; let createDiffInstanceSpy; let createModelSpy; + let applyExtensionSpy; const waitForEditorSetup = () => new Promise((resolve) => { @@ -124,11 +127,28 @@ describe('RepoEditor', () => { const findEditor = () => wrapper.find('[data-testid="editor-container"]'); const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li'); const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]'); + const expectEditorMarkdownExtension = (shouldHaveExtension) => { + if (shouldHaveExtension) { + expect(applyExtensionSpy).toHaveBeenCalledWith( + wrapper.vm.editor, + expect.any(EditorMarkdownExtension), + ); + // TODO: spying on extensions causes Jest to blow up, so we have to assert on + // the public property the extension adds, as opposed to the args passed to the ctor + expect(wrapper.vm.editor.previewMarkdownPath).toBe(PREVIEW_MARKDOWN_PATH); + } else { + expect(applyExtensionSpy).not.toHaveBeenCalledWith( + wrapper.vm.editor, + expect.any(EditorMarkdownExtension), + ); + } + }; beforeEach(() => { createInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_CODE_INSTANCE_FN); createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN); createModelSpy = jest.spyOn(monacoEditor, 'createModel'); + applyExtensionSpy = jest.spyOn(SourceEditor, 'instanceApplyExtension'); jest.spyOn(service, 'getFileData').mockResolvedValue(); jest.spyOn(service, 'getRawFileData').mockResolvedValue(); }); @@ -280,13 +300,8 @@ describe('RepoEditor', () => { '$prefix install markdown extension for $activeFile.name in $viewer viewer', async ({ activeFile, viewer, shouldHaveMarkdownExtension } = {}) => { await createComponent({ state: { viewer }, activeFile }); - if (shouldHaveMarkdownExtension) { - expect(vm.editor.previewMarkdownPath).toBe(PREVIEW_MARKDOWN_PATH); - expect(vm.editor.togglePreview).toBeDefined(); - } else { - expect(vm.editor.previewMarkdownPath).toBeUndefined(); - expect(vm.editor.togglePreview).toBeUndefined(); - } + + expectEditorMarkdownExtension(shouldHaveMarkdownExtension); }, ); }); diff --git a/spec/frontend/ide/services/terminals_spec.js b/spec/frontend/ide/services/terminals_spec.js new file mode 100644 index 00000000000..788fdb6471c --- /dev/null +++ b/spec/frontend/ide/services/terminals_spec.js @@ -0,0 +1,51 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as terminalService from '~/ide/services/terminals'; +import axios from '~/lib/utils/axios_utils'; + +const TEST_PROJECT_PATH = 'lorem/ipsum/dolar'; +const TEST_BRANCH = 'ref'; + +describe('~/ide/services/terminals', () => { + let axiosSpy; + let mock; + const prevRelativeUrlRoot = gon.relative_url_root; + + beforeEach(() => { + axiosSpy = jest.fn().mockReturnValue([200, {}]); + + mock = new MockAdapter(axios); + mock.onPost(/.*/).reply((...args) => axiosSpy(...args)); + }); + + afterEach(() => { + gon.relative_url_root = prevRelativeUrlRoot; + mock.restore(); + }); + + it.each` + method | relativeUrlRoot | url + ${'checkConfig'} | ${''} | ${`/${TEST_PROJECT_PATH}/ide_terminals/check_config`} + ${'checkConfig'} | ${'/'} | ${`/${TEST_PROJECT_PATH}/ide_terminals/check_config`} + ${'checkConfig'} | ${'/gitlabbin'} | ${`/gitlabbin/${TEST_PROJECT_PATH}/ide_terminals/check_config`} + ${'create'} | ${''} | ${`/${TEST_PROJECT_PATH}/ide_terminals`} + ${'create'} | ${'/'} | ${`/${TEST_PROJECT_PATH}/ide_terminals`} + ${'create'} | ${'/gitlabbin'} | ${`/gitlabbin/${TEST_PROJECT_PATH}/ide_terminals`} + `( + 'when $method called, posts request to $url (relative_url_root=$relativeUrlRoot)', + async ({ method, url, relativeUrlRoot }) => { + gon.relative_url_root = relativeUrlRoot; + + await terminalService[method](TEST_PROJECT_PATH, TEST_BRANCH); + + expect(axiosSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: JSON.stringify({ + branch: TEST_BRANCH, + format: 'json', + }), + url, + }), + ); + }, + ); +}); diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index 00733615f81..2f8447af518 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -86,6 +86,14 @@ describe('WebIDE utils', () => { expect(isTextFile({ name: 'abc.dat', content: '' })).toBe(true); expect(isTextFile({ name: 'abc.dat', content: ' ' })).toBe(true); }); + + it('returns true if there is a `binary` property already set on the file object', () => { + expect(isTextFile({ name: 'abc.txt', content: '' })).toBe(true); + expect(isTextFile({ name: 'abc.txt', content: '', binary: true })).toBe(false); + + expect(isTextFile({ name: 'abc.tex', content: 'éêė' })).toBe(false); + expect(isTextFile({ name: 'abc.tex', content: 'éêė', binary: false })).toBe(true); + }); }); describe('trimPathComponents', () => { diff --git a/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js new file mode 100644 index 00000000000..60f0780fdb3 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js @@ -0,0 +1,90 @@ +import { GlButton, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { STATUSES } from '~/import_entities/constants'; +import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue'; +import { generateFakeEntry } from '../graphql/fixtures'; + +describe('import actions cell', () => { + let wrapper; + + const createComponent = (props) => { + wrapper = shallowMount(ImportActionsCell, { + propsData: { + groupPathRegex: /^[a-zA-Z]+$/, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when import status is NONE', () => { + beforeEach(() => { + const group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + createComponent({ group }); + }); + + it('renders import button', () => { + const button = wrapper.findComponent(GlButton); + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Import'); + }); + + it('does not render icon with a hint', () => { + expect(wrapper.findComponent(GlIcon).exists()).toBe(false); + }); + }); + + describe('when import status is FINISHED', () => { + beforeEach(() => { + const group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED }); + createComponent({ group }); + }); + + it('renders re-import button', () => { + const button = wrapper.findComponent(GlButton); + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Re-import'); + }); + + it('renders icon with a hint', () => { + const icon = wrapper.findComponent(GlIcon); + expect(icon.exists()).toBe(true); + expect(icon.attributes().title).toBe( + 'Re-import creates a new group. It does not sync with the existing group.', + ); + }); + }); + + it('does not render import button when group import is in progress', () => { + const group = generateFakeEntry({ id: 1, status: STATUSES.STARTED }); + createComponent({ group }); + + const button = wrapper.findComponent(GlButton); + expect(button.exists()).toBe(false); + }); + + it('renders import button as disabled when there are validation errors', () => { + const group = generateFakeEntry({ + id: 1, + status: STATUSES.NONE, + validation_errors: [{ field: 'new_name', message: 'something ' }], + }); + createComponent({ group }); + + const button = wrapper.findComponent(GlButton); + expect(button.props().disabled).toBe(true); + }); + + it('emits import-group event when import button is clicked', () => { + const group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + createComponent({ group }); + + const button = wrapper.findComponent(GlButton); + button.vm.$emit('click'); + + expect(wrapper.emitted('import-group')).toHaveLength(1); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js new file mode 100644 index 00000000000..2a56efd1cbb --- /dev/null +++ b/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js @@ -0,0 +1,59 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { STATUSES } from '~/import_entities/constants'; +import ImportSourceCell from '~/import_entities/import_groups/components/import_source_cell.vue'; +import { generateFakeEntry } from '../graphql/fixtures'; + +describe('import source cell', () => { + let wrapper; + let group; + + const createComponent = (props) => { + wrapper = shallowMount(ImportSourceCell, { + propsData: { + ...props, + }, + stubs: { GlSprintf }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when group status is NONE', () => { + beforeEach(() => { + group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + createComponent({ group }); + }); + + it('renders link to a group', () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes().href).toBe(group.web_url); + expect(link.text()).toContain(group.full_path); + }); + + it('does not render last imported line', () => { + expect(wrapper.text()).not.toContain('Last imported to'); + }); + }); + + describe('when group status is FINISHED', () => { + beforeEach(() => { + group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED }); + createComponent({ group }); + }); + + it('renders link to a group', () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes().href).toBe(group.web_url); + expect(link.text()).toContain(group.full_path); + }); + + it('renders last imported line', () => { + expect(wrapper.text()).toMatchInterpolatedText( + 'fake_group_1 Last imported to root/last-group1', + ); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index bbd8463e685..f43e545e049 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -15,6 +15,7 @@ import stubChildren from 'helpers/stub_children'; import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { STATUSES } from '~/import_entities/constants'; +import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; @@ -163,11 +164,8 @@ describe('import table', () => { it('invokes importGroups mutation when row button is clicked', async () => { jest.spyOn(apolloProvider.defaultClient, 'mutate'); - const triggerImportButton = wrapper - .findAllComponents(GlButton) - .wrappers.find((w) => w.text() === 'Import'); - triggerImportButton.vm.$emit('click'); + wrapper.findComponent(ImportActionsCell).vm.$emit('import-group'); await waitForPromises(); expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ mutation: importGroupsMutation, @@ -329,7 +327,7 @@ describe('import table', () => { }); it('does not allow selecting already started groups', async () => { - const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.FINISHED })]; + const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.STARTED })]; createComponent({ bulkImportSourceGroups: () => ({ diff --git a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js index 8231297e594..be83a61841f 100644 --- a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js @@ -1,14 +1,10 @@ -import { GlButton, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui'; +import { GlDropdownItem, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue'; import { STATUSES } from '~/import_entities/constants'; import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; import { availableNamespacesFixture } from '../graphql/fixtures'; -Vue.use(VueApollo); - const getFakeGroup = (status) => ({ web_url: 'https://fake.host/', full_path: 'fake_group_1', @@ -26,9 +22,6 @@ describe('import target cell', () => { let wrapper; let group; - const findByText = (cmp, text) => { - return wrapper.findAll(cmp).wrappers.find((node) => node.text().indexOf(text) === 0); - }; const findNameInput = () => wrapper.find(GlFormInput); const findNamespaceDropdown = () => wrapper.find(ImportGroupDropdown); @@ -117,10 +110,6 @@ describe('import target cell', () => { createComponent({ group }); }); - it('does not render Import button', () => { - expect(findByText(GlButton, 'Import')).toBe(undefined); - }); - it('renders namespace dropdown as disabled', () => { expect(findNamespaceDropdown().attributes('disabled')).toBe('true'); }); @@ -132,17 +121,8 @@ describe('import target cell', () => { createComponent({ group }); }); - it('does not render Import button', () => { - expect(findByText(GlButton, 'Import')).toBe(undefined); - }); - - it('does not render namespace dropdown', () => { - expect(findNamespaceDropdown().exists()).toBe(false); - }); - - it('renders target as link', () => { - const TARGET_LINK = `${group.import_target.target_namespace}/${group.import_target.new_name}`; - expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true); + it('renders namespace dropdown as enabled', () => { + expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined); }); }); @@ -179,9 +159,6 @@ describe('import target cell', () => { }, }); - jest.runOnlyPendingTimers(); - await nextTick(); - expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE); }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js index ec50dfd037f..e1d65095888 100644 --- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -259,6 +259,10 @@ describe('Bulk import resolvers', () => { target_namespace: 'root', new_name: 'group1', }, + last_import_target: { + target_namespace: 'root', + new_name: 'group1', + }, validation_errors: [], }, ], @@ -414,19 +418,32 @@ describe('Bulk import resolvers', () => { }); }); - it('setImportProgress updates group progress', async () => { + it('setImportProgress updates group progress and sets import target', async () => { const NEW_STATUS = 'dummy'; const FAKE_JOB_ID = 5; + const IMPORT_TARGET = { + __typename: 'ClientBulkImportTarget', + new_name: 'fake_name', + target_namespace: 'fake_target', + }; const { data: { - setImportProgress: { progress }, + setImportProgress: { progress, last_import_target: lastImportTarget }, }, } = await client.mutate({ mutation: setImportProgressMutation, - variables: { sourceGroupId: GROUP_ID, status: NEW_STATUS, jobId: FAKE_JOB_ID }, + variables: { + sourceGroupId: GROUP_ID, + status: NEW_STATUS, + jobId: FAKE_JOB_ID, + importTarget: IMPORT_TARGET, + }, }); - expect(progress).toMatchObject({ + expect(lastImportTarget).toStrictEqual(IMPORT_TARGET); + + expect(progress).toStrictEqual({ + __typename: clientTypenames.BulkImportProgress, id: FAKE_JOB_ID, status: NEW_STATUS, }); @@ -442,7 +459,8 @@ describe('Bulk import resolvers', () => { variables: { id: FAKE_JOB_ID, status: NEW_STATUS }, }); - expect(statusInResponse).toMatchObject({ + expect(statusInResponse).toStrictEqual({ + __typename: clientTypenames.BulkImportProgress, id: FAKE_JOB_ID, status: NEW_STATUS, }); @@ -460,7 +478,13 @@ describe('Bulk import resolvers', () => { variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE }, }); - expect(validationErrors).toMatchObject([{ field: FAKE_FIELD, message: FAKE_MESSAGE }]); + expect(validationErrors).toStrictEqual([ + { + __typename: clientTypenames.BulkImportValidationError, + field: FAKE_FIELD, + message: FAKE_MESSAGE, + }, + ]); }); it('removeValidationError removes error from group', async () => { @@ -481,7 +505,7 @@ describe('Bulk import resolvers', () => { variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD }, }); - expect(validationErrors).toMatchObject([]); + expect(validationErrors).toStrictEqual([]); }); }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js index 6f66066b312..d1bd52693b6 100644 --- a/spec/frontend/import_entities/import_groups/graphql/fixtures.js +++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js @@ -9,6 +9,10 @@ export const generateFakeEntry = ({ id, status, ...rest }) => ({ target_namespace: 'root', new_name: `group${id}`, }, + last_import_target: { + target_namespace: 'root', + new_name: `last-group${id}`, + }, id, progress: { id: `test-${id}`, diff --git a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js index bae715edac0..f06babcb149 100644 --- a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js @@ -20,7 +20,7 @@ describe('SourceGroupsManager', () => { describe('storage management', () => { const IMPORT_ID = 1; - const IMPORT_TARGET = { destination_name: 'demo', destination_namespace: 'foo' }; + const IMPORT_TARGET = { new_name: 'demo', target_namespace: 'foo' }; const STATUS = 'FAKE_STATUS'; const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS }; diff --git a/spec/frontend/import_entities/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js index f2bfc61381c..0ebe8525b5a 100644 --- a/spec/frontend/import_entities/import_projects/store/actions_spec.js +++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js @@ -85,7 +85,7 @@ describe('import_projects store actions', () => { afterEach(() => mock.restore()); - it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { + it('commits REQUEST_REPOS, SET_PAGE, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { mock.onGet(MOCK_ENDPOINT).reply(200, payload); return testAction( @@ -93,8 +93,8 @@ describe('import_projects store actions', () => { null, localState, [ - { type: SET_PAGE, payload: 1 }, { type: REQUEST_REPOS }, + { type: SET_PAGE, payload: 1 }, { type: RECEIVE_REPOS_SUCCESS, payload: convertObjectPropsToCamelCase(payload, { deep: true }), @@ -104,19 +104,14 @@ describe('import_projects store actions', () => { ); }); - it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_ERROR and SET_PAGE again mutations on an unsuccessful request', () => { + it('commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => { mock.onGet(MOCK_ENDPOINT).reply(500); return testAction( fetchRepos, null, localState, - [ - { type: SET_PAGE, payload: 1 }, - { type: REQUEST_REPOS }, - { type: SET_PAGE, payload: 0 }, - { type: RECEIVE_REPOS_ERROR }, - ], + [{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }], [], ); }); @@ -135,7 +130,7 @@ describe('import_projects store actions', () => { expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`); }); - it('correctly updates current page on an unsuccessful request', () => { + it('correctly keeps current page on an unsuccessful request', () => { mock.onGet(MOCK_ENDPOINT).reply(500); const CURRENT_PAGE = 5; @@ -143,10 +138,7 @@ describe('import_projects store actions', () => { fetchRepos, null, { ...localState, pageInfo: { page: CURRENT_PAGE } }, - expect.arrayContaining([ - { type: SET_PAGE, payload: CURRENT_PAGE + 1 }, - { type: SET_PAGE, payload: CURRENT_PAGE }, - ]), + expect.arrayContaining([]), [], ); }); @@ -159,12 +151,7 @@ describe('import_projects store actions', () => { fetchRepos, null, { ...localState, filter: 'filter' }, - [ - { type: SET_PAGE, payload: 1 }, - { type: REQUEST_REPOS }, - { type: SET_PAGE, payload: 0 }, - { type: RECEIVE_REPOS_ERROR }, - ], + [{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }], [], ); @@ -183,8 +170,8 @@ describe('import_projects store actions', () => { null, { ...localState, filter: 'filter' }, [ - { type: SET_PAGE, payload: 1 }, { type: REQUEST_REPOS }, + { type: SET_PAGE, payload: 1 }, { type: RECEIVE_REPOS_SUCCESS, payload: convertObjectPropsToCamelCase(payload, { deep: true }), diff --git a/spec/frontend/invite_members/components/import_a_project_modal_spec.js b/spec/frontend/invite_members/components/import_a_project_modal_spec.js new file mode 100644 index 00000000000..fecbf84fb57 --- /dev/null +++ b/spec/frontend/invite_members/components/import_a_project_modal_spec.js @@ -0,0 +1,167 @@ +import { GlFormGroup, GlSprintf, GlModal } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import * as ProjectsApi from '~/api/projects_api'; +import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue'; +import ProjectSelect from '~/invite_members/components/project_select.vue'; +import axios from '~/lib/utils/axios_utils'; + +let wrapper; +let mock; + +const projectId = '1'; +const projectName = 'test name'; +const projectToBeImported = { id: '2' }; +const $toast = { + show: jest.fn(), +}; + +const createComponent = () => { + wrapper = shallowMountExtended(ImportAProjectModal, { + propsData: { + projectId, + projectName, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + GlSprintf, + GlFormGroup: stubComponent(GlFormGroup, { + props: ['state', 'invalidFeedback'], + }), + }, + mocks: { + $toast, + }, + }); +}; + +beforeEach(() => { + gon.api_version = 'v4'; + mock = new MockAdapter(axios); +}); + +afterEach(() => { + wrapper.destroy(); + mock.restore(); +}); + +describe('ImportAProjectModal', () => { + const findIntroText = () => wrapper.find({ ref: 'modalIntro' }).text(); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findImportButton = () => wrapper.findByTestId('import-button'); + const clickImportButton = () => findImportButton().vm.$emit('click'); + const clickCancelButton = () => findCancelButton().vm.$emit('click'); + const findFormGroup = () => wrapper.findByTestId('form-group'); + const formGroupInvalidFeedback = () => findFormGroup().props('invalidFeedback'); + const formGroupErrorState = () => findFormGroup().props('state'); + const findProjectSelect = () => wrapper.findComponent(ProjectSelect); + + describe('rendering the modal', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the modal with the correct title', () => { + expect(wrapper.findComponent(GlModal).props('title')).toBe( + 'Import members from another project', + ); + }); + + it('renders the Cancel button text correctly', () => { + expect(findCancelButton().text()).toBe('Cancel'); + }); + + it('renders the Import button text correctly', () => { + expect(findImportButton().text()).toBe('Import project members'); + }); + + it('renders the modal intro text correctly', () => { + expect(findIntroText()).toBe("You're importing members to the test name project."); + }); + + it('renders the Import button modal without isLoading', () => { + expect(findImportButton().props('loading')).toBe(false); + }); + + it('sets isLoading to true when the Invite button is clicked', async () => { + clickImportButton(); + + await wrapper.vm.$nextTick(); + + expect(findImportButton().props('loading')).toBe(true); + }); + }); + + describe('submitting the import form', () => { + describe('when the import is successful', () => { + beforeEach(() => { + createComponent(); + + findProjectSelect().vm.$emit('input', projectToBeImported); + + jest.spyOn(ProjectsApi, 'importProjectMembers').mockResolvedValue(); + + clickImportButton(); + }); + + it('calls Api importProjectMembers', () => { + expect(ProjectsApi.importProjectMembers).toHaveBeenCalledWith( + projectId, + projectToBeImported.id, + ); + }); + + it('displays the successful toastMessage', () => { + expect($toast.show).toHaveBeenCalledWith( + 'Successfully imported', + wrapper.vm.$options.toastOptions, + ); + }); + + it('sets isLoading to false after success', () => { + expect(findImportButton().props('loading')).toBe(false); + }); + }); + + describe('when the import fails', () => { + beforeEach(async () => { + createComponent(); + + findProjectSelect().vm.$emit('input', projectToBeImported); + + jest + .spyOn(ProjectsApi, 'importProjectMembers') + .mockRejectedValue({ response: { data: { success: false } } }); + + clickImportButton(); + await waitForPromises(); + }); + + it('displays the generic error message', () => { + expect(formGroupInvalidFeedback()).toBe('Unable to import project members'); + expect(formGroupErrorState()).toBe(false); + }); + + it('sets isLoading to false after error', () => { + expect(findImportButton().props('loading')).toBe(false); + }); + + it('clears the error when the modal is closed with an error', async () => { + expect(formGroupInvalidFeedback()).toBe('Unable to import project members'); + expect(formGroupErrorState()).toBe(false); + + clickCancelButton(); + + await wrapper.vm.$nextTick(); + + expect(formGroupInvalidFeedback()).toBe(''); + expect(formGroupErrorState()).not.toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index f57af61ad5b..b2ebb9e4a47 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -79,14 +79,14 @@ describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement it('does not add tracking attributes', () => { createComponent(); - expect(findButton().attributes('data-track-event')).toBeUndefined(); + expect(findButton().attributes('data-track-action')).toBeUndefined(); expect(findButton().attributes('data-track-label')).toBeUndefined(); }); it('adds tracking attributes', () => { createComponent({ label: '_label_', event: '_event_' }); - expect(findButton().attributes('data-track-event')).toBe('_event_'); + expect(findButton().attributes('data-track-action')).toBe('_event_'); expect(findButton().attributes('data-track-label')).toBe('_label_'); }); }); diff --git a/spec/frontend/invite_members/components/project_select_spec.js b/spec/frontend/invite_members/components/project_select_spec.js new file mode 100644 index 00000000000..acc062b5fff --- /dev/null +++ b/spec/frontend/invite_members/components/project_select_spec.js @@ -0,0 +1,105 @@ +import { GlSearchBoxByType, GlAvatarLabeled, GlDropdownItem } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import * as projectsApi from '~/api/projects_api'; +import ProjectSelect from '~/invite_members/components/project_select.vue'; +import { allProjects, project1 } from '../mock_data/api_response_data'; + +describe('ProjectSelect', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ProjectSelect, {}); + }; + + beforeEach(() => { + jest.spyOn(projectsApi, 'getProjects').mockResolvedValue(allProjects); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdownItem = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); + const findAvatarLabeled = (index) => findDropdownItem(index).findComponent(GlAvatarLabeled); + const findEmptyResultMessage = () => wrapper.findByTestId('empty-result-message'); + const findErrorMessage = () => wrapper.findByTestId('error-message'); + + it('renders GlSearchBoxByType with default attributes', () => { + expect(findSearchBoxByType().exists()).toBe(true); + expect(findSearchBoxByType().vm.$attrs).toMatchObject({ + placeholder: 'Search projects', + }); + }); + + describe('when user types in the search input', () => { + let resolveApiRequest; + let rejectApiRequest; + + beforeEach(() => { + jest.spyOn(projectsApi, 'getProjects').mockImplementation( + () => + new Promise((resolve, reject) => { + resolveApiRequest = resolve; + rejectApiRequest = reject; + }), + ); + + findSearchBoxByType().vm.$emit('input', project1.name); + }); + + it('calls the API', () => { + resolveApiRequest({ data: allProjects }); + + expect(projectsApi.getProjects).toHaveBeenCalledWith(project1.name, { + active: true, + exclude_internal: true, + }); + }); + + it('displays loading icon while waiting for API call to resolve and then sets loading false', async () => { + expect(findSearchBoxByType().props('isLoading')).toBe(true); + + resolveApiRequest({ data: allProjects }); + await waitForPromises(); + + expect(findSearchBoxByType().props('isLoading')).toBe(false); + expect(findEmptyResultMessage().exists()).toBe(false); + expect(findErrorMessage().exists()).toBe(false); + }); + + it('displays a dropdown item and avatar for each project fetched', async () => { + resolveApiRequest({ data: allProjects }); + await waitForPromises(); + + allProjects.forEach((project, index) => { + expect(findDropdownItem(index).attributes('name')).toBe(project.name_with_namespace); + expect(findAvatarLabeled(index).attributes()).toMatchObject({ + src: project.avatar_url, + 'entity-id': String(project.id), + 'entity-name': project.name_with_namespace, + }); + expect(findAvatarLabeled(index).props('label')).toBe(project.name_with_namespace); + }); + }); + + it('displays the empty message when the API results are empty', async () => { + resolveApiRequest({ data: [] }); + await waitForPromises(); + + expect(findEmptyResultMessage().text()).toBe('No matching results'); + }); + + it('displays the error message when the fetch fails', async () => { + rejectApiRequest(); + await waitForPromises(); + + expect(findErrorMessage().text()).toBe( + 'There was an error fetching the projects. Please try again.', + ); + }); + }); +}); diff --git a/spec/frontend/invite_members/mock_data/api_response_data.js b/spec/frontend/invite_members/mock_data/api_response_data.js new file mode 100644 index 00000000000..9509422b603 --- /dev/null +++ b/spec/frontend/invite_members/mock_data/api_response_data.js @@ -0,0 +1,13 @@ +export const project1 = { + id: 1, + name: 'Project One', + name_with_namespace: 'Project One', + avatar_url: 'test1', +}; +export const project2 = { + id: 2, + name: 'Project One', + name_with_namespace: 'Project Two', + avatar_url: 'test2', +}; +export const allProjects = [project1, project2]; diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index babe3a66578..bd05cb1ac5a 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -1,7 +1,8 @@ import { GlIntersectionObserver } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import '~/behaviors/markdown/render_gfm'; import IssuableApp from '~/issue_show/components/app.vue'; import DescriptionComponent from '~/issue_show/components/description.vue'; @@ -33,13 +34,17 @@ describe('Issuable output', () => { let realtimeRequestCount = 0; let wrapper; - const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); - const findLockedBadge = () => wrapper.find('[data-testid="locked"]'); - const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]'); + const findStickyHeader = () => wrapper.findByTestId('issue-sticky-header'); + const findLockedBadge = () => wrapper.findByTestId('locked'); + const findConfidentialBadge = () => wrapper.findByTestId('confidential'); + const findHiddenBadge = () => wrapper.findByTestId('hidden'); const findAlert = () => wrapper.find('.alert'); const mountComponent = (props = {}, options = {}, data = {}) => { - wrapper = mount(IssuableApp, { + wrapper = mountExtended(IssuableApp, { + directives: { + GlTooltip: createMockDirective(), + }, propsData: { ...appProps, ...props }, provide: { fullPath: 'gitlab-org/incidents', @@ -539,8 +544,8 @@ describe('Issuable output', () => { it.each` title | isConfidential - ${'does not show confidential badge when issue is not confidential'} | ${true} - ${'shows confidential badge when issue is confidential'} | ${false} + ${'does not show confidential badge when issue is not confidential'} | ${false} + ${'shows confidential badge when issue is confidential'} | ${true} `('$title', async ({ isConfidential }) => { wrapper.setProps({ isConfidential }); @@ -551,8 +556,8 @@ describe('Issuable output', () => { it.each` title | isLocked - ${'does not show locked badge when issue is not locked'} | ${true} - ${'shows locked badge when issue is locked'} | ${false} + ${'does not show locked badge when issue is not locked'} | ${false} + ${'shows locked badge when issue is locked'} | ${true} `('$title', async ({ isLocked }) => { wrapper.setProps({ isLocked }); @@ -560,6 +565,27 @@ describe('Issuable output', () => { expect(findLockedBadge().exists()).toBe(isLocked); }); + + it.each` + title | isHidden + ${'does not show hidden badge when issue is not hidden'} | ${false} + ${'shows hidden badge when issue is hidden'} | ${true} + `('$title', async ({ isHidden }) => { + wrapper.setProps({ isHidden }); + + await nextTick(); + + const hiddenBadge = findHiddenBadge(); + + expect(hiddenBadge.exists()).toBe(isHidden); + + if (isHidden) { + expect(hiddenBadge.attributes('title')).toBe( + 'This issue is hidden because its author has been banned', + ); + expect(getBinding(hiddenBadge.element, 'gl-tooltip')).not.toBeUndefined(); + } + }); }); }); 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 0cb1092135f..8d79a5eed35 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -5,17 +5,17 @@ import { cloneDeep } from 'lodash'; import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; -import getIssuesCountQuery from 'ee_else_ce/issues_list/queries/get_issues_count.query.graphql'; +import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import { + getIssuesCountsQueryResponse, getIssuesQueryResponse, filteredTokens, locationSearch, urlParams, - getIssuesCountQueryResponse, } from 'jest/issues_list/mock_data'; import createFlash from '~/flash'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -63,15 +63,15 @@ describe('IssuesListApp component', () => { canBulkUpdate: false, emptyStateSvgPath: 'empty-state.svg', exportCsvPath: 'export/csv/path', + fullPath: 'path/to/project', + hasAnyIssues: true, hasBlockedIssuesFeature: true, hasIssueWeightsFeature: true, hasIterationsFeature: true, - hasProjectIssues: true, + isProject: true, isSignedIn: true, - issuesPath: 'path/to/issues', jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', - projectPath: 'path/to/project', rssPath: 'rss/path', showNewIssueLink: true, signInPath: 'sign/in/path', @@ -97,12 +97,12 @@ describe('IssuesListApp component', () => { const mountComponent = ({ provide = {}, issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse), - issuesQueryCountResponse = jest.fn().mockResolvedValue(getIssuesCountQueryResponse), + issuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse), mountFn = shallowMount, } = {}) => { const requestHandlers = [ [getIssuesQuery, issuesQueryResponse], - [getIssuesCountQuery, issuesQueryCountResponse], + [getIssuesCountsQuery, issuesCountsQueryResponse], ]; const apolloProvider = createMockApollo(requestHandlers); @@ -134,7 +134,7 @@ describe('IssuesListApp component', () => { it('renders', () => { expect(findIssuableList().props()).toMatchObject({ - namespace: defaultProvide.projectPath, + namespace: defaultProvide.fullPath, recentSearchesStorageKey: 'issues', searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder, sortOptions: getSortOptions(true, true), @@ -191,7 +191,7 @@ describe('IssuesListApp component', () => { setWindowLocation(search); wrapper = mountComponent({ - provide: { ...defaultProvide, isSignedIn: true }, + provide: { isSignedIn: true }, mountFn: mount, }); @@ -208,7 +208,15 @@ describe('IssuesListApp component', () => { describe('when user is not signed in', () => { it('does not render', () => { - wrapper = mountComponent({ provide: { ...defaultProvide, isSignedIn: false } }); + wrapper = mountComponent({ provide: { isSignedIn: false } }); + + expect(findCsvImportExportButtons().exists()).toBe(false); + }); + }); + + describe('when in a group context', () => { + it('does not render', () => { + wrapper = mountComponent({ provide: { isProject: false } }); expect(findCsvImportExportButtons().exists()).toBe(false); }); @@ -349,7 +357,7 @@ describe('IssuesListApp component', () => { beforeEach(() => { setWindowLocation(`?search=no+results`); - wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); }); it('shows empty state', () => { @@ -363,7 +371,7 @@ describe('IssuesListApp component', () => { describe('when "Open" tab has no issues', () => { beforeEach(() => { - wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); }); it('shows empty state', () => { @@ -379,7 +387,7 @@ describe('IssuesListApp component', () => { beforeEach(() => { setWindowLocation(`?state=${IssuableStates.Closed}`); - wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); }); it('shows empty state', () => { @@ -395,7 +403,7 @@ describe('IssuesListApp component', () => { describe('when user is logged in', () => { beforeEach(() => { wrapper = mountComponent({ - provide: { hasProjectIssues: false, isSignedIn: true }, + provide: { hasAnyIssues: false, isSignedIn: true }, mountFn: mount, }); }); @@ -434,7 +442,7 @@ describe('IssuesListApp component', () => { describe('when user is logged out', () => { beforeEach(() => { wrapper = mountComponent({ - provide: { hasProjectIssues: false, isSignedIn: false }, + provide: { hasAnyIssues: false, isSignedIn: false }, }); }); @@ -571,9 +579,9 @@ describe('IssuesListApp component', () => { describe('errors', () => { describe.each` - error | mountOption | message - ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues} - ${'fetching issue counts'} | ${'issuesQueryCountResponse'} | ${IssuesListApp.i18n.errorFetchingCounts} + error | mountOption | message + ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues} + ${'fetching issue counts'} | ${'issuesCountsQueryResponse'} | ${IssuesListApp.i18n.errorFetchingCounts} `('when there is an error $error', ({ mountOption, message }) => { beforeEach(() => { wrapper = mountComponent({ @@ -625,78 +633,99 @@ describe('IssuesListApp component', () => { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/1', iid: '101', - title: 'Issue one', + reference: 'group/project#1', + webPath: '/group/project/-/issues/1', }; const issueTwo = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/2', iid: '102', - title: 'Issue two', + reference: 'group/project#2', + webPath: '/group/project/-/issues/2', }; const issueThree = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/3', iid: '103', - title: 'Issue three', + reference: 'group/project#3', + webPath: '/group/project/-/issues/3', }; const issueFour = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/4', iid: '104', - title: 'Issue four', + reference: 'group/project#4', + webPath: '/group/project/-/issues/4', }; - const response = { + const response = (isProject = true) => ({ data: { - project: { + [isProject ? 'project' : 'group']: { issues: { ...defaultQueryResponse.data.project.issues, nodes: [issueOne, issueTwo, issueThree, issueFour], }, }, }, - }; - - beforeEach(() => { - wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockResolvedValue(response) }); - jest.runOnlyPendingTimers(); }); describe('when successful', () => { - describe.each` - description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId - ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} - ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} - ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} - ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} - `( - 'when moving issue $description', - ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { - it('makes API call to reorder the issue', async () => { - findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); - - await waitForPromises(); - - expect(axiosMock.history.put[0]).toMatchObject({ - url: joinPaths(defaultProvide.issuesPath, issueToMove.iid, 'reorder'), - data: JSON.stringify({ - move_before_id: getIdFromGraphQLId(moveBeforeId), - move_after_id: getIdFromGraphQLId(moveAfterId), - }), + describe.each([true, false])('when isProject=%s', (isProject) => { + describe.each` + description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId + ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} + ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} + ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} + ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} + `( + 'when moving issue $description', + ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { isProject }, + issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)), + }); + jest.runOnlyPendingTimers(); }); - }); - }, - ); + + it('makes API call to reorder the issue', async () => { + findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); + + await waitForPromises(); + + expect(axiosMock.history.put[0]).toMatchObject({ + url: joinPaths(issueToMove.webPath, 'reorder'), + data: JSON.stringify({ + move_before_id: getIdFromGraphQLId(moveBeforeId), + move_after_id: getIdFromGraphQLId(moveAfterId), + group_full_path: isProject ? undefined : defaultProvide.fullPath, + }), + }); + }); + }, + ); + }); }); describe('when unsuccessful', () => { + beforeEach(() => { + wrapper = mountComponent({ + issuesQueryResponse: jest.fn().mockResolvedValue(response()), + }); + jest.runOnlyPendingTimers(); + }); + it('displays an error message', async () => { - axiosMock.onPut(joinPaths(defaultProvide.issuesPath, issueOne.iid, 'reorder')).reply(500); + axiosMock.onPut(joinPaths(issueOne.webPath, 'reorder')).reply(500); findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError }); + expect(createFlash).toHaveBeenCalledWith({ + message: IssuesListApp.i18n.reorderError, + captureError: true, + error: new Error('Request failed with status code 500'), + }); }); }); }); diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index d3f3f2f9f23..720f9cac986 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -29,6 +29,7 @@ export const getIssuesQueryResponse = { updatedAt: '2021-05-22T04:08:01Z', upvotes: 3, userDiscussionsCount: 4, + webPath: 'project/-/issues/789', webUrl: 'project/-/issues/789', assignees: { nodes: [ @@ -70,10 +71,16 @@ export const getIssuesQueryResponse = { }, }; -export const getIssuesCountQueryResponse = { +export const getIssuesCountsQueryResponse = { data: { project: { - issues: { + openedIssues: { + count: 1, + }, + closedIssues: { + count: 1, + }, + allIssues: { count: 1, }, }, 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 f2142ce1fcf..891ba9c223c 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 @@ -128,8 +128,26 @@ 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" > + <!----> + <div class="gl-search-box-by-type" > @@ -255,8 +273,26 @@ 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" > + <!----> + <div class="gl-search-box-by-type" > diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index 1f4dd7d6216..f8a0059bf21 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -140,7 +140,7 @@ describe('Job App', () => { it('should render provided job information', () => { expect(wrapper.find('.header-main-content').text().replace(/\s+/g, ' ').trim()).toContain( - 'passed Job #4757 triggered 1 year ago by Root', + 'passed Job test triggered 1 year ago by Root', ); }); @@ -154,7 +154,7 @@ describe('Job App', () => { setupAndMount().then(() => { expect( wrapper.find('.header-main-content').text().replace(/\s+/g, ' ').trim(), - ).toContain('passed Job #4757 created 3 weeks ago by Root'); + ).toContain('passed Job test created 3 weeks ago by Root'); })); }); }); diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js new file mode 100644 index 00000000000..1b1e2d4df8f --- /dev/null +++ b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js @@ -0,0 +1,126 @@ +import { GlModal } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ActionsCell from '~/jobs/components/table/cells/actions_cell.vue'; +import JobPlayMutation from '~/jobs/components/table/graphql/mutations/job_play.mutation.graphql'; +import JobRetryMutation from '~/jobs/components/table/graphql/mutations/job_retry.mutation.graphql'; +import JobUnscheduleMutation from '~/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql'; +import { playableJob, retryableJob, scheduledJob } from '../../../mock_data'; + +describe('Job actions cell', () => { + let wrapper; + let mutate; + + const findRetryButton = () => wrapper.findByTestId('retry'); + const findPlayButton = () => wrapper.findByTestId('play'); + const findDownloadArtifactsButton = () => wrapper.findByTestId('download-artifacts'); + const findCountdownButton = () => wrapper.findByTestId('countdown'); + const findPlayScheduledJobButton = () => wrapper.findByTestId('play-scheduled'); + const findUnscheduleButton = () => wrapper.findByTestId('unschedule'); + + const findModal = () => wrapper.findComponent(GlModal); + + const MUTATION_SUCCESS = { data: { JobRetryMutation: { jobId: retryableJob.id } } }; + const MUTATION_SUCCESS_UNSCHEDULE = { + data: { JobUnscheduleMutation: { jobId: scheduledJob.id } }, + }; + const MUTATION_SUCCESS_PLAY = { data: { JobPlayMutation: { jobId: playableJob.id } } }; + + const $toast = { + show: jest.fn(), + }; + + const createComponent = (jobType, mutationType = MUTATION_SUCCESS, props = {}) => { + mutate = jest.fn().mockResolvedValue(mutationType); + + wrapper = shallowMountExtended(ActionsCell, { + propsData: { + job: jobType, + ...props, + }, + mocks: { + $apollo: { + mutate, + }, + $toast, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('does not display an artifacts download button', () => { + createComponent(retryableJob); + + expect(findDownloadArtifactsButton().exists()).toBe(false); + }); + + it.each` + button | action | jobType + ${findPlayButton} | ${'play'} | ${playableJob} + ${findRetryButton} | ${'retry'} | ${retryableJob} + ${findDownloadArtifactsButton} | ${'download artifacts'} | ${playableJob} + `('displays the $action button', ({ button, jobType }) => { + createComponent(jobType); + + expect(button().exists()).toBe(true); + }); + + it.each` + button | mutationResult | action | jobType | mutationFile + ${findPlayButton} | ${MUTATION_SUCCESS_PLAY} | ${'play'} | ${playableJob} | ${JobPlayMutation} + ${findRetryButton} | ${MUTATION_SUCCESS} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} + `('performs the $action mutation', ({ button, mutationResult, jobType, mutationFile }) => { + createComponent(jobType, mutationResult); + + button().vm.$emit('click'); + + expect(mutate).toHaveBeenCalledWith({ + mutation: mutationFile, + variables: { + id: jobType.id, + }, + }); + }); + + describe('Scheduled Jobs', () => { + const today = () => new Date('2021-08-31'); + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(today); + }); + + it('displays the countdown, play and unschedule buttons', () => { + createComponent(scheduledJob); + + expect(findCountdownButton().exists()).toBe(true); + expect(findPlayScheduledJobButton().exists()).toBe(true); + expect(findUnscheduleButton().exists()).toBe(true); + }); + + it('unschedules a job', () => { + createComponent(scheduledJob, MUTATION_SUCCESS_UNSCHEDULE); + + findUnscheduleButton().vm.$emit('click'); + + expect(mutate).toHaveBeenCalledWith({ + mutation: JobUnscheduleMutation, + variables: { + id: scheduledJob.id, + }, + }); + }); + + it('shows the play job confirmation modal', async () => { + createComponent(scheduledJob, MUTATION_SUCCESS); + + findPlayScheduledJobButton().vm.$emit('click'); + + await nextTick(); + + expect(findModal().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/jobs/components/table/cells.vue/duration_cell_spec.js b/spec/frontend/jobs/components/table/cells/duration_cell_spec.js index 763a4b0eaa2..763a4b0eaa2 100644 --- a/spec/frontend/jobs/components/table/cells.vue/duration_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/duration_cell_spec.js diff --git a/spec/frontend/jobs/components/table/cells.vue/job_cell_spec.js b/spec/frontend/jobs/components/table/cells/job_cell_spec.js index fc4e5586349..fc4e5586349 100644 --- a/spec/frontend/jobs/components/table/cells.vue/job_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/job_cell_spec.js diff --git a/spec/frontend/jobs/components/table/cells.vue/pipeline_cell_spec.js b/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js index 1f5e0a7aa21..1f5e0a7aa21 100644 --- a/spec/frontend/jobs/components/table/cells.vue/pipeline_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index 57f0b852ff8..43755b46bc9 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -1555,7 +1555,11 @@ export const mockJobsQueryResponse = { cancelable: false, active: false, stuck: false, - userPermissions: { readBuild: true, __typename: 'JobPermissions' }, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + __typename: 'JobPermissions', + }, __typename: 'CiJob', }, ], @@ -1573,3 +1577,179 @@ export const mockJobsQueryEmptyResponse = { }, }, }; + +export const retryableJob = { + artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + detailsPath: '/root/test-job-artifacts/-/jobs/1981', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + action: { + buttonTitle: 'Retry this job', + icon: 'retry', + method: 'post', + path: '/root/test-job-artifacts/-/jobs/1981/retry', + title: 'Retry', + __typename: 'StatusAction', + }, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/1981', + refName: 'main', + refPath: '/root/test-job-artifacts/-/commits/main', + tags: [], + shortSha: '75daf01b', + commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/288', + path: '/root/test-job-artifacts/-/pipelines/288', + user: { + webPath: '/root', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + __typename: 'UserCore', + }, + __typename: 'Pipeline', + }, + stage: { name: 'test', __typename: 'CiStage' }, + name: 'hello_world', + duration: 7, + finishedAt: '2021-08-30T20:33:56Z', + coverage: null, + retryable: true, + playable: false, + cancelable: false, + active: false, + stuck: false, + userPermissions: { readBuild: true, __typename: 'JobPermissions' }, + __typename: 'CiJob', +}; + +export const playableJob = { + artifacts: { + nodes: [ + { + downloadPath: '/root/test-job-artifacts/-/jobs/1982/artifacts/download?file_type=trace', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: true, + triggered: null, + createdByTag: false, + detailedStatus: { + detailsPath: '/root/test-job-artifacts/-/jobs/1982', + group: 'success', + icon: 'status_success', + label: 'manual play action', + text: 'passed', + tooltip: 'passed', + action: { + buttonTitle: 'Trigger this manual action', + icon: 'play', + method: 'post', + path: '/root/test-job-artifacts/-/jobs/1982/play', + title: 'Play', + __typename: 'StatusAction', + }, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/1982', + refName: 'main', + refPath: '/root/test-job-artifacts/-/commits/main', + tags: [], + shortSha: '75daf01b', + commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/288', + path: '/root/test-job-artifacts/-/pipelines/288', + user: { + webPath: '/root', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + __typename: 'UserCore', + }, + __typename: 'Pipeline', + }, + stage: { name: 'test', __typename: 'CiStage' }, + name: 'hello_world_delayed', + duration: 6, + finishedAt: '2021-08-30T20:36:12Z', + coverage: null, + retryable: true, + playable: true, + cancelable: false, + active: false, + stuck: false, + userPermissions: { readBuild: true, readJobArtifacts: true, __typename: 'JobPermissions' }, + __typename: 'CiJob', +}; + +export const scheduledJob = { + artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' }, + allowFailure: false, + status: 'SCHEDULED', + scheduledAt: '2021-08-31T22:36:05Z', + manualJob: true, + triggered: null, + createdByTag: false, + detailedStatus: { + detailsPath: '/root/test-job-artifacts/-/jobs/1986', + group: 'scheduled', + icon: 'status_scheduled', + label: 'unschedule action', + text: 'delayed', + tooltip: 'delayed manual action (%{remainingTime})', + action: { + buttonTitle: 'Unschedule job', + icon: 'time-out', + method: 'post', + path: '/root/test-job-artifacts/-/jobs/1986/unschedule', + title: 'Unschedule', + __typename: 'StatusAction', + }, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/1986', + refName: 'main', + refPath: '/root/test-job-artifacts/-/commits/main', + tags: [], + shortSha: '75daf01b', + commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/290', + path: '/root/test-job-artifacts/-/pipelines/290', + user: { + webPath: '/root', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + __typename: 'UserCore', + }, + __typename: 'Pipeline', + }, + stage: { name: 'test', __typename: 'CiStage' }, + name: 'hello_world_delayed', + duration: null, + finishedAt: null, + coverage: null, + retryable: false, + playable: true, + cancelable: false, + active: false, + stuck: false, + userPermissions: { readBuild: true, __typename: 'JobPermissions' }, + __typename: 'CiJob', +}; diff --git a/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js b/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js deleted file mode 100644 index 3fb38a74c70..00000000000 --- a/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js +++ /dev/null @@ -1,21 +0,0 @@ -import { mockTracking } from 'helpers/tracking_helper'; -import trackLearnGitlab from '~/learn_gitlab/track_learn_gitlab'; - -describe('trackTrialUserErrors', () => { - let spy; - - describe('when an error is present', () => { - beforeEach(() => { - spy = mockTracking('projects:learn_gitlab_index', document.body, jest.spyOn); - }); - - it('tracks the error message', () => { - trackLearnGitlab(); - - expect(spy).toHaveBeenCalledWith('projects:learn_gitlab:index', 'page_init', { - label: 'learn_gitlab', - property: 'Growth::Activation::Experiment::LearnGitLabB', - }); - }); - }); -}); diff --git a/spec/frontend/lib/apollo/instrumentation_link_spec.js b/spec/frontend/lib/apollo/instrumentation_link_spec.js new file mode 100644 index 00000000000..ef686129257 --- /dev/null +++ b/spec/frontend/lib/apollo/instrumentation_link_spec.js @@ -0,0 +1,54 @@ +import { testApolloLink } from 'helpers/test_apollo_link'; +import { getInstrumentationLink, FEATURE_CATEGORY_HEADER } from '~/lib/apollo/instrumentation_link'; + +const TEST_FEATURE_CATEGORY = 'foo_feature'; + +describe('~/lib/apollo/instrumentation_link', () => { + const setFeatureCategory = (val) => { + window.gon.feature_category = val; + }; + + afterEach(() => { + getInstrumentationLink.cache.clear(); + }); + + describe('getInstrumentationLink', () => { + describe('with no gon.feature_category', () => { + beforeEach(() => { + setFeatureCategory(null); + }); + + it('returns null', () => { + expect(getInstrumentationLink()).toBe(null); + }); + }); + + describe('with gon.feature_category', () => { + beforeEach(() => { + setFeatureCategory(TEST_FEATURE_CATEGORY); + }); + + it('returns memoized apollo link', () => { + const result = getInstrumentationLink(); + + // expect.any(ApolloLink) doesn't work for some reason... + expect(result).toHaveProp('request'); + expect(result).toBe(getInstrumentationLink()); + }); + + it('adds a feature category header from the returned apollo link', async () => { + const defaultHeaders = { Authorization: 'foo' }; + const operation = await testApolloLink(getInstrumentationLink(), { + context: { headers: defaultHeaders }, + }); + + const { headers } = operation.getContext(); + + expect(headers).toEqual({ + ...defaultHeaders, + [FEATURE_CATEGORY_HEADER]: TEST_FEATURE_CATEGORY, + }); + }); + }); + }); +}); diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js index fa8dbb12a08..324441fa2c9 100644 --- a/spec/frontend/lib/dompurify_spec.js +++ b/spec/frontend/lib/dompurify_spec.js @@ -44,6 +44,31 @@ describe('~/lib/dompurify', () => { expect(sanitize('<strong></strong>', { ALLOWED_TAGS: [] })).toBe(''); }); + describe('includes default configuration', () => { + it('with empty config', () => { + const svgIcon = '<svg width="100"><use></use></svg>'; + expect(sanitize(svgIcon, {})).toBe(svgIcon); + }); + + it('with valid config', () => { + expect(sanitize('<a href="#" data-remote="true"></a>', { ALLOWED_TAGS: ['a'] })).toBe( + '<a href="#"></a>', + ); + }); + }); + + it("doesn't sanitize local references", () => { + const htmlHref = `<svg><use href="#some-element"></use></svg>`; + const htmlXlink = `<svg><use xlink:href="#some-element"></use></svg>`; + + expect(sanitize(htmlHref)).toBe(htmlHref); + expect(sanitize(htmlXlink)).toBe(htmlXlink); + }); + + it("doesn't sanitize gl-emoji", () => { + expect(sanitize('<p><gl-emoji>💯</gl-emoji></p>')).toBe('<p><gl-emoji>💯</gl-emoji></p>'); + }); + describe.each` type | gon ${'root'} | ${rootGon} diff --git a/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap b/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap new file mode 100644 index 00000000000..791ec05befd --- /dev/null +++ b/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`~/lib/logger/hello logHello console logs a friendly hello message 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_deferred_spec.js b/spec/frontend/lib/logger/hello_deferred_spec.js new file mode 100644 index 00000000000..3233cbff0dc --- /dev/null +++ b/spec/frontend/lib/logger/hello_deferred_spec.js @@ -0,0 +1,17 @@ +import waitForPromises from 'helpers/wait_for_promises'; +import { logHello } from '~/lib/logger/hello'; +import { logHelloDeferred } from '~/lib/logger/hello_deferred'; + +jest.mock('~/lib/logger/hello'); + +describe('~/lib/logger/hello_deferred', () => { + it('dynamically imports and calls logHello', async () => { + logHelloDeferred(); + + expect(logHello).not.toHaveBeenCalled(); + + await waitForPromises(); + + expect(logHello).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/lib/logger/hello_spec.js b/spec/frontend/lib/logger/hello_spec.js new file mode 100644 index 00000000000..39abe0e0dd0 --- /dev/null +++ b/spec/frontend/lib/logger/hello_spec.js @@ -0,0 +1,20 @@ +import { logHello } from '~/lib/logger/hello'; + +describe('~/lib/logger/hello', () => { + let consoleLogSpy; + + beforeEach(() => { + // We don't `mockImplementation` so we can validate there's no errors thrown + consoleLogSpy = jest.spyOn(console, 'log'); + }); + + describe('logHello', () => { + it('console logs a friendly hello message', () => { + expect(consoleLogSpy).not.toHaveBeenCalled(); + + logHello(); + + expect(consoleLogSpy.mock.calls).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/lib/logger/index_spec.js b/spec/frontend/lib/logger/index_spec.js new file mode 100644 index 00000000000..9382fafe4de --- /dev/null +++ b/spec/frontend/lib/logger/index_spec.js @@ -0,0 +1,23 @@ +import { logError, LOG_PREFIX } from '~/lib/logger'; + +describe('~/lib/logger', () => { + let consoleErrorSpy; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error'); + consoleErrorSpy.mockImplementation(); + }); + + describe('logError', () => { + it('sends given message to console.error', () => { + const message = 'Lorem ipsum dolar sit amit'; + const error = new Error('lorem ipsum'); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + logError(message, error); + + expect(consoleErrorSpy).toHaveBeenCalledWith(LOG_PREFIX, `${message}\n`, error); + }); + }); +}); diff --git a/spec/frontend/lib/utils/accessor_spec.js b/spec/frontend/lib/utils/accessor_spec.js index 752a88296e6..63497d795ce 100644 --- a/spec/frontend/lib/utils/accessor_spec.js +++ b/spec/frontend/lib/utils/accessor_spec.js @@ -6,60 +6,9 @@ describe('AccessorUtilities', () => { const testError = new Error('test error'); - describe('isPropertyAccessSafe', () => { - let base; - - it('should return `true` if access is safe', () => { - base = { - testProp: 'testProp', - }; - expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(true); - }); - - it('should return `false` if access throws an error', () => { - base = { - get testProp() { - throw testError; - }, - }; - - expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); - }); - - it('should return `false` if property is undefined', () => { - base = {}; - - expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); - }); - }); - - describe('isFunctionCallSafe', () => { - const base = {}; - - it('should return `true` if calling is safe', () => { - base.func = () => {}; - - expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(true); - }); - - it('should return `false` if calling throws an error', () => { - base.func = () => { - throw new Error('test error'); - }; - - expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); - }); - - it('should return `false` if function is undefined', () => { - base.func = undefined; - - expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); - }); - }); - - describe('isLocalStorageAccessSafe', () => { + describe('canUseLocalStorage', () => { it('should return `true` if access is safe', () => { - expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(true); + expect(AccessorUtilities.canUseLocalStorage()).toBe(true); }); it('should return `false` if access to .setItem isnt safe', () => { @@ -67,19 +16,19 @@ describe('AccessorUtilities', () => { throw testError; }); - expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(false); + expect(AccessorUtilities.canUseLocalStorage()).toBe(false); }); it('should set a test item if access is safe', () => { - AccessorUtilities.isLocalStorageAccessSafe(); + AccessorUtilities.canUseLocalStorage(); - expect(window.localStorage.setItem).toHaveBeenCalledWith('isLocalStorageAccessSafe', 'true'); + expect(window.localStorage.setItem).toHaveBeenCalledWith('canUseLocalStorage', 'true'); }); it('should remove the test item if access is safe', () => { - AccessorUtilities.isLocalStorageAccessSafe(); + AccessorUtilities.canUseLocalStorage(); - expect(window.localStorage.removeItem).toHaveBeenCalledWith('isLocalStorageAccessSafe'); + expect(window.localStorage.removeItem).toHaveBeenCalledWith('canUseLocalStorage'); }); }); }); diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js new file mode 100644 index 00000000000..942ba56196e --- /dev/null +++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js @@ -0,0 +1,120 @@ +import * as utils from '~/lib/utils/datetime/date_format_utility'; + +describe('date_format_utility.js', () => { + describe('padWithZeros', () => { + it.each` + input | output + ${0} | ${'00'} + ${'1'} | ${'01'} + ${'10'} | ${'10'} + ${'100'} | ${'100'} + ${100} | ${'100'} + ${'a'} | ${'0a'} + ${'foo'} | ${'foo'} + `('properly pads $input to match $output', ({ input, output }) => { + expect(utils.padWithZeros(input)).toEqual([output]); + }); + + it('accepts multiple arguments', () => { + expect(utils.padWithZeros(1, '2', 3)).toEqual(['01', '02', '03']); + }); + + it('returns an empty array provided no argument', () => { + expect(utils.padWithZeros()).toEqual([]); + }); + }); + + describe('stripTimezoneFromISODate', () => { + it.each` + input | expectedOutput + ${'2021-08-16T00:00:00Z'} | ${'2021-08-16T00:00:00'} + ${'2021-08-16T10:30:00+02:00'} | ${'2021-08-16T10:30:00'} + ${'2021-08-16T10:30:00-05:30'} | ${'2021-08-16T10:30:00'} + `('returns $expectedOutput when given $input', ({ input, expectedOutput }) => { + expect(utils.stripTimezoneFromISODate(input)).toBe(expectedOutput); + }); + + it('returns null if date is invalid', () => { + expect(utils.stripTimezoneFromISODate('Invalid date')).toBe(null); + }); + }); + + describe('dateToYearMonthDate', () => { + it.each` + date | expectedOutput + ${new Date('2021-08-05')} | ${{ year: '2021', month: '08', day: '05' }} + ${new Date('2021-12-24')} | ${{ year: '2021', month: '12', day: '24' }} + `('returns $expectedOutput provided $date', ({ date, expectedOutput }) => { + expect(utils.dateToYearMonthDate(date)).toEqual(expectedOutput); + }); + + it('throws provided an invalid date', () => { + expect(() => utils.dateToYearMonthDate('Invalid date')).toThrow( + 'Argument should be a Date instance', + ); + }); + }); + + describe('timeToHoursMinutes', () => { + it.each` + time | expectedOutput + ${'23:12'} | ${{ hours: '23', minutes: '12' }} + ${'23:12'} | ${{ hours: '23', minutes: '12' }} + `('returns $expectedOutput provided $time', ({ time, expectedOutput }) => { + expect(utils.timeToHoursMinutes(time)).toEqual(expectedOutput); + }); + + it('throws provided an invalid time', () => { + expect(() => utils.timeToHoursMinutes('Invalid time')).toThrow('Invalid time provided'); + }); + }); + + describe('dateAndTimeToISOString', () => { + it('computes the date properly', () => { + expect(utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00')).toBe( + '2021-08-16T10:00:00.000Z', + ); + }); + + it('computes the date properly with an offset', () => { + expect(utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00', '-04:00')).toBe( + '2021-08-16T10:00:00.000-04:00', + ); + }); + + it('throws if date in invalid', () => { + expect(() => utils.dateAndTimeToISOString('Invalid date', '10:00')).toThrow( + 'Argument should be a Date instance', + ); + }); + + it('throws if time in invalid', () => { + expect(() => utils.dateAndTimeToISOString(new Date('2021-08-16'), '')).toThrow( + 'Invalid time provided', + ); + }); + + it('throws if offset is invalid', () => { + expect(() => + utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00', 'not an offset'), + ).toThrow('Could not initialize date'); + }); + }); + + describe('dateToTimeInputValue', () => { + it.each` + input | expectedOutput + ${new Date('2021-08-16T10:00:00.000Z')} | ${'10:00'} + ${new Date('2021-08-16T22:30:00.000Z')} | ${'22:30'} + ${new Date('2021-08-16T22:30:00.000-03:00')} | ${'01:30'} + `('extracts $expectedOutput out of $input', ({ input, expectedOutput }) => { + expect(utils.dateToTimeInputValue(input)).toBe(expectedOutput); + }); + + it('throws if date is invalid', () => { + expect(() => utils.dateToTimeInputValue('Invalid date')).toThrow( + 'Argument should be a Date instance', + ); + }); + }); +}); diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js index 7c4c20e651f..cb8b1c7ca9a 100644 --- a/spec/frontend/lib/utils/dom_utils_spec.js +++ b/spec/frontend/lib/utils/dom_utils_spec.js @@ -5,6 +5,7 @@ import { parseBooleanDataAttributes, isElementVisible, isElementHidden, + getParents, } from '~/lib/utils/dom_utils'; const TEST_MARGIN = 5; @@ -193,4 +194,18 @@ describe('DOM Utils', () => { }); }, ); + + describe('getParents', () => { + it('gets all parents of an element', () => { + const el = document.createElement('div'); + el.innerHTML = '<p><span><strong><mark>hello world'; + + expect(getParents(el.querySelector('mark'))).toEqual([ + el.querySelector('strong'), + el.querySelector('span'), + el.querySelector('p'), + el, + ]); + }); + }); }); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index beedb9b2eba..acbf1a975b8 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -88,6 +88,25 @@ describe('init markdown', () => { expect(textArea.value).toEqual(`${initialValue}\n- `); }); + it('unescapes new line characters', () => { + const initialValue = ''; + + textArea.value = initialValue; + textArea.selectionStart = 0; + textArea.selectionEnd = 0; + + insertMarkdownText({ + textArea, + text: textArea.value, + tag: '```suggestion:-0+0\n{text}\n```', + blockTag: true, + selected: '# Does not parse the %br currently.', + wrap: false, + }); + + expect(textArea.value).toContain('# Does not parse the \\n currently.'); + }); + it('inserts the tag on the same line if the current line only contains spaces', () => { const initialValue = ' '; diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index c8ac7ffc9d9..6f186ba3227 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -645,29 +645,6 @@ describe('URL utility', () => { }); }); - describe('urlParamsToObject', () => { - it('parses path for label with trailing +', () => { - // eslint-disable-next-line import/no-deprecated - expect(urlUtils.urlParamsToObject('label_name[]=label%2B', {})).toEqual({ - label_name: ['label+'], - }); - }); - - it('parses path for milestone with trailing +', () => { - // eslint-disable-next-line import/no-deprecated - expect(urlUtils.urlParamsToObject('milestone_title=A%2B', {})).toEqual({ - milestone_title: 'A+', - }); - }); - - it('parses path for search terms with spaces', () => { - // eslint-disable-next-line import/no-deprecated - expect(urlUtils.urlParamsToObject('search=two+words', {})).toEqual({ - search: 'two words', - }); - }); - }); - describe('queryToObject', () => { it.each` case | query | options | result diff --git a/spec/frontend/members/components/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js index ea9eb7bf923..1dc913e5c78 100644 --- a/spec/frontend/members/components/modals/leave_modal_spec.js +++ b/spec/frontend/members/components/modals/leave_modal_spec.js @@ -99,10 +99,14 @@ describe('LeaveModal', () => { }); }); - it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", () => { + it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", async () => { + wrapper.destroy(); + const memberWithoutOncallSchedules = cloneDeep(member); - delete (memberWithoutOncallSchedules, 'user.oncallSchedules'); + delete memberWithoutOncallSchedules.user.oncallSchedules; createComponent({ member: memberWithoutOncallSchedules }); + await nextTick(); + expect(findOncallSchedulesList().exists()).toBe(false); }); }); diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index 23e9bf8b447..ced9b71125b 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -34,6 +34,44 @@ describe('MergeRequestTabs', () => { gl.mrWidget = {}; }); + describe('clickTab', () => { + let params; + + beforeEach(() => { + document.documentElement.scrollTop = 100; + + params = { + metaKey: false, + ctrlKey: false, + which: 1, + stopImmediatePropagation() {}, + preventDefault() {}, + currentTarget: { + getAttribute(attr) { + return attr === 'href' ? 'a/tab/url' : null; + }, + }, + }; + }); + + it("stores the current scroll position if there's an active tab", () => { + testContext.class.currentTab = 'someTab'; + + testContext.class.clickTab(params); + + expect(testContext.class.scrollPositions.someTab).toBe(100); + }); + + it("doesn't store a scroll position if there's no active tab", () => { + // this happens on first load, and we just don't want to store empty values in the `null` property + testContext.class.currentTab = null; + + testContext.class.clickTab(params); + + expect(testContext.class.scrollPositions).toEqual({}); + }); + }); + describe('opensInNewTab', () => { const windowTarget = '_blank'; let clickTabParams; @@ -258,6 +296,7 @@ describe('MergeRequestTabs', () => { beforeEach(() => { jest.spyOn(mainContent, 'getBoundingClientRect').mockReturnValue({ top: 10 }); jest.spyOn(tabContent, 'getBoundingClientRect').mockReturnValue({ top: 100 }); + jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); jest.spyOn(document, 'querySelector').mockImplementation((selector) => { return selector === '.content-wrapper' ? mainContent : tabContent; }); @@ -267,8 +306,6 @@ describe('MergeRequestTabs', () => { it('calls window scrollTo with options if document has scrollBehavior', () => { document.documentElement.style.scrollBehavior = ''; - jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); - testContext.class.tabShown('commits', 'foobar'); expect(window.scrollTo.mock.calls[0][0]).toEqual({ top: 39, behavior: 'smooth' }); @@ -276,11 +313,50 @@ describe('MergeRequestTabs', () => { it('calls window scrollTo with two args if document does not have scrollBehavior', () => { jest.spyOn(document.documentElement, 'style', 'get').mockReturnValue({}); - jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); testContext.class.tabShown('commits', 'foobar'); expect(window.scrollTo.mock.calls[0]).toEqual([0, 39]); }); + + describe('when switching tabs', () => { + const SCROLL_TOP = 100; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + beforeEach(() => { + jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); + testContext.class.mergeRequestTabs = document.createElement('div'); + testContext.class.mergeRequestTabPanes = document.createElement('div'); + testContext.class.currentTab = 'tab'; + testContext.class.scrollPositions = { newTab: SCROLL_TOP }; + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('scrolls to the stored position, if one is stored', () => { + testContext.class.tabShown('newTab'); + + jest.advanceTimersByTime(250); + + expect(window.scrollTo.mock.calls[0][0]).toEqual({ + top: SCROLL_TOP, + left: 0, + behavior: 'auto', + }); + }); + + it('scrolls to 0, if no position is stored', () => { + testContext.class.tabShown('unknownTab'); + + jest.advanceTimersByTime(250); + + expect(window.scrollTo.mock.calls[0][0]).toEqual({ top: 0, left: 0, behavior: 'auto' }); + }); + }); }); }); diff --git a/spec/frontend/milestones/stores/mutations_spec.js b/spec/frontend/milestones/stores/mutations_spec.js index 91b2acf23c5..a53d6ca5de1 100644 --- a/spec/frontend/milestones/stores/mutations_spec.js +++ b/spec/frontend/milestones/stores/mutations_spec.js @@ -174,6 +174,35 @@ describe('Milestones combobox Vuex store mutations', () => { }); }); + it('falls back to the length of list if pagination headers are missing', () => { + const response = { + data: [ + { + title: 'v0.1', + }, + { + title: 'v0.2', + }, + ], + headers: {}, + }; + + mutations[types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response); + + expect(state.matches.projectMilestones).toEqual({ + list: [ + { + title: 'v0.1', + }, + { + title: 'v0.2', + }, + ], + error: null, + totalCount: 2, + }); + }); + describe(`${types.RECEIVE_PROJECT_MILESTONES_ERROR}`, () => { it('updates state.matches.projectMilestones to an empty state with the error object', () => { const error = new Error('Something went wrong!'); @@ -227,6 +256,35 @@ describe('Milestones combobox Vuex store mutations', () => { }); }); + it('falls back to the length of data received if pagination headers are missing', () => { + const response = { + data: [ + { + title: 'group-0.1', + }, + { + title: 'group-0.2', + }, + ], + headers: {}, + }; + + mutations[types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response); + + expect(state.matches.groupMilestones).toEqual({ + list: [ + { + title: 'group-0.1', + }, + { + title: 'group-0.2', + }, + ], + error: null, + totalCount: 2, + }); + }); + describe(`${types.RECEIVE_GROUP_MILESTONES_ERROR}`, () => { it('updates state.matches.groupMilestones to an empty state with the error object', () => { const error = new Error('Something went wrong!'); 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 08f9e07244f..05538dbaeee 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -36,11 +36,15 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <gl-dropdown-stub category="primary" class="flex-grow-1" + clearalltext="Clear all" data-qa-selector="environments_dropdown" headertext="" hideheaderborder="true" + highlighteditemstitle="Selected" + 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/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js index deeee5d6589..707efa21528 100644 --- a/spec/frontend/notebook/cells/markdown_spec.js +++ b/spec/frontend/notebook/cells/markdown_spec.js @@ -1,3 +1,4 @@ +import { mount } from '@vue/test-utils'; import katex from 'katex'; import Vue from 'vue'; import MarkdownComponent from '~/notebook/cells/markdown.vue'; @@ -6,6 +7,28 @@ const Component = Vue.extend(MarkdownComponent); window.katex = katex; +function buildCellComponent(cell, relativePath = '') { + return mount(Component, { + propsData: { + cell, + }, + provide: { + relativeRawPath: relativePath, + }, + }).vm; +} + +function buildMarkdownComponent(markdownContent, relativePath = '') { + return buildCellComponent( + { + cell_type: 'markdown', + metadata: {}, + source: markdownContent, + }, + relativePath, + ); +} + describe('Markdown component', () => { let vm; let cell; @@ -17,12 +40,7 @@ describe('Markdown component', () => { // eslint-disable-next-line prefer-destructuring cell = json.cells[1]; - vm = new Component({ - propsData: { - cell, - }, - }); - vm.$mount(); + vm = buildCellComponent(cell); return vm.$nextTick(); }); @@ -61,17 +79,36 @@ describe('Markdown component', () => { expect(findLink().getAttribute('data-type')).toBe(null); }); + describe('When parsing images', () => { + it.each([ + [ + 'for relative images in root folder, it does', + '![](local_image.png)\n', + 'src="/raw/local_image', + ], + [ + 'for relative images in child folders, it does', + '![](data/local_image.png)\n', + 'src="/raw/data', + ], + ["for embedded images, it doesn't", '![](data:image/jpeg;base64)\n', 'src="data:'], + ["for images urls, it doesn't", '![](http://image.png)\n', 'src="http:'], + ])('%s', async ([testMd, mustContain]) => { + vm = buildMarkdownComponent([testMd], '/raw/'); + + await vm.$nextTick(); + + expect(vm.$el.innerHTML).toContain(mustContain); + }); + }); + describe('tables', () => { beforeEach(() => { json = getJSONFixture('blob/notebook/markdown-table.json'); }); it('renders images and text', () => { - vm = new Component({ - propsData: { - cell: json.cells[0], - }, - }).$mount(); + vm = buildCellComponent(json.cells[0]); return vm.$nextTick().then(() => { const images = vm.$el.querySelectorAll('img'); @@ -102,48 +139,28 @@ describe('Markdown component', () => { }); it('renders multi-line katex', async () => { - vm = new Component({ - propsData: { - cell: json.cells[0], - }, - }).$mount(); + vm = buildCellComponent(json.cells[0]); await vm.$nextTick(); expect(vm.$el.querySelector('.katex')).not.toBeNull(); }); it('renders inline katex', async () => { - vm = new Component({ - propsData: { - cell: json.cells[1], - }, - }).$mount(); + vm = buildCellComponent(json.cells[1]); await vm.$nextTick(); expect(vm.$el.querySelector('p:first-child .katex')).not.toBeNull(); }); it('renders multiple inline katex', async () => { - vm = new Component({ - propsData: { - cell: json.cells[1], - }, - }).$mount(); + vm = buildCellComponent(json.cells[1]); await vm.$nextTick(); expect(vm.$el.querySelectorAll('p:nth-child(2) .katex')).toHaveLength(4); }); it('output cell in case of katex error', async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ['Some invalid $a & b$ inline formula $b & c$\n', '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent(['Some invalid $a & b$ inline formula $b & c$\n', '\n']); await vm.$nextTick(); // expect one paragraph with no katex formula in it @@ -152,15 +169,10 @@ describe('Markdown component', () => { }); it('output cell and render remaining formula in case of katex error', async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ['An invalid $a & b$ inline formula and a vaild one $b = c$\n', '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent([ + 'An invalid $a & b$ inline formula and a vaild one $b = c$\n', + '\n', + ]); await vm.$nextTick(); // expect one paragraph with no katex formula in it @@ -169,15 +181,7 @@ describe('Markdown component', () => { }); it('renders math formula in list object', async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent(["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n']); await vm.$nextTick(); // expect one list with a katex formula in it @@ -186,15 +190,7 @@ describe('Markdown component', () => { }); it("renders math formula with tick ' in it", async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent(["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n']); await vm.$nextTick(); // expect one list with a katex formula in it @@ -203,15 +199,7 @@ describe('Markdown component', () => { }); it('renders math formula with less-than-operator < in it', async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ['- list with inline $a=2$ inline formula $a + b < c$\n', '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent(['- list with inline $a=2$ inline formula $a + b < c$\n', '\n']); await vm.$nextTick(); // expect one list with a katex formula in it @@ -220,15 +208,7 @@ describe('Markdown component', () => { }); it('renders math formula with greater-than-operator > in it', async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ['- list with inline $a=2$ inline formula $a + b > c$\n', '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent(['- list with inline $a=2$ inline formula $a + b > c$\n', '\n']); await vm.$nextTick(); // expect one list with a katex formula in it diff --git a/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js b/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js index 0b585ab860b..803ac4a219d 100644 --- a/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js +++ b/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js @@ -90,7 +90,8 @@ export default [ ' </g>\n', '</svg>', ].join(), - output: '<svg height="115.02pt" id="svg2"', + output: + '<svg xmlns="http://www.w3.org/2000/svg" width="388.84pt" version="1.0" id="svg2" height="115.02pt">', }, ], ]; diff --git a/spec/frontend/notebook/index_spec.js b/spec/frontend/notebook/index_spec.js index 945af08e4d5..4d0dacaf37e 100644 --- a/spec/frontend/notebook/index_spec.js +++ b/spec/frontend/notebook/index_spec.js @@ -1,3 +1,4 @@ +import { mount } from '@vue/test-utils'; import Vue from 'vue'; import Notebook from '~/notebook/index.vue'; @@ -13,14 +14,16 @@ describe('Notebook component', () => { jsonWithWorksheet = getJSONFixture('blob/notebook/worksheets.json'); }); + function buildComponent(notebook) { + return mount(Component, { + propsData: { notebook, codeCssClass: 'js-code-class' }, + provide: { relativeRawPath: '' }, + }).vm; + } + describe('without JSON', () => { beforeEach((done) => { - vm = new Component({ - propsData: { - notebook: {}, - }, - }); - vm.$mount(); + vm = buildComponent({}); setImmediate(() => { done(); @@ -34,13 +37,7 @@ describe('Notebook component', () => { describe('with JSON', () => { beforeEach((done) => { - vm = new Component({ - propsData: { - notebook: json, - codeCssClass: 'js-code-class', - }, - }); - vm.$mount(); + vm = buildComponent(json); setImmediate(() => { done(); @@ -66,13 +63,7 @@ describe('Notebook component', () => { describe('with worksheets', () => { beforeEach((done) => { - vm = new Component({ - propsData: { - notebook: jsonWithWorksheet, - codeCssClass: 'js-code-class', - }, - }); - vm.$mount(); + vm = buildComponent(jsonWithWorksheet); setImmediate(() => { done(); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index bb79b43205b..c3a51c51de0 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -10,6 +10,7 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import CommentForm from '~/notes/components/comment_form.vue'; +import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue'; import * as constants from '~/notes/constants'; import eventHub from '~/notes/event_hub'; import { COMMENT_FORM } from '~/notes/i18n'; @@ -33,8 +34,8 @@ describe('issue_comment_form component', () => { const findAddToReviewButton = () => wrapper.findByTestId('add-to-review-button'); const findAddCommentNowButton = () => wrapper.findByTestId('add-comment-now-button'); const findConfidentialNoteCheckbox = () => wrapper.findByTestId('confidential-note-checkbox'); - const findCommentGlDropdown = () => wrapper.findByTestId('comment-button'); - const findCommentButton = () => findCommentGlDropdown().find('button'); + const findCommentTypeDropdown = () => wrapper.findComponent(CommentTypeDropdown); + const findCommentButton = () => findCommentTypeDropdown().find('button'); const findErrorAlerts = () => wrapper.findAllComponents(GlAlert).wrappers; async function clickCommentButton({ waitForComponent = true, waitForNetwork = true } = {}) { @@ -381,7 +382,7 @@ describe('issue_comment_form component', () => { it('should render comment button as disabled', () => { mountComponent(); - expect(findCommentGlDropdown().props('disabled')).toBe(true); + expect(findCommentTypeDropdown().props('disabled')).toBe(true); }); it('should enable comment button if it has note', async () => { @@ -389,7 +390,7 @@ describe('issue_comment_form component', () => { await wrapper.setData({ note: 'Foo' }); - expect(findCommentGlDropdown().props('disabled')).toBe(false); + expect(findCommentTypeDropdown().props('disabled')).toBe(false); }); it('should update buttons texts when it has note', () => { @@ -624,7 +625,7 @@ describe('issue_comment_form component', () => { it('when no drafts exist, should not render', () => { mountComponent(); - expect(findCommentGlDropdown().exists()).toBe(true); + expect(findCommentTypeDropdown().exists()).toBe(true); expect(findAddToReviewButton().exists()).toBe(false); expect(findAddCommentNowButton().exists()).toBe(false); }); @@ -637,7 +638,7 @@ describe('issue_comment_form component', () => { it('should render', () => { mountComponent(); - expect(findCommentGlDropdown().exists()).toBe(false); + expect(findCommentTypeDropdown().exists()).toBe(false); expect(findAddToReviewButton().exists()).toBe(true); expect(findAddCommentNowButton().exists()).toBe(true); }); diff --git a/spec/frontend/notes/components/comment_type_dropdown_spec.js b/spec/frontend/notes/components/comment_type_dropdown_spec.js new file mode 100644 index 00000000000..5e1cb813369 --- /dev/null +++ b/spec/frontend/notes/components/comment_type_dropdown_spec.js @@ -0,0 +1,64 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue'; +import * as constants from '~/notes/constants'; +import { COMMENT_FORM } from '~/notes/i18n'; + +describe('CommentTypeDropdown component', () => { + let wrapper; + + const findCommentGlDropdown = () => wrapper.findComponent(GlDropdown); + const findCommentDropdownOption = () => wrapper.findAllComponents(GlDropdownItem).at(0); + const findDiscussionDropdownOption = () => wrapper.findAllComponents(GlDropdownItem).at(1); + + const mountComponent = ({ props = {} } = {}) => { + wrapper = extendedWrapper( + mount(CommentTypeDropdown, { + propsData: { + noteableDisplayName: 'issue', + noteType: constants.COMMENT, + ...props, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Should label action button "Comment" and correct dropdown item checked when selected', () => { + mountComponent({ props: { noteType: constants.COMMENT } }); + + expect(findCommentGlDropdown().props()).toMatchObject({ text: COMMENT_FORM.comment }); + expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: true }); + expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: false }); + }); + + it('Should label action button "Start Thread" and correct dropdown item option checked when selected', () => { + mountComponent({ props: { noteType: constants.DISCUSSION } }); + + expect(findCommentGlDropdown().props()).toMatchObject({ text: COMMENT_FORM.startThread }); + expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: false }); + expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: true }); + }); + + 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'); + + expect(wrapper.emitted('change')[0]).toEqual([constants.COMMENT]); + expect(wrapper.emitted('change').length).toEqual(1); + }); + + it('Should emit `click` event when clicking on the action button', () => { + mountComponent({ props: { noteType: constants.DISCUSSION } }); + + findCommentGlDropdown().vm.$emit('click'); + + expect(wrapper.emitted('click').length > 0).toBe(true); + }); +}); diff --git a/spec/frontend/notes/old_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js index 0cf43b8fd97..34623f8aa13 100644 --- a/spec/frontend/notes/old_notes_spec.js +++ b/spec/frontend/notes/deprecated_notes_spec.js @@ -14,9 +14,8 @@ import * as urlUtility from '~/lib/utils/url_utility'; window.jQuery = $; require('autosize'); require('~/commons'); -require('~/notes'); +const Notes = require('~/deprecated_notes').default; -const { Notes } = window; const FLASH_TYPE_ALERT = 'alert'; const NOTES_POST_PATH = /(.*)\/notes\?html=true$/; const fixture = 'snippets/show.html'; @@ -31,7 +30,7 @@ gl.utils.disableButtonIfEmptyField = () => {}; // the following test is unreliable and failing in main 2-3 times a day // see https://gitlab.com/gitlab-org/gitlab/issues/206906#note_290602581 // eslint-disable-next-line jest/no-disabled-tests -describe.skip('Old Notes (~/notes.js)', () => { +describe.skip('Old Notes (~/deprecated_notes.js)', () => { beforeEach(() => { loadFixtures(fixture); @@ -67,7 +66,7 @@ describe.skip('Old Notes (~/notes.js)', () => { it('calls postComment when comment button is clicked', () => { jest.spyOn(Notes.prototype, 'postComment'); - new window.Notes('', []); + new Notes('', []); $('.js-comment-button').click(); expect(Notes.prototype.postComment).toHaveBeenCalled(); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap index 45d261625b4..451cf743e35 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap @@ -177,15 +177,6 @@ exports[`PackageTitle renders without tags 1`] = ` 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]" - /> - </div> </div> </div> diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js index 0504a42dfcf..7a71a1cea0f 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js @@ -1,10 +1,11 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { conanMetadata, mavenMetadata, nugetMetadata, packageData, + composerMetadata, + pypiMetadata, } from 'jest/packages_and_registries/package_registry/mock_data'; import component from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import { @@ -12,12 +13,15 @@ import { PACKAGE_TYPE_CONAN, PACKAGE_TYPE_MAVEN, PACKAGE_TYPE_NPM, + PACKAGE_TYPE_COMPOSER, + PACKAGE_TYPE_PYPI, } from '~/packages_and_registries/package_registry/constants'; -import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; const mavenPackage = { packageType: PACKAGE_TYPE_MAVEN, metadata: mavenMetadata() }; const conanPackage = { packageType: PACKAGE_TYPE_CONAN, metadata: conanMetadata() }; const nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() }; +const composerPackage = { packageType: PACKAGE_TYPE_COMPOSER, metadata: composerMetadata() }; +const pypiPackage = { packageType: PACKAGE_TYPE_PYPI, metadata: pypiMetadata() }; const npmPackage = { packageType: PACKAGE_TYPE_NPM, metadata: {} }; describe('Package Additional Metadata', () => { @@ -32,8 +36,7 @@ describe('Package Additional Metadata', () => { wrapper = shallowMountExtended(component, { propsData: { ...defaultProps, ...props }, stubs: { - DetailsRow, - GlSprintf, + component: { template: '<div data-testid="component-is"></div>' }, }, }); }; @@ -45,12 +48,7 @@ describe('Package Additional Metadata', () => { const findTitle = () => wrapper.findByTestId('title'); const findMainArea = () => wrapper.findByTestId('main'); - const findNugetSource = () => wrapper.findByTestId('nuget-source'); - const findNugetLicense = () => wrapper.findByTestId('nuget-license'); - const findConanRecipe = () => wrapper.findByTestId('conan-recipe'); - const findMavenApp = () => wrapper.findByTestId('maven-app'); - const findMavenGroup = () => wrapper.findByTestId('maven-group'); - const findElementLink = (container) => container.findComponent(GlLink); + const findComponentIs = () => wrapper.findByTestId('component-is'); it('has the correct title', () => { mountComponent(); @@ -62,11 +60,13 @@ describe('Package Additional Metadata', () => { }); it.each` - packageEntity | visible | packageType - ${mavenPackage} | ${true} | ${PACKAGE_TYPE_MAVEN} - ${conanPackage} | ${true} | ${PACKAGE_TYPE_CONAN} - ${nugetPackage} | ${true} | ${PACKAGE_TYPE_NUGET} - ${npmPackage} | ${false} | ${PACKAGE_TYPE_NPM} + packageEntity | visible | packageType + ${mavenPackage} | ${true} | ${PACKAGE_TYPE_MAVEN} + ${conanPackage} | ${true} | ${PACKAGE_TYPE_CONAN} + ${nugetPackage} | ${true} | ${PACKAGE_TYPE_NUGET} + ${composerPackage} | ${true} | ${PACKAGE_TYPE_COMPOSER} + ${pypiPackage} | ${true} | ${PACKAGE_TYPE_PYPI} + ${npmPackage} | ${false} | ${PACKAGE_TYPE_NPM} `( `It is $visible that the component is visible when the package is $packageType`, ({ packageEntity, visible }) => { @@ -74,57 +74,11 @@ describe('Package Additional Metadata', () => { expect(findTitle().exists()).toBe(visible); expect(findMainArea().exists()).toBe(visible); + expect(findComponentIs().exists()).toBe(visible); + + if (visible) { + expect(findComponentIs().props('packageEntity')).toEqual(packageEntity); + } }, ); - - describe('nuget metadata', () => { - beforeEach(() => { - mountComponent({ packageEntity: nugetPackage }); - }); - - it.each` - name | finderFunction | text | link | icon - ${'source'} | ${findNugetSource} | ${'Source project located at projectUrl'} | ${'projectUrl'} | ${'project'} - ${'license'} | ${findNugetLicense} | ${'License information located at licenseUrl'} | ${'licenseUrl'} | ${'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.metadata[link]); - }); - }); - - describe('conan metadata', () => { - beforeEach(() => { - mountComponent({ packageEntity: conanPackage }); - }); - - it.each` - name | finderFunction | text | icon - ${'recipe'} | ${findConanRecipe} | ${'Recipe: package-8/1.0.0@gitlab-org+gitlab-test/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: appName'} | ${'information-o'} - ${'group'} | ${findMavenGroup} | ${'App group: appGroup'} | ${'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_and_registries/package_registry/components/details/metadata/composer_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js new file mode 100644 index 00000000000..e744680cb9a --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js @@ -0,0 +1,58 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + packageData, + composerMetadata, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import component from '~/packages_and_registries/package_registry/components/details/metadata/composer.vue'; +import { PACKAGE_TYPE_COMPOSER } from '~/packages_and_registries/package_registry/constants'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +const composerPackage = { packageType: PACKAGE_TYPE_COMPOSER, metadata: composerMetadata() }; + +describe('Composer Metadata', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended(component, { + propsData: { packageEntity: packageData(composerPackage) }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findComposerTargetSha = () => wrapper.findByTestId('composer-target-sha'); + const findComposerTargetShaCopyButton = () => wrapper.findComponent(ClipboardButton); + const findComposerJson = () => wrapper.findByTestId('composer-json'); + + beforeEach(() => { + mountComponent(); + }); + + it.each` + name | finderFunction | text | icon + ${'target-sha'} | ${findComposerTargetSha} | ${'Target SHA: b83d6e391c22777fca1ed3012fce84f633d7fed0'} | ${'information-o'} + ${'composer-json'} | ${findComposerJson} | ${'Composer.json with license: MIT and version: 1.0.0'} | ${'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); + }); + + it('target-sha has a copy button', () => { + expect(findComposerTargetShaCopyButton().exists()).toBe(true); + expect(findComposerTargetShaCopyButton().props()).toMatchObject({ + text: 'b83d6e391c22777fca1ed3012fce84f633d7fed0', + title: 'Copy target SHA', + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js new file mode 100644 index 00000000000..46593047f1f --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js @@ -0,0 +1,48 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + conanMetadata, + packageData, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import component from '~/packages_and_registries/package_registry/components/details/metadata/conan.vue'; +import { PACKAGE_TYPE_CONAN } from '~/packages_and_registries/package_registry/constants'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +const conanPackage = { packageType: PACKAGE_TYPE_CONAN, metadata: conanMetadata() }; + +describe('Conan Metadata', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended(component, { + propsData: { + packageEntity: packageData(conanPackage), + }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findConanRecipe = () => wrapper.findByTestId('conan-recipe'); + + beforeEach(() => { + mountComponent(); + }); + + it.each` + name | finderFunction | text | icon + ${'recipe'} | ${findConanRecipe} | ${'Recipe: package-8/1.0.0@gitlab-org+gitlab-test/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); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js new file mode 100644 index 00000000000..bc54cf1cb98 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js @@ -0,0 +1,52 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + mavenMetadata, + packageData, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import component from '~/packages_and_registries/package_registry/components/details/metadata/maven.vue'; +import { PACKAGE_TYPE_MAVEN } from '~/packages_and_registries/package_registry/constants'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +const mavenPackage = { packageType: PACKAGE_TYPE_MAVEN, metadata: mavenMetadata() }; + +describe('Maven Metadata', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended(component, { + propsData: { + packageEntity: { + ...packageData(mavenPackage), + }, + }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findMavenApp = () => wrapper.findByTestId('maven-app'); + const findMavenGroup = () => wrapper.findByTestId('maven-group'); + + beforeEach(() => { + mountComponent(); + }); + + it.each` + name | finderFunction | text | icon + ${'app'} | ${findMavenApp} | ${'App name: appName'} | ${'information-o'} + ${'group'} | ${findMavenGroup} | ${'App group: appGroup'} | ${'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_and_registries/package_registry/components/details/metadata/nuget_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js new file mode 100644 index 00000000000..279900edff2 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js @@ -0,0 +1,55 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + nugetMetadata, + packageData, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import component from '~/packages_and_registries/package_registry/components/details/metadata/nuget.vue'; +import { PACKAGE_TYPE_NUGET } from '~/packages_and_registries/package_registry/constants'; + +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +const nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() }; + +describe('Nuget Metadata', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended(component, { + propsData: { + packageEntity: { + ...packageData(nugetPackage), + }, + }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findNugetSource = () => wrapper.findByTestId('nuget-source'); + const findNugetLicense = () => wrapper.findByTestId('nuget-license'); + const findElementLink = (container) => container.findComponent(GlLink); + + beforeEach(() => { + mountComponent({ packageEntity: nugetPackage }); + }); + + it.each` + name | finderFunction | text | link | icon + ${'source'} | ${findNugetSource} | ${'Source project located at projectUrl'} | ${'projectUrl'} | ${'project'} + ${'license'} | ${findNugetLicense} | ${'License information located at licenseUrl'} | ${'licenseUrl'} | ${'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.metadata[link]); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js new file mode 100644 index 00000000000..c4481c3f20b --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js @@ -0,0 +1,48 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { packageData, pypiMetadata } from 'jest/packages_and_registries/package_registry/mock_data'; +import component from '~/packages_and_registries/package_registry/components/details/metadata/pypi.vue'; +import { PACKAGE_TYPE_PYPI } from '~/packages_and_registries/package_registry/constants'; + +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +const pypiPackage = { packageType: PACKAGE_TYPE_PYPI, metadata: pypiMetadata() }; + +describe('Package Additional Metadata', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended(component, { + propsData: { + packageEntity: { + ...packageData(pypiPackage), + }, + }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findPypiRequiredPython = () => wrapper.findByTestId('pypi-required-python'); + + beforeEach(() => { + mountComponent(); + }); + + it.each` + name | finderFunction | text | icon + ${'pypi-required-python'} | ${findPypiRequiredPython} | ${'Required Python: 1.0.0'} | ${'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_and_registries/package_registry/components/details/package_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js index 327f6d81905..d59c3184e4e 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js @@ -1,5 +1,6 @@ import { GlIcon, GlSprintf } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PackageTags from '~/packages/shared/components/package_tags.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; @@ -30,6 +31,9 @@ describe('PackageTitle', () => { TitleArea, GlSprintf, }, + directives: { + GlResizeObserver: createMockDirective(), + }, }); return wrapper.vm.$nextTick(); } @@ -51,7 +55,7 @@ describe('PackageTitle', () => { describe('renders', () => { it('without tags', async () => { - await createComponent(); + await createComponent({ ...packageData(), packageFiles: { nodes: packageFiles() } }); expect(wrapper.element).toMatchSnapshot(); }); @@ -64,12 +68,26 @@ describe('PackageTitle', () => { it('with tags on mobile', async () => { jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false); + await createComponent(); await wrapper.vm.$nextTick(); expect(findPackageBadges()).toHaveLength(packageTags().length); }); + + it('when the page is resized', async () => { + await createComponent(); + + expect(findPackageBadges()).toHaveLength(0); + + jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false); + const { value } = getBinding(wrapper.element, 'gl-resize-observer'); + value(); + + await wrapper.vm.$nextTick(); + expect(findPackageBadges()).toHaveLength(packageTags().length); + }); }); describe('package title', () => { 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 new file mode 100644 index 00000000000..dbebdeeb452 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap @@ -0,0 +1,68 @@ +// 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/packages_list_app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js new file mode 100644 index 00000000000..6c871a34d50 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js @@ -0,0 +1,273 @@ +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_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js new file mode 100644 index 00000000000..b624e66482d --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js @@ -0,0 +1,217 @@ +import { GlTable, GlPagination, GlModal } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { last } from 'lodash'; +import Vuex from 'vuex'; +import stubChildren from 'helpers/stub_children'; +import { packageList } from 'jest/packages/mock_data'; +import PackagesListRow 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 PackagesList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; +import Tracking from '~/tracking'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_list', () => { + let wrapper; + let store; + + const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' }; + + const findPackagesListLoader = () => wrapper.find(PackagesListLoader); + const findPackageListPagination = () => wrapper.find(GlPagination); + const findPackageListDeleteModal = () => wrapper.find(GlModal); + const findEmptySlot = () => wrapper.find(EmptySlotStub); + const findPackagesListRow = () => wrapper.find(PackagesListRow); + + const createStore = (isGroupPage, packages, isLoading) => { + const state = { + isLoading, + packages, + pagination: { + perPage: 1, + total: 1, + page: 1, + }, + config: { + isGroupPage, + }, + sorting: { + orderBy: 'version', + sort: 'desc', + }, + }; + store = new Vuex.Store({ + state, + getters: { + getList: () => packages, + }, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = ({ + isGroupPage = false, + packages = packageList, + isLoading = false, + ...options + } = {}) => { + createStore(isGroupPage, packages, isLoading); + + wrapper = mount(PackagesList, { + localVue, + store, + stubs: { + ...stubChildren(PackagesList), + GlTable, + GlModal, + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when is loading', () => { + beforeEach(() => { + mountComponent({ + packages: [], + isLoading: true, + }); + }); + + it('shows skeleton loader when loading', () => { + expect(findPackagesListLoader().exists()).toBe(true); + }); + }); + + describe('when is not loading', () => { + beforeEach(() => { + mountComponent(); + }); + + it('does not show skeleton loader when not loading', () => { + expect(findPackagesListLoader().exists()).toBe(false); + }); + }); + + describe('layout', () => { + beforeEach(() => { + mountComponent(); + }); + + it('contains a pagination component', () => { + const sorting = findPackageListPagination(); + expect(sorting.exists()).toBe(true); + }); + + it('contains a modal component', () => { + const sorting = findPackageListDeleteModal(); + expect(sorting.exists()).toBe(true); + }); + }); + + describe('when the user can destroy the package', () => { + beforeEach(() => { + mountComponent(); + }); + + it('setItemToBeDeleted sets itemToBeDeleted and open the modal', () => { + const mockModalShow = jest.spyOn(wrapper.vm.$refs.packageListDeleteModal, 'show'); + const item = last(wrapper.vm.list); + + findPackagesListRow().vm.$emit('packageToDelete', item); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.itemToBeDeleted).toEqual(item); + expect(mockModalShow).toHaveBeenCalled(); + }); + }); + + it('deleteItemConfirmation resets itemToBeDeleted', () => { + wrapper.setData({ itemToBeDeleted: 1 }); + wrapper.vm.deleteItemConfirmation(); + expect(wrapper.vm.itemToBeDeleted).toEqual(null); + }); + + it('deleteItemConfirmation emit package:delete', () => { + const itemToBeDeleted = { id: 2 }; + wrapper.setData({ itemToBeDeleted }); + wrapper.vm.deleteItemConfirmation(); + return wrapper.vm.$nextTick(() => { + expect(wrapper.emitted('package:delete')[0]).toEqual([itemToBeDeleted]); + }); + }); + + it('deleteItemCanceled resets itemToBeDeleted', () => { + wrapper.setData({ itemToBeDeleted: 1 }); + wrapper.vm.deleteItemCanceled(); + expect(wrapper.vm.itemToBeDeleted).toEqual(null); + }); + }); + + describe('when the list is empty', () => { + beforeEach(() => { + mountComponent({ + packages: [], + slots: { + 'empty-state': EmptySlotStub, + }, + }); + }); + + it('show the empty slot', () => { + const emptySlot = findEmptySlot(); + expect(emptySlot.exists()).toBe(true); + }); + }); + + describe('pagination component', () => { + let pagination; + let modelEvent; + + beforeEach(() => { + mountComponent(); + pagination = findPackageListPagination(); + // retrieve the event used by v-model, a more sturdy approach than hardcoding it + modelEvent = pagination.vm.$options.model.event; + }); + + it('emits page:changed events when the page changes', () => { + pagination.vm.$emit(modelEvent, 2); + expect(wrapper.emitted('page:changed')).toEqual([[2]]); + }); + }); + + describe('tracking', () => { + let eventSpy; + let utilSpy; + const category = 'foo'; + + beforeEach(() => { + mountComponent(); + eventSpy = jest.spyOn(Tracking, 'event'); + utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); + wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } }); + }); + + it('tracking category calls packageTypeToTrackCategory', () => { + expect(wrapper.vm.tracking.category).toBe(category); + expect(utilSpy).toHaveBeenCalledWith('conan'); + }); + + it('deleteItemConfirmation calls event', () => { + wrapper.vm.deleteItemConfirmation(); + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.DELETE_PACKAGE, + expect.any(Object), + ); + }); + }); +}); 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 new file mode 100644 index 00000000000..42bc9fa3a9e --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -0,0 +1,128 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +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'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Package Search', () => { + let wrapper; + let store; + + 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, + stubs: { + UrlSync, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('has a registry search component', () => { + mountComponent(); + + 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.each` + isGroupPage | page + ${false} | ${'project'} + ${true} | ${'group'} + `('in a $page page binds the right props', ({ isGroupPage }) => { + mountComponent(isGroupPage); + + 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(isGroupPage), + }); + }); + + it('on sorting:changed emits update event and calls vuex setSorting', () => { + const payload = { sort: 'foo' }; + + mountComponent(); + + findRegistrySearch().vm.$emit('sorting:changed', payload); + + expect(store.dispatch).toHaveBeenCalledWith('setSorting', payload); + expect(wrapper.emitted('update')).toEqual([[]]); + }); + + it('on filter:changed calls vuex setFilter', () => { + const payload = ['foo']; + + mountComponent(); + + findRegistrySearch().vm.$emit('filter:changed', payload); + + expect(store.dispatch).toHaveBeenCalledWith('setFilter', payload); + }); + + it('on filter:submit emits update event', () => { + mountComponent(); + + findRegistrySearch().vm.$emit('filter:submit'); + + expect(wrapper.emitted('update')).toEqual([[]]); + }); + + it('has a UrlSync component', () => { + mountComponent(); + + expect(findUrlSync().exists()).toBe(true); + }); + + it('on query:changed calls updateQuery from UrlSync', () => { + jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {}); + + mountComponent(); + + findRegistrySearch().vm.$emit('query:changed'); + + expect(UrlSync.methods.updateQuery).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js new file mode 100644 index 00000000000..3fa96ce1d29 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js @@ -0,0 +1,71 @@ +import { shallowMount } from '@vue/test-utils'; +import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list/constants'; +import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +describe('PackageTitle', () => { + let wrapper; + let store; + + const findTitleArea = () => wrapper.find(TitleArea); + const findMetadataItem = () => wrapper.find(MetadataItem); + + const mountComponent = (propsData = { helpUrl: 'foo' }) => { + wrapper = shallowMount(PackageTitle, { + store, + propsData, + stubs: { + TitleArea, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('title area', () => { + it('exists', () => { + mountComponent(); + + expect(findTitleArea().exists()).toBe(true); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findTitleArea().props()).toMatchObject({ + title: LIST_TITLE_TEXT, + infoMessages: [{ text: LIST_INTRO_TEXT, link: 'foo' }], + }); + }); + }); + + describe.each` + count | exist | text + ${null} | ${false} | ${''} + ${undefined} | ${false} | ${''} + ${0} | ${true} | ${'0 Packages'} + ${1} | ${true} | ${'1 Package'} + ${2} | ${true} | ${'2 Packages'} + `('when count is $count metadata item', ({ count, exist, text }) => { + beforeEach(() => { + mountComponent({ count, helpUrl: 'foo' }); + }); + + it(`is ${exist} that it exists`, () => { + expect(findMetadataItem().exists()).toBe(exist); + }); + + if (exist) { + it('has the correct props', () => { + expect(findMetadataItem().props()).toMatchObject({ + icon: 'package', + text, + }); + }); + } + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js new file mode 100644 index 00000000000..b0cbe34f0b9 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js @@ -0,0 +1,48 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages/list/components/tokens/package_type_token.vue'; +import { PACKAGE_TYPES } from '~/packages/list/constants'; + +describe('packages_filter', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); + const findFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); + + const mountComponent = ({ attrs, listeners } = {}) => { + wrapper = shallowMount(component, { + attrs, + listeners, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('it binds all of his attrs to filtered search token', () => { + mountComponent({ attrs: { foo: 'bar' } }); + + expect(findFilteredSearchToken().attributes('foo')).toBe('bar'); + }); + + it('it binds all of his events to filtered search token', () => { + const clickListener = jest.fn(); + mountComponent({ listeners: { click: clickListener } }); + + findFilteredSearchToken().vm.$emit('click'); + + expect(clickListener).toHaveBeenCalled(); + }); + + it.each(PACKAGE_TYPES.map((p, index) => [p, index]))( + 'displays a suggestion for %p', + (packageType, index) => { + mountComponent(); + const item = findFilteredSearchSuggestions().at(index); + expect(item.text()).toBe(packageType.title); + expect(item.props('value')).toBe(packageType.type); + }, + ); +}); 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 98ff29ef728..9438a2d2d72 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -133,7 +133,7 @@ export const composerMetadata = () => ({ }, }); -export const pypyMetadata = () => ({ +export const pypiMetadata = () => ({ requiredPython: '1.0.0', }); @@ -157,7 +157,7 @@ export const packageDetailsQuery = (extendPackage) => ({ metadata: { ...conanMetadata(), ...composerMetadata(), - ...pypyMetadata(), + ...pypiMetadata(), ...mavenMetadata(), ...nugetMetadata(), }, diff --git a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js index 63c1260560b..f84800d8266 100644 --- a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js +++ b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js @@ -65,7 +65,7 @@ describe('CustomizeHomepageBanner', () => { await wrapper.vm.$nextTick(); const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`); - expect(button.attributes('data-track-event')).toEqual(preferencesTrackingEvent); + expect(button.attributes('data-track-action')).toEqual(preferencesTrackingEvent); expect(button.attributes('data-track-label')).toEqual(provide.trackLabel); }); 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 4ba9120d196..417567c9f4c 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 @@ -11,8 +11,12 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] <gl-dropdown-stub category="primary" + clearalltext="Clear all" headertext="" hideheaderborder="true" + highlighteditemstitle="Selected" + highlighteditemstitleclass="gl-px-5" + showhighlighteditemstitle="true" size="medium" text="rspec" variant="default" diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap deleted file mode 100644 index 091edc7505c..00000000000 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap +++ /dev/null @@ -1,604 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Learn GitLab Design B renders correctly 1`] = ` -<div> - <div - class="row" - > - <div - class="gl-mb-7 col-md-8 col-lg-7" - > - <h1 - class="gl-font-size-h1" - > - Learn GitLab - </h1> - - <p - class="gl-text-gray-700 gl-mb-0" - > - Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project. - </p> - </div> - </div> - - <div - class="gl-mb-3" - > - <p - class="gl-text-gray-500 gl-mb-2" - data-testid="completion-percentage" - > - 22% completed - </p> - - <div - class="progress" - max="9" - value="2" - > - <div - aria-valuemax="9" - aria-valuemin="0" - aria-valuenow="2" - class="progress-bar" - role="progressbar" - style="width: 22.22222222222222%;" - /> - </div> - </div> - - <h2 - class="gl-font-lg gl-mb-3" - > - Set up your workspace - </h2> - - <p - class="gl-text-gray-700 gl-mb-6" - > - Complete these tasks first so you can enjoy GitLab's features to their fullest: - </p> - - <div - class="row row-cols-2 row-cols-md-3 row-cols-lg-4" - > - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <svg - aria-hidden="true" - class="gl-text-green-500 gl-icon s16" - data-testid="completed-icon" - role="img" - > - <use - href="#check-circle-filled" - /> - </svg> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Invite your colleagues" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Invite your colleagues - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - GitLab works best as a team. Invite your colleague to enjoy all features. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Invite your colleagues" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Invite your colleagues - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <svg - aria-hidden="true" - class="gl-text-green-500 gl-icon s16" - data-testid="completed-icon" - role="img" - > - <use - href="#check-circle-filled" - /> - </svg> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Create or import a repository" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Create or import a repository - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Create or import your first repository into your new project. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Create or import a repository" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Create or import a repository - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <!----> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Set-up CI/CD" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Set up CI/CD - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Save time by automating your integration and deployment tasks. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Set-up CI/CD" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Set-up CI/CD - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <!----> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Try GitLab Ultimate for free" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Start a free Ultimate trial - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Try all GitLab features for 30 days, no credit card required. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Try GitLab Ultimate for free" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Try GitLab Ultimate for free - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <span - class="gl-text-gray-500 gl-font-sm gl-font-style-italic" - data-testid="trial-only" - > - Trial only - </span> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Add code owners" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Add code owners - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Prevent unexpected changes to important assets by assigning ownership of files and paths. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Add code owners" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Add code owners - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <span - class="gl-text-gray-500 gl-font-sm gl-font-style-italic" - data-testid="trial-only" - > - Trial only - </span> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Enable require merge approvals" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Add merge request approval - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Route code reviews to the right reviewers, every time. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Enable require merge approvals" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Enable require merge approvals - </a> - </div> - </div> - - <!----> - </div> - </div> - </div> - - <h2 - class="gl-font-lg gl-mb-3" - > - Plan and execute - </h2> - - <p - class="gl-text-gray-700 gl-mb-6" - > - Create a workflow for your new workspace, and learn how GitLab features work together: - </p> - - <div - class="row row-cols-2 row-cols-md-3 row-cols-lg-4" - > - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <!----> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Create an issue" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Create an issue - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Create/import issues (tickets) to collaborate on ideas and plan work. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Create an issue" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Create an issue - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <!----> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Submit a merge request (MR)" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Submit a merge request - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Review and edit proposed changes to source code. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Submit a merge request (MR)" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Submit a merge request (MR) - </a> - </div> - </div> - - <!----> - </div> - </div> - </div> - - <h2 - class="gl-font-lg gl-mb-3" - > - Deploy - </h2> - - <p - class="gl-text-gray-700 gl-mb-6" - > - Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure: - </p> - - <div - class="row row-cols-2 row-cols-lg-4 g-2 g-lg-3" - > - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <!----> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Run a Security scan using CI/CD" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Run a Security scan using CI/CD - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Scan your code to uncover vulnerabilities before deploying. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Run a Security scan using CI/CD" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Run a Security scan using CI/CD - </a> - </div> - </div> - - <!----> - </div> - </div> - </div> -</div> -`; diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap index 59b42de2485..3aa0e99a858 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Learn GitLab Design A renders correctly 1`] = ` +exports[`Learn GitLab renders correctly 1`] = ` <div> <div class="row" @@ -136,7 +136,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Set up CI/CD" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -157,7 +157,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Start a free Ultimate trial" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -178,7 +178,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Add code owners" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -206,7 +206,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Add merge request approval" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -270,7 +270,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Create an issue" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -291,7 +291,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Submit a merge request" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -348,7 +348,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Run a Security scan using CI/CD" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js deleted file mode 100644 index 207944bfa1f..00000000000 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import { GlProgressBar } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import LearnGitlabB from '~/pages/projects/learn_gitlab/components/learn_gitlab_b.vue'; -import { testActions } from './mock_data'; - -describe('Learn GitLab Design B', () => { - let wrapper; - - const createWrapper = () => { - wrapper = mount(LearnGitlabB, { propsData: { actions: testActions } }); - }; - - beforeEach(() => { - createWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('renders correctly', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders the progress percentage', () => { - const text = wrapper.find('[data-testid="completion-percentage"]').text(); - - expect(text).toBe('22% completed'); - }); - - it('renders the progress bar with correct values', () => { - const progressBar = wrapper.findComponent(GlProgressBar); - - expect(progressBar.attributes('value')).toBe('2'); - expect(progressBar.attributes('max')).toBe('9'); - }); -}); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js index ac997c1f237..f8099d7e95a 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js @@ -1,13 +1,13 @@ import { GlProgressBar } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue'; +import LearnGitlab from '~/pages/projects/learn_gitlab/components/learn_gitlab.vue'; import { testActions, testSections } from './mock_data'; -describe('Learn GitLab Design A', () => { +describe('Learn GitLab', () => { let wrapper; const createWrapper = () => { - wrapper = mount(LearnGitlabA, { propsData: { actions: testActions, sections: testSections } }); + wrapper = mount(LearnGitlab, { propsData: { actions: testActions, sections: testSections } }); }; beforeEach(() => { 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 new file mode 100644 index 00000000000..8a7f9229503 --- /dev/null +++ b/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js @@ -0,0 +1,122 @@ +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/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js index de0d70a07d7..f3d76ca2c1b 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -42,11 +42,6 @@ describe('Interval Pattern Input Component', () => { wrapper = mount(IntervalPatternInput, { propsData: { ...props }, - provide: { - glFeatures: { - ciDailyLimitForPipelineSchedules: true, - }, - }, data() { return { randomHour: data?.hour || mockHour, diff --git a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js index 6aa725fbd7d..601fcfedbe0 100644 --- a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js +++ b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js @@ -21,7 +21,7 @@ describe('SigninTabsMemoizer', () => { beforeEach(() => { loadFixtures(fixtureTemplate); - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); }); it('does nothing if no tab was previously selected', () => { @@ -90,7 +90,7 @@ describe('SigninTabsMemoizer', () => { }); it('should set .isLocalStorageAvailable', () => { - expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled(); expect(memo.isLocalStorageAvailable).toBe(true); }); }); diff --git a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js index 39081e07e52..2f934898ef1 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js @@ -1,5 +1,6 @@ import { GlFormTextarea, GlFormInput, GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; import { objectToQuery, redirectTo } from '~/lib/utils/url_utility'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; @@ -48,7 +49,10 @@ describe('Pipeline Editor | Commit section', () => { let wrapper; let mockMutate; - const defaultProps = { ciFileContent: mockCiYml }; + const defaultProps = { + ciFileContent: mockCiYml, + commitSha: mockCommitSha, + }; const createComponent = ({ props = {}, options = {}, provide = {} } = {}) => { mockMutate = jest.fn().mockResolvedValue({ @@ -67,7 +71,6 @@ describe('Pipeline Editor | Commit section', () => { provide: { ...mockProvide, ...provide }, data() { return { - commitSha: mockCommitSha, currentBranch: mockDefaultBranch, isNewCiConfigFile: Boolean(options?.isNewCiConfigfile), }; @@ -97,8 +100,7 @@ describe('Pipeline Editor | Commit section', () => { await findCommitForm().find('[data-testid="new-mr-checkbox"]').setChecked(openMergeRequest); } await findCommitForm().find('[type="submit"]').trigger('click'); - // Simulate the write to local cache that occurs after a commit - await wrapper.setData({ commitSha: mockCommitNextSha }); + await waitForPromises(); }; const cancelCommitForm = async () => { @@ -175,6 +177,10 @@ describe('Pipeline Editor | Commit section', () => { expect(wrapper.emitted('commit')[0]).toEqual([{ type: COMMIT_SUCCESS }]); }); + it('emits an event to refetch the commit sha', () => { + expect(wrapper.emitted('updateCommitSha')).toHaveLength(1); + }); + it('shows no saving state', () => { expect(findCommitBtnLoadingIcon().exists()).toBe(false); }); @@ -188,7 +194,6 @@ describe('Pipeline Editor | Commit section', () => { update: expect.any(Function), variables: { ...mockVariables, - lastCommitId: mockCommitNextSha, branch: mockDefaultBranch, }, }); @@ -215,6 +220,10 @@ describe('Pipeline Editor | Commit section', () => { }, }); }); + + it('does not emit an event to refetch the commit sha', () => { + expect(wrapper.emitted('updateCommitSha')).toBeUndefined(); + }); }); describe('when the user commits changes to open a new merge request', () => { 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 c6c7f593cc5..85222f2ecbb 100644 --- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js @@ -42,15 +42,12 @@ describe('Pipeline Editor | Text editor component', () => { defaultBranch: mockDefaultBranch, glFeatures, }, + propsData: { + commitSha: mockCommitSha, + }, attrs: { value: mockCiYml, }, - // Simulate graphQL client query result - data() { - return { - commitSha: mockCommitSha, - }; - }, listeners: { [EDITOR_READY_EVENT]: editorReadyListener, }, diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js index 85b51d08f88..b5881790b0b 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -247,15 +247,6 @@ describe('Pipeline editor branch switcher', () => { expect(wrapper.emitted('refetchContent')).toBeUndefined(); }); - - it('emits the updateCommitSha event when selecting a different branch', async () => { - expect(wrapper.emitted('updateCommitSha')).toBeUndefined(); - - const branch = findDropdownItems().at(1); - branch.vm.$emit('click'); - - expect(wrapper.emitted('updateCommitSha')).toHaveLength(1); - }); }); describe('when searching', () => { diff --git a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js index 94a0a7d14ee..e24de832d6d 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js @@ -4,16 +4,10 @@ import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipelin describe('Pipeline editor file nav', () => { let wrapper; - const mockProvide = { - glFeatures: { - pipelineEditorBranchSwitcher: true, - }, - }; const createComponent = ({ provide = {} } = {}) => { wrapper = shallowMount(PipelineEditorFileNav, { provide: { - ...mockProvide, ...provide, }, }); @@ -34,16 +28,4 @@ describe('Pipeline editor file nav', () => { expect(findBranchSwitcher().exists()).toBe(true); }); }); - - describe('with branch switcher feature flag OFF', () => { - it('does not render the branch switcher', () => { - createComponent({ - provide: { - glFeatures: { pipelineEditorBranchSwitcher: false }, - }, - }); - - expect(findBranchSwitcher().exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js index a95921359cc..753682d438b 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js @@ -27,13 +27,11 @@ describe('Pipeline Status', () => { wrapper = shallowMount(PipelineStatus, { localVue, apolloProvider: mockApollo, + propsData: { + commitSha: mockCommitSha, + }, provide: mockProvide, stubs: { GlLink, GlSprintf }, - data() { - return { - commitSha: mockCommitSha, - }; - }, }); }; 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 76c68e21180..b019bae886c 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 @@ -7,7 +7,6 @@ describe('Pipeline editor empty state', () => { let wrapper; const defaultProvide = { glFeatures: { - pipelineEditorBranchSwitcher: true, pipelineEditorEmptyStateAction: false, }, emptyStateIllustrationPath: 'my/svg/path', @@ -82,17 +81,5 @@ describe('Pipeline editor empty state', () => { await findConfirmButton().vm.$emit('click'); expect(wrapper.emitted(expectedEvent)).toHaveLength(1); }); - - describe('with branch switcher feature flag OFF', () => { - it('does not render the file nav', () => { - createComponent({ - provide: { - glFeatures: { pipelineEditorBranchSwitcher: false }, - }, - }); - - expect(findFileNav().exists()).toBe(false); - }); - }); }); }); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 4d4a8c21d78..f2104f25324 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -156,30 +156,43 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => { }; }; -export const mockNewCommitShaResults = { +export const mockCommitShaResults = { data: { project: { - pipelines: { - nodes: [ - { - id: 'gid://gitlab/Ci::Pipeline/1', - sha: 'd0d56d363d8a3f67a8ab9fc00207d468f30032ca', - path: `/${mockProjectFullPath}/-/pipelines/488`, - commitPath: `/${mockProjectFullPath}/-/commit/d0d56d363d8a3f67a8ab9fc00207d468f30032ca`, + repository: { + tree: { + lastCommit: { + sha: mockCommitSha, }, - { - id: 'gid://gitlab/Ci::Pipeline/2', - sha: 'fcab2ece40b26f428dfa3aa288b12c3c5bdb06aa', - path: `/${mockProjectFullPath}/-/pipelines/487`, - commitPath: `/${mockProjectFullPath}/-/commit/fcab2ece40b26f428dfa3aa288b12c3c5bdb06aa`, + }, + }, + }, + }, +}; + +export const mockNewCommitShaResults = { + data: { + project: { + repository: { + tree: { + lastCommit: { + sha: 'eeff1122', }, - { - id: 'gid://gitlab/Ci::Pipeline/3', - sha: '6c16b17c7f94a438ae19a96c285bb49e3c632cf4', - path: `/${mockProjectFullPath}/-/pipelines/433`, - commitPath: `/${mockProjectFullPath}/-/commit/6c16b17c7f94a438ae19a96c285bb49e3c632cf4`, + }, + }, + }, + }, +}; + +export const mockEmptyCommitShaResults = { + data: { + project: { + repository: { + tree: { + lastCommit: { + sha: '', }, - ], + }, }, }, }, diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 0c5c08d7190..393cad0546b 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -26,9 +26,11 @@ import { mockBlobContentQueryResponseNoCiFile, mockCiYml, mockCommitSha, + mockCommitShaResults, mockDefaultBranch, - mockProjectFullPath, + mockEmptyCommitShaResults, mockNewCommitShaResults, + mockProjectFullPath, } from './mock_data'; const localVue = createLocalVue(); @@ -54,7 +56,6 @@ describe('Pipeline editor app component', () => { let mockBlobContentData; let mockCiConfigData; let mockGetTemplate; - let mockUpdateCommitSha; let mockLatestCommitShaQuery; let mockPipelineQuery; @@ -71,6 +72,11 @@ describe('Pipeline editor app component', () => { SourceEditor: MockSourceEditor, PipelineEditorEmptyState, }, + data() { + return { + commitSha: '', + }; + }, mocks: { $apollo: { queries: { @@ -96,18 +102,7 @@ describe('Pipeline editor app component', () => { [getPipelineQuery, mockPipelineQuery], ]; - const resolvers = { - Query: { - commitSha() { - return mockCommitSha; - }, - }, - Mutation: { - updateCommitSha: mockUpdateCommitSha, - }, - }; - - mockApollo = createMockApollo(handlers, resolvers); + mockApollo = createMockApollo(handlers); const options = { localVue, @@ -137,7 +132,6 @@ describe('Pipeline editor app component', () => { mockBlobContentData = jest.fn(); mockCiConfigData = jest.fn(); mockGetTemplate = jest.fn(); - mockUpdateCommitSha = jest.fn(); mockLatestCommitShaQuery = jest.fn(); mockPipelineQuery = jest.fn(); }); @@ -159,11 +153,16 @@ describe('Pipeline editor app component', () => { beforeEach(() => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse); + mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); }); describe('when file exists', () => { beforeEach(async () => { await createComponentWithApollo(); + + jest + .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') + .mockImplementation(jest.fn()); }); it('shows pipeline editor home component', () => { @@ -181,18 +180,32 @@ describe('Pipeline editor app component', () => { sha: mockCommitSha, }); }); + + it('does not poll for the commit sha', () => { + expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0); + }); }); describe('when no CI config file exists', () => { - it('shows an empty state and does not show editor home component', async () => { + beforeEach(async () => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile); await createComponentWithApollo(); + jest + .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') + .mockImplementation(jest.fn()); + }); + + it('shows an empty state and does not show editor home component', async () => { expect(findEmptyState().exists()).toBe(true); expect(findAlert().exists()).toBe(false); expect(findEditorHome().exists()).toBe(false); }); + it('does not poll for the commit sha', () => { + expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0); + }); + describe('because of a fetching error', () => { it('shows a unkown error message', async () => { const loadUnknownFailureText = 'The CI configuration was not loaded, please try again.'; @@ -230,6 +243,7 @@ describe('Pipeline editor app component', () => { describe('when landing on the empty state with feature flag on', () => { it('user can click on CTA button and see an empty editor', async () => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile); + mockLatestCommitShaQuery.mockResolvedValue(mockEmptyCommitShaResults); await createComponentWithApollo({ provide: { @@ -254,9 +268,9 @@ describe('Pipeline editor app component', () => { const updateSuccessMessage = 'Your changes have been successfully committed.'; describe('and the commit mutation succeeds', () => { - beforeEach(() => { + beforeEach(async () => { window.scrollTo = jest.fn(); - createComponent(); + await createComponentWithApollo(); findEditorHome().vm.$emit('commit', { type: COMMIT_SUCCESS }); }); @@ -268,7 +282,43 @@ describe('Pipeline editor app component', () => { it('scrolls to the top of the page to bring attention to the confirmation message', () => { expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); }); + + it('polls for commit sha while pipeline data is not yet available for current branch', async () => { + jest + .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') + .mockImplementation(jest.fn()); + + // simulate a commit to the current branch + findEditorHome().vm.$emit('updateCommitSha'); + await waitForPromises(); + + expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(1); + }); + + it('stops polling for commit sha when pipeline data is available for newly committed branch', async () => { + jest + .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling') + .mockImplementation(jest.fn()); + + mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); + await wrapper.vm.$apollo.queries.commitSha.refetch(); + + expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1); + }); + + it('stops polling for commit sha when pipeline data is available for current branch', async () => { + jest + .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling') + .mockImplementation(jest.fn()); + + mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults); + findEditorHome().vm.$emit('updateCommitSha'); + await waitForPromises(); + + expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1); + }); }); + describe('and the commit mutation fails', () => { const commitFailedReasons = ['Commit failed']; @@ -320,6 +370,10 @@ describe('Pipeline editor app component', () => { }); describe('when refetching content', () => { + beforeEach(() => { + mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); + }); + it('refetches blob content', async () => { await createComponentWithApollo(); jest @@ -352,6 +406,7 @@ describe('Pipeline editor app component', () => { const originalLocation = window.location.href; beforeEach(() => { + mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); setWindowLocation('?template=Android'); }); @@ -371,45 +426,4 @@ describe('Pipeline editor app component', () => { expect(findTextEditor().exists()).toBe(true); }); }); - - describe('when updating commit sha', () => { - const newCommitSha = mockNewCommitShaResults.data.project.pipelines.nodes[0].sha; - - beforeEach(async () => { - mockUpdateCommitSha.mockResolvedValue(newCommitSha); - mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults); - await createComponentWithApollo(); - }); - - it('fetches updated commit sha for the new branch', async () => { - expect(mockLatestCommitShaQuery).not.toHaveBeenCalled(); - - wrapper - .findComponent(PipelineEditorHome) - .vm.$emit('updateCommitSha', { newBranch: 'new-branch' }); - await waitForPromises(); - - expect(mockLatestCommitShaQuery).toHaveBeenCalledWith({ - projectPath: mockProjectFullPath, - ref: 'new-branch', - }); - }); - - it('updates commit sha with the newly fetched commit sha', async () => { - expect(mockUpdateCommitSha).not.toHaveBeenCalled(); - - wrapper - .findComponent(PipelineEditorHome) - .vm.$emit('updateCommitSha', { newBranch: 'new-branch' }); - await waitForPromises(); - - expect(mockUpdateCommitSha).toHaveBeenCalled(); - expect(mockUpdateCommitSha).toHaveBeenCalledWith( - expect.any(Object), - { commitSha: mockNewCommitShaResults.data.project.pipelines.nodes[0].sha }, - expect.any(Object), - expect.any(Object), - ); - }); - }); }); diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index 2a3f4f56f36..9e2bf1bd367 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -45,6 +45,7 @@ describe('Pipeline New Form', () => { const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf); const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findCCAlert = () => wrapper.findComponent(CreditCardValidationRequiredAlert); const getFormPostParams = () => JSON.parse(mock.history.post[0].data); const selectBranch = (branch) => { @@ -387,7 +388,7 @@ describe('Pipeline New Form', () => { }); it('does not show the credit card validation required alert', () => { - expect(wrapper.findComponent(CreditCardValidationRequiredAlert).exists()).toBe(false); + expect(findCCAlert().exists()).toBe(false); }); describe('when the error response is credit card validation required', () => { @@ -408,7 +409,19 @@ describe('Pipeline New Form', () => { it('shows credit card validation required alert', () => { expect(findErrorAlert().exists()).toBe(false); - expect(wrapper.findComponent(CreditCardValidationRequiredAlert).exists()).toBe(true); + expect(findCCAlert().exists()).toBe(true); + }); + + it('clears error and hides the alert on dismiss', async () => { + expect(findCCAlert().exists()).toBe(true); + expect(wrapper.vm.$data.error).toBe(mockCreditCardValidationRequiredError.errors[0]); + + findCCAlert().vm.$emit('dismiss'); + + await wrapper.vm.$nextTick(); + + expect(findCCAlert().exists()).toBe(false); + expect(wrapper.vm.$data.error).toBe(null); }); }); }); diff --git a/spec/frontend/pipelines/__snapshots__/parsing_utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap index 60625d301c0..60625d301c0 100644 --- a/spec/frontend/pipelines/__snapshots__/parsing_utils_spec.js.snap +++ b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index e0ba6b2e8da..661c8d99477 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -33,8 +33,6 @@ describe('Pipelines filtered search', () => { }; beforeEach(() => { - window.gon = { features: { pipelineSourceFilter: true } }; - mock = new MockAdapter(axios); jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 1fba3823161..4b2b61c8edd 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -1,5 +1,5 @@ import { mount, shallowMount } from '@vue/test-utils'; -import { GRAPHQL, LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants'; +import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import JobItem from '~/pipelines/components/graph/job_item.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; @@ -54,9 +54,6 @@ describe('graph component', () => { ...data, }; }, - provide: { - dataMethod: GRAPHQL, - }, stubs: { 'links-inner': true, 'linked-pipeline': true, diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index 4c7ea5edda9..cbc5d11403e 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -14,7 +14,29 @@ describe('pipeline graph job item', () => { }; const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500'; - const delayedJobFixture = getJSONFixture('jobs/delayed.json'); + + const delayedJob = { + __typename: 'CiJob', + name: 'delayed job', + scheduledAt: '2015-07-03T10:01:00.000Z', + needs: [], + status: { + __typename: 'DetailedStatus', + icon: 'status_scheduled', + tooltip: 'delayed manual action (%{remainingTime})', + hasDetails: true, + detailsPath: '/root/kinder-pipe/-/jobs/5339', + group: 'scheduled', + action: { + __typename: 'StatusAction', + icon: 'time-out', + title: 'Unschedule', + path: '/frontend-fixtures/builds-project/-/jobs/142/unschedule', + buttonTitle: 'Unschedule job', + }, + }, + }; + const mockJob = { id: 4256, name: 'test', @@ -24,8 +46,8 @@ describe('pipeline graph job item', () => { label: 'passed', tooltip: 'passed', group: 'success', - details_path: '/root/ci-mock/builds/4256', - has_details: true, + detailsPath: '/root/ci-mock/builds/4256', + hasDetails: true, action: { icon: 'retry', title: 'Retry', @@ -42,8 +64,8 @@ describe('pipeline graph job item', () => { text: 'passed', label: 'passed', group: 'success', - details_path: '/root/ci-mock/builds/4257', - has_details: false, + detailsPath: '/root/ci-mock/builds/4257', + hasDetails: false, }, }; @@ -58,7 +80,7 @@ describe('pipeline graph job item', () => { wrapper.vm.$nextTick(() => { const link = wrapper.find('a'); - expect(link.attributes('href')).toBe(mockJob.status.details_path); + expect(link.attributes('href')).toBe(mockJob.status.detailsPath); expect(link.attributes('title')).toBe(`${mockJob.name} - ${mockJob.status.label}`); @@ -145,7 +167,7 @@ describe('pipeline graph job item', () => { describe('for delayed job', () => { it('displays remaining time in tooltip', () => { createWrapper({ - job: delayedJobFixture, + job: delayedJob, }); expect(findJobWithLink().attributes('title')).toBe( diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index c7d95526a0c..af5cd907dd8 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -4,11 +4,7 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants'; import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; -import mockData from './linked_pipelines_mock_data'; - -const mockPipeline = mockData.triggered[0]; -const validTriggeredPipelineId = mockPipeline.project.id; -const invalidTriggeredPipelineId = mockPipeline.project.id + 5; +import mockPipeline from './linked_pipelines_mock_data'; describe('Linked pipeline', () => { let wrapper; @@ -39,10 +35,10 @@ describe('Linked pipeline', () => { describe('rendered output', () => { const props = { pipeline: mockPipeline, - projectId: invalidTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, expanded: false, + isLoading: false, }; beforeEach(() => { @@ -60,7 +56,7 @@ describe('Linked pipeline', () => { }); it('should render the pipeline status icon svg', () => { - expect(wrapper.find('.ci-status-icon-failed svg').exists()).toBe(true); + expect(wrapper.find('.ci-status-icon-success svg').exists()).toBe(true); }); it('should have a ci-status child component', () => { @@ -73,8 +69,8 @@ describe('Linked pipeline', () => { it('should correctly compute the tooltip text', () => { expect(wrapper.vm.tooltipText).toContain(mockPipeline.project.name); - expect(wrapper.vm.tooltipText).toContain(mockPipeline.details.status.label); - expect(wrapper.vm.tooltipText).toContain(mockPipeline.source_job.name); + expect(wrapper.vm.tooltipText).toContain(mockPipeline.status.label); + expect(wrapper.vm.tooltipText).toContain(mockPipeline.sourceJob.name); expect(wrapper.vm.tooltipText).toContain(mockPipeline.id); }); @@ -82,11 +78,7 @@ describe('Linked pipeline', () => { const titleAttr = findLinkedPipeline().attributes('title'); expect(titleAttr).toContain(mockPipeline.project.name); - expect(titleAttr).toContain(mockPipeline.details.status.label); - }); - - it('sets the loading prop to false', () => { - expect(findButton().props('loading')).toBe(false); + expect(titleAttr).toContain(mockPipeline.status.label); }); it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => { @@ -96,18 +88,20 @@ describe('Linked pipeline', () => { describe('parent/child', () => { const downstreamProps = { - pipeline: mockPipeline, - projectId: validTriggeredPipelineId, + pipeline: { + ...mockPipeline, + multiproject: false, + }, columnTitle: 'Downstream', type: DOWNSTREAM, expanded: false, + isLoading: false, }; const upstreamProps = { ...downstreamProps, columnTitle: 'Upstream', type: UPSTREAM, - expanded: false, }; it('parent/child label container should exist', () => { @@ -122,7 +116,7 @@ describe('Linked pipeline', () => { it('should have the name of the trigger job on the card when it is a child pipeline', () => { createWrapper(downstreamProps); - expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.source_job.name); + expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.sourceJob.name); }); it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => { @@ -132,12 +126,12 @@ describe('Linked pipeline', () => { it('downstream pipeline should contain the correct link', () => { createWrapper(downstreamProps); - expect(findPipelineLink().attributes('href')).toBe(mockData.triggered_by.path); + expect(findPipelineLink().attributes('href')).toBe(downstreamProps.pipeline.path); }); it('upstream pipeline should contain the correct link', () => { createWrapper(upstreamProps); - expect(findPipelineLink().attributes('href')).toBe(mockData.triggered_by.path); + expect(findPipelineLink().attributes('href')).toBe(upstreamProps.pipeline.path); }); it.each` @@ -183,11 +177,11 @@ describe('Linked pipeline', () => { describe('when isLoading is true', () => { const props = { - pipeline: { ...mockPipeline, isLoading: true }, - projectId: invalidTriggeredPipelineId, + pipeline: mockPipeline, columnTitle: 'Downstream', type: DOWNSTREAM, expanded: false, + isLoading: true, }; beforeEach(() => { @@ -202,10 +196,10 @@ describe('Linked pipeline', () => { describe('on click/hover', () => { const props = { pipeline: mockPipeline, - projectId: validTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, expanded: false, + isLoading: false, }; beforeEach(() => { @@ -228,7 +222,7 @@ describe('Linked pipeline', () => { it('should emit downstreamHovered with job name on mouseover', () => { findLinkedPipeline().trigger('mouseover'); - expect(wrapper.emitted().downstreamHovered).toStrictEqual([['trigger_job']]); + expect(wrapper.emitted().downstreamHovered).toStrictEqual([['test_c']]); }); it('should emit downstreamHovered with empty string on mouseleave', () => { @@ -238,7 +232,7 @@ describe('Linked pipeline', () => { it('should emit pipelineExpanded with job name and expanded state on click', () => { findExpandButton().trigger('click'); - expect(wrapper.emitted().pipelineExpandToggle).toStrictEqual([['trigger_job', true]]); + expect(wrapper.emitted().pipelineExpandToggle).toStrictEqual([['test_c', true]]); }); }); }); diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index 24cc6e76098..2f03b846525 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -4,7 +4,6 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { DOWNSTREAM, - GRAPHQL, UPSTREAM, LAYER_VIEW, STAGE_VIEW, @@ -52,9 +51,6 @@ describe('Linked Pipelines Column', () => { ...defaultProps, ...props, }, - provide: { - dataMethod: GRAPHQL, - }, }); }; diff --git a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js index eb05669463b..955b70cbd3b 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js @@ -1,3800 +1,22 @@ export default { - id: 23211253, - user: { - id: 3585, - name: 'Achilleas Pipinellis', - username: 'axil', - state: 'active', - avatar_url: 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png', - web_url: 'https://gitlab.com/axil', - status_tooltip_html: - '\u003cspan class="user-status-emoji has-tooltip" title="I like pizza" data-html="true" data-placement="top"\u003e\u003cgl-emoji title="slice of pizza" data-name="pizza" data-unicode-version="6.0"\u003e🍕\u003c/gl-emoji\u003e\u003c/span\u003e', - path: '/axil', + __typename: 'Pipeline', + id: 195, + iid: '5', + path: '/root/elemenohpee/-/pipelines/195', + status: { + __typename: 'DetailedStatus', + group: 'success', + label: 'passed', + icon: 'status_success', }, - active: false, - coverage: null, - source: 'push', - source_job: { - name: 'trigger_job', + sourceJob: { + __typename: 'CiJob', + name: 'test_c', }, - created_at: '2018-06-05T11:31:30.452Z', - updated_at: '2018-10-31T16:35:31.305Z', - path: '/gitlab-org/gitlab-runner/pipelines/23211253', - flags: { - latest: false, - stuck: false, - auto_devops: false, - merge_request: false, - yaml_errors: false, - retryable: false, - cancelable: false, - failure_reason: false, + project: { + __typename: 'Project', + name: 'elemenohpee', + fullPath: 'root/elemenohpee', }, - details: { - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/pipelines/23211253', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - duration: 53, - finished_at: '2018-10-31T16:35:31.299Z', - stages: [ - { - name: 'prebuild', - title: 'prebuild: passed', - groups: [ - { - name: 'review-docs-deploy', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'manual play action', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469032', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 72469032, - name: 'review-docs-deploy', - started: '2018-10-31T16:34:58.778Z', - archived: false, - build_path: '/gitlab-org/gitlab-runner/-/jobs/72469032', - retry_path: '/gitlab-org/gitlab-runner/-/jobs/72469032/retry', - play_path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play', - playable: true, - scheduled: false, - created_at: '2018-06-05T11:31:30.495Z', - updated_at: '2018-10-31T16:35:31.251Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'manual play action', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469032', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/pipelines/23211253#prebuild', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/gitlab-org/gitlab-runner/pipelines/23211253#prebuild', - dropdown_path: '/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild', - }, - { - name: 'test', - title: 'test: passed', - groups: [ - { - name: 'docs check links', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469033', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-runner/-/jobs/72469033/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 72469033, - name: 'docs check links', - started: '2018-06-05T11:31:33.240Z', - archived: false, - build_path: '/gitlab-org/gitlab-runner/-/jobs/72469033', - retry_path: '/gitlab-org/gitlab-runner/-/jobs/72469033/retry', - playable: false, - scheduled: false, - created_at: '2018-06-05T11:31:30.627Z', - updated_at: '2018-06-05T11:31:54.363Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469033', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-runner/-/jobs/72469033/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/pipelines/23211253#test', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/gitlab-org/gitlab-runner/pipelines/23211253#test', - dropdown_path: '/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test', - }, - { - name: 'cleanup', - title: 'cleanup: skipped', - groups: [ - { - name: 'review-docs-cleanup', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual stop action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469034', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'stop', - title: 'Stop', - path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play', - method: 'post', - button_title: 'Stop this environment', - }, - }, - jobs: [ - { - id: 72469034, - name: 'review-docs-cleanup', - started: null, - archived: false, - build_path: '/gitlab-org/gitlab-runner/-/jobs/72469034', - play_path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play', - playable: true, - scheduled: false, - created_at: '2018-06-05T11:31:30.760Z', - updated_at: '2018-06-05T11:31:56.037Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual stop action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469034', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'stop', - title: 'Stop', - path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play', - method: 'post', - button_title: 'Stop this environment', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/pipelines/23211253#cleanup', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-org/gitlab-runner/pipelines/23211253#cleanup', - dropdown_path: '/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'review-docs-cleanup', - path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play', - playable: true, - scheduled: false, - }, - { - name: 'review-docs-deploy', - path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play', - playable: true, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - ref: { - name: 'docs/add-development-guide-to-readme', - path: '/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme', - tag: false, - branch: true, - merge_request: false, - }, - commit: { - id: '8083eb0a920572214d0dccedd7981f05d535ad46', - short_id: '8083eb0a', - title: 'Add link to development guide in readme', - created_at: '2018-06-05T11:30:48.000Z', - parent_ids: ['1d7cf79b5a1a2121b9474ac20d61c1b8f621289d'], - message: - 'Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n', - author_name: 'Achilleas Pipinellis', - author_email: 'axil@gitlab.com', - authored_date: '2018-06-05T11:30:48.000Z', - committer_name: 'Achilleas Pipinellis', - committer_email: 'axil@gitlab.com', - committed_date: '2018-06-05T11:30:48.000Z', - author: { - id: 3585, - name: 'Achilleas Pipinellis', - username: 'axil', - state: 'active', - avatar_url: 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png', - web_url: 'https://gitlab.com/axil', - status_tooltip_html: null, - path: '/axil', - }, - author_gravatar_url: - 'https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80\u0026d=identicon', - commit_url: - 'https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46', - commit_path: '/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46', - }, - project: { id: 20 }, - triggered_by: { - id: 12, - user: { - id: 376774, - name: 'Alessio Caiazza', - username: 'nolith', - state: 'active', - avatar_url: 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png', - web_url: 'https://gitlab.com/nolith', - status_tooltip_html: null, - path: '/nolith', - }, - active: false, - coverage: null, - source: 'pipeline', - source_job: { - name: 'trigger_job', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051', - details: { - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - duration: 118, - finished_at: '2018-10-31T16:41:40.615Z', - stages: [ - { - name: 'build-images', - title: 'build-images: skipped', - groups: [ - { - name: 'image:bootstrap', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 11421321982853, - name: 'image:bootstrap', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.704Z', - updated_at: '2018-10-31T16:35:24.118Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:builder-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 1149822131854, - name: 'image:builder-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.728Z', - updated_at: '2018-10-31T16:35:24.070Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:nginx-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 11498285523424, - name: 'image:nginx-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.753Z', - updated_at: '2018-10-31T16:35:24.033Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images', - }, - { - name: 'build', - title: 'build: failed', - groups: [ - { - name: 'compile_dev', - size: 1, - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 1149846949786, - name: 'compile_dev', - started: '2018-10-31T16:39:41.598Z', - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:39:41.138Z', - updated_at: '2018-10-31T16:41:40.072Z', - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - recoverable: false, - }, - ], - }, - ], - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build', - }, - { - name: 'deploy', - title: 'deploy: skipped', - groups: [ - { - name: 'review', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 11498282342357, - name: 'review', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.805Z', - updated_at: '2018-10-31T16:41:40.569Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - { - name: 'review_stop', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 114982858, - name: 'review_stop', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.840Z', - updated_at: '2018-10-31T16:41:40.480Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'image:bootstrap', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - }, - { - name: 'image:builder-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - }, - { - name: 'image:nginx-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - }, - { - name: 'review_stop', - path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play', - playable: false, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - project: { - id: 20, - name: 'Test', - full_path: '/gitlab-com/gitlab-docs', - full_name: 'GitLab.com / GitLab Docs', - }, - triggered_by: { - id: 349932310342451, - user: { - id: 376774, - name: 'Alessio Caiazza', - username: 'nolith', - state: 'active', - avatar_url: - 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png', - web_url: 'https://gitlab.com/nolith', - status_tooltip_html: null, - path: '/nolith', - }, - active: false, - coverage: null, - source: 'pipeline', - source_job: { - name: 'trigger_job', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051', - details: { - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - duration: 118, - finished_at: '2018-10-31T16:41:40.615Z', - stages: [ - { - name: 'build-images', - title: 'build-images: skipped', - groups: [ - { - name: 'image:bootstrap', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 11421321982853, - name: 'image:bootstrap', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.704Z', - updated_at: '2018-10-31T16:35:24.118Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:builder-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 1149822131854, - name: 'image:builder-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.728Z', - updated_at: '2018-10-31T16:35:24.070Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:nginx-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 11498285523424, - name: 'image:nginx-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.753Z', - updated_at: '2018-10-31T16:35:24.033Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - dropdown_path: - '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images', - }, - { - name: 'build', - title: 'build: failed', - groups: [ - { - name: 'compile_dev', - size: 1, - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 1149846949786, - name: 'compile_dev', - started: '2018-10-31T16:39:41.598Z', - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:39:41.138Z', - updated_at: '2018-10-31T16:41:40.072Z', - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - recoverable: false, - }, - ], - }, - ], - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build', - }, - { - name: 'deploy', - title: 'deploy: skipped', - groups: [ - { - name: 'review', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 11498282342357, - name: 'review', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.805Z', - updated_at: '2018-10-31T16:41:40.569Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - { - name: 'review_stop', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 114982858, - name: 'review_stop', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.840Z', - updated_at: '2018-10-31T16:41:40.480Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'image:bootstrap', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - }, - { - name: 'image:builder-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - }, - { - name: 'image:nginx-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - }, - { - name: 'review_stop', - path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play', - playable: false, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - project: { - id: 20, - name: 'GitLab Docs', - full_path: '/gitlab-com/gitlab-docs', - full_name: 'GitLab.com / GitLab Docs', - }, - }, - triggered: [], - }, - triggered: [ - { - id: 34993051, - user: { - id: 376774, - name: 'Alessio Caiazza', - username: 'nolith', - state: 'active', - avatar_url: - 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png', - web_url: 'https://gitlab.com/nolith', - status_tooltip_html: null, - path: '/nolith', - }, - active: false, - coverage: null, - source: 'pipeline', - source_job: { - name: 'trigger_job', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051', - details: { - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - duration: 118, - finished_at: '2018-10-31T16:41:40.615Z', - stages: [ - { - name: 'build-images', - title: 'build-images: skipped', - groups: [ - { - name: 'image:bootstrap', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 114982853, - name: 'image:bootstrap', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.704Z', - updated_at: '2018-10-31T16:35:24.118Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:builder-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 114982854, - name: 'image:builder-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.728Z', - updated_at: '2018-10-31T16:35:24.070Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:nginx-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 114982855, - name: 'image:nginx-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.753Z', - updated_at: '2018-10-31T16:35:24.033Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - dropdown_path: - '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images', - }, - { - name: 'build', - title: 'build: failed', - groups: [ - { - name: 'compile_dev', - size: 1, - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 114984694, - name: 'compile_dev', - started: '2018-10-31T16:39:41.598Z', - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:39:41.138Z', - updated_at: '2018-10-31T16:41:40.072Z', - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - recoverable: false, - }, - ], - }, - ], - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build', - }, - { - name: 'deploy', - title: 'deploy: skipped', - groups: [ - { - name: 'review', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 114982857, - name: 'review', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.805Z', - updated_at: '2018-10-31T16:41:40.569Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - { - name: 'review_stop', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 114982858, - name: 'review_stop', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.840Z', - updated_at: '2018-10-31T16:41:40.480Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'image:bootstrap', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - }, - { - name: 'image:builder-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - }, - { - name: 'image:nginx-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - }, - { - name: 'review_stop', - path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play', - playable: false, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - project: { - id: 20, - name: 'GitLab Docs', - full_path: '/gitlab-com/gitlab-docs', - full_name: 'GitLab.com / GitLab Docs', - }, - }, - { - id: 34993052, - user: { - id: 376774, - name: 'Alessio Caiazza', - username: 'nolith', - state: 'active', - avatar_url: - 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png', - web_url: 'https://gitlab.com/nolith', - status_tooltip_html: null, - path: '/nolith', - }, - active: false, - coverage: null, - source: 'pipeline', - source_job: { - name: 'trigger_job', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051', - details: { - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - duration: 118, - finished_at: '2018-10-31T16:41:40.615Z', - stages: [ - { - name: 'build-images', - title: 'build-images: skipped', - groups: [ - { - name: 'image:bootstrap', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 114982853, - name: 'image:bootstrap', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.704Z', - updated_at: '2018-10-31T16:35:24.118Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:builder-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 114982854, - name: 'image:builder-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.728Z', - updated_at: '2018-10-31T16:35:24.070Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:nginx-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 1224982855, - name: 'image:nginx-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.753Z', - updated_at: '2018-10-31T16:35:24.033Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - dropdown_path: - '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images', - }, - { - name: 'build', - title: 'build: failed', - groups: [ - { - name: 'compile_dev', - size: 1, - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 1123984694, - name: 'compile_dev', - started: '2018-10-31T16:39:41.598Z', - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:39:41.138Z', - updated_at: '2018-10-31T16:41:40.072Z', - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - recoverable: false, - }, - ], - }, - ], - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build', - }, - { - name: 'deploy', - title: 'deploy: skipped', - groups: [ - { - name: 'review', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 1143232982857, - name: 'review', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.805Z', - updated_at: '2018-10-31T16:41:40.569Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - { - name: 'review_stop', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 114921313182858, - name: 'review_stop', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.840Z', - updated_at: '2018-10-31T16:41:40.480Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'image:bootstrap', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - }, - { - name: 'image:builder-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - }, - { - name: 'image:nginx-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - }, - { - name: 'review_stop', - path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play', - playable: false, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - project: { - id: 20, - name: 'GitLab Docs', - full_path: '/gitlab-com/gitlab-docs', - full_name: 'GitLab.com / GitLab Docs', - }, - triggered: [ - { - id: 26, - user: null, - active: false, - coverage: null, - source: 'push', - source_job: { - name: 'trigger_job', - }, - created_at: '2019-01-06T17:48:37.599Z', - updated_at: '2019-01-06T17:48:38.371Z', - path: '/h5bp/html5-boilerplate/pipelines/26', - flags: { - latest: true, - stuck: false, - auto_devops: false, - merge_request: false, - yaml_errors: false, - retryable: true, - cancelable: false, - failure_reason: false, - }, - details: { - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - duration: null, - finished_at: '2019-01-06T17:48:38.370Z', - stages: [ - { - name: 'build', - title: 'build: passed', - groups: [ - { - name: 'build:linux', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/526', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/526/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 526, - name: 'build:linux', - started: '2019-01-06T08:48:20.236Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/526', - retry_path: '/h5bp/html5-boilerplate/-/jobs/526/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.806Z', - updated_at: '2019-01-06T17:48:37.806Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/526', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/526/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'build:osx', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/527', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/527/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 527, - name: 'build:osx', - started: '2019-01-06T07:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/527', - retry_path: '/h5bp/html5-boilerplate/-/jobs/527/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.846Z', - updated_at: '2019-01-06T17:48:37.846Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/527', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/527/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26#build', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/h5bp/html5-boilerplate/pipelines/26#build', - dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=build', - }, - { - name: 'test', - title: 'test: passed with warnings', - groups: [ - { - name: 'jenkins', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: null, - group: 'success', - tooltip: null, - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - jobs: [ - { - id: 546, - name: 'jenkins', - started: '2019-01-06T11:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/546', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.359Z', - updated_at: '2019-01-06T17:48:38.359Z', - status: { - icon: 'status_success', - text: 'passed', - label: null, - group: 'success', - tooltip: null, - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - }, - ], - }, - { - name: 'rspec:linux', - size: 3, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - jobs: [ - { - id: 528, - name: 'rspec:linux 0 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/528', - retry_path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.885Z', - updated_at: '2019-01-06T17:48:37.885Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/528', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - { - id: 529, - name: 'rspec:linux 1 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/529', - retry_path: '/h5bp/html5-boilerplate/-/jobs/529/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.907Z', - updated_at: '2019-01-06T17:48:37.907Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/529', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/529/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - { - id: 530, - name: 'rspec:linux 2 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/530', - retry_path: '/h5bp/html5-boilerplate/-/jobs/530/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.927Z', - updated_at: '2019-01-06T17:48:37.927Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/530', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/530/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'rspec:osx', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/535', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/535/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 535, - name: 'rspec:osx', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/535', - retry_path: '/h5bp/html5-boilerplate/-/jobs/535/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.018Z', - updated_at: '2019-01-06T17:48:38.018Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/535', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/535/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'rspec:windows', - size: 3, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - jobs: [ - { - id: 531, - name: 'rspec:windows 0 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/531', - retry_path: '/h5bp/html5-boilerplate/-/jobs/531/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.944Z', - updated_at: '2019-01-06T17:48:37.944Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/531', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/531/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - { - id: 532, - name: 'rspec:windows 1 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/532', - retry_path: '/h5bp/html5-boilerplate/-/jobs/532/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.962Z', - updated_at: '2019-01-06T17:48:37.962Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/532', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/532/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - { - id: 534, - name: 'rspec:windows 2 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/534', - retry_path: '/h5bp/html5-boilerplate/-/jobs/534/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.999Z', - updated_at: '2019-01-06T17:48:37.999Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/534', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/534/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'spinach:linux', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/536', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/536/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 536, - name: 'spinach:linux', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/536', - retry_path: '/h5bp/html5-boilerplate/-/jobs/536/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.050Z', - updated_at: '2019-01-06T17:48:38.050Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/536', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/536/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'spinach:osx', - size: 1, - status: { - icon: 'status_warning', - text: 'failed', - label: 'failed (allowed to fail)', - group: 'failed-with-warnings', - tooltip: 'failed - (unknown failure) (allowed to fail)', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/537', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/537/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 537, - name: 'spinach:osx', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/537', - retry_path: '/h5bp/html5-boilerplate/-/jobs/537/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.069Z', - updated_at: '2019-01-06T17:48:38.069Z', - status: { - icon: 'status_warning', - text: 'failed', - label: 'failed (allowed to fail)', - group: 'failed-with-warnings', - tooltip: 'failed - (unknown failure) (allowed to fail)', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/537', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/537/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - callout_message: 'There is an unknown failure, please try again', - recoverable: true, - }, - ], - }, - ], - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26#test', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/h5bp/html5-boilerplate/pipelines/26#test', - dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=test', - }, - { - name: 'security', - title: 'security: passed', - groups: [ - { - name: 'container_scanning', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/541', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/541/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 541, - name: 'container_scanning', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/541', - retry_path: '/h5bp/html5-boilerplate/-/jobs/541/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.186Z', - updated_at: '2019-01-06T17:48:38.186Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/541', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/541/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'dast', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/538', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/538/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 538, - name: 'dast', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/538', - retry_path: '/h5bp/html5-boilerplate/-/jobs/538/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.087Z', - updated_at: '2019-01-06T17:48:38.087Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/538', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/538/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'dependency_scanning', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/540', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/540/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 540, - name: 'dependency_scanning', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/540', - retry_path: '/h5bp/html5-boilerplate/-/jobs/540/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.153Z', - updated_at: '2019-01-06T17:48:38.153Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/540', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/540/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'sast', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/539', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/539/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 539, - name: 'sast', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/539', - retry_path: '/h5bp/html5-boilerplate/-/jobs/539/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.121Z', - updated_at: '2019-01-06T17:48:38.121Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/539', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/539/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26#security', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/h5bp/html5-boilerplate/pipelines/26#security', - dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=security', - }, - { - name: 'deploy', - title: 'deploy: passed', - groups: [ - { - name: 'production', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/544', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 544, - name: 'production', - started: null, - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/544', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.313Z', - updated_at: '2019-01-06T17:48:38.313Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/544', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - { - name: 'staging', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/542', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/542/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 542, - name: 'staging', - started: '2019-01-06T11:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/542', - retry_path: '/h5bp/html5-boilerplate/-/jobs/542/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.219Z', - updated_at: '2019-01-06T17:48:38.219Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/542', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/542/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'stop staging', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/543', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 543, - name: 'stop staging', - started: null, - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/543', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.283Z', - updated_at: '2019-01-06T17:48:38.283Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/543', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26#deploy', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/h5bp/html5-boilerplate/pipelines/26#deploy', - dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=deploy', - }, - { - name: 'notify', - title: 'notify: passed', - groups: [ - { - name: 'slack', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'manual play action', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/545', - illustration: { - image: - '/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'play', - title: 'Play', - path: '/h5bp/html5-boilerplate/-/jobs/545/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 545, - name: 'slack', - started: null, - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/545', - retry_path: '/h5bp/html5-boilerplate/-/jobs/545/retry', - play_path: '/h5bp/html5-boilerplate/-/jobs/545/play', - playable: true, - scheduled: false, - created_at: '2019-01-06T17:48:38.341Z', - updated_at: '2019-01-06T17:48:38.341Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'manual play action', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/545', - illustration: { - image: - '/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'play', - title: 'Play', - path: '/h5bp/html5-boilerplate/-/jobs/545/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26#notify', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/h5bp/html5-boilerplate/pipelines/26#notify', - dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=notify', - }, - ], - artifacts: [ - { - name: 'build:linux', - expired: null, - expire_at: null, - path: '/h5bp/html5-boilerplate/-/jobs/526/artifacts/download', - browse_path: '/h5bp/html5-boilerplate/-/jobs/526/artifacts/browse', - }, - { - name: 'build:osx', - expired: null, - expire_at: null, - path: '/h5bp/html5-boilerplate/-/jobs/527/artifacts/download', - browse_path: '/h5bp/html5-boilerplate/-/jobs/527/artifacts/browse', - }, - ], - manual_actions: [ - { - name: 'stop staging', - path: '/h5bp/html5-boilerplate/-/jobs/543/play', - playable: false, - scheduled: false, - }, - { - name: 'production', - path: '/h5bp/html5-boilerplate/-/jobs/544/play', - playable: false, - scheduled: false, - }, - { - name: 'slack', - path: '/h5bp/html5-boilerplate/-/jobs/545/play', - playable: true, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - ref: { - name: 'main', - path: '/h5bp/html5-boilerplate/commits/main', - tag: false, - branch: true, - merge_request: false, - }, - commit: { - id: 'bad98c453eab56d20057f3929989251d45cd1a8b', - short_id: 'bad98c45', - title: 'remove instances of shrink-to-fit=no (#2103)', - created_at: '2018-12-17T20:52:18.000Z', - parent_ids: ['49130f6cfe9ff1f749015d735649a2bc6f66cf3a'], - message: - 'remove instances of shrink-to-fit=no (#2103)\n\ncloses #2102\r\n\r\nPer my findings, the need for it as a default was rectified with the release of iOS 9.3, where the viewport no longer shrunk to accommodate overflow, as was introduced in iOS 9.', - author_name: "Scott O'Hara", - author_email: 'scottaohara@users.noreply.github.com', - authored_date: '2018-12-17T20:52:18.000Z', - committer_name: 'Rob Larsen', - committer_email: 'rob@drunkenfist.com', - committed_date: '2018-12-17T20:52:18.000Z', - author: null, - author_gravatar_url: - 'https://www.gravatar.com/avatar/6d597df7cf998d16cbe00ccac063b31e?s=80\u0026d=identicon', - commit_url: - 'http://localhost:3001/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b', - commit_path: '/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b', - }, - retry_path: '/h5bp/html5-boilerplate/pipelines/26/retry', - triggered_by: { - id: 4, - user: null, - active: false, - coverage: null, - source: 'push', - source_job: { - name: 'trigger_job', - }, - path: '/gitlab-org/gitlab-test/pipelines/4', - details: { - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-test/pipelines/4', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - }, - project: { - id: 1, - name: 'Gitlab Test', - full_path: '/gitlab-org/gitlab-test', - full_name: 'Gitlab Org / Gitlab Test', - }, - }, - triggered: [], - project: { - id: 20, - name: 'GitLab Docs', - full_path: '/gitlab-com/gitlab-docs', - full_name: 'GitLab.com / GitLab Docs', - }, - }, - ], - }, - ], + multiproject: true, }; diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index e531e26a858..9e51003da66 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -24,7 +24,7 @@ describe('Pipeline details header', () => { const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const defaultProvideOptions = { - pipelineId: 14, + pipelineId: '14', pipelineIid: 1, paths: { pipelinesPath: '/namespace/my-project/-/pipelines', diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js index ce33b6011bf..a606595b37d 100644 --- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js +++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlDropdown, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlDropdown, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -51,6 +51,7 @@ describe('Pipeline Multi Actions Dropdown', () => { const findAlert = () => wrapper.findComponent(GlAlert); const findDropdown = () => wrapper.findComponent(GlDropdown); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAllArtifactItems = () => wrapper.findAllByTestId(artifactItemTestId); const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId); const findEmptyMessage = () => wrapper.findByTestId('artifacts-empty-message'); @@ -103,6 +104,15 @@ describe('Pipeline Multi Actions Dropdown', () => { expect(findEmptyMessage().exists()).toBe(true); }); + describe('while loading artifacts', () => { + it('should render a loading spinner and no empty message', () => { + createComponent({ mockData: { isLoading: true, artifacts: [] } }); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findEmptyMessage().exists()).toBe(false); + }); + }); + describe('with a failing request', () => { it('should render an error message', async () => { const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 76feaaad1ec..aa30062c987 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -105,8 +105,6 @@ describe('Pipelines', () => { }); beforeEach(() => { - window.gon = { features: { pipelineSourceFilter: true } }; - mock = new MockAdapter(axios); jest.spyOn(window.history, 'pushState'); diff --git a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js index 5d15f0a3c55..684d2d0664a 100644 --- a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js @@ -1,5 +1,6 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { PIPELINE_SOURCES } from 'ee_else_ce/pipelines/components/pipelines_list/tokens/constants'; import { stubComponent } from 'helpers/stub_component'; import PipelineSourceToken from '~/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue'; @@ -44,7 +45,7 @@ describe('Pipeline Source Token', () => { describe('shows sources correctly', () => { it('renders all pipeline sources available', () => { - expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.sources.length); + expect(findAllFilteredSearchSuggestions()).toHaveLength(PIPELINE_SOURCES.length); }); }); }); diff --git a/spec/frontend/pipelines/parsing_utils_spec.js b/spec/frontend/pipelines/utils_spec.js index 3a270c1c1b5..1c23a7e4fcf 100644 --- a/spec/frontend/pipelines/parsing_utils_spec.js +++ b/spec/frontend/pipelines/utils_spec.js @@ -1,6 +1,5 @@ import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { - createNodeDict, makeLinksFromNodes, filterByAncestors, generateColumnsFromLayersListBare, @@ -9,6 +8,7 @@ import { removeOrphanNodes, getMaxNodes, } from '~/pipelines/components/parsing_utils'; +import { createNodeDict } from '~/pipelines/utils'; import { mockParsedGraphQLNodes, missingJob } from './components/dag/mock_data'; import { generateResponse, mockPipelineResponse } from './graph/mock_data'; diff --git a/spec/frontend/pipelines_spec.js b/spec/frontend/pipelines_spec.js deleted file mode 100644 index add91fbcc23..00000000000 --- a/spec/frontend/pipelines_spec.js +++ /dev/null @@ -1,17 +0,0 @@ -import Pipelines from '~/pipelines'; - -describe('Pipelines', () => { - beforeEach(() => { - loadFixtures('static/pipeline_graph.html'); - }); - - it('should be defined', () => { - expect(Pipelines).toBeDefined(); - }); - - it('should create a `Pipelines` instance without options', () => { - expect(() => { - new Pipelines(); // eslint-disable-line no-new - }).not.toThrow(); - }); -}); diff --git a/spec/frontend/popovers/components/popovers_spec.js b/spec/frontend/popovers/components/popovers_spec.js index 25c509346d1..2751a878e51 100644 --- a/spec/frontend/popovers/components/popovers_spec.js +++ b/spec/frontend/popovers/components/popovers_spec.js @@ -54,17 +54,20 @@ describe('popovers/components/popovers.vue', () => { expect(wrapper.findAll(GlPopover)).toHaveLength(1); }); - it('supports HTML content', async () => { - const content = 'content with <b>HTML</b>'; - await buildWrapper( - createPopoverTarget({ - content, - html: true, - }), - ); - const html = wrapper.find(GlPopover).html(); - - expect(html).toContain(content); + describe('supports HTML content', () => { + const svgIcon = '<svg><use xlink:href="icons.svg#test"></use></svg>'; + + it.each` + description | content | render + ${'renders html content correctly'} | ${'<b>HTML</b>'} | ${'<b>HTML</b>'} + ${'removes any unsafe content'} | ${'<script>alert(XSS)</script>'} | ${''} + ${'renders svg icons correctly'} | ${svgIcon} | ${svgIcon} + `('$description', async ({ content, render }) => { + await buildWrapper(createPopoverTarget({ content, html: true })); + + const html = wrapper.find(GlPopover).html(); + expect(html).toContain(render); + }); }); it.each` diff --git a/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js b/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js index b5ee62f2042..6ef49390c47 100644 --- a/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js @@ -60,7 +60,7 @@ describe('~/projects/pipelines/charts/components/pipeline_charts.vue', () => { expect(chart.props('yAxisTitle')).toBe('Minutes'); expect(chart.props('xAxisTitle')).toBe('Commit'); expect(chart.props('bars')).toBe(wrapper.vm.timesChartTransformedData); - expect(chart.props('option')).toBe(wrapper.vm.$options.timesChartOptions); + expect(chart.props('option')).toBe(wrapper.vm.chartOptions); }); }); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js index 5323c1afbb5..eacf858f22c 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js @@ -107,6 +107,29 @@ describe('ServiceDeskSetting', () => { }); }); + describe('project suffix', () => { + it('input is hidden', () => { + wrapper = createComponent({ + props: { customEmailEnabled: false }, + }); + + const input = wrapper.findByTestId('project-suffix'); + + expect(input.exists()).toBe(false); + }); + + it('input is enabled', () => { + wrapper = createComponent({ + props: { customEmailEnabled: true }, + }); + + const input = wrapper.findByTestId('project-suffix'); + + expect(input.exists()).toBe(true); + expect(input.attributes('disabled')).toBeUndefined(); + }); + }); + describe('customEmail is the same as incomingEmail', () => { const email = 'foo@bar.com'; diff --git a/spec/frontend/projects/storage_counter/components/app_spec.js b/spec/frontend/projects/storage_counter/components/app_spec.js new file mode 100644 index 00000000000..f3da01e0602 --- /dev/null +++ b/spec/frontend/projects/storage_counter/components/app_spec.js @@ -0,0 +1,150 @@ +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import StorageCounterApp from '~/projects/storage_counter/components/app.vue'; +import { TOTAL_USAGE_DEFAULT_TEXT } from '~/projects/storage_counter/constants'; +import getProjectStorageCount from '~/projects/storage_counter/queries/project_storage.query.graphql'; +import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue'; +import { + mockGetProjectStorageCountGraphQLResponse, + mockEmptyResponse, + projectData, + defaultProvideValues, +} from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Storage counter app', () => { + let wrapper; + + const createMockApolloProvider = ({ reject = false, mockedValue } = {}) => { + let response; + + if (reject) { + response = jest.fn().mockRejectedValue(mockedValue || new Error('GraphQL error')); + } else { + response = jest.fn().mockResolvedValue(mockedValue); + } + + const requestHandlers = [[getProjectStorageCount, response]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = ({ provide = {}, mockApollo } = {}) => { + wrapper = extendedWrapper( + shallowMount(StorageCounterApp, { + localVue, + apolloProvider: mockApollo, + provide: { + ...defaultProvideValues, + ...provide, + }, + }), + ); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findUsagePercentage = () => wrapper.findByTestId('total-usage'); + const findUsageQuotasHelpLink = () => wrapper.findByTestId('usage-quotas-help-link'); + const findUsageGraph = () => wrapper.findComponent(UsageGraph); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with apollo fetching successful', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockGetProjectStorageCountGraphQLResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('renders correct total usage', () => { + expect(findUsagePercentage().text()).toBe(projectData.storage.totalUsage); + }); + + it('renders correct usage quotas help link', () => { + expect(findUsageQuotasHelpLink().attributes('href')).toBe( + defaultProvideValues.helpLinks.usageQuotasHelpPagePath, + ); + }); + }); + + describe('with apollo loading', () => { + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApolloProvider({ + mockedValue: new Promise(() => {}), + }); + createComponent({ mockApollo }); + }); + + it('should show loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('with apollo returning empty data', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockEmptyResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('shows default text for total usage', () => { + expect(findUsagePercentage().text()).toBe(TOTAL_USAGE_DEFAULT_TEXT); + }); + }); + + describe('with apollo fetching error', () => { + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApolloProvider(); + createComponent({ mockApollo, reject: true }); + }); + + it('renders gl-alert', () => { + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('rendering <usage-graph />', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockGetProjectStorageCountGraphQLResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('renders usage-graph component if project.statistics exists', () => { + expect(findUsageGraph().exists()).toBe(true); + }); + + it('passes project.statistics to usage-graph component', () => { + const { + __typename, + ...statistics + } = mockGetProjectStorageCountGraphQLResponse.data.project.statistics; + expect(findUsageGraph().props('rootStorageStatistics')).toMatchObject(statistics); + }); + }); +}); diff --git a/spec/frontend/projects/storage_counter/components/storage_table_spec.js b/spec/frontend/projects/storage_counter/components/storage_table_spec.js new file mode 100644 index 00000000000..14298318fff --- /dev/null +++ b/spec/frontend/projects/storage_counter/components/storage_table_spec.js @@ -0,0 +1,62 @@ +import { GlTable } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import StorageTable from '~/projects/storage_counter/components/storage_table.vue'; +import { projectData, defaultProvideValues } from '../mock_data'; + +describe('StorageTable', () => { + let wrapper; + + const defaultProps = { + storageTypes: projectData.storage.storageTypes, + }; + + const createComponent = (props = {}) => { + wrapper = extendedWrapper( + mount(StorageTable, { + propsData: { + ...defaultProps, + ...props, + }, + }), + ); + }; + + const findTable = () => wrapper.findComponent(GlTable); + + beforeEach(() => { + createComponent(); + }); + afterEach(() => { + wrapper.destroy(); + }); + + describe('with storage types', () => { + it.each(projectData.storage.storageTypes)( + 'renders table row correctly %o', + ({ storageType: { id, name, description } }) => { + expect(wrapper.findByTestId(`${id}-name`).text()).toBe(name); + expect(wrapper.findByTestId(`${id}-description`).text()).toBe(description); + expect(wrapper.findByTestId(`${id}-help-link`).attributes('href')).toBe( + defaultProvideValues.helpLinks[id.replace(`Size`, `HelpPagePath`)] + .replace(`Size`, ``) + .replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`), + ); + }, + ); + }); + + describe('without storage types', () => { + beforeEach(() => { + createComponent({ storageTypes: [] }); + }); + + it('should render the table header <th>', () => { + expect(findTable().find('th').exists()).toBe(true); + }); + + it('should not render any table data <td>', () => { + expect(findTable().find('td').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/projects/storage_counter/mock_data.js b/spec/frontend/projects/storage_counter/mock_data.js new file mode 100644 index 00000000000..b9fa68b3ec7 --- /dev/null +++ b/spec/frontend/projects/storage_counter/mock_data.js @@ -0,0 +1,109 @@ +export const mockGetProjectStorageCountGraphQLResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + statistics: { + buildArtifactsSize: 400000.0, + pipelineArtifactsSize: 25000.0, + lfsObjectsSize: 4800000.0, + packagesSize: 3800000.0, + repositorySize: 3900000.0, + snippetsSize: 1200000.0, + storageSize: 15300000.0, + uploadsSize: 900000.0, + wikiSize: 300000.0, + __typename: 'ProjectStatistics', + }, + __typename: 'Project', + }, + }, +}; + +export const mockEmptyResponse = { data: { project: null } }; + +export const defaultProvideValues = { + projectPath: '/project-path', + helpLinks: { + usageQuotasHelpPagePath: '/usage-quotas', + buildArtifactsHelpPagePath: '/build-artifacts', + lfsObjectsHelpPagePath: '/lsf-objects', + packagesHelpPagePath: '/packages', + repositoryHelpPagePath: '/repository', + snippetsHelpPagePath: '/snippets', + uploadsHelpPagePath: '/uploads', + wikiHelpPagePath: '/wiki', + }, +}; + +export const projectData = { + storage: { + totalUsage: '14.6 MiB', + storageTypes: [ + { + storageType: { + id: 'buildArtifactsSize', + name: 'Artifacts', + description: 'Pipeline artifacts and job artifacts, created with CI/CD.', + warningMessage: + 'There is a known issue with Artifact storage where the total could be incorrect for some projects. More details and progress are available in %{warningLinkStart}the epic%{warningLinkEnd}.', + helpPath: '/build-artifacts', + }, + value: 400000, + }, + { + storageType: { + id: 'lfsObjectsSize', + name: 'LFS Storage', + description: 'Audio samples, videos, datasets, and graphics.', + helpPath: '/lsf-objects', + }, + value: 4800000, + }, + { + storageType: { + id: 'packagesSize', + name: 'Packages', + description: 'Code packages and container images.', + helpPath: '/packages', + }, + value: 3800000, + }, + { + storageType: { + id: 'repositorySize', + name: 'Repository', + description: 'Git repository, managed by the Gitaly service.', + helpPath: '/repository', + }, + value: 3900000, + }, + { + storageType: { + id: 'snippetsSize', + name: 'Snippets', + description: 'Shared bits of code and text.', + helpPath: '/snippets', + }, + value: 1200000, + }, + { + storageType: { + id: 'uploadsSize', + name: 'Uploads', + description: 'File attachments and smaller design graphics.', + helpPath: '/uploads', + }, + value: 900000, + }, + { + storageType: { + id: 'wikiSize', + name: 'Wiki', + description: 'Wiki content.', + helpPath: '/wiki', + }, + value: 300000, + }, + ], + }, +}; diff --git a/spec/frontend/projects/storage_counter/utils_spec.js b/spec/frontend/projects/storage_counter/utils_spec.js new file mode 100644 index 00000000000..57c755266a0 --- /dev/null +++ b/spec/frontend/projects/storage_counter/utils_spec.js @@ -0,0 +1,17 @@ +import { parseGetProjectStorageResults } from '~/projects/storage_counter/utils'; +import { + mockGetProjectStorageCountGraphQLResponse, + projectData, + defaultProvideValues, +} from './mock_data'; + +describe('parseGetProjectStorageResults', () => { + it('parses project statistics correctly', () => { + expect( + parseGetProjectStorageResults( + mockGetProjectStorageCountGraphQLResponse.data, + defaultProvideValues.helpLinks, + ), + ).toMatchObject(projectData); + }); +}); diff --git a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js index 71c22998b08..6576ce70d60 100644 --- a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js +++ b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js @@ -1,51 +1,91 @@ import { GlBanner } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { setCookie, parseBoolean } from '~/lib/utils/common_utils'; +import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; +import { mockTracking } from 'helpers/tracking_helper'; import TerraformNotification from '~/projects/terraform_notification/components/terraform_notification.vue'; - -jest.mock('~/lib/utils/common_utils'); +import { + EVENT_LABEL, + DISMISS_EVENT, + CLICK_EVENT, +} from '~/projects/terraform_notification/constants'; const terraformImagePath = '/path/to/image'; -const bannerDismissedKey = 'terraform_notification_dismissed'; describe('TerraformNotificationBanner', () => { let wrapper; + let trackingSpy; + let userCalloutDismissSpy; const provideData = { terraformImagePath, - bannerDismissedKey, }; const findBanner = () => wrapper.findComponent(GlBanner); - beforeEach(() => { + const createComponent = ({ shouldShowCallout = true } = {}) => { + userCalloutDismissSpy = jest.fn(); + wrapper = shallowMount(TerraformNotification, { provide: provideData, - stubs: { GlBanner }, + stubs: { + GlBanner, + UserCalloutDismisser: makeMockUserCalloutDismisser({ + dismiss: userCalloutDismissSpy, + shouldShowCallout, + }), + }, }); + }; + + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); afterEach(() => { wrapper.destroy(); - parseBoolean.mockReturnValue(false); }); - describe('when the dismiss cookie is not set', () => { + describe('when user has already dismissed the banner', () => { + beforeEach(() => { + createComponent({ + shouldShowCallout: false, + }); + }); + it('should not render the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + }); + + describe("when user hasn't yet dismissed the banner", () => { it('should render the banner', () => { expect(findBanner().exists()).toBe(true); }); }); describe('when close button is clicked', () => { - beforeEach(async () => { - await findBanner().vm.$emit('close'); + beforeEach(() => { + wrapper.vm.$refs.calloutDismisser.dismiss = userCalloutDismissSpy; + findBanner().vm.$emit('close'); + }); + it('should send the dismiss event', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, DISMISS_EVENT, { + label: EVENT_LABEL, + }); }); + it('should call the dismiss callback', () => { + expect(userCalloutDismissSpy).toHaveBeenCalledTimes(1); + }); + }); - it('should set the cookie with the bannerDismissedKey', () => { - expect(setCookie).toHaveBeenCalledWith(bannerDismissedKey, true); + describe('when docs link is clicked', () => { + beforeEach(() => { + findBanner().vm.$emit('primary'); }); - it('should remove the banner', () => { - expect(findBanner().exists()).toBe(false); + it('should send button click event', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, CLICK_EVENT, { + label: EVENT_LABEL, + }); }); }); }); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index d462995328b..8331adcdfc2 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -375,6 +375,30 @@ describe('Blob content viewer component', () => { expect(findBlobHeader().props('isBinary')).toBe(true); }, ); + + it('passes the correct header props when viewing a non-text file', async () => { + fullFactory({ + mockData: { + blobInfo: { + ...simpleMockData, + simpleViewer: { + ...simpleMockData.simpleViewer, + fileType: 'image', + }, + }, + }, + stubs: { + BlobContent: true, + BlobReplace: true, + }, + }); + + await nextTick(); + + expect(findBlobHeader().props('hideViewerSwitcher')).toBe(true); + expect(findBlobHeader().props('isBinary')).toBe(true); + expect(findBlobEdit().props('showEditButton')).toBe(false); + }); }); describe('BlobButtonGroup', () => { diff --git a/spec/frontend/repository/components/blob_viewers/image_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/image_viewer_spec.js new file mode 100644 index 00000000000..6735dddf51e --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/image_viewer_spec.js @@ -0,0 +1,25 @@ +import { shallowMount } from '@vue/test-utils'; +import ImageViewer from '~/repository/components/blob_viewers/image_viewer.vue'; + +describe('Image Viewer', () => { + let wrapper; + + const propsData = { + url: 'some/image.png', + alt: 'image.png', + }; + + const createComponent = () => { + wrapper = shallowMount(ImageViewer, { propsData }); + }; + + const findImage = () => wrapper.find('[data-testid="image"]'); + + it('renders a Source Editor component', () => { + createComponent(); + + expect(findImage().exists()).toBe(true); + expect(findImage().attributes('src')).toBe(propsData.url); + expect(findImage().attributes('alt')).toBe(propsData.alt); + }); +}); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 1d1ec58100f..e36287eff29 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import filesQuery from 'shared_queries/repository/files.query.graphql'; +import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import FilePreview from '~/repository/components/preview/index.vue'; import FileTable from '~/repository/components/table/index.vue'; import TreeContent from '~/repository/components/tree_content.vue'; @@ -22,6 +22,7 @@ function factory(path, data = () => ({})) { provide: { glFeatures: { increasePageSizeExponentially: true, + paginatedTreeGraphqlQuery: true, }, }, }); @@ -58,7 +59,7 @@ describe('Repository table component', () => { it('normalizes edge nodes', () => { factory('/'); - const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]); + const output = vm.vm.normalizeData('blobs', { nodes: ['1', '2'] }); expect(output).toEqual(['1', '2']); }); @@ -168,7 +169,7 @@ describe('Repository table component', () => { vm.vm.fetchFiles(); expect($apollo.query).toHaveBeenCalledWith({ - query: filesQuery, + query: paginatedTreeQuery, variables: { pageSize, nextPageCursor: '', 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 c1596711be7..3292f635f6b 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -2,6 +2,7 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; 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 { updateHistory } from '~/lib/utils/url_utility'; @@ -14,16 +15,20 @@ import RunnerPagination from '~/runner/components/runner_pagination.vue'; import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; import { + ADMIN_FILTERED_SEARCH_NAMESPACE, CREATED_ASC, CREATED_DESC, DEFAULT_SORT, INSTANCE_TYPE, PARAM_KEY_STATUS, + PARAM_KEY_RUNNER_TYPE, + PARAM_KEY_TAG, STATUS_ACTIVE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import { captureException } from '~/runner/sentry_utils'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { runnersData, runnersDataPaginated } from '../mock_data'; @@ -47,10 +52,14 @@ describe('AdminRunnersApp', () => { const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); const findRunnerList = () => wrapper.findComponent(RunnerList); - const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); + const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); + const findRunnerPaginationPrev = () => + findRunnerPagination().findByLabelText('Go to previous page'); + const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); - const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { const handlers = [[getRunnersQuery, mockRunnersQuery]]; wrapper = mountFn(AdminRunnersApp, { @@ -68,7 +77,7 @@ describe('AdminRunnersApp', () => { setWindowLocation('/admin/runners'); mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); - createComponentWithApollo(); + createComponent(); await waitForPromises(); }); @@ -77,8 +86,16 @@ 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); + }); + it('shows the runners list', () => { - expect(runnersData.data.runners.nodes).toMatchObject(findRunnerList().props('runners')); + expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes); }); it('requests the runners with no filters', () => { @@ -90,20 +107,38 @@ describe('AdminRunnersApp', () => { }); }); - it('shows the runner type help', () => { - expect(findRunnerTypeHelp().exists()).toBe(true); + it('sets tokens in the filtered search', () => { + createComponent({ mountFn: mount }); + + expect(findFilteredSearch().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_RUNNER_TYPE, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_TAG, + recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`, + }), + ]); }); - it('shows the runner setup instructions', () => { - expect(findRunnerManualSetupHelp().exists()).toBe(true); - expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + it('shows the active runner count', () => { + createComponent({ mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch( + `Runners currently online: ${mockActiveRunnersCount}`, + ); }); describe('when a filter is preselected', () => { beforeEach(async () => { setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); - createComponentWithApollo(); + createComponent(); await waitForPromises(); }); @@ -133,7 +168,7 @@ describe('AdminRunnersApp', () => { describe('when a filter is selected by the user', () => { beforeEach(() => { findRunnerFilteredSearchBar().vm.$emit('input', { - filters: [{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }], + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], sort: CREATED_ASC, }); }); @@ -154,11 +189,19 @@ describe('AdminRunnersApp', () => { }); }); + it('when runners have not loaded, shows a loading state', () => { + createComponent(); + expect(findRunnerList().props('loading')).toBe(true); + }); + describe('when no runners are found', () => { beforeEach(async () => { - mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } }); - createComponentWithApollo(); - await waitForPromises(); + mockRunnersQuery = jest.fn().mockResolvedValue({ + data: { + runners: { nodes: [] }, + }, + }); + createComponent(); }); it('shows a message for no results', async () => { @@ -166,17 +209,14 @@ describe('AdminRunnersApp', () => { }); }); - it('when runners have not loaded, shows a loading state', () => { - createComponentWithApollo(); - expect(findRunnerList().props('loading')).toBe(true); - }); - describe('when runners query fails', () => { - beforeEach(async () => { + beforeEach(() => { mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); - createComponentWithApollo(); + createComponent(); + }); - await waitForPromises(); + it('error is shown to the user', async () => { + expect(createFlash).toHaveBeenCalledTimes(1); }); it('error is reported to sentry', async () => { @@ -185,17 +225,13 @@ describe('AdminRunnersApp', () => { component: 'AdminRunnersApp', }); }); - - it('error is shown to the user', async () => { - expect(createFlash).toHaveBeenCalledTimes(1); - }); }); describe('Pagination', () => { beforeEach(() => { mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated); - createComponentWithApollo({ mountFn: mount }); + createComponent({ mountFn: mount }); }); it('more pages can be selected', () => { @@ -203,14 +239,11 @@ describe('AdminRunnersApp', () => { }); it('cannot navigate to the previous page', () => { - expect(findRunnerPagination().find('[aria-disabled]').text()).toBe('Prev'); + expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true'); }); it('navigates to the next page', async () => { - const nextPageBtn = findRunnerPagination().find('a'); - expect(nextPageBtn.text()).toBe('Next'); - - await nextPageBtn.trigger('click'); + await findRunnerPaginationNext().trigger('click'); expect(mockRunnersQuery).toHaveBeenLastCalledWith({ sort: CREATED_DESC, diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js index 85cf7ea92df..46948af1f28 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -2,8 +2,16 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config'; import TagToken from '~/runner/components/search_tokens/tag_token.vue'; -import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG } from '~/runner/constants'; +import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config'; +import { typeTokenConfig } from '~/runner/components/search_tokens/type_token_config'; +import { + PARAM_KEY_STATUS, + PARAM_KEY_RUNNER_TYPE, + PARAM_KEY_TAG, + STATUS_ACTIVE, +} from '~/runner/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -13,12 +21,12 @@ describe('RunnerList', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); - const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message'); + const findActiveRunnersMessage = () => wrapper.findByTestId('runner-count'); const mockDefaultSort = 'CREATED_DESC'; const mockOtherSort = 'CONTACTED_DESC'; const mockFilters = [ - { type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }, + { type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }, { type: 'filtered-search-term', value: { data: '' } }, ]; const mockActiveRunnersCount = 2; @@ -28,13 +36,16 @@ describe('RunnerList', () => { shallowMount(RunnerFilteredSearchBar, { propsData: { namespace: 'runners', + tokens: [], value: { filters: [], sort: mockDefaultSort, }, - activeRunnersCount: mockActiveRunnersCount, ...props, }, + slots: { + 'runner-count': `Runners currently online: ${mockActiveRunnersCount}`, + }, stubs: { FilteredSearch, GlFilteredSearch, @@ -64,12 +75,6 @@ describe('RunnerList', () => { ); }); - it('Displays a large active runner count', () => { - createComponent({ props: { activeRunnersCount: 2000 } }); - - expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000'); - }); - it('sets sorting options', () => { const SORT_OPTIONS_COUNT = 2; @@ -78,7 +83,13 @@ describe('RunnerList', () => { expect(findSortOptions().at(1).text()).toBe('Last contact'); }); - it('sets tokens', () => { + it('sets tokens to the filtered search', () => { + createComponent({ + props: { + tokens: [statusTokenConfig, typeTokenConfig, tagTokenConfig], + }, + }); + expect(findFilteredSearch().props('tokens')).toEqual([ expect.objectContaining({ type: PARAM_KEY_STATUS, diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 5fff3581e39..344d1e5c150 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -56,7 +56,7 @@ describe('RunnerList', () => { }); it('Displays a list of runners', () => { - expect(findRows()).toHaveLength(3); + expect(findRows()).toHaveLength(4); expect(findSkeletonLoader().exists()).toBe(false); }); diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js index 15029d7a911..0e0844a785b 100644 --- a/spec/frontend/runner/components/runner_update_form_spec.js +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -54,7 +54,7 @@ describe('RunnerUpdateForm', () => { ? ACCESS_LEVEL_REF_PROTECTED : ACCESS_LEVEL_NOT_PROTECTED, runUntagged: findRunUntaggedCheckbox().element.checked, - locked: findLockedCheckbox().element.checked, + locked: findLockedCheckbox().element?.checked || false, ipAddress: findIpInput().element.value, maximumTimeout: findMaxJobTimeoutInput().element.value || null, tagList: findTagsInput().element.value.split(',').filter(Boolean), @@ -153,15 +153,15 @@ describe('RunnerUpdateForm', () => { }); it.each` - runnerType | attrDisabled | outcome - ${INSTANCE_TYPE} | ${'disabled'} | ${'disabled'} - ${GROUP_TYPE} | ${'disabled'} | ${'disabled'} - ${PROJECT_TYPE} | ${undefined} | ${'enabled'} - `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, attrDisabled }) => { + runnerType | exists | outcome + ${INSTANCE_TYPE} | ${false} | ${'hidden'} + ${GROUP_TYPE} | ${false} | ${'hidden'} + ${PROJECT_TYPE} | ${true} | ${'shown'} + `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, exists }) => { const runner = { ...mockRunner, runnerType }; createComponent({ props: { runner } }); - expect(findLockedCheckbox().attributes('disabled')).toBe(attrDisabled); + expect(findLockedCheckbox().exists()).toBe(exists); }); describe('On submit, runner gets updated', () => { 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 6a0863e92b4..e80da40e3bd 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -1,26 +1,85 @@ -import { shallowMount } from '@vue/test-utils'; +import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +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 { 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, + CREATED_DESC, + DEFAULT_SORT, + INSTANCE_TYPE, + PARAM_KEY_STATUS, + PARAM_KEY_RUNNER_TYPE, + STATUS_ACTIVE, + RUNNER_PAGE_SIZE, +} from '~/runner/constants'; +import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue'; +import { captureException } from '~/runner/sentry_utils'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { groupRunnersData, groupRunnersDataPaginated } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); +const mockGroupFullPath = 'group1'; const mockRegistrationToken = 'AABBCC'; +const mockRunners = groupRunnersData.data.group.runners.nodes; +const mockGroupRunnersLimitedCount = mockRunners.length; + +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); 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)); + const findRunnerPaginationPrev = () => + findRunnerPagination().findByLabelText('Go to previous page'); + const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); + const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); + + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + const handlers = [[getGroupRunnersQuery, mockGroupRunnersQuery]]; - const createComponent = ({ mountFn = shallowMount } = {}) => { wrapper = mountFn(GroupRunnersApp, { + localVue, + apolloProvider: createMockApollo(handlers), propsData: { registrationToken: mockRegistrationToken, + groupFullPath: mockGroupFullPath, + groupRunnersLimitedCount: mockGroupRunnersLimitedCount, + ...props, }, }); }; - beforeEach(() => { + beforeEach(async () => { + setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`); + + mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData); + createComponent(); + await waitForPromises(); }); it('shows the runner type help', () => { @@ -28,7 +87,179 @@ describe('GroupRunnersApp', () => { }); it('shows the runner setup instructions', () => { - expect(findRunnerManualSetupHelp().exists()).toBe(true); expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); }); + + it('shows the runners list', () => { + expect(findRunnerList().props('runners')).toEqual(groupRunnersData.data.group.runners.nodes); + }); + + it('requests the runners with group path and no other filters', () => { + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: undefined, + type: undefined, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('sets tokens in the filtered search', () => { + createComponent({ mountFn: mount }); + + expect(findFilteredSearch().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_RUNNER_TYPE, + options: expect.any(Array), + }), + ]); + }); + + describe('shows the active runner count', () => { + it('with a regular value', () => { + createComponent({ mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch( + `Runners in this group: ${mockGroupRunnersLimitedCount}`, + ); + }); + + it('at the limit', () => { + createComponent({ props: { groupRunnersLimitedCount: 1000 }, mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000`); + }); + + it('over the limit', () => { + createComponent({ props: { groupRunnersLimitedCount: 1001 }, mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000+`); + }); + }); + + describe('when a filter is preselected', () => { + beforeEach(async () => { + setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`); + + createComponent(); + await waitForPromises(); + }); + + it('sets the filters in the search bar', () => { + expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + filters: [ + { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } }, + ], + sort: 'CREATED_DESC', + pagination: { page: 1 }, + }); + }); + + it('requests the runners with filter parameters', () => { + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ACTIVE, + type: INSTANCE_TYPE, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(() => { + findRunnerFilteredSearchBar().vm.$emit('input', { + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], + sort: CREATED_ASC, + }); + }); + + it('updates the browser url', () => { + expect(updateHistory).toHaveBeenLastCalledWith({ + title: expect.any(String), + url: 'http://test.host/groups/group1/-/runners?status[]=ACTIVE&sort=CREATED_ASC', + }); + }); + + it('requests the runners with filters', () => { + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ACTIVE, + sort: CREATED_ASC, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + it('when runners have not loaded, shows a loading state', () => { + createComponent(); + expect(findRunnerList().props('loading')).toBe(true); + }); + + describe('when no runners are found', () => { + beforeEach(async () => { + mockGroupRunnersQuery = jest.fn().mockResolvedValue({ + data: { + group: { + runners: { nodes: [] }, + }, + }, + }); + createComponent(); + }); + + it('shows a message for no results', async () => { + expect(wrapper.text()).toContain('No runners found'); + }); + }); + + describe('when runners query fails', () => { + beforeEach(() => { + mockGroupRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); + createComponent(); + }); + + it('error is shown to the user', async () => { + expect(createFlash).toHaveBeenCalledTimes(1); + }); + + it('error is reported to sentry', async () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Network error: Error!'), + component: 'GroupRunnersApp', + }); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated); + + createComponent({ mountFn: mount }); + }); + + it('more pages can be selected', () => { + expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next'); + }); + + it('cannot navigate to the previous page', () => { + expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true'); + }); + + it('navigates to the next page', async () => { + await findRunnerPaginationNext().trigger('click'); + + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: groupRunnersDataPaginated.data.group.runners.pageInfo.endCursor, + }); + }); + }); }); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 8f551feca6e..c90b9a4c426 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -1,6 +1,14 @@ +const runnerFixture = (filename) => getJSONFixture(`graphql/runner/${filename}`); + // Fixtures generated by: spec/frontend/fixtures/runner.rb -export const runnersData = getJSONFixture('graphql/runner/get_runners.query.graphql.json'); -export const runnersDataPaginated = getJSONFixture( - 'graphql/runner/get_runners.query.graphql.paginated.json', + +// 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'); + +// Group queries +export const groupRunnersData = runnerFixture('get_group_runners.query.graphql.json'); +export const groupRunnersDataPaginated = runnerFixture( + 'get_group_runners.query.graphql.paginated.json', ); -export const runnerData = getJSONFixture('graphql/runner/get_runner.query.graphql.json'); diff --git a/spec/frontend/search/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js index 6908bcbd283..9fa3bfc1f9a 100644 --- a/spec/frontend/search/highlight_blob_search_result_spec.js +++ b/spec/frontend/search/highlight_blob_search_result_spec.js @@ -9,6 +9,6 @@ describe('search/highlight_blob_search_result', () => { it('highlights lines with search term occurrence', () => { setHighlightClass(searchKeyword); - expect(document.querySelectorAll('.blob-result .hll').length).toBe(4); + expect(document.querySelectorAll('.js-blob-result .hll').length).toBe(4); }); }); diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index 9f8c83f2873..b50248bb295 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -142,7 +142,13 @@ describe('Global Search Store Actions', () => { actions.fetchProjects({ commit: mockCommit, state }); expect(Api.groupProjects).not.toHaveBeenCalled(); - expect(Api.projects).toHaveBeenCalled(); + expect(Api.projects).toHaveBeenCalledWith( + state.query.search, + { + order_by: 'similarity', + }, + expect.any(Function), + ); }); }); }); diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js index cd7f7dc3b5f..bcdad9f89dd 100644 --- a/spec/frontend/search/store/utils_spec.js +++ b/spec/frontend/search/store/utils_spec.js @@ -14,7 +14,7 @@ const CURRENT_TIME = new Date().getTime(); useLocalStorageSpy(); jest.mock('~/lib/utils/accessor', () => ({ - isLocalStorageAccessSafe: jest.fn().mockReturnValue(true), + canUseLocalStorage: jest.fn().mockReturnValue(true), })); describe('Global Search Store Utils', () => { diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index fc5eeee9687..455db325066 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -70,8 +70,7 @@ describe('Shortcuts', () => { const mdShortcuts = $(this).data('md-shortcuts'); // jQuery.map() automatically unwraps arrays, so we - // have to double wrap the array to counteract this: - // https://stackoverflow.com/a/4875669/1063392 + // have to double wrap the array to counteract this return mdShortcuts ? [mdShortcuts] : undefined; }) .get(); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js index 8504684d23a..39f63b2a9f4 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -206,7 +206,7 @@ describe('Sidebar assignees widget', () => { status: null, }, ], - id: 1, + id: 'gid://gitlab/Issue/1', }, ], ]); diff --git a/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js b/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js index 57b9a10b23e..859e63b3df6 100644 --- a/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js +++ b/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -45,6 +45,14 @@ describe('Sidebar Participants Widget', () => { expect(findParticipants().props('loading')).toBe(true); }); + it('emits toggleSidebar event when participants child component emits toggleSidebar', async () => { + createComponent(); + findParticipants().vm.$emit('toggleSidebar'); + + await nextTick(); + expect(wrapper.emitted('toggleSidebar')).toEqual([[]]); + }); + describe('when participants are loaded', () => { beforeEach(() => { createComponent({ diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js index ab08a1e65e2..7455f684380 100644 --- a/spec/frontend/sidebar/sidebar_labels_spec.js +++ b/spec/frontend/sidebar/sidebar_labels_spec.js @@ -156,7 +156,7 @@ describe('sidebar labels', () => { variables: { input: { iid: defaultProps.iid, - labelIds: [toLabelGid(27), toLabelGid(28), toLabelGid(29), toLabelGid(40)], + labelIds: [toLabelGid(29), toLabelGid(28), toLabelGid(27), toLabelGid(40)], operationMode: MutationOperationMode.Replace, projectPath: defaultProps.projectPath, }, diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js index 019ded87093..cb84c142d55 100644 --- a/spec/frontend/sidebar/sidebar_mediator_spec.js +++ b/spec/frontend/sidebar/sidebar_mediator_spec.js @@ -63,8 +63,6 @@ describe('Sidebar mediator', () => { expect(mediator.store.assignees).toEqual(mockData.assignees); expect(mediator.store.humanTimeEstimate).toEqual(mockData.human_time_estimate); expect(mediator.store.humanTotalTimeSpent).toEqual(mockData.human_total_time_spent); - expect(mediator.store.participants).toEqual(mockData.participants); - expect(mediator.store.subscribed).toEqual(mockData.subscribed); expect(mediator.store.timeEstimate).toEqual(mockData.time_estimate); expect(mediator.store.totalTimeSpent).toEqual(mockData.total_time_spent); }); @@ -117,19 +115,4 @@ describe('Sidebar mediator', () => { urlSpy.mockRestore(); }); }); - - it('toggle subscription', () => { - mediator.store.setSubscribedState(false); - mock.onPost(mediatorMockData.toggleSubscriptionEndpoint).reply(200, {}); - const spy = jest - .spyOn(mediator.service, 'toggleSubscription') - .mockReturnValue(Promise.resolve()); - - return mediator.toggleSubscription().then(() => { - expect(spy).toHaveBeenCalled(); - expect(mediator.store.subscribed).toEqual(true); - - spy.mockRestore(); - }); - }); }); diff --git a/spec/frontend/sidebar/sidebar_store_spec.js b/spec/frontend/sidebar/sidebar_store_spec.js index 7b73dc868b7..3930dabfcfa 100644 --- a/spec/frontend/sidebar/sidebar_store_spec.js +++ b/spec/frontend/sidebar/sidebar_store_spec.js @@ -16,17 +16,6 @@ const ANOTHER_ASSINEE = { avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', }; -const PARTICIPANT = { - id: 1, - state: 'active', - username: 'marcene', - name: 'Allie Will', - web_url: 'foo.com', - avatar_url: 'gravatar.com/avatar/xxx', -}; - -const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }]; - describe('Sidebar store', () => { let testContext; @@ -113,28 +102,6 @@ describe('Sidebar store', () => { expect(testContext.store.changing).toBe(true); }); - it('sets participants data', () => { - expect(testContext.store.participants.length).toEqual(0); - - testContext.store.setParticipantsData({ - participants: PARTICIPANT_LIST, - }); - - expect(testContext.store.isFetching.participants).toEqual(false); - expect(testContext.store.participants.length).toEqual(PARTICIPANT_LIST.length); - }); - - it('sets subcriptions data', () => { - expect(testContext.store.subscribed).toEqual(null); - - testContext.store.setSubscriptionsData({ - subscribed: true, - }); - - expect(testContext.store.isFetching.subscriptions).toEqual(false); - expect(testContext.store.subscribed).toEqual(true); - }); - it('set assigned data', () => { const users = { assignees: UsersMockHelper.createNumberRandomUsers(3), @@ -147,11 +114,11 @@ describe('Sidebar store', () => { }); it('sets fetching state', () => { - expect(testContext.store.isFetching.participants).toEqual(true); + expect(testContext.store.isFetching.assignees).toEqual(true); - testContext.store.setFetchingState('participants', false); + testContext.store.setFetchingState('assignees', false); - expect(testContext.store.isFetching.participants).toEqual(false); + expect(testContext.store.isFetching.assignees).toEqual(false); }); it('sets loading state', () => { diff --git a/spec/frontend/sidebar/track_invite_members_spec.js b/spec/frontend/sidebar/track_invite_members_spec.js index 6c96e4cfc76..5946e3320c4 100644 --- a/spec/frontend/sidebar/track_invite_members_spec.js +++ b/spec/frontend/sidebar/track_invite_members_spec.js @@ -10,7 +10,7 @@ describe('Track user dropdown open', () => { document.body.innerHTML = ` <div id="dummy-wrapper-element"> <div class="js-sidebar-assignee-dropdown"> - <div class="js-invite-members-track" data-track-event="_track_event_" data-track-label="_track_label_"> + <div class="js-invite-members-track" data-track-action="_track_event_" data-track-label="_track_label_"> </div> </div> </div> diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index 22e206bb483..40bc6fe6aa5 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -28,6 +28,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = data-uploads-path="" > <markdown-header-stub + data-testid="markdownHeader" linecontent="" suggestionstartindex="0" /> diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index a17efdd61a9..21fed51ff10 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -1,10 +1,15 @@ import { setHTMLFixture } from 'helpers/fixtures'; +import { TEST_HOST } from 'helpers/test_constants'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; -import { getExperimentData } from '~/experimentation/utils'; +import { getExperimentData, getAllExperimentContexts } from '~/experimentation/utils'; import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; +import { REFERRER_TTL, URLS_CACHE_STORAGE_KEY } from '~/tracking/constants'; import getStandardContext from '~/tracking/get_standard_context'; -jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); +jest.mock('~/experimentation/utils', () => ({ + getExperimentData: jest.fn(), + getAllExperimentContexts: jest.fn(), +})); describe('Tracking', () => { let standardContext; @@ -12,9 +17,11 @@ describe('Tracking', () => { let bindDocumentSpy; let trackLoadEventsSpy; let enableFormTracking; + let setAnonymousUrlsSpy; beforeAll(() => { window.gl = window.gl || {}; + window.gl.snowplowUrls = {}; window.gl.snowplowStandardContext = { schema: 'iglu:com.gitlab/gitlab_standard', data: { @@ -29,6 +36,7 @@ describe('Tracking', () => { beforeEach(() => { getExperimentData.mockReturnValue(undefined); + getAllExperimentContexts.mockReturnValue([]); window.snowplow = window.snowplow || (() => {}); window.snowplowOptions = { @@ -70,6 +78,7 @@ describe('Tracking', () => { enableFormTracking = jest .spyOn(Tracking, 'enableFormTracking') .mockImplementation(() => null); + setAnonymousUrlsSpy = jest.spyOn(Tracking, 'setAnonymousUrls').mockImplementation(() => null); }); it('should activate features based on what has been enabled', () => { @@ -100,6 +109,36 @@ describe('Tracking', () => { 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', () => { @@ -266,6 +305,110 @@ describe('Tracking', () => { }); }); + describe('.setAnonymousUrls', () => { + afterEach(() => { + window.gl.snowplowPseudonymizedPageUrl = ''; + localStorage.removeItem(URLS_CACHE_STORAGE_KEY); + }); + + it('does nothing if URLs are not provided', () => { + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).toBe(null); + }); + + it('sets the page URL when provided and populates the cache', () => { + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', TEST_HOST); + expect(JSON.parse(localStorage.getItem(URLS_CACHE_STORAGE_KEY))[0]).toStrictEqual({ + url: TEST_HOST, + referrer: '', + originalUrl: window.location.href, + timestamp: Date.now(), + }); + }); + + it('appends the hash/fragment to the pseudonymized URL', () => { + const hash = 'first-heading'; + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + window.location.hash = hash; + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', `${TEST_HOST}#${hash}`); + }); + + it('does not set the referrer URL by default', () => { + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', expect.any(String)); + }); + + describe('with referrers cache', () => { + const testUrl = '/namespace:1/project:2/-/merge_requests/5'; + const testOriginalUrl = '/my-namespace/my-project/-/merge_requests/'; + const setUrlsCache = (data) => + localStorage.setItem(URLS_CACHE_STORAGE_KEY, JSON.stringify(data)); + + beforeEach(() => { + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + Object.defineProperty(document, 'referrer', { value: '', configurable: true }); + }); + + it('does nothing if a referrer can not be found', () => { + setUrlsCache([ + { + url: testUrl, + originalUrl: TEST_HOST, + timestamp: Date.now(), + }, + ]); + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', expect.any(String)); + }); + + it('sets referrer URL from the page URL found in cache', () => { + Object.defineProperty(document, 'referrer', { value: testOriginalUrl }); + setUrlsCache([ + { + url: testUrl, + originalUrl: testOriginalUrl, + timestamp: Date.now(), + }, + ]); + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).toHaveBeenCalledWith('setReferrerUrl', testUrl); + }); + + it('ignores and removes old entries from the cache', () => { + const oldTimestamp = Date.now() - (REFERRER_TTL + 1); + Object.defineProperty(document, 'referrer', { value: testOriginalUrl }); + setUrlsCache([ + { + url: testUrl, + originalUrl: testOriginalUrl, + timestamp: oldTimestamp, + }, + ]); + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', testUrl); + expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).not.toContain(oldTimestamp); + }); + }); + }); + describe.each` term ${'event'} @@ -349,7 +492,7 @@ describe('Tracking', () => { it('includes experiment data if linked to an experiment', () => { const mockExperimentData = { variant: 'candidate', - experiment: 'repo_integrations_link', + experiment: 'example', key: '2bff73f6bb8cc11156c50a8ba66b9b8b', }; getExperimentData.mockReturnValue(mockExperimentData); diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap new file mode 100644 index 00000000000..a6c36764c41 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`New ready to merge state component renders permission text if canMerge (false) is false 1`] = ` +<div + class="mr-widget-body media" +> + <status-icon-stub + status="success" + /> + + <p + class="media-body gl-m-0! gl-font-weight-bold" + > + + Ready to merge by members who can write to the target branch. + + </p> +</div> +`; + +exports[`New ready to merge state component renders permission text if canMerge (true) is false 1`] = ` +<div + class="mr-widget-body media" +> + <status-icon-stub + status="success" + /> + + <p + class="media-body gl-m-0! gl-font-weight-bold" + > + + Ready to merge! + + </p> +</div> +`; diff --git a/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js new file mode 100644 index 00000000000..bdad0bada5f --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import MergeChecksFailed from '~/vue_merge_request_widget/components/states/merge_checks_failed.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(MergeChecksFailed, { + propsData, + }); +} + +describe('Merge request widget merge checks failed state component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + mrState | displayText + ${{ isPipelineFailed: true }} | ${'pipelineFailed'} + ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'} + ${{ hasMergeableDiscussionsState: true }} | ${'unresolvedDiscussions'} + `('display $displayText text for $mrState', ({ mrState, displayText }) => { + factory({ mr: mrState }); + + expect(wrapper.text()).toContain(MergeChecksFailed.i18n[displayText]); + }); + + describe('unresolved discussions', () => { + it('renders jump to button', () => { + factory({ mr: { hasMergeableDiscussionsState: true } }); + + expect(wrapper.find('[data-testid="jumpToUnresolved"]').exists()).toBe(true); + }); + + it('renders resolve thread button', () => { + factory({ + mr: { + hasMergeableDiscussionsState: true, + createIssueToResolveDiscussionsPath: 'https://gitlab.com', + }, + }); + + expect(wrapper.find('[data-testid="resolveIssue"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="resolveIssue"]').attributes('href')).toBe( + 'https://gitlab.com', + ); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js index c6bfca4516f..e2d79c61b9b 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js @@ -45,7 +45,7 @@ describe('UnresolvedDiscussions', () => { expect(wrapper.element.innerText).toContain(`Merge blocked: all threads must be resolved.`); expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); - expect(wrapper.element.innerText).toContain('Resolve all threads in new issue'); + expect(wrapper.element.innerText).toContain('Create issue to resolve all threads'); expect(wrapper.element.querySelector('.js-create-issue').getAttribute('href')).toEqual( TEST_HOST, ); @@ -57,7 +57,7 @@ describe('UnresolvedDiscussions', () => { expect(wrapper.element.innerText).toContain(`Merge blocked: all threads must be resolved.`); expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); - expect(wrapper.element.innerText).not.toContain('Resolve all threads in new issue'); + expect(wrapper.element.innerText).not.toContain('Create issue to resolve all threads'); expect(wrapper.element.querySelector('.js-create-issue')).toEqual(null); }); }); 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 0609086997b..61e44140efc 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 @@ -64,7 +64,7 @@ describe('Wip', () => { expect(vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); expect(createFlash).toHaveBeenCalledWith({ - message: 'The merge request can now be merged.', + message: 'Marked as ready. Merging is now allowed.', type: 'notice', }); done(); diff --git a/spec/frontend/vue_mr_widget/components/states/new_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/new_ready_to_merge_spec.js new file mode 100644 index 00000000000..5ec9654a4af --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/states/new_ready_to_merge_spec.js @@ -0,0 +1,31 @@ +import { shallowMount } from '@vue/test-utils'; +import ReadyToMerge from '~/vue_merge_request_widget/components/states/new_ready_to_merge.vue'; + +let wrapper; + +function factory({ canMerge }) { + wrapper = shallowMount(ReadyToMerge, { + propsData: { + mr: {}, + }, + data() { + return { canMerge }; + }, + }); +} + +describe('New ready to merge state component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + canMerge + ${true} + ${false} + `('renders permission text if canMerge ($canMerge) is false', ({ canMerge }) => { + factory({ canMerge }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); 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 bab928318ce..c7758b0faef 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 @@ -3,9 +3,13 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <gl-dropdown-stub category="primary" + clearalltext="Clear all" headertext="" hideheaderborder="true" + highlighteditemstitle="Selected" + highlighteditemstitleclass="gl-px-5" right="true" + showhighlighteditemstitle="true" size="medium" text="Clone" variant="info" diff --git a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap index db174346729..7f655d67ae8 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap @@ -2,7 +2,7 @@ exports[`Code Block with default props renders correctly 1`] = ` <pre - class="code-block rounded" + class="code-block rounded code" > <code class="d-block" @@ -14,7 +14,7 @@ exports[`Code Block with default props renders correctly 1`] = ` exports[`Code Block with maxHeight set to "200px" renders correctly 1`] = ` <pre - class="code-block rounded" + class="code-block rounded code" style="max-height: 200px; overflow-y: auto;" > <code diff --git a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap index f4f9cc288f9..87eaabf4e98 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap @@ -9,7 +9,6 @@ exports[`MemoryGraph Render chart should draw container with chart 1`] = ` data="Nov 12 2019 19:17:33,2.87,Nov 12 2019 19:18:33,2.78,Nov 12 2019 19:19:33,2.78,Nov 12 2019 19:20:33,3.01" height="25" tooltiplabel="MB" - variant="gray900" /> </div> `; 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 c4f351eb58d..f2ff12b2acd 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 @@ -3,9 +3,13 @@ exports[`SplitButton renders actionItems 1`] = ` <gl-dropdown-stub category="primary" + clearalltext="Clear all" 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/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js index 6a31742141b..d91853e7b79 100644 --- a/spec/frontend/vue_shared/components/commit_spec.js +++ b/spec/frontend/vue_shared/components/commit_spec.js @@ -162,8 +162,6 @@ describe('Commit component', () => { expect(refEl.attributes('href')).toBe(props.commitRef.ref_url); - expect(refEl.attributes('title')).toBe(props.commitRef.name); - expect(findIcon('branch').exists()).toBe(true); }); }); @@ -195,8 +193,6 @@ describe('Commit component', () => { expect(refEl.attributes('href')).toBe(props.mergeRequestRef.path); - expect(refEl.attributes('title')).toBe(props.mergeRequestRef.title); - expect(findIcon('git-merge').exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js new file mode 100644 index 00000000000..04f63b4bd45 --- /dev/null +++ b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js @@ -0,0 +1,176 @@ +import { + GlSprintf, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, +} from '@gitlab/ui'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DiffStatsDropdown, { i18n } from '~/vue_shared/components/diff_stats_dropdown.vue'; + +jest.mock('fuzzaldrin-plus', () => ({ + filter: jest.fn().mockReturnValue([]), +})); + +const mockFiles = [ + { + added: 0, + href: '#a5cc2925ca8258af241be7e5b0381edf30266302', + icon: 'file-modified', + iconColor: '', + name: '', + path: '.gitignore', + removed: 3, + title: '.gitignore', + }, + { + added: 1, + href: '#fa288d1472d29beccb489a676f68739ad365fc47', + icon: 'file-modified', + iconColor: 'danger', + name: 'package-lock.json', + path: 'lock/file/path', + removed: 1, + }, +]; + +describe('Diff Stats Dropdown', () => { + let wrapper; + + const createComponent = ({ changed = 0, added = 0, deleted = 0, files = [] } = {}) => { + wrapper = shallowMountExtended(DiffStatsDropdown, { + propsData: { + changed, + added, + deleted, + files, + }, + stubs: { + GlSprintf, + GlDropdown, + }, + }); + }; + + const findChanged = () => wrapper.findComponent(GlDropdown); + const findChangedFiles = () => findChanged().findAllComponents(GlDropdownItem); + const findNoFilesText = () => findChanged().findComponent(GlDropdownText); + const findCollapsed = () => wrapper.findByTestId('diff-stats-additions-deletions-expanded'); + const findExpanded = () => wrapper.findByTestId('diff-stats-additions-deletions-collapsed'); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + + describe('file item', () => { + beforeEach(() => { + createComponent({ files: mockFiles }); + }); + + it('when no file name provided ', () => { + expect(findChangedFiles().at(0).text()).toContain(i18n.noFileNameAvailable); + }); + + it('when all file data is available', () => { + const fileData = findChangedFiles().at(1); + const fileText = findChangedFiles().at(1).text(); + expect(fileText).toContain(mockFiles[1].name); + expect(fileText).toContain(mockFiles[1].path); + expect(fileData.props()).toMatchObject({ + iconName: mockFiles[1].icon, + iconColor: mockFiles[1].iconColor, + }); + }); + + it('when no files changed', () => { + createComponent({ files: [] }); + expect(findNoFilesText().text()).toContain(i18n.noFilesFound); + }); + }); + + describe.each` + changed | added | deleted | expectedDropdownHeader | expectedAddedDeletedExpanded | expectedAddedDeletedCollapsed + ${0} | ${0} | ${0} | ${'0 changed files'} | ${'+0 -0'} | ${'with 0 additions and 0 deletions'} + ${2} | ${0} | ${2} | ${'2 changed files'} | ${'+0 -2'} | ${'with 0 additions and 2 deletions'} + ${2} | ${2} | ${0} | ${'2 changed files'} | ${'+2 -0'} | ${'with 2 additions and 0 deletions'} + ${2} | ${1} | ${1} | ${'2 changed files'} | ${'+1 -1'} | ${'with 1 addition and 1 deletion'} + ${1} | ${0} | ${1} | ${'1 changed file'} | ${'+0 -1'} | ${'with 0 additions and 1 deletion'} + ${1} | ${1} | ${0} | ${'1 changed file'} | ${'+1 -0'} | ${'with 1 addition and 0 deletions'} + ${4} | ${2} | ${2} | ${'4 changed files'} | ${'+2 -2'} | ${'with 2 additions and 2 deletions'} + `( + 'when there are $changed changed file(s), $added added and $deleted deleted file(s)', + ({ + changed, + added, + deleted, + expectedDropdownHeader, + expectedAddedDeletedExpanded, + expectedAddedDeletedCollapsed, + }) => { + beforeAll(() => { + createComponent({ changed, added, deleted }); + }); + + afterAll(() => { + wrapper.destroy(); + }); + + it(`dropdown header should be '${expectedDropdownHeader}'`, () => { + expect(findChanged().props('text')).toBe(expectedDropdownHeader); + }); + + it(`added and deleted count in expanded section should be '${expectedAddedDeletedExpanded}'`, () => { + expect(findExpanded().text()).toBe(expectedAddedDeletedExpanded); + }); + + it(`added and deleted count in collapsed section should be '${expectedAddedDeletedCollapsed}'`, () => { + expect(findCollapsed().text()).toBe(expectedAddedDeletedCollapsed); + }); + }, + ); + + describe('fuzzy file search', () => { + beforeEach(() => { + createComponent({ files: mockFiles }); + }); + + it('should call `fuzzaldrinPlus.filter` to search for files when the search query is NOT empty', async () => { + const searchStr = 'file name'; + findSearchBox().vm.$emit('input', searchStr); + await nextTick(); + expect(fuzzaldrinPlus.filter).toHaveBeenCalledWith(mockFiles, searchStr, { key: 'name' }); + }); + + it('should NOT call `fuzzaldrinPlus.filter` to search for files when the search query is empty', async () => { + const searchStr = ''; + findSearchBox().vm.$emit('input', searchStr); + await nextTick(); + expect(fuzzaldrinPlus.filter).not.toHaveBeenCalled(); + }); + }); + + describe('selecting file dropdown item', () => { + beforeEach(() => { + createComponent({ files: mockFiles }); + }); + + it('updates the URL ', () => { + findChangedFiles().at(0).vm.$emit('click'); + expect(window.location.hash).toBe(mockFiles[0].href); + findChangedFiles().at(1).vm.$emit('click'); + expect(window.location.hash).toBe(mockFiles[1].href); + }); + }); + + describe('on dropdown open', () => { + beforeEach(() => { + createComponent(); + }); + + it('should set the search input focus', () => { + wrapper.vm.$refs.search.focusInput = jest.fn(); + findChanged().vm.$emit('shown'); + + expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js index 1b97011bf7f..d85b6e6d115 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -25,7 +25,7 @@ import { const mockStorageKey = 'recent-tokens'; function setLocalStorageAvailability(isAvailable) { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(isAvailable); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(isAvailable); } describe('Filtered Search Utils', () => { @@ -309,7 +309,7 @@ describe('urlQueryToFilter', () => { { [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], }, - { filteredSearchTermKey: 'search', legacySpacesDecode: false }, + { filteredSearchTermKey: 'search' }, ], [ 'search=my terms&foo=bar&nop=xxx', 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 529844817d3..bfb593bf82d 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 @@ -11,7 +11,10 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; -import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + DEFAULT_MILESTONES, + DEFAULT_MILESTONES_GRAPHQL, +} from '~/vue_shared/components/filtered_search_bar/constants'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import { mockMilestoneToken, mockMilestones, mockRegularMilestone } from '../mock_data'; @@ -191,5 +194,22 @@ describe('MilestoneToken', () => { expect(suggestions.at(index).text()).toBe(milestone.text); }); }); + + describe('when getActiveMilestones is called and milestones is empty', () => { + beforeEach(() => { + wrapper = createComponent({ + active: true, + config: { ...mockMilestoneToken, defaultMilestones: DEFAULT_MILESTONES_GRAPHQL }, + }); + }); + + it('finds the correct value from the activeToken', () => { + DEFAULT_MILESTONES_GRAPHQL.forEach(({ value, title }) => { + const activeToken = wrapper.vm.getActiveMilestone([], value); + + expect(activeToken.title).toEqual(title); + }); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js index b54d120b55b..42f4439df51 100644 --- a/spec/frontend/vue_shared/components/header_ci_component_spec.js +++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js @@ -16,8 +16,6 @@ describe('Header CI Component', () => { text: 'failed', details_path: 'path', }, - itemName: 'job', - itemId: 123, time: '2017-05-08T14:57:39.781Z', user: { web_url: 'path', @@ -55,17 +53,13 @@ describe('Header CI Component', () => { describe('render', () => { beforeEach(() => { - createComponent(); + createComponent({ itemName: 'Pipeline' }); }); it('should render status badge', () => { expect(findIconBadge().exists()).toBe(true); }); - it('should render item name and id', () => { - expect(findHeaderItemText().text()).toBe('job #123'); - }); - it('should render timeago date', () => { expect(findTimeAgo().exists()).toBe(true); }); @@ -83,9 +77,29 @@ describe('Header CI Component', () => { }); }); + describe('with item id', () => { + beforeEach(() => { + createComponent({ itemName: 'Pipeline', itemId: '123' }); + }); + + it('should render item name and id', () => { + expect(findHeaderItemText().text()).toBe('Pipeline #123'); + }); + }); + + describe('without item id', () => { + beforeEach(() => { + createComponent({ itemName: 'Job build_job' }); + }); + + it('should render item name', () => { + expect(findHeaderItemText().text()).toBe('Job build_job'); + }); + }); + describe('slot', () => { it('should render header action buttons', () => { - createComponent({}, { slots: { default: 'Test Actions' } }); + createComponent({ itemName: 'Job build_job' }, { slots: { default: 'Test Actions' } }); expect(findActionButtons().exists()).toBe(true); expect(findActionButtons().text()).toBe('Test Actions'); @@ -94,7 +108,7 @@ describe('Header CI Component', () => { describe('shouldRenderTriggeredLabel', () => { it('should render created keyword when the shouldRenderTriggeredLabel is false', () => { - createComponent({ shouldRenderTriggeredLabel: false }); + createComponent({ shouldRenderTriggeredLabel: false, itemName: 'Job build_job' }); expect(wrapper.text()).toContain('created'); expect(wrapper.text()).not.toContain('triggered'); diff --git a/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js b/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js index 573501233b9..ad8331afcff 100644 --- a/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js +++ b/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js @@ -1,5 +1,7 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createStore as createMrStore } from '~/mr_notes/stores'; import createIssueStore from '~/notes/stores'; import IssuableHeaderWarnings from '~/vue_shared/components/issuable/issuable_header_warnings.vue'; @@ -12,52 +14,53 @@ localVue.use(Vuex); describe('IssuableHeaderWarnings', () => { let wrapper; - let store; - const findConfidentialIcon = () => wrapper.find('[data-testid="confidential"]'); - const findLockedIcon = () => wrapper.find('[data-testid="locked"]'); + const findConfidentialIcon = () => wrapper.findByTestId('confidential'); + const findLockedIcon = () => wrapper.findByTestId('locked'); + const findHiddenIcon = () => wrapper.findByTestId('hidden'); const renderTestMessage = (renders) => (renders ? 'renders' : 'does not render'); - const setLock = (locked) => { - store.getters.getNoteableData.discussion_locked = locked; - }; - - const setConfidential = (confidential) => { - store.getters.getNoteableData.confidential = confidential; - }; - - const createComponent = () => { - wrapper = shallowMount(IssuableHeaderWarnings, { store, localVue }); + const createComponent = ({ store, provide }) => { + wrapper = shallowMountExtended(IssuableHeaderWarnings, { + store, + localVue, + provide, + directives: { + GlTooltip: createMockDirective(), + }, + }); }; afterEach(() => { wrapper.destroy(); wrapper = null; - store = null; }); describe.each` issuableType ${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR} `(`when issuableType=$issuableType`, ({ issuableType }) => { - beforeEach(() => { - store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore(); - createComponent(); - }); - describe.each` - lockStatus | confidentialStatus - ${true} | ${true} - ${true} | ${false} - ${false} | ${true} - ${false} | ${false} + lockStatus | confidentialStatus | hiddenStatus + ${true} | ${true} | ${false} + ${true} | ${false} | ${false} + ${false} | ${true} | ${false} + ${false} | ${false} | ${false} + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${true} `( - `when locked=$lockStatus and confidential=$confidentialStatus`, - ({ lockStatus, confidentialStatus }) => { + `when locked=$lockStatus, confidential=$confidentialStatus, and hidden=$hiddenStatus`, + ({ lockStatus, confidentialStatus, hiddenStatus }) => { + const store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore(); + beforeEach(() => { - setLock(lockStatus); - setConfidential(confidentialStatus); + store.getters.getNoteableData.confidential = confidentialStatus; + store.getters.getNoteableData.discussion_locked = lockStatus; + + createComponent({ store, provide: { hidden: hiddenStatus } }); }); it(`${renderTestMessage(lockStatus)} the locked icon`, () => { @@ -67,6 +70,19 @@ describe('IssuableHeaderWarnings', () => { it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => { expect(findConfidentialIcon().exists()).toBe(confidentialStatus); }); + + it(`${renderTestMessage(confidentialStatus)} the hidden icon`, () => { + const hiddenIcon = findHiddenIcon(); + + expect(hiddenIcon.exists()).toBe(hiddenStatus); + + if (hiddenStatus) { + expect(hiddenIcon.attributes('title')).toBe( + 'This issue is hidden because its author has been banned', + ); + expect(getBinding(hiddenIcon.element, 'gl-tooltip')).not.toBeUndefined(); + } + }); }, ); }); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 442032840e1..76e1a1162ad 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -32,7 +32,7 @@ describe('Markdown field component', () => { axiosMock.restore(); }); - function createSubject() { + function createSubject(lines = []) { // We actually mount a wrapper component so that we can force Vue to rerender classes in order to test a regression // caused by mixing Vanilla JS and Vue. subject = mount( @@ -60,6 +60,7 @@ describe('Markdown field component', () => { markdownPreviewPath, isSubmitting: false, textareaValue, + lines, }, }, ); @@ -243,4 +244,14 @@ describe('Markdown field component', () => { }); }); }); + + describe('suggestions', () => { + it('escapes new line characters', () => { + createSubject([{ rich_text: 'hello world\\n' }]); + + expect(subject.find('[data-testid="markdownHeader"]').props('lineContent')).toBe( + 'hello world%br', + ); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js index fb0009ebb8d..75aa3bc7096 100644 --- a/spec/frontend/vue_shared/components/registry/title_area_spec.js +++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js @@ -135,15 +135,16 @@ describe('title area', () => { }, }); }; + it('shows dynamic slots', async () => { mountComponent(); // we manually add a new slot to simulate dynamic slots being evaluated after the initial mount wrapper.vm.$slots[DYNAMIC_SLOT] = createDynamicSlot(); + // updating the slots like we do on line 141 does not cause the updated lifecycle-hook to be triggered + wrapper.vm.$forceUpdate(); await wrapper.vm.$nextTick(); - expect(findDynamicSlot().exists()).toBe(false); - await wrapper.vm.$nextTick(); expect(findDynamicSlot().exists()).toBe(true); }); @@ -160,10 +161,8 @@ describe('title area', () => { 'metadata-foo': wrapper.vm.$slots['metadata-foo'], }; - await wrapper.vm.$nextTick(); - expect(findDynamicSlot().exists()).toBe(false); - expect(findMetadataSlot('metadata-foo').exists()).toBe(true); - + // updating the slots like we do on line 159 does not cause the updated lifecycle-hook to be triggered + wrapper.vm.$forceUpdate(); await wrapper.vm.$nextTick(); expect(findSlotOrderElements().at(0).attributes('data-testid')).toBe(DYNAMIC_SLOT); diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js index 69db3ec7132..ad692a38e65 100644 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js @@ -21,6 +21,7 @@ describe('RunnerAwsDeploymentsModal', () => { wrapper = shallowMount(RunnerAwsDeploymentsModal, { propsData: { modalId: 'runner-aws-deployments-modal', + imgSrc: '/assets/aws-cloud-formation.png', }, }); }; diff --git a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap index ed085fb66dc..165caea2751 100644 --- a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap +++ b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap @@ -8,12 +8,25 @@ exports[`Settings Block renders the correct markup 1`] = ` class="settings-header" > <h4> - <div - data-testid="title-slot" - /> + <span + aria-controls="settings_content_3" + aria-expanded="false" + class="gl-cursor-pointer" + data-testid="section-title-button" + id="settings_label_2" + role="button" + tabindex="0" + > + <div + data-testid="title-slot" + /> + </span> </h4> <gl-button-stub + aria-controls="settings_content_3" + aria-expanded="false" + aria-label="Expand settings section" buttontextclasses="" category="primary" icon="" @@ -33,7 +46,11 @@ exports[`Settings Block renders the correct markup 1`] = ` </div> <div + aria-labelledby="settings_label_2" class="settings-content" + id="settings_content_3" + role="region" + tabindex="-1" > <div data-testid="default-slot" diff --git a/spec/frontend/vue_shared/components/settings/settings_block_spec.js b/spec/frontend/vue_shared/components/settings/settings_block_spec.js index be5a15631eb..528dfd89690 100644 --- a/spec/frontend/vue_shared/components/settings/settings_block_spec.js +++ b/spec/frontend/vue_shared/components/settings/settings_block_spec.js @@ -1,12 +1,12 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import component from '~/vue_shared/components/settings/settings_block.vue'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; describe('Settings Block', () => { let wrapper; const mountComponent = (propsData) => { - wrapper = shallowMount(component, { + wrapper = shallowMount(SettingsBlock, { propsData, slots: { title: '<div data-testid="title-slot"></div>', @@ -18,13 +18,25 @@ describe('Settings Block', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); const findTitleSlot = () => wrapper.find('[data-testid="title-slot"]'); const findDescriptionSlot = () => wrapper.find('[data-testid="description-slot"]'); - const findExpandButton = () => wrapper.find(GlButton); + const findExpandButton = () => wrapper.findComponent(GlButton); + const findSectionTitleButton = () => wrapper.find('[data-testid="section-title-button"]'); + + const expectExpandedState = ({ expanded = true } = {}) => { + const settingsExpandButton = findExpandButton(); + + expect(wrapper.classes('expanded')).toBe(expanded); + expect(settingsExpandButton.text()).toBe( + expanded ? SettingsBlock.i18n.collapseText : SettingsBlock.i18n.expandText, + ); + expect(settingsExpandButton.attributes('aria-label')).toBe( + expanded ? SettingsBlock.i18n.collapseAriaLabel : SettingsBlock.i18n.expandAriaLabel, + ); + }; it('renders the correct markup', () => { mountComponent(); @@ -75,33 +87,41 @@ describe('Settings Block', () => { it('is collapsed by default', () => { mountComponent(); - expect(wrapper.classes('expanded')).toBe(false); + expectExpandedState({ expanded: false }); }); it('adds expanded class when the expand button is clicked', async () => { mountComponent(); - expect(wrapper.classes('expanded')).toBe(false); - expect(findExpandButton().text()).toBe('Expand'); - await findExpandButton().vm.$emit('click'); - expect(wrapper.classes('expanded')).toBe(true); - expect(findExpandButton().text()).toBe('Collapse'); + expectExpandedState({ expanded: true }); }); - it('is expanded when `defaultExpanded` is true no matter what', async () => { - mountComponent({ defaultExpanded: true }); + it('adds expanded class when the section title is clicked', async () => { + mountComponent(); - expect(wrapper.classes('expanded')).toBe(true); + await findSectionTitleButton().trigger('click'); - await findExpandButton().vm.$emit('click'); + expectExpandedState({ expanded: true }); + }); - expect(wrapper.classes('expanded')).toBe(true); + describe('when `collapsible` is `false`', () => { + beforeEach(() => { + mountComponent({ collapsible: false }); + }); - await findExpandButton().vm.$emit('click'); + it('does not render clickable section title', () => { + expect(findSectionTitleButton().exists()).toBe(false); + }); + + it('contains expanded class', () => { + expect(wrapper.classes('expanded')).toBe(true); + }); - expect(wrapper.classes('expanded')).toBe(true); + it('does not render expand toggle button', () => { + expect(findExpandButton().exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index a1942e59571..e39e8794fdd 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -124,7 +124,7 @@ describe('DropdownContentsLabelsView', () => { }); it('returns false when provided `label` param is not one of the selected labels', () => { - expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false); + expect(wrapper.vm.isLabelSelected(mockLabels[1])).toBe(false); }); }); @@ -203,7 +203,7 @@ describe('DropdownContentsLabelsView', () => { it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => { jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); wrapper.setData({ - currentHighlightItem: 1, + currentHighlightItem: 2, }); wrapper.vm.handleKeyDown({ @@ -213,7 +213,7 @@ describe('DropdownContentsLabelsView', () => { expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([ { - ...mockLabels[1], + ...mockLabels[2], set: true, }, ]); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js index c90e63313b2..960ea77cb6e 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js @@ -6,7 +6,7 @@ import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dro import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; -import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data'; +import { mockConfig, mockLabels, mockRegularLabel, mockScopedLabel } from './mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -14,6 +14,9 @@ localVue.use(Vuex); describe('DropdownValue', () => { let wrapper; + const findAllLabels = () => wrapper.findAllComponents(GlLabel); + const findLabel = (index) => findAllLabels().at(index).props('title'); + const createComponent = (initialState = {}, slots = {}) => { const store = new Vuex.Store(labelsSelectModule()); @@ -28,7 +31,6 @@ describe('DropdownValue', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('methods', () => { @@ -82,7 +84,17 @@ describe('DropdownValue', () => { it('renders labels when `selectedLabels` is not empty', () => { createComponent(); - expect(wrapper.findAll(GlLabel).length).toBe(2); + expect(findAllLabels()).toHaveLength(2); + }); + + it('orders scoped labels first', () => { + createComponent({ selectedLabels: mockLabels }); + + expect(findAllLabels()).toHaveLength(mockLabels.length); + expect(findLabel(0)).toBe('Foo::Bar'); + expect(findLabel(1)).toBe('Boog'); + expect(findLabel(2)).toBe('Bug'); + expect(findLabel(3)).toBe('Foo Label'); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js index 730afcbecab..1faa3b0af1d 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js @@ -15,22 +15,22 @@ export const mockScopedLabel = { }; export const mockLabels = [ - mockRegularLabel, - mockScopedLabel, { - id: 28, - title: 'Bug', + id: 29, + title: 'Boog', description: 'Label for bugs', color: '#FF0000', textColor: '#FFFFFF', }, { - id: 29, - title: 'Boog', + id: 28, + title: 'Bug', description: 'Label for bugs', color: '#FF0000', textColor: '#FFFFFF', }, + mockRegularLabel, + mockScopedLabel, ]; export const mockCollapsedLabels = [ diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js deleted file mode 100644 index 0a42d389b67..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js +++ /dev/null @@ -1,91 +0,0 @@ -import { GlIcon, GlButton } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; - -import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue'; - -import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store'; - -import { mockConfig } from './mock_data'; - -let store; -const localVue = createLocalVue(); -localVue.use(Vuex); - -const createComponent = (initialState = mockConfig) => { - store = new Vuex.Store(labelSelectModule()); - - store.dispatch('setInitialState', initialState); - - return shallowMount(DropdownButton, { - localVue, - store, - }); -}; - -describe('DropdownButton', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - const findDropdownButton = () => wrapper.find(GlButton); - const findDropdownText = () => wrapper.find('.dropdown-toggle-text'); - const findDropdownIcon = () => wrapper.find(GlIcon); - - describe('methods', () => { - describe('handleButtonClick', () => { - it.each` - variant | expectPropagationStopped - ${'standalone'} | ${true} - ${'embedded'} | ${false} - `( - 'toggles dropdown content and handles event propagation when `state.variant` is "$variant"', - ({ variant, expectPropagationStopped }) => { - const event = { stopPropagation: jest.fn() }; - - wrapper = createComponent({ ...mockConfig, variant }); - - findDropdownButton().vm.$emit('click', event); - - expect(store.state.showDropdownContents).toBe(true); - expect(event.stopPropagation).toHaveBeenCalledTimes(expectPropagationStopped ? 1 : 0); - }, - ); - }); - }); - - describe('template', () => { - it('renders component container element', () => { - expect(wrapper.find(GlButton).element).toBe(wrapper.element); - }); - - it('renders default button text element', () => { - const dropdownTextEl = findDropdownText(); - - expect(dropdownTextEl.exists()).toBe(true); - expect(dropdownTextEl.text()).toBe('Label'); - }); - - it('renders provided button text element', () => { - store.state.dropdownButtonText = 'Custom label'; - const dropdownTextEl = findDropdownText(); - - return wrapper.vm.$nextTick().then(() => { - expect(dropdownTextEl.text()).toBe('Custom label'); - }); - }); - - it('renders chevron icon element', () => { - const iconEl = findDropdownIcon(); - - expect(iconEl.exists()).toBe(true); - expect(iconEl.props('name')).toBe('chevron-down'); - }); - }); -}); 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 90bc1980ac3..843298a1406 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 @@ -7,7 +7,12 @@ import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; 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 { mockSuggestedColors, createLabelSuccessfulResponse } from './mock_data'; +import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; +import { + mockSuggestedColors, + createLabelSuccessfulResponse, + labelsQueryResponse, +} from './mock_data'; jest.mock('~/flash'); @@ -44,6 +49,14 @@ describe('DropdownContentsCreateView', () => { const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => { const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: projectLabelsQuery, + data: labelsQueryResponse.data, + variables: { + fullPath: '', + searchTerm: '', + }, + }); wrapper = shallowMount(DropdownContentsCreateView, { localVue, 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 8bd944a3d54..537bbc8e71e 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 @@ -45,8 +45,6 @@ describe('DropdownContentsLabelsView', () => { provide: { projectPath: 'test', iid: 1, - allowLabelCreate: true, - labelsManagePath: '/gitlab-org/my-project/-/labels', variant: DropdownVariant.Sidebar, ...injected, }, @@ -69,10 +67,7 @@ describe('DropdownContentsLabelsView', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findLabelsList = () => wrapper.find('[data-testid="labels-list"]'); - const findDropdownWrapper = () => wrapper.find('[data-testid="dropdown-wrapper"]'); - const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); const findNoResultsMessage = () => wrapper.find('[data-testid="no-results"]'); - const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); describe('when loading labels', () => { it('renders disabled search input field', async () => { @@ -109,40 +104,6 @@ describe('DropdownContentsLabelsView', () => { expect(findLabelsList().exists()).toBe(true); expect(findLabels()).toHaveLength(2); }); - - it('changes highlighted label correctly on pressing down button', async () => { - expect(findLabels().at(0).attributes('highlight')).toBeUndefined(); - - await findDropdownWrapper().trigger('keydown.down'); - expect(findLabels().at(0).attributes('highlight')).toBe('true'); - - await findDropdownWrapper().trigger('keydown.down'); - expect(findLabels().at(1).attributes('highlight')).toBe('true'); - expect(findLabels().at(0).attributes('highlight')).toBeUndefined(); - }); - - it('changes highlighted label correctly on pressing up button', async () => { - await findDropdownWrapper().trigger('keydown.down'); - await findDropdownWrapper().trigger('keydown.down'); - expect(findLabels().at(1).attributes('highlight')).toBe('true'); - - await findDropdownWrapper().trigger('keydown.up'); - expect(findLabels().at(0).attributes('highlight')).toBe('true'); - }); - - it('changes label selected state when Enter is pressed', async () => { - expect(findLabels().at(0).attributes('islabelset')).toBeUndefined(); - await findDropdownWrapper().trigger('keydown.down'); - await findDropdownWrapper().trigger('keydown.enter'); - - expect(findLabels().at(0).attributes('islabelset')).toBe('true'); - }); - - it('emits `closeDropdown event` when Esc button is pressed', () => { - findDropdownWrapper().trigger('keydown.esc'); - - expect(wrapper.emitted('closeDropdown')).toEqual([[selectedLabels]]); - }); }); it('when search returns 0 results', async () => { @@ -170,44 +131,4 @@ describe('DropdownContentsLabelsView', () => { await waitForPromises(); expect(createFlash).toHaveBeenCalled(); }); - - it('does not render footer on standalone dropdown', () => { - createComponent({ injected: { variant: DropdownVariant.Standalone } }); - - expect(findDropdownFooter().exists()).toBe(false); - }); - - it('renders footer on sidebar dropdown', () => { - createComponent(); - - expect(findDropdownFooter().exists()).toBe(true); - }); - - it('renders footer on embedded dropdown', () => { - createComponent({ injected: { variant: DropdownVariant.Embedded } }); - - expect(findDropdownFooter().exists()).toBe(true); - }); - - it('does not render create label button if `allowLabelCreate` is false', () => { - createComponent({ injected: { allowLabelCreate: false } }); - - expect(findCreateLabelButton().exists()).toBe(false); - }); - - describe('when `allowLabelCreate` is true', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders create label button', () => { - expect(findCreateLabelButton().exists()).toBe(true); - }); - - it('emits `toggleDropdownContentsCreateView` event on create label button click', () => { - findCreateLabelButton().vm.$emit('click'); - - expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]); - }); - }); }); 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 3c2fd0c5acc..a1b40a891ec 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,77 +1,127 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; +import { GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; 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 labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store'; - -import { mockConfig, mockLabels } from './mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -const createComponent = (initialState = mockConfig, defaultProps = {}) => { - const store = new Vuex.Store(labelsSelectModule()); - - store.dispatch('setInitialState', initialState); - - return shallowMount(DropdownContents, { - propsData: { - ...defaultProps, - labelsCreateTitle: 'test', - selectedLabels: mockLabels, - allowMultiselect: true, - labelsListTitle: 'Assign labels', - footerCreateLabelTitle: 'create', - footerManageLabelTitle: 'manage', - }, - localVue, - store, - }); -}; +import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; +import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; + +import { mockLabels } from './mock_data'; describe('DropdownContent', () => { let wrapper; + const createComponent = ({ props = {}, injected = {} } = {}) => { + wrapper = shallowMount(DropdownContents, { + propsData: { + labelsCreateTitle: 'test', + selectedLabels: mockLabels, + allowMultiselect: true, + labelsListTitle: 'Assign labels', + footerCreateLabelTitle: 'create', + footerManageLabelTitle: 'manage', + dropdownButtonText: 'Labels', + variant: 'sidebar', + ...props, + }, + provide: { + allowLabelCreate: true, + labelsManagePath: 'foo/bar', + ...injected, + }, + stubs: { + GlDropdown, + }, + }); + }; + beforeEach(() => { - wrapper = createComponent(); + createComponent(); }); afterEach(() => { wrapper.destroy(); }); - describe('computed', () => { - describe('dropdownContentsView', () => { - it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => { - wrapper.vm.$store.dispatch('toggleDropdownContentsCreateView'); + const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); + const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); + const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]'); - expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-create-view'); - }); + describe('Create view', () => { + beforeEach(() => { + wrapper.vm.toggleDropdownContentsCreateView(); + }); - it('returns string "dropdown-contents-labels-view" when `showDropdownContentsCreateView` prop is `false`', () => { - expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-labels-view'); - }); + it('renders create view when `showDropdownContentsCreateView` prop is `true`', () => { + expect(wrapper.findComponent(DropdownContentsCreateView).exists()).toBe(true); + }); + + it('does not render footer', () => { + expect(findDropdownFooter().exists()).toBe(false); + }); + + it('does not render create label button', () => { + expect(findCreateLabelButton().exists()).toBe(false); + }); + + it('renders go back button', () => { + expect(findGoBackButton().exists()).toBe(true); }); }); - describe('template', () => { - it('renders component container element with class `labels-select-dropdown-contents` and no styles', () => { - expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents'); - expect(wrapper.attributes('style')).toBeUndefined(); + describe('Labels view', () => { + it('renders labels view when `showDropdownContentsCreateView` when `showDropdownContentsCreateView` prop is `false`', () => { + expect(wrapper.findComponent(DropdownContentsLabelsView).exists()).toBe(true); }); - describe('when `renderOnTop` is true', () => { - it.each` - variant | expected - ${DropdownVariant.Sidebar} | ${'bottom: 3rem'} - ${DropdownVariant.Standalone} | ${'bottom: 2rem'} - ${DropdownVariant.Embedded} | ${'bottom: 2rem'} - `('renders upward for $variant variant', ({ variant, expected }) => { - wrapper = createComponent({ ...mockConfig, variant }, { renderOnTop: true }); + it('renders footer on sidebar dropdown', () => { + expect(findDropdownFooter().exists()).toBe(true); + }); + + it('does not render footer on standalone dropdown', () => { + createComponent({ props: { variant: DropdownVariant.Standalone } }); + + expect(findDropdownFooter().exists()).toBe(false); + }); - expect(wrapper.attributes('style')).toContain(expected); + it('renders footer on embedded dropdown', () => { + createComponent({ props: { variant: DropdownVariant.Embedded } }); + + expect(findDropdownFooter().exists()).toBe(true); + }); + + it('does not render go back button', () => { + expect(findGoBackButton().exists()).toBe(false); + }); + + it('does not render create label button if `allowLabelCreate` is false', () => { + createComponent({ injected: { allowLabelCreate: false } }); + + expect(findCreateLabelButton().exists()).toBe(false); + }); + + describe('when `allowLabelCreate` is true', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders create label button', () => { + expect(findCreateLabelButton().exists()).toBe(true); }); + + it('triggers `toggleDropdownContent` method on create label button click', () => { + jest.spyOn(wrapper.vm, 'toggleDropdownContent').mockImplementation(() => {}); + findCreateLabelButton().trigger('click'); + + expect(wrapper.vm.toggleDropdownContent).toHaveBeenCalled(); + }); + }); + }); + + 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/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js deleted file mode 100644 index d2401a1f725..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; - -import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue'; - -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store'; - -import { mockConfig } from './mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -const createComponent = (initialState = mockConfig) => { - const store = new Vuex.Store(labelsSelectModule()); - - store.dispatch('setInitialState', initialState); - - return shallowMount(DropdownTitle, { - localVue, - store, - propsData: { - labelsSelectInProgress: false, - }, - }); -}; - -describe('DropdownTitle', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('template', () => { - it('renders component container element with string "Labels"', () => { - expect(wrapper.text()).toContain('Labels'); - }); - - it('renders edit link', () => { - const editBtnEl = wrapper.find(GlButton); - - expect(editBtnEl.exists()).toBe(true); - expect(editBtnEl.text()).toBe('Edit'); - }); - - it('renders loading icon element when `labelsSelectInProgress` prop is true', () => { - wrapper.setProps({ - labelsSelectInProgress: true, - }); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js index b3ffee2d020..e7e78cd7a33 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js @@ -9,8 +9,8 @@ describe('DropdownValue', () => { let wrapper; const findAllLabels = () => wrapper.findAllComponents(GlLabel); - const findRegularLabel = () => findAllLabels().at(0); - const findScopedLabel = () => findAllLabels().at(1); + const findRegularLabel = () => findAllLabels().at(1); + const findScopedLabel = () => findAllLabels().at(0); const findWrapper = () => wrapper.find('[data-testid="value-wrapper"]'); const findEmptyPlaceholder = () => wrapper.find('[data-testid="empty-placeholder"]'); @@ -20,11 +20,13 @@ describe('DropdownValue', () => { propsData: { selectedLabels: [mockRegularLabel, mockScopedLabel], allowLabelRemove: true, - allowScopedLabels: true, labelsFilterBasePath: '/gitlab-org/my-project/issues', labelsFilterParam: 'label_name', ...props, }, + provide: { + allowScopedLabels: true, + }, }); }; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js index 23810339833..6e8841411a2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js @@ -1,4 +1,3 @@ -import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; @@ -6,16 +5,10 @@ import { mockRegularLabel } from './mock_data'; const mockLabel = { ...mockRegularLabel, set: true }; -const createComponent = ({ - label = mockLabel, - isLabelSet = mockLabel.set, - highlight = true, -} = {}) => +const createComponent = ({ label = mockLabel } = {}) => shallowMount(LabelItem, { propsData: { label, - isLabelSet, - highlight, }, }); @@ -31,45 +24,6 @@ describe('LabelItem', () => { }); describe('template', () => { - it('renders gl-link component', () => { - expect(wrapper.find(GlLink).exists()).toBe(true); - }); - - it('renders component root with class `is-focused` when `highlight` prop is true', () => { - const wrapperTemp = createComponent({ - highlight: true, - }); - - expect(wrapperTemp.classes()).toContain('is-focused'); - - wrapperTemp.destroy(); - }); - - it('renders visible gl-icon component when `isLabelSet` prop is true', () => { - const wrapperTemp = createComponent({ - isLabelSet: true, - }); - - const iconEl = wrapperTemp.find(GlIcon); - - expect(iconEl.isVisible()).toBe(true); - expect(iconEl.props('name')).toBe('mobile-issue-close'); - - wrapperTemp.destroy(); - }); - - it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => { - const wrapperTemp = createComponent({ - isLabelSet: false, - }); - - const placeholderEl = wrapperTemp.find('[data-testid="no-icon"]'); - - expect(placeholderEl.isVisible()).toBe(true); - - wrapperTemp.destroy(); - }); - it('renders label color element', () => { const colorEl = wrapper.find('[data-testid="label-color-box"]'); 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 e17dfd93efc..a18511fa21d 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,193 +1,74 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; - -import { isInViewport } from '~/lib/utils/common_utils'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; -import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue'; +import { shallowMount } from '@vue/test-utils'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; -import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.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 LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store'; - import { mockConfig } from './mock_data'; -jest.mock('~/lib/utils/common_utils', () => ({ - isInViewport: jest.fn().mockReturnValue(true), -})); - -const localVue = createLocalVue(); -localVue.use(Vuex); - describe('LabelsSelectRoot', () => { let wrapper; - let store; const createComponent = (config = mockConfig, slots = {}) => { wrapper = shallowMount(LabelsSelectRoot, { - localVue, slots, - store, propsData: config, stubs: { - 'dropdown-contents': DropdownContents, + DropdownContents, + SidebarEditableItem, }, provide: { iid: '1', projectPath: 'test', + canUpdate: true, + allowLabelEdit: true, }, }); }; - beforeEach(() => { - store = new Vuex.Store(labelsSelectModule()); - }); - afterEach(() => { wrapper.destroy(); }); - describe('methods', () => { - describe('handleDropdownClose', () => { - beforeEach(() => { - createComponent(); - }); - - it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => { - wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]); - - expect(wrapper.emitted().updateSelectedLabels).toBeTruthy(); - expect(wrapper.emitted().onDropdownClose).toBeTruthy(); - }); - - it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => { - wrapper.vm.handleDropdownClose([]); - - expect(wrapper.emitted().updateSelectedLabels).toBeFalsy(); - expect(wrapper.emitted().onDropdownClose).toBeTruthy(); - }); - }); - - describe('handleCollapsedValueClick', () => { - it('emits `toggleCollapse` event on component', () => { - createComponent(); - wrapper.vm.handleCollapsedValueClick(); - - expect(wrapper.emitted().toggleCollapse).toBeTruthy(); - }); - }); + it('renders component with classes `labels-select-wrapper position-relative`', () => { + createComponent(); + expect(wrapper.classes()).toEqual(['labels-select-wrapper', 'position-relative']); }); - describe('template', () => { - it('renders component with classes `labels-select-wrapper position-relative`', () => { - createComponent(); - expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative'); - }); - - it.each` - variant | cssClass - ${'standalone'} | ${'is-standalone'} - ${'embedded'} | ${'is-embedded'} - `( - 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', - ({ variant, cssClass }) => { - createComponent({ - ...mockConfig, - variant, - }); - - return wrapper.vm.$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); - }); - - it('renders `dropdown-title` component', async () => { - createComponent(); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownTitle).exists()).toBe(true); - }); - - it('renders `dropdown-value` component', async () => { - createComponent(mockConfig, { - default: 'None', + it.each` + variant | cssClass + ${'standalone'} | ${'is-standalone'} + ${'embedded'} | ${'is-embedded'} + `( + 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', + ({ variant, cssClass }) => { + createComponent({ + ...mockConfig, + variant, }); - await wrapper.vm.$nextTick; - - const valueComp = wrapper.find(DropdownValue); - - expect(valueComp.exists()).toBe(true); - expect(valueComp.text()).toBe('None'); - }); - - it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => { - createComponent(); - wrapper.vm.$store.dispatch('toggleDropdownButton'); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownButton).exists()).toBe(true); - }); - - it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => { - createComponent(); - wrapper.vm.$store.dispatch('toggleDropdownContents'); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownContents).exists()).toBe(true); - }); - describe('sets content direction based on viewport', () => { - describe.each(Object.values(DropdownVariant))( - 'when labels variant is "%s"', - ({ variant }) => { - beforeEach(() => { - createComponent({ ...mockConfig, variant }); - wrapper.vm.$store.dispatch('toggleDropdownContents'); - }); - - it('set direction when out of viewport', () => { - isInViewport.mockImplementation(() => false); - wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true); - }); - }); - - it('does not set direction when inside of viewport', () => { - isInViewport.mockImplementation(() => true); - wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); - }); - }); - }, - ); - }); - }); + return wrapper.vm.$nextTick(() => { + expect(wrapper.classes()).toContain(cssClass); + }); + }, + ); - it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => { + it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { createComponent(); - - jest.spyOn(store, 'dispatch').mockResolvedValue(); - await wrapper.setProps({ isEditing: true }); - - expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents'); + await wrapper.vm.$nextTick; + expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); }); - it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => { - createComponent(); + it('renders `dropdown-value` component', async () => { + createComponent(mockConfig, { + default: 'None', + }); + await wrapper.vm.$nextTick; - jest.spyOn(store, 'dispatch').mockResolvedValue(); - await wrapper.setProps({ isEditing: false }); + const valueComp = wrapper.find(DropdownValue); - expect(store.dispatch).not.toHaveBeenCalled(); + expect(valueComp.exists()).toBe(true); + expect(valueComp.text()).toBe('None'); }); }); 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 5dd8fc1b8b2..fceaabec2d0 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,18 +34,12 @@ export const mockLabels = [ ]; export const mockConfig = { - allowLabelEdit: true, - allowLabelCreate: true, - allowScopedLabels: true, allowMultiselect: true, labelsListTitle: 'Assign labels', labelsCreateTitle: 'Create label', variant: 'sidebar', - dropdownOnly: false, selectedLabels: [mockRegularLabel, mockScopedLabel], labelsSelectInProgress: false, - labelsFetchPath: '/gitlab-org/my-project/-/labels.json', - labelsManagePath: '/gitlab-org/my-project/-/labels', labelsFilterBasePath: '/gitlab-org/my-project/issues', labelsFilterParam: 'label_name', footerCreateLabelTitle: 'create', @@ -83,9 +77,7 @@ export const createLabelSuccessfulResponse = { id: 'gid://gitlab/ProjectLabel/126', color: '#dc143c', description: null, - descriptionHtml: '', title: 'ewrwrwer', - textColor: '#FFFFFF', __typename: 'Label', }, errors: [], diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js deleted file mode 100644 index ee905410ffa..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js +++ /dev/null @@ -1,85 +0,0 @@ -import testAction from 'helpers/vuex_action_helper'; -import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions'; -import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types'; -import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state'; - -jest.mock('~/flash'); - -describe('LabelsSelect Actions', () => { - let state; - const mockInitialState = { - labels: [], - selectedLabels: [], - }; - - beforeEach(() => { - state = { ...defaultState() }; - }); - - describe('setInitialState', () => { - it('sets initial store state', (done) => { - testAction( - actions.setInitialState, - mockInitialState, - state, - [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }], - [], - done, - ); - }); - }); - - describe('toggleDropdownButton', () => { - it('toggles dropdown button', (done) => { - testAction( - actions.toggleDropdownButton, - {}, - state, - [{ type: types.TOGGLE_DROPDOWN_BUTTON }], - [], - done, - ); - }); - }); - - describe('toggleDropdownContents', () => { - it('toggles dropdown contents', (done) => { - testAction( - actions.toggleDropdownContents, - {}, - state, - [{ type: types.TOGGLE_DROPDOWN_CONTENTS }], - [], - done, - ); - }); - }); - - describe('toggleDropdownContentsCreateView', () => { - it('toggles dropdown create view', (done) => { - testAction( - actions.toggleDropdownContentsCreateView, - {}, - state, - [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }], - [], - done, - ); - }); - }); - - describe('updateSelectedLabels', () => { - it('updates `state.labels` based on provided `labels` param', (done) => { - const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - - testAction( - actions.updateSelectedLabels, - labels, - state, - [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }], - [], - done, - ); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js deleted file mode 100644 index 40eb0323146..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters'; - -describe('LabelsSelect Getters', () => { - describe('dropdownButtonText', () => { - it.each` - labelType | dropdownButtonText | expected - ${'default'} | ${''} | ${'Label'} - ${'custom'} | ${'Custom label'} | ${'Custom label'} - `( - 'returns $labelType text when state.labels has no selected labels', - ({ dropdownButtonText, expected }) => { - const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - const selectedLabels = []; - const state = { labels, selectedLabels, dropdownButtonText }; - - expect(getters.dropdownButtonText(state, {})).toBe(expected); - }, - ); - - it('returns label title when state.labels has only 1 label', () => { - const labels = [{ id: 1, title: 'Foobar', set: true }]; - - expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( - 'Foobar', - ); - }); - - it('returns first label title and remaining labels count when state.labels has more than 1 label', () => { - const labels = [ - { id: 1, title: 'Foo', set: true }, - { id: 2, title: 'Bar', set: true }, - ]; - - expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( - 'Foo +1 more', - ); - }); - }); - - describe('selectedLabelsList', () => { - it('returns array of IDs of all labels within `state.selectedLabels`', () => { - const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - - expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]); - }); - }); - - describe('isDropdownVariantSidebar', () => { - it('returns `true` when `state.variant` is "sidebar"', () => { - expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true); - }); - }); - - describe('isDropdownVariantStandalone', () => { - it('returns `true` when `state.variant` is "standalone"', () => { - expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js deleted file mode 100644 index 1f0e0eee420..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js +++ /dev/null @@ -1,115 +0,0 @@ -import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types'; -import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations'; - -describe('LabelsSelect Mutations', () => { - describe(`${types.SET_INITIAL_STATE}`, () => { - it('initializes provided props to store state', () => { - const state = {}; - mutations[types.SET_INITIAL_STATE](state, { - labels: 'foo', - }); - - expect(state.labels).toEqual('foo'); - }); - }); - - describe(`${types.TOGGLE_DROPDOWN_BUTTON}`, () => { - it('toggles value of `state.showDropdownButton`', () => { - const state = { - showDropdownButton: false, - }; - mutations[types.TOGGLE_DROPDOWN_BUTTON](state); - - expect(state.showDropdownButton).toBe(true); - }); - }); - - describe(`${types.TOGGLE_DROPDOWN_CONTENTS}`, () => { - it('toggles value of `state.showDropdownButton` when `state.dropdownOnly` is false', () => { - const state = { - dropdownOnly: false, - showDropdownButton: false, - variant: 'sidebar', - }; - mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); - - expect(state.showDropdownButton).toBe(true); - }); - - it('toggles value of `state.showDropdownContents`', () => { - const state = { - showDropdownContents: false, - }; - mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); - - expect(state.showDropdownContents).toBe(true); - }); - - it('sets value of `state.showDropdownContentsCreateView` to `false` when `showDropdownContents` is true', () => { - const state = { - showDropdownContents: false, - showDropdownContentsCreateView: true, - }; - mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); - - expect(state.showDropdownContentsCreateView).toBe(false); - }); - }); - - describe(`${types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW}`, () => { - it('toggles value of `state.showDropdownContentsCreateView`', () => { - const state = { - showDropdownContentsCreateView: false, - }; - mutations[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state); - - expect(state.showDropdownContentsCreateView).toBe(true); - }); - }); - - describe(`${types.UPDATE_SELECTED_LABELS}`, () => { - let labels; - - beforeEach(() => { - labels = [ - { id: 1, title: 'scoped::test', set: true }, - { id: 2, set: false, title: 'scoped::one' }, - { id: 3, title: '' }, - { id: 4, title: '' }, - ]; - }); - - it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => { - const updatedLabelIds = [2]; - const state = { - labels, - }; - mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] }); - - state.labels.forEach((label) => { - if (updatedLabelIds.includes(label.id)) { - expect(label.touched).toBe(true); - expect(label.set).toBe(true); - } - }); - }); - - describe('when label is scoped', () => { - it('unsets the currently selected scoped label and sets the current label', () => { - const state = { - labels, - }; - mutations[types.UPDATE_SELECTED_LABELS](state, { - labels: [{ id: 2, title: 'scoped::one' }], - }); - - expect(state.labels).toEqual([ - { id: 1, title: 'scoped::test', set: false }, - { id: 2, set: true, title: 'scoped::one', touched: true }, - { id: 3, title: '' }, - { id: 4, title: '' }, - ]); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js b/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js new file mode 100644 index 00000000000..103eee4b9a8 --- /dev/null +++ b/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js @@ -0,0 +1,137 @@ +import { shallowMount } from '@vue/test-utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue'; + +let data; +let wrapper; + +function mountComponent({ rootStorageStatistics, limit }) { + wrapper = shallowMount(UsageGraph, { + propsData: { + rootStorageStatistics, + limit, + }, + }); +} +function findStorageTypeUsagesSerialized() { + return wrapper + .findAll('[data-testid="storage-type-usage"]') + .wrappers.map((wp) => wp.element.style.flex); +} + +describe('Storage Counter usage graph component', () => { + beforeEach(() => { + data = { + rootStorageStatistics: { + wikiSize: 5000, + repositorySize: 4000, + packagesSize: 3000, + lfsObjectsSize: 2000, + buildArtifactsSize: 500, + pipelineArtifactsSize: 500, + snippetsSize: 2000, + storageSize: 17000, + uploadsSize: 1000, + }, + limit: 2000, + }; + mountComponent(data); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the legend in order', () => { + const types = wrapper.findAll('[data-testid="storage-type-legend"]'); + + const { + buildArtifactsSize, + pipelineArtifactsSize, + lfsObjectsSize, + packagesSize, + repositorySize, + wikiSize, + snippetsSize, + uploadsSize, + } = data.rootStorageStatistics; + + expect(types.at(0).text()).toMatchInterpolatedText(`Wikis ${numberToHumanSize(wikiSize)}`); + expect(types.at(1).text()).toMatchInterpolatedText( + `Repositories ${numberToHumanSize(repositorySize)}`, + ); + expect(types.at(2).text()).toMatchInterpolatedText( + `Packages ${numberToHumanSize(packagesSize)}`, + ); + expect(types.at(3).text()).toMatchInterpolatedText( + `LFS Objects ${numberToHumanSize(lfsObjectsSize)}`, + ); + expect(types.at(4).text()).toMatchInterpolatedText( + `Snippets ${numberToHumanSize(snippetsSize)}`, + ); + expect(types.at(5).text()).toMatchInterpolatedText( + `Artifacts ${numberToHumanSize(buildArtifactsSize + pipelineArtifactsSize)}`, + ); + expect(types.at(6).text()).toMatchInterpolatedText(`Uploads ${numberToHumanSize(uploadsSize)}`); + }); + + describe('when storage type is not used', () => { + beforeEach(() => { + data.rootStorageStatistics.wikiSize = 0; + mountComponent(data); + }); + + it('filters the storage type', () => { + expect(wrapper.text()).not.toContain('Wikis'); + }); + }); + + describe('when there is no storage usage', () => { + beforeEach(() => { + data.rootStorageStatistics.storageSize = 0; + mountComponent(data); + }); + + it('it does not render', () => { + expect(wrapper.html()).toEqual(''); + }); + }); + + describe('when limit is 0', () => { + beforeEach(() => { + data.limit = 0; + mountComponent(data); + }); + + it('sets correct flex values', () => { + expect(findStorageTypeUsagesSerialized()).toStrictEqual([ + '0.29411764705882354', + '0.23529411764705882', + '0.17647058823529413', + '0.11764705882352941', + '0.11764705882352941', + '0.058823529411764705', + '0.058823529411764705', + ]); + }); + }); + + describe('when storage exceeds limit', () => { + beforeEach(() => { + data.limit = data.rootStorageStatistics.storageSize - 1; + mountComponent(data); + }); + + it('it does render correclty', () => { + expect(findStorageTypeUsagesSerialized()).toStrictEqual([ + '0.29411764705882354', + '0.23529411764705882', + '0.17647058823529413', + '0.11764705882352941', + '0.11764705882352941', + '0.058823529411764705', + '0.058823529411764705', + ]); + }); + }); +}); 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 538e67ef354..926223e0670 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 @@ -94,7 +94,7 @@ describe('User Popover Component', () => { const bio = 'My super interesting bio'; it('should show only bio if work information is not available', () => { - const user = { ...DEFAULT_PROPS.user, bio, bioHtml: bio }; + const user = { ...DEFAULT_PROPS.user, bio }; createWrapper({ user }); @@ -117,7 +117,6 @@ describe('User Popover Component', () => { const user = { ...DEFAULT_PROPS.user, bio, - bioHtml: bio, workInformation: 'Frontend Engineer at GitLab', }; @@ -127,16 +126,15 @@ describe('User Popover Component', () => { expect(findWorkInformation().text()).toBe('Frontend Engineer at GitLab'); }); - it('should not encode special characters in bio', () => { + it('should encode special characters in bio', () => { const user = { ...DEFAULT_PROPS.user, - bio: 'I like CSS', - bioHtml: 'I like <b>CSS</b>', + bio: 'I like <b>CSS</b>', }; createWrapper({ user }); - expect(findBio().html()).toContain('I like <b>CSS</b>'); + expect(findBio().html()).toContain('I like <b>CSS</b>'); }); it('shows icon for bio', () => { @@ -250,6 +248,13 @@ describe('User Popover Component', () => { const securityBotDocsLink = findSecurityBotDocsLink(); expect(securityBotDocsLink.exists()).toBe(true); expect(securityBotDocsLink.attributes('href')).toBe(SECURITY_BOT_USER.websiteUrl); + expect(securityBotDocsLink.text()).toBe('Learn more about GitLab Security Bot'); + }); + + it("doesn't escape user's name", () => { + createWrapper({ user: { ...SECURITY_BOT_USER, name: '%<>\';"' } }); + const securityBotDocsLink = findSecurityBotDocsLink(); + expect(securityBotDocsLink.text()).toBe('Learn more about %<>\';"'); }); }); }); diff --git a/spec/frontend/zen_mode_spec.js b/spec/frontend/zen_mode_spec.js index bf4b57d8afb..13f221fd9d9 100644 --- a/spec/frontend/zen_mode_spec.js +++ b/spec/frontend/zen_mode_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import Dropzone from 'dropzone'; import $ from 'jquery'; import Mousetrap from 'mousetrap'; -import initNotes from '~/init_notes'; +import GLForm from '~/gl_form'; import * as utils from '~/lib/utils/common_utils'; import ZenMode from '~/zen_mode'; @@ -34,7 +34,9 @@ describe('ZenMode', () => { mock.onGet().reply(200); loadFixtures(fixtureName); - initNotes(); + + const form = $('.js-new-note-form'); + new GLForm(form); // eslint-disable-line no-new dropzoneForElementSpy = jest.spyOn(Dropzone, 'forElement').mockImplementation(() => ({ enable: () => true, diff --git a/spec/frontend_integration/README.md b/spec/frontend_integration/README.md index 573a385d81e..377294fb19f 100644 --- a/spec/frontend_integration/README.md +++ b/spec/frontend_integration/README.md @@ -11,6 +11,33 @@ Frontend integration specs: As a result, they deserve their own special place. +## Run frontend integration tests locally + +The frontend integration specs are all about testing integration frontend bundles against a +mock backend. The mock backend is built using the fixtures and GraphQL schema. + +We can generate the necessary fixtures and GraphQL schema by running: + +```shell +bundle exec rake frontend:fixtures gitlab:graphql:schema:dump +``` + +Then we can use [Jest](https://jestjs.io/) to run the frontend integration tests: + +```shell +yarn jest:integration <path-to-integration-test> +``` + +If you'd like to run the frontend integration specs **without** setting up the fixtures first, then you +can set `GL_IGNORE_WARNINGS=1`: + +```shell +GL_IGNORE_WARNINGS=1 yarn jest:integration <path-to-integration-test> +``` + +The `jest-integration` job executes the frontend integration tests in our +CI/CD pipelines. + ## References - https://docs.gitlab.com/ee/development/testing_guide/testing_levels.html#frontend-integration-tests diff --git a/spec/javascripts/fly_out_nav_browser_spec.js b/spec/frontend_integration/fly_out_nav_browser_spec.js index 12ea0e262bc..ef2afa20528 100644 --- a/spec/javascripts/fly_out_nav_browser_spec.js +++ b/spec/frontend_integration/fly_out_nav_browser_spec.js @@ -1,7 +1,3 @@ -// this file can't be migrated to jest because it relies on the browser to perform integration tests: -// (specifically getClientBoundingRect and mouse movements) -// see: https://gitlab.com/groups/gitlab-org/-/epics/895#what-if-theres-a-karma-spec-which-is-simply-unmovable-to-jest-ie-it-is-dependent-on-a-running-browser-environment - import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { SIDEBAR_COLLAPSED_CLASS } from '~/contextual_sidebar'; import { @@ -25,14 +21,46 @@ describe('Fly out sidebar navigation', () => { let el; let breakpointSize = 'lg'; + const OLD_SIDEBAR_WIDTH = 200; + const CONTAINER_INITIAL_BOUNDING_RECT = { + x: 8, + y: 8, + width: 769, + height: 0, + top: 8, + right: 777, + bottom: 8, + left: 8, + }; + const SUB_ITEMS_INITIAL_BOUNDING_RECT = { + x: 148, + y: 8, + width: 0, + height: 150, + top: 8, + right: 148, + bottom: 158, + left: 148, + }; + const mockBoundingClientRect = (elem, rect) => { + jest.spyOn(elem, 'getBoundingClientRect').mockReturnValue(rect); + }; + + const findSubItems = () => document.querySelector('.sidebar-sub-level-items'); + const mockBoundingRects = () => { + const subItems = findSubItems(); + mockBoundingClientRect(el, CONTAINER_INITIAL_BOUNDING_RECT); + mockBoundingClientRect(subItems, SUB_ITEMS_INITIAL_BOUNDING_RECT); + }; + const mockSidebarFragment = (styleProps = '') => + `<div class="sidebar-sub-level-items" style="${styleProps}"></div>`; + beforeEach(() => { el = document.createElement('div'); el.style.position = 'relative'; document.body.appendChild(el); - spyOn(GlBreakpointInstance, 'getBreakpointSize').and.callFake(() => breakpointSize); - - setOpenMenu(null); + jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockImplementation(() => breakpointSize); }); afterEach(() => { @@ -52,24 +80,16 @@ describe('Fly out sidebar navigation', () => { expect(calculateTop(boundingRect, 100)).toBe(100); }); - - it('returns boundingRect - bottomOverflow', () => { - const boundingRect = { - top: window.innerHeight - 50, - height: 100, - }; - - expect(calculateTop(boundingRect, 100)).toBe(window.innerHeight - 50); - }); }); describe('getHideSubItemsInterval', () => { beforeEach(() => { - el.innerHTML = - '<div class="sidebar-sub-level-items" style="position: fixed; top: 0; left: 100px; height: 150px;"></div>'; + el.innerHTML = mockSidebarFragment('position: fixed; top: 0; left: 100px; height: 150px;'); + mockBoundingRects(); }); it('returns 0 if currentOpenMenu is nil', () => { + setOpenMenu(null); expect(getHideSubItemsInterval()).toBe(0); }); @@ -92,7 +112,7 @@ describe('Fly out sidebar navigation', () => { }); it('returns 0 when mouse is below sub-items', () => { - const subItems = el.querySelector('.sidebar-sub-level-items'); + const subItems = findSubItems(); showSubLevelItems(el); documentMouseMove({ @@ -112,6 +132,7 @@ describe('Fly out sidebar navigation', () => { clientX: el.getBoundingClientRect().left, clientY: el.getBoundingClientRect().top, }); + showSubLevelItems(el); documentMouseMove({ clientX: el.getBoundingClientRect().left + 20, @@ -124,17 +145,20 @@ describe('Fly out sidebar navigation', () => { describe('mouseLeaveTopItem', () => { beforeEach(() => { - spyOn(el.classList, 'remove'); + jest.spyOn(el.classList, 'remove'); }); it('removes is-over class if currentOpenMenu is null', () => { + setOpenMenu(null); + mouseLeaveTopItem(el); expect(el.classList.remove).toHaveBeenCalledWith('is-over'); }); it('removes is-over class if currentOpenMenu is null & there are sub-items', () => { - el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>'; + setOpenMenu(null); + el.innerHTML = mockSidebarFragment('position: absolute'); mouseLeaveTopItem(el); @@ -142,9 +166,10 @@ describe('Fly out sidebar navigation', () => { }); it('does not remove is-over class if currentOpenMenu is the passed in sub-items', () => { - el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>'; + setOpenMenu(null); + el.innerHTML = mockSidebarFragment('position: absolute'); - setOpenMenu(el.querySelector('.sidebar-sub-level-items')); + setOpenMenu(findSubItems()); mouseLeaveTopItem(el); expect(el.classList.remove).not.toHaveBeenCalled(); @@ -153,29 +178,33 @@ describe('Fly out sidebar navigation', () => { describe('mouseEnterTopItems', () => { beforeEach(() => { - el.innerHTML = - '<div class="sidebar-sub-level-items" style="position: absolute; top: 0; left: 100px; height: 200px;"></div>'; + el.innerHTML = mockSidebarFragment( + `position: absolute; top: 0; left: 100px; height: ${OLD_SIDEBAR_WIDTH}px;`, + ); + mockBoundingRects(); }); it('shows sub-items after 0ms if no menu is open', (done) => { + const subItems = findSubItems(); mouseEnterTopItems(el); expect(getHideSubItemsInterval()).toBe(0); setTimeout(() => { - expect(el.querySelector('.sidebar-sub-level-items').style.display).toBe('block'); - + expect(subItems.style.display).toBe('block'); done(); }); }); it('shows sub-items after 300ms if a menu is currently open', (done) => { + const subItems = findSubItems(); + documentMouseMove({ clientX: el.getBoundingClientRect().left, clientY: el.getBoundingClientRect().top, }); - setOpenMenu(el.querySelector('.sidebar-sub-level-items')); + setOpenMenu(subItems); documentMouseMove({ clientX: el.getBoundingClientRect().left + 20, @@ -184,10 +213,8 @@ describe('Fly out sidebar navigation', () => { mouseEnterTopItems(el, 0); - expect(getHideSubItemsInterval()).toBe(300); - setTimeout(() => { - expect(el.querySelector('.sidebar-sub-level-items').style.display).toBe('block'); + expect(subItems.style.display).toBe('block'); done(); }); @@ -196,11 +223,11 @@ describe('Fly out sidebar navigation', () => { describe('showSubLevelItems', () => { beforeEach(() => { - el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>'; + el.innerHTML = mockSidebarFragment('position: absolute'); }); it('adds is-over class to el', () => { - spyOn(el.classList, 'add'); + jest.spyOn(el.classList, 'add'); showSubLevelItems(el); @@ -212,17 +239,17 @@ describe('Fly out sidebar navigation', () => { showSubLevelItems(el); - expect(el.querySelector('.sidebar-sub-level-items').style.display).not.toBe('block'); + expect(findSubItems().style.display).not.toBe('block'); }); it('shows sub-items', () => { showSubLevelItems(el); - expect(el.querySelector('.sidebar-sub-level-items').style.display).toBe('block'); + expect(findSubItems().style.display).toBe('block'); }); it('shows collapsed only sub-items if icon only sidebar', () => { - const subItems = el.querySelector('.sidebar-sub-level-items'); + const subItems = findSubItems(); const sidebar = document.createElement('div'); sidebar.classList.add(SIDEBAR_COLLAPSED_CLASS); subItems.classList.add('is-fly-out-only'); @@ -231,11 +258,11 @@ describe('Fly out sidebar navigation', () => { showSubLevelItems(el); - expect(el.querySelector('.sidebar-sub-level-items').style.display).toBe('block'); + expect(findSubItems().style.display).toBe('block'); }); it('does not show collapsed only sub-items if icon only sidebar', () => { - const subItems = el.querySelector('.sidebar-sub-level-items'); + const subItems = findSubItems(); subItems.classList.add('is-fly-out-only'); showSubLevelItems(el); @@ -245,9 +272,9 @@ describe('Fly out sidebar navigation', () => { it('sets transform of sub-items', () => { const sidebar = document.createElement('div'); - const subItems = el.querySelector('.sidebar-sub-level-items'); + const subItems = findSubItems(); - sidebar.style.width = '200px'; + sidebar.style.width = `${OLD_SIDEBAR_WIDTH}px`; document.body.appendChild(sidebar); @@ -255,18 +282,20 @@ describe('Fly out sidebar navigation', () => { showSubLevelItems(el); expect(subItems.style.transform).toBe( - `translate3d(200px, ${ + `translate3d(${OLD_SIDEBAR_WIDTH}px, ${ Math.floor(el.getBoundingClientRect().top) - getHeaderHeight() - }px, 0px)`, + }px, 0)`, ); }); it('sets is-above when element is above', () => { - const subItems = el.querySelector('.sidebar-sub-level-items'); + const subItems = findSubItems(); + mockBoundingRects(); + subItems.style.height = `${window.innerHeight + el.offsetHeight}px`; el.style.top = `${window.innerHeight - el.offsetHeight}px`; - spyOn(subItems.classList, 'add'); + jest.spyOn(subItems.classList, 'add'); showSubLevelItems(el); @@ -313,9 +342,9 @@ describe('Fly out sidebar navigation', () => { describe('subItemsMouseLeave', () => { beforeEach(() => { - el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>'; + el.innerHTML = mockSidebarFragment('position: absolute'); - setOpenMenu(el.querySelector('.sidebar-sub-level-items')); + setOpenMenu(findSubItems()); }); it('hides subMenu if element is not hovered', () => { diff --git a/spec/javascripts/lib/utils/browser_spec.js b/spec/frontend_integration/lib/utils/browser_spec.js index f41fa2503b1..6c72e29076d 100644 --- a/spec/javascripts/lib/utils/browser_spec.js +++ b/spec/frontend_integration/lib/utils/browser_spec.js @@ -1,17 +1,18 @@ -/** - * This file should only contain browser specific specs. - * If you need to add or update a spec, please see spec/frontend/lib/utils/*.js - * https://gitlab.com/gitlab-org/gitlab/issues/194242#note_292137135 - * https://gitlab.com/groups/gitlab-org/-/epics/895#what-if-theres-a-karma-spec-which-is-simply-unmovable-to-jest-ie-it-is-dependent-on-a-running-browser-environment - */ - import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; import * as commonUtils from '~/lib/utils/common_utils'; describe('common_utils browser specific specs', () => { + const mockOffsetHeight = (elem, offsetHeight) => { + Object.defineProperty(elem, 'offsetHeight', { value: offsetHeight }); + }; + + const mockBoundingClientRect = (elem, rect) => { + jest.spyOn(elem, 'getBoundingClientRect').mockReturnValue(rect); + }; + describe('contentTop', () => { it('does not add height for fileTitle or compareVersionsHeader if screen is too small', () => { - spyOn(breakpointInstance, 'isDesktop').and.returnValue(false); + jest.spyOn(breakpointInstance, 'isDesktop').mockReturnValue(false); setFixtures(` <div class="diff-file file-title-flex-parent"> @@ -26,7 +27,7 @@ describe('common_utils browser specific specs', () => { }); it('adds height for fileTitle and compareVersionsHeader screen is large enough', () => { - spyOn(breakpointInstance, 'isDesktop').and.returnValue(true); + jest.spyOn(breakpointInstance, 'isDesktop').mockReturnValue(true); setFixtures(` <div class="diff-file file-title-flex-parent"> @@ -37,6 +38,8 @@ describe('common_utils browser specific specs', () => { </div> `); + mockOffsetHeight(document.querySelector('.diff-file'), 100); + mockOffsetHeight(document.querySelector('.mr-version-controls'), 18); expect(commonUtils.contentTop()).toBe(18); }); }); @@ -54,6 +57,17 @@ describe('common_utils browser specific specs', () => { it('returns true when provided `el` is in viewport', () => { el.setAttribute('style', `position: absolute; right: ${window.innerWidth + 0.2};`); + mockBoundingClientRect(el, { + x: 8, + y: 8, + width: 0, + height: 0, + top: 8, + right: 8, + bottom: 8, + left: 8, + }); + document.body.appendChild(el); expect(commonUtils.isInViewport(el)).toBe(true); @@ -61,6 +75,17 @@ describe('common_utils browser specific specs', () => { it('returns false when provided `el` is not in viewport', () => { el.setAttribute('style', 'position: absolute; top: -1000px; left: -1000px;'); + mockBoundingClientRect(el, { + x: -1000, + y: -1000, + width: 0, + height: 0, + top: -1000, + right: -1000, + bottom: -1000, + left: -1000, + }); + document.body.appendChild(el); expect(commonUtils.isInViewport(el)).toBe(false); diff --git a/spec/graphql/mutations/custom_emoji/destroy_spec.rb b/spec/graphql/mutations/custom_emoji/destroy_spec.rb new file mode 100644 index 00000000000..4667812cc80 --- /dev/null +++ b/spec/graphql/mutations/custom_emoji/destroy_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::CustomEmoji::Destroy do + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:custom_emoji) { create(:custom_emoji, group: group) } + + let(:args) { { id: custom_emoji.to_global_id } } + let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } + + context 'field tests' do + subject { described_class } + + it { is_expected.to have_graphql_arguments(:id) } + it { is_expected.to have_graphql_field(:custom_emoji) } + end + + shared_examples 'does not delete custom emoji' do + it 'raises exception' do + expect { subject } + .to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + shared_examples 'deletes custom emoji' do + it 'returns deleted custom emoji' do + result = subject + + expect(result[:custom_emoji][:name]).to eq(custom_emoji.name) + end + end + + describe '#resolve' do + subject { mutation.resolve(**args) } + + context 'when the user' do + context 'has no permissions' do + it_behaves_like 'does not delete custom emoji' + end + + context 'when the user is developer and not the owner of custom emoji' do + before do + group.add_developer(user) + end + + it_behaves_like 'does not delete custom emoji' + end + end + + context 'when user' do + context 'is maintainer' do + before do + group.add_maintainer(user) + end + + it_behaves_like 'deletes custom emoji' + end + + context 'is owner' do + before do + group.add_owner(user) + end + + it_behaves_like 'deletes custom emoji' + end + + context 'is developer and creator of the emoji' do + before do + group.add_developer(user) + custom_emoji.update_attribute(:creator, user) + end + + it_behaves_like 'deletes custom emoji' + end + end + end +end diff --git a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb new file mode 100644 index 00000000000..ab430b9240b --- /dev/null +++ b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::CustomerRelations::Organizations::Create do + let_it_be(:user) { create(:user) } + + let(:valid_params) do + attributes_for(:organization, + group: group, + description: 'This company is super important!', + default_rate: 1_000 + ) + end + + describe 'create organizations mutation' do + 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 + let_it_be(:group) { create(:group) } + + before do + group.add_guest(user) + end + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when the user has permission' do + let_it_be(:group) { create(:group) } + + before_all do + group.add_reporter(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) + end + end + + context 'when the params are invalid' do + before do + valid_params[:name] = nil + end + + it 'returns the validation error' do + expect(resolve_mutation[:errors]).to eq(["Name can't be blank"]) + end + end + + context 'when the user has permission to create an organization' do + it 'creates organization with correct values' do + expect(resolve_mutation[:organization]).to have_attributes(valid_params) + end + end + end + end + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_organization) } +end diff --git a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb new file mode 100644 index 00000000000..f5aa6c00301 --- /dev/null +++ b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +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(:organization) { create(:organization, group: group) } + let(:attributes) do + { + id: organization.to_global_id, + name: name, + default_rate: default_rate, + 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 an organization' do + let_it_be(:group) { create(:group) } + + before do + group.add_guest(user) + end + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + 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' + + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + 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) + end + + it 'updates the organization with correct values' do + expect(resolve_mutation[:organization]).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) + end + end + end + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_organization) } +end diff --git a/spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb b/spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb new file mode 100644 index 00000000000..792e87f0d25 --- /dev/null +++ b/spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::DependencyProxy::ImageTtlGroupPolicy::Update do + using RSpec::Parameterized::TableSyntax + + let_it_be_with_reload(:group) { create(:group) } + let_it_be(:user) { create(:user) } + + let(:params) { { group_path: group.full_path } } + + 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 'returning a success' do + it 'returns the dependency proxy image ttl group policy with no errors' do + expect(subject).to eq( + dependency_proxy_image_ttl_policy: ttl_policy, + errors: [] + ) + end + end + + shared_examples 'updating the dependency proxy image ttl policy' do + it_behaves_like 'updating the dependency proxy image ttl policy attributes', + from: { enabled: true, ttl: 90 }, + to: { enabled: false, ttl: 2 } + + it_behaves_like 'returning a success' + + context 'with invalid params' do + let_it_be(:params) { { group_path: group.full_path, enabled: nil } } + + it "doesn't create the dependency proxy image ttl policy" do + expect { subject }.not_to change { DependencyProxy::ImageTtlGroupPolicy.count } + end + + it 'does not update' do + expect { subject } + .not_to change { ttl_policy.reload.enabled } + end + + it 'returns an error' do + expect(subject).to eq( + dependency_proxy_image_ttl_policy: nil, + errors: ['Enabled is not included in the list'] + ) + end + end + end + + shared_examples 'denying access to dependency proxy image ttl policy' do + it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + before do + stub_config(dependency_proxy: { enabled: true }) + end + + context 'with existing dependency proxy image ttl policy' do + let_it_be(:ttl_policy) { create(:image_ttl_group_policy, group: group) } + let_it_be(:params) do + { group_path: group.full_path, + enabled: false, + ttl: 2 } + end + + where(:user_role, :shared_examples_name) do + :maintainer | 'updating the dependency proxy image ttl policy' + :developer | 'updating the dependency proxy image ttl policy' + :reporter | 'denying access to dependency proxy image ttl policy' + :guest | 'denying access to dependency proxy image ttl policy' + :anonymous | 'denying access to dependency proxy image ttl policy' + end + + with_them do + before do + group.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + end + + context 'without existing dependency proxy image ttl policy' do + let_it_be(:ttl_policy) { group.dependency_proxy_image_ttl_policy } + + where(:user_role, :shared_examples_name) do + :maintainer | 'creating the dependency proxy image ttl policy' + :developer | 'creating the dependency proxy image ttl policy' + :reporter | 'denying access to dependency proxy image ttl policy' + :guest | 'denying access to dependency proxy image ttl policy' + :anonymous | 'denying access to dependency proxy image ttl policy' + end + + with_them do + before do + group.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + end + end +end diff --git a/spec/graphql/resolvers/board_list_issues_resolver_spec.rb b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb index 6ffc8b045e9..26040f4ec1a 100644 --- a/spec/graphql/resolvers/board_list_issues_resolver_spec.rb +++ b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb @@ -17,21 +17,38 @@ RSpec.describe Resolvers::BoardListIssuesResolver do # auth is handled by the parent object context 'when authorized' do - let!(:issue1) { create(:issue, project: project, labels: [label], relative_position: 10) } - let!(:issue2) { create(:issue, project: project, labels: [label, label2], relative_position: 12) } - let!(:issue3) { create(:issue, project: project, labels: [label, label3], relative_position: 10) } + let!(:issue1) { create(:issue, project: project, labels: [label], relative_position: 10, milestone: started_milestone) } + let!(:issue2) { create(:issue, project: project, labels: [label, label2], relative_position: 12, milestone: started_milestone) } + let!(:issue3) { create(:issue, project: project, labels: [label, label3], relative_position: 10, milestone: future_milestone) } + let!(:issue4) { create(:issue, project: project, labels: [label], relative_position: nil) } - it 'returns the issues in the correct order' do + let(:wildcard_started) { 'STARTED' } + let(:filters) { { milestone_title: ["started"], milestone_wildcard_id: wildcard_started } } + + it 'raises a mutually exclusive filter error when milstone wildcard and title are provided' do + expect do + resolve_board_list_issues(args: { filters: filters }) + end.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + end + + it 'returns issues in the correct order with non-nil relative positions', :aggregate_failures do # by relative_position and then ID - issues = resolve_board_list_issues + result = resolve_board_list_issues - expect(issues.map(&:id)).to eq [issue3.id, issue1.id, issue2.id] + expect(result.map(&:id)).to eq [issue3.id, issue1.id, issue2.id, issue4.id] + expect(result.map(&:relative_position)).not_to include(nil) end it 'finds only issues matching filters' do result = resolve_board_list_issues(args: { filters: { label_name: [label.title], not: { label_name: [label2.title] } } }) - expect(result).to match_array([issue1, issue3]) + expect(result).to match_array([issue1, issue3, issue4]) + end + + it 'finds only issues filtered by milestone wildcard' do + result = resolve_board_list_issues(args: { filters: { milestone_wildcard_id: wildcard_started } }) + + expect(result).to match_array([issue1, issue2]) end it 'finds only issues matching search param' do @@ -49,7 +66,7 @@ RSpec.describe Resolvers::BoardListIssuesResolver do it 'accepts assignee wildcard id NONE' do result = resolve_board_list_issues(args: { filters: { assignee_wildcard_id: 'NONE' } }) - expect(result).to match_array([issue1, issue2, issue3]) + expect(result).to match_array([issue1, issue2, issue3, issue4]) end it 'accepts assignee wildcard id ANY' do @@ -71,6 +88,9 @@ RSpec.describe Resolvers::BoardListIssuesResolver do let(:board_parent) { user_project } let(:project) { user_project } + let_it_be(:started_milestone) { create(:milestone, project: user_project, title: 'started milestone', start_date: 1.day.ago, due_date: 1.day.from_now) } + let_it_be(:future_milestone) { create(:milestone, project: user_project, title: 'future milestone', start_date: 1.day.from_now) } + it_behaves_like 'group and project board list issues resolver' end @@ -84,11 +104,14 @@ RSpec.describe Resolvers::BoardListIssuesResolver do let(:board_parent) { group } let!(:project) { create(:project, :private, group: group) } + let_it_be(:started_milestone) { create(:milestone, group: group, title: 'started milestone', start_date: 1.day.ago, due_date: 1.day.from_now) } + let_it_be(:future_milestone) { create(:milestone, group: group, title: 'future milestone', start_date: 1.day.from_now) } + it_behaves_like 'group and project board list issues resolver' end end def resolve_board_list_issues(args: {}, current_user: user) - resolve(described_class, obj: list, args: args, ctx: { current_user: current_user }) + resolve(described_class, obj: list, args: args, ctx: { current_user: current_user }).items end end diff --git a/spec/graphql/resolvers/ci/group_runners_resolver_spec.rb b/spec/graphql/resolvers/ci/group_runners_resolver_spec.rb new file mode 100644 index 00000000000..89a2437a189 --- /dev/null +++ b/spec/graphql/resolvers/ci/group_runners_resolver_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Ci::GroupRunnersResolver do + include GraphqlHelpers + + describe '#resolve' do + subject { resolve(described_class, obj: obj, ctx: { current_user: user }, args: args) } + + include_context 'runners resolver setup' + + let(:obj) { group } + let(:args) { {} } + + # First, we can do a couple of basic real tests to verify common cases. That ensures that the code works. + context 'when user cannot see runners' do + it 'returns no runners' do + expect(subject.items.to_a).to eq([]) + end + end + + context 'with user as group owner' do + before do + group.add_owner(user) + end + + it 'returns all the runners' do + expect(subject.items.to_a).to contain_exactly(inactive_project_runner, offline_project_runner, group_runner, subgroup_runner) + end + + context 'with membership direct' do + let(:args) { { membership: :direct } } + + it 'returns only direct runners' do + expect(subject.items.to_a).to contain_exactly(group_runner) + end + end + end + + # Then, we can check specific edge cases for this resolver + context 'with obj set to nil' do + let(:obj) { nil } + + it 'raises an error' do + expect { subject }.to raise_error('Expected group missing') + end + end + + context 'with obj not set to group' do + let(:obj) { build(:project) } + + it 'raises an error' do + expect { subject }.to raise_error('Expected group missing') + end + end + + # Here we have a mocked part. We assume that all possible edge cases are covered in RunnersFinder spec. So we don't need to test them twice. + # Only thing we can do is to verify that args from the resolver is correctly transformed to params of the Finder and we return the Finder's result back. + describe 'Allowed query arguments' do + let(:finder) { instance_double(::Ci::RunnersFinder) } + let(:args) do + { + status: 'active', + type: :group_type, + tag_list: ['active_runner'], + search: 'abc', + sort: :contacted_asc, + membership: :descendants + } + end + + let(:expected_params) do + { + status_status: 'active', + type_type: :group_type, + tag_name: ['active_runner'], + preload: { tag_name: nil }, + search: 'abc', + sort: 'contacted_asc', + membership: :descendants, + group: group + } + end + + it 'calls RunnersFinder with expected arguments' do + allow(::Ci::RunnersFinder).to receive(:new).with(current_user: user, params: expected_params).once.and_return(finder) + allow(finder).to receive(:execute).once.and_return([:execute_return_value]) + + expect(subject.items.to_a).to eq([:execute_return_value]) + end + end + end +end diff --git a/spec/graphql/resolvers/ci/runners_resolver_spec.rb b/spec/graphql/resolvers/ci/runners_resolver_spec.rb index 5ac15d5729f..bb8dadeca40 100644 --- a/spec/graphql/resolvers/ci/runners_resolver_spec.rb +++ b/spec/graphql/resolvers/ci/runners_resolver_spec.rb @@ -5,185 +5,70 @@ require 'spec_helper' RSpec.describe Resolvers::Ci::RunnersResolver do include GraphqlHelpers - let_it_be(:user) { create_default(:user, :admin) } - let_it_be(:group) { create(:group, :public) } - let_it_be(:project) { create(:project, :repository, :public) } - - let_it_be(:inactive_project_runner) do - create(:ci_runner, :project, projects: [project], description: 'inactive project runner', token: 'abcdef', active: false, contacted_at: 1.minute.ago, tag_list: %w(project_runner)) - end - - let_it_be(:offline_project_runner) do - create(:ci_runner, :project, projects: [project], description: 'offline project runner', token: 'defghi', contacted_at: 1.day.ago, tag_list: %w(project_runner active_runner)) - end - - let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], token: 'mnopqr', description: 'group runner', contacted_at: 1.second.ago) } - let_it_be(:instance_runner) { create(:ci_runner, :instance, description: 'shared runner', token: 'stuvxz', contacted_at: 2.minutes.ago, tag_list: %w(instance_runner active_runner)) } - describe '#resolve' do - subject { resolve(described_class, ctx: { current_user: user }, args: args).items.to_a } - - let(:args) do - {} - end - - context 'when the user cannot see runners' do - let(:user) { create(:user) } - - it 'returns no runners' do - is_expected.to be_empty - end - end - - context 'without sort' do - it 'returns all the runners' do - is_expected.to contain_exactly(inactive_project_runner, offline_project_runner, group_runner, instance_runner) - end - end - - context 'with a sort argument' do - context "set to :contacted_asc" do - let(:args) do - { sort: :contacted_asc } - end - - it { is_expected.to eq([offline_project_runner, instance_runner, inactive_project_runner, group_runner]) } - end - - context "set to :contacted_desc" do - let(:args) do - { sort: :contacted_desc } - end - - it { is_expected.to eq([offline_project_runner, instance_runner, inactive_project_runner, group_runner].reverse) } - end - - context "set to :created_at_desc" do - let(:args) do - { sort: :created_at_desc } - end + let(:obj) { nil } + let(:args) { {} } - it { is_expected.to eq([instance_runner, group_runner, offline_project_runner, inactive_project_runner]) } - end - - context "set to :created_at_asc" do - let(:args) do - { sort: :created_at_asc } - end - - it { is_expected.to eq([instance_runner, group_runner, offline_project_runner, inactive_project_runner].reverse) } - end - end + subject { resolve(described_class, obj: obj, ctx: { current_user: user }, args: args) } - context 'when type is filtered' do - let(:args) do - { type: runner_type.to_s } - end + include_context 'runners resolver setup' - context 'to instance runners' do - let(:runner_type) { :instance_type } + # First, we can do a couple of basic real tests to verify common cases. That ensures that the code works. + context 'when user cannot see runners' do + let(:user) { build(:user) } - it 'returns the instance runner' do - is_expected.to contain_exactly(instance_runner) - end - end - - context 'to group runners' do - let(:runner_type) { :group_type } - - it 'returns the group runner' do - is_expected.to contain_exactly(group_runner) - end - end - - context 'to project runners' do - let(:runner_type) { :project_type } - - it 'returns the project runner' do - is_expected.to contain_exactly(inactive_project_runner, offline_project_runner) - end + it 'returns no runners' do + expect(subject.items.to_a).to eq([]) end end - context 'when status is filtered' do - let(:args) do - { status: runner_status.to_s } - end - - context 'to active runners' do - let(:runner_status) { :active } - - it 'returns the instance and group runners' do - is_expected.to contain_exactly(offline_project_runner, group_runner, instance_runner) - end - end - - context 'to offline runners' do - let(:runner_status) { :offline } + context 'when user can see runners' do + let(:obj) { nil } - it 'returns the offline project runner' do - is_expected.to contain_exactly(offline_project_runner) - end + it 'returns all the runners' do + expect(subject.items.to_a).to contain_exactly(inactive_project_runner, offline_project_runner, group_runner, subgroup_runner, instance_runner) end end - context 'when tag list is filtered' do - let(:args) do - { tag_list: tag_list } - end - - context 'with "project_runner" tag' do - let(:tag_list) { ['project_runner'] } + # Then, we can check specific edge cases for this resolver + context 'with obj not set to nil' do + let(:obj) { build(:project) } - it 'returns the project_runner runners' do - is_expected.to contain_exactly(offline_project_runner, inactive_project_runner) - end - end - - context 'with "project_runner" and "active_runner" tags as comma-separated string' do - let(:tag_list) { ['project_runner,active_runner'] } - - it 'returns the offline_project_runner runner' do - is_expected.to contain_exactly(offline_project_runner) - end - end - - context 'with "active_runner" and "instance_runner" tags as array' do - let(:tag_list) { %w[instance_runner active_runner] } - - it 'returns the offline_project_runner runner' do - is_expected.to contain_exactly(instance_runner) - end + it 'raises an error' do + expect { subject }.to raise_error(a_string_including('Unexpected parent type')) end end - context 'when text is filtered' do + # Here we have a mocked part. We assume that all possible edge cases are covered in RunnersFinder spec. So we don't need to test them twice. + # Only thing we can do is to verify that args from the resolver is correctly transformed to params of the Finder and we return the Finder's result back. + describe 'Allowed query arguments' do + let(:finder) { instance_double(::Ci::RunnersFinder) } let(:args) do - { search: search_term } - end - - context 'to "project"' do - let(:search_term) { 'project' } - - it 'returns both project runners' do - is_expected.to contain_exactly(inactive_project_runner, offline_project_runner) - end - end - - context 'to "group"' do - let(:search_term) { 'group' } - - it 'returns group runner' do - is_expected.to contain_exactly(group_runner) - end - end - - context 'to "defghi"' do - let(:search_term) { 'defghi' } - - it 'returns runners containing term in token' do - is_expected.to contain_exactly(offline_project_runner) - end + { + status: 'active', + type: :instance_type, + tag_list: ['active_runner'], + search: 'abc', + sort: :contacted_asc + } + end + + let(:expected_params) do + { + status_status: 'active', + type_type: :instance_type, + tag_name: ['active_runner'], + preload: { tag_name: nil }, + search: 'abc', + sort: 'contacted_asc' + } + end + + it 'calls RunnersFinder with expected arguments' do + allow(::Ci::RunnersFinder).to receive(:new).with(current_user: user, params: expected_params).once.and_return(finder) + allow(finder).to receive(:execute).once.and_return([:execute_return_value]) + + expect(subject.items.to_a).to eq([:execute_return_value]) end end end diff --git a/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb b/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb index 6f6855c8f84..865e892b12d 100644 --- a/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb +++ b/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb @@ -27,7 +27,7 @@ RSpec.describe ResolvesPipelines do project.add_developer(current_user) end - it { is_expected.to have_graphql_arguments(:status, :ref, :sha) } + it { is_expected.to have_graphql_arguments(:status, :ref, :sha, :source) } it 'finds all pipelines' do expect(resolve_pipelines).to contain_exactly(pipeline, failed_pipeline, ref_pipeline, sha_pipeline) @@ -45,6 +45,30 @@ RSpec.describe ResolvesPipelines do expect(resolve_pipelines(sha: 'deadbeef')).to contain_exactly(sha_pipeline) end + context 'filtering by source' do + let_it_be(:source_pipeline) { create(:ci_pipeline, project: project, source: 'web') } + + context 'when `dast_view_scans` feature flag is disabled' do + before do + stub_feature_flags(dast_view_scans: false) + end + + it 'does not filter by source' do + expect(resolve_pipelines(source: 'web')).to contain_exactly(pipeline, failed_pipeline, ref_pipeline, sha_pipeline, source_pipeline) + end + end + + context 'when `dast_view_scans` feature flag is enabled' do + it 'does filter by source' do + expect(resolve_pipelines(source: 'web')).to contain_exactly(source_pipeline) + end + + it 'returns all the pipelines' do + expect(resolve_pipelines).to contain_exactly(pipeline, failed_pipeline, ref_pipeline, sha_pipeline, source_pipeline) + end + end + end + it 'does not return any pipelines if the user does not have access' do expect(resolve_pipelines({}, {})).to be_empty end diff --git a/spec/graphql/resolvers/group_resolver_spec.rb b/spec/graphql/resolvers/group_resolver_spec.rb index a03e7854177..ed406d14772 100644 --- a/spec/graphql/resolvers/group_resolver_spec.rb +++ b/spec/graphql/resolvers/group_resolver_spec.rb @@ -20,10 +20,15 @@ RSpec.describe Resolvers::GroupResolver do end it 'resolves an unknown full_path to nil' do - result = batch_sync { resolve_group('unknown/project') } + result = batch_sync { resolve_group('unknown/group') } expect(result).to be_nil end + + it 'treats group full path as case insensitive' do + result = batch_sync { resolve_group(group1.full_path.upcase) } + expect(result).to eq group1 + end end def resolve_group(full_path) diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index 6e187e57729..e992b2b04ae 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -6,6 +6,7 @@ RSpec.describe Resolvers::IssuesResolver do include GraphqlHelpers let_it_be(:current_user) { create(:user) } + let_it_be(:reporter) { create(:user) } let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } @@ -19,6 +20,7 @@ RSpec.describe Resolvers::IssuesResolver do let_it_be(:issue4) { create(:issue) } let_it_be(:label1) { create(:label, project: project) } let_it_be(:label2) { create(:label, project: project) } + let_it_be(:upvote_award) { create(:award_emoji, :upvote, user: current_user, awardable: issue1) } specify do expect(described_class).to have_nullable_graphql_type(Types::IssueType.connection_type) @@ -27,6 +29,7 @@ RSpec.describe Resolvers::IssuesResolver do context "with a project" do before_all do project.add_developer(current_user) + project.add_reporter(reporter) create(:label_link, label: label1, target: issue1) create(:label_link, label: label1, target: issue2) create(:label_link, label: label2, target: issue2) @@ -198,6 +201,27 @@ RSpec.describe Resolvers::IssuesResolver do end end + context 'filtering by reaction emoji' do + let_it_be(:downvoted_issue) { create(:issue, project: project) } + let_it_be(:downvote_award) { create(:award_emoji, :downvote, user: current_user, awardable: downvoted_issue) } + + it 'filters by reaction emoji' do + expect(resolve_issues(my_reaction_emoji: upvote_award.name)).to contain_exactly(issue1) + end + + it 'filters by reaction emoji wildcard "none"' do + expect(resolve_issues(my_reaction_emoji: 'none')).to contain_exactly(issue2) + end + + it 'filters by reaction emoji wildcard "any"' do + expect(resolve_issues(my_reaction_emoji: 'any')).to contain_exactly(issue1, downvoted_issue) + end + + it 'filters by negated reaction emoji' do + expect(resolve_issues(not: { my_reaction_emoji: downvote_award.name })).to contain_exactly(issue1, issue2) + end + end + context 'when searching issues' do it 'returns correct issues' do expect(resolve_issues(search: 'foo')).to contain_exactly(issue2) @@ -235,6 +259,14 @@ RSpec.describe Resolvers::IssuesResolver do it 'returns issues without the specified assignee_id' do expect(resolve_issues(not: { assignee_id: [assignee.id] })).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) } + + it 'returns issues without the specified author_username' do + expect(resolve_issues(not: { author_username: issue1.author.username })).to contain_exactly(issue_by_reporter) + end + end end describe 'sorting' do @@ -382,6 +414,22 @@ RSpec.describe Resolvers::IssuesResolver do end end end + + context 'when sorting by title' do + let_it_be(:project) { create(:project, :public) } + let_it_be(:issue1) { create(:issue, project: project, title: 'foo') } + let_it_be(:issue2) { create(:issue, project: project, title: 'bar') } + let_it_be(:issue3) { create(:issue, project: project, title: 'baz') } + let_it_be(:issue4) { create(:issue, project: project, title: 'Baz 2') } + + it 'sorts issues ascending' do + expect(resolve_issues(sort: :title_asc).to_a).to eq [issue2, issue3, issue4, issue1] + end + + it 'sorts issues descending' do + expect(resolve_issues(sort: :title_desc).to_a).to eq [issue1, issue4, issue3, issue2] + end + end end it 'returns issues user can see' do diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb index 64ee0d4f9cc..a897acf7eba 100644 --- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb +++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb @@ -294,16 +294,6 @@ RSpec.describe Resolvers::MergeRequestsResolver do nils_last(mr.metrics.merged_at) end - context 'when label filter is given and the optimized_issuable_label_filter feature flag is off' do - before do - stub_feature_flags(optimized_issuable_label_filter: false) - end - - it 'does not raise PG::GroupingError' do - expect { resolve_mr(project, sort: :merged_at_desc, labels: %w[a b]) }.not_to raise_error - end - end - context 'when sorting by closed at' do before do merge_request_1.metrics.update!(latest_closed_at: 10.days.ago) diff --git a/spec/graphql/resolvers/project_resolver_spec.rb b/spec/graphql/resolvers/project_resolver_spec.rb index d0661c27b95..cd3fdc788e6 100644 --- a/spec/graphql/resolvers/project_resolver_spec.rb +++ b/spec/graphql/resolvers/project_resolver_spec.rb @@ -25,6 +25,11 @@ RSpec.describe Resolvers::ProjectResolver do expect(result).to be_nil end + + it 'treats project full path as case insensitive' do + result = batch_sync { resolve_project(project1.full_path.upcase) } + expect(result).to eq project1 + end end it 'does not increase complexity depending on number of load limits' do diff --git a/spec/graphql/resolvers/users/groups_resolver_spec.rb b/spec/graphql/resolvers/users/groups_resolver_spec.rb new file mode 100644 index 00000000000..0fdb6da5ae9 --- /dev/null +++ b/spec/graphql/resolvers/users/groups_resolver_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Users::GroupsResolver do + include GraphqlHelpers + include AdminModeHelper + + describe '#resolve' do + let_it_be(:user) { create(:user) } + let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') } + let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer') } + let_it_be(:public_developer_group) { create(:group, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') } + let_it_be(:public_maintainer_group) { create(:group, name: 'a public maintainer', path: 'a-public-maintainer') } + + subject(:resolved_items) { resolve_groups(args: group_arguments, current_user: current_user, obj: resolver_object) } + + let(:group_arguments) { {} } + let(:current_user) { user } + let(:resolver_object) { user } + + before_all do + guest_group.add_guest(user) + private_maintainer_group.add_maintainer(user) + public_developer_group.add_developer(user) + public_maintainer_group.add_maintainer(user) + end + + context 'when paginatable_namespace_drop_down_for_project_creation feature flag is disabled' do + before do + stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false) + end + + it { is_expected.to be_nil } + end + + context 'when resolver object is current user' do + context 'when permission is :create_projects' do + let(:group_arguments) { { permission_scope: :create_projects } } + + specify do + is_expected.to match( + [ + public_maintainer_group, + private_maintainer_group, + public_developer_group + ] + ) + end + end + + specify do + is_expected.to match( + [ + public_maintainer_group, + private_maintainer_group, + public_developer_group, + guest_group + ] + ) + end + + context 'when search is provided' do + let(:group_arguments) { { search: 'maintainer' } } + + specify do + is_expected.to match( + [ + public_maintainer_group, + private_maintainer_group + ] + ) + end + end + end + + context 'when resolver object is different from current user' do + let(:current_user) { create(:user) } + + it { is_expected.to be_nil } + + context 'when current_user is admin' do + let(:current_user) { create(:user, :admin) } + + before do + enable_admin_mode!(current_user) + end + + specify do + is_expected.to match( + [ + public_maintainer_group, + private_maintainer_group, + public_developer_group, + guest_group + ] + ) + end + end + end + end + + def resolve_groups(args:, current_user:, obj:) + resolve(described_class, args: args, ctx: { current_user: current_user }, obj: obj)&.items + end +end diff --git a/spec/graphql/types/ci/job_type_spec.rb b/spec/graphql/types/ci/job_type_spec.rb index 54fe0c4b707..e95a7da4fe5 100644 --- a/spec/graphql/types/ci/job_type_spec.rb +++ b/spec/graphql/types/ci/job_type_spec.rb @@ -4,7 +4,6 @@ require 'spec_helper' RSpec.describe Types::Ci::JobType do specify { expect(described_class.graphql_name).to eq('CiJob') } - specify { expect(described_class).to require_graphql_authorizations(:read_commit_status) } specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Ci::Job) } it 'exposes the expected fields' do diff --git a/spec/graphql/types/customer_relations/contact_type_spec.rb b/spec/graphql/types/customer_relations/contact_type_spec.rb new file mode 100644 index 00000000000..a51ee705fb0 --- /dev/null +++ b/spec/graphql/types/customer_relations/contact_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CustomerRelationsContact'] do + let(:fields) { %i[id organization first_name last_name phone email description created_at updated_at] } + + it { expect(described_class.graphql_name).to eq('CustomerRelationsContact') } + it { expect(described_class).to have_graphql_fields(fields) } + it { expect(described_class).to require_graphql_authorizations(:read_contact) } +end diff --git a/spec/graphql/types/customer_relations/organization_type_spec.rb b/spec/graphql/types/customer_relations/organization_type_spec.rb new file mode 100644 index 00000000000..2562748477c --- /dev/null +++ b/spec/graphql/types/customer_relations/organization_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CustomerRelationsOrganization'] do + let(:fields) { %i[id name default_rate description created_at updated_at] } + + it { expect(described_class.graphql_name).to eq('CustomerRelationsOrganization') } + it { expect(described_class).to have_graphql_fields(fields) } + it { expect(described_class).to require_graphql_authorizations(:read_organization) } +end diff --git a/spec/graphql/types/dependency_proxy/blob_type_spec.rb b/spec/graphql/types/dependency_proxy/blob_type_spec.rb new file mode 100644 index 00000000000..e1c8471975e --- /dev/null +++ b/spec/graphql/types/dependency_proxy/blob_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['DependencyProxyBlob'] do + it 'includes dependency proxy blob fields' do + expected_fields = %w[ + file_name size created_at updated_at + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/dependency_proxy/group_setting_type_spec.rb b/spec/graphql/types/dependency_proxy/group_setting_type_spec.rb new file mode 100644 index 00000000000..7c6d7b8aece --- /dev/null +++ b/spec/graphql/types/dependency_proxy/group_setting_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['DependencyProxySetting'] do + it 'includes dependency proxy blob fields' do + expected_fields = %w[ + enabled + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb b/spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb new file mode 100644 index 00000000000..46347e0434f --- /dev/null +++ b/spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['DependencyProxyImageTtlGroupPolicy'] do + it { expect(described_class.graphql_name).to eq('DependencyProxyImageTtlGroupPolicy') } + + it { expect(described_class.description).to eq('Group-level Dependency Proxy TTL policy settings') } + + it { expect(described_class).to require_graphql_authorizations(:read_dependency_proxy) } + + it 'includes dependency proxy image ttl policy fields' do + expected_fields = %w[enabled ttl created_at updated_at] + + expect(described_class).to have_graphql_fields(*expected_fields).only + end +end diff --git a/spec/graphql/types/dependency_proxy/manifest_type_spec.rb b/spec/graphql/types/dependency_proxy/manifest_type_spec.rb new file mode 100644 index 00000000000..18cc89adfcb --- /dev/null +++ b/spec/graphql/types/dependency_proxy/manifest_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['DependencyProxyManifest'] do + it 'includes dependency proxy manifest fields' do + expected_fields = %w[ + file_name image_name size created_at updated_at digest + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/group_type_spec.rb b/spec/graphql/types/group_type_spec.rb index 33250f8e6af..dca2c930eea 100644 --- a/spec/graphql/types/group_type_spec.rb +++ b/spec/graphql/types/group_type_spec.rb @@ -18,7 +18,11 @@ RSpec.describe GitlabSchema.types['Group'] do two_factor_grace_period auto_devops_enabled emails_disabled mentions_disabled parent boards milestones group_members merge_requests container_repositories container_repositories_count - packages shared_runners_setting timelogs + packages dependency_proxy_setting dependency_proxy_manifests + dependency_proxy_blobs dependency_proxy_image_count + dependency_proxy_blob_count dependency_proxy_total_size + dependency_proxy_image_prefix dependency_proxy_image_ttl_policy + shared_runners_setting timelogs organizations contacts ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb index b0aa11ee5ad..559f347810b 100644 --- a/spec/graphql/types/issue_type_spec.rb +++ b/spec/graphql/types/issue_type_spec.rb @@ -15,7 +15,7 @@ RSpec.describe GitlabSchema.types['Issue'] do it 'has specific fields' do fields = %i[id iid title description state reference author assignees updated_by participants labels milestone due_date - confidential discussion_locked upvotes downvotes merge_requests_count user_notes_count user_discussions_count web_path web_url relative_position + 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] @@ -201,4 +201,54 @@ RSpec.describe GitlabSchema.types['Issue'] do end end end + + describe 'hidden', :enable_admin_mode do + let_it_be(:admin) { create(:user, :admin)} + let_it_be(:banned_user) { create(:user, :banned) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public) } + let_it_be(:hidden_issue) { create(:issue, project: project, author: banned_user) } + let_it_be(:visible_issue) { create(:issue, project: project, author: user) } + + let(:issue) { hidden_issue } + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + issue(iid: "#{issue.iid}") { + hidden + } + } + } + ) + end + + subject { GitlabSchema.execute(query, context: { current_user: admin }).as_json } + + context 'when `ban_user_feature_flag` is enabled' do + context 'when issue is hidden' do + it 'returns `true`' do + expect(subject.dig('data', 'project', 'issue', 'hidden')).to eq(true) + end + end + + context 'when issue is visible' do + let(:issue) { visible_issue } + + it 'returns `false`' do + expect(subject.dig('data', 'project', 'issue', 'hidden')).to eq(false) + end + end + end + + context 'when `ban_user_feature_flag` is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it 'returns `nil`' do + expect(subject.dig('data', 'project', 'issue', 'hidden')).to be_nil + end + end + end end diff --git a/spec/graphql/types/merge_requests/reviewer_type_spec.rb b/spec/graphql/types/merge_requests/reviewer_type_spec.rb index 4ede8e5788f..4d357a922f8 100644 --- a/spec/graphql/types/merge_requests/reviewer_type_spec.rb +++ b/spec/graphql/types/merge_requests/reviewer_type_spec.rb @@ -33,6 +33,7 @@ RSpec.describe GitlabSchema.types['MergeRequestReviewer'] do merge_request_interaction namespace timelogs + groups ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/project_statistics_type_spec.rb b/spec/graphql/types/project_statistics_type_spec.rb index 407ce82e73a..f515907b6a8 100644 --- a/spec/graphql/types/project_statistics_type_spec.rb +++ b/spec/graphql/types/project_statistics_type_spec.rb @@ -6,6 +6,6 @@ RSpec.describe GitlabSchema.types['ProjectStatistics'] do it 'has all the required fields' do expect(described_class).to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size, :build_artifacts_size, :packages_size, :commit_count, - :wiki_size, :snippets_size, :uploads_size) + :wiki_size, :snippets_size, :pipeline_artifacts_size, :uploads_size) end end diff --git a/spec/graphql/types/terraform/state_version_type_spec.rb b/spec/graphql/types/terraform/state_version_type_spec.rb index 18f869e4f1f..b015a2045da 100644 --- a/spec/graphql/types/terraform/state_version_type_spec.rb +++ b/spec/graphql/types/terraform/state_version_type_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['TerraformStateVersion'] do + include GraphqlHelpers + it { expect(described_class.graphql_name).to eq('TerraformStateVersion') } it { expect(described_class).to require_graphql_authorizations(:read_terraform_state) } @@ -19,4 +21,60 @@ RSpec.describe GitlabSchema.types['TerraformStateVersion'] do it { expect(described_class.fields['createdAt'].type).to be_non_null } it { expect(described_class.fields['updatedAt'].type).to be_non_null } end + + describe 'query' do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:terraform_state) { create(:terraform_state, :with_version, :locked, project: project) } + + before do + project.add_developer(user) + end + + let(:query) do + <<~GRAPHQL + query { + project(fullPath: "#{project.full_path}") { + terraformState(name: "#{terraform_state.name}") { + latestVersion { + id + job { + name + } + } + } + } + } + GRAPHQL + end + + subject(:execute) { GitlabSchema.execute(query, context: { current_user: user }).as_json } + + shared_examples 'returning latest version' do + it 'returns latest version of terraform state' do + expect(execute.dig('data', 'project', 'terraformState', 'latestVersion', 'id')).to eq( + global_id_of(terraform_state.latest_version) + ) + end + end + + it_behaves_like 'returning latest version' + + it 'returns job of the latest version' do + expect(execute.dig('data', 'project', 'terraformState', 'latestVersion', 'job')).to be_present + end + + context 'when user cannot read jobs' do + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(user, :read_commit_status, terraform_state.latest_version).and_return(false) + end + + it_behaves_like 'returning latest version' + + it 'does not return job of the latest version' do + expect(execute.dig('data', 'project', 'terraformState', 'latestVersion', 'job')).not_to be_present + end + end + end end diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb index 363ccdf88b7..0bad8c95ba2 100644 --- a/spec/graphql/types/user_type_spec.rb +++ b/spec/graphql/types/user_type_spec.rb @@ -38,6 +38,7 @@ RSpec.describe GitlabSchema.types['User'] do callouts namespace timelogs + groups ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/helpers/analytics/cycle_analytics_helper_spec.rb b/spec/helpers/analytics/cycle_analytics_helper_spec.rb new file mode 100644 index 00000000000..d906646e25c --- /dev/null +++ b/spec/helpers/analytics/cycle_analytics_helper_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true +require "spec_helper" + +RSpec.describe Analytics::CycleAnalyticsHelper do + describe '#cycle_analytics_initial_data' do + let(:user) { create(:user, name: 'fake user', username: 'fake_user') } + let(:image_path_keys) { [:empty_state_svg_path, :no_data_svg_path, :no_access_svg_path] } + let(:api_path_keys) { [:milestones_path, :labels_path] } + let(:additional_data_keys) { [:full_path, :group_id, :group_path, :project_id, :request_path] } + let(:group) { create(:group) } + + subject(:cycle_analytics_data) { helper.cycle_analytics_initial_data(project, group) } + + before do + project.add_maintainer(user) + end + + context 'when a group is present' do + let(:project) { create(:project, group: group) } + + it "sets the correct data keys" do + expect(cycle_analytics_data.keys) + .to match_array(api_path_keys + image_path_keys + additional_data_keys) + end + + it "sets group paths" do + expect(cycle_analytics_data) + .to include({ + full_path: project.full_path, + group_path: "/#{project.namespace.name}", + group_id: project.namespace.id, + request_path: "/#{project.full_path}/-/value_stream_analytics", + milestones_path: "/groups/#{group.name}/-/milestones.json", + labels_path: "/groups/#{group.name}/-/labels.json" + }) + end + end + + context 'when a group is not present' do + let(:group) { nil } + let(:project) { create(:project) } + + it "sets the correct data keys" do + expect(cycle_analytics_data.keys) + .to match_array(image_path_keys + api_path_keys + additional_data_keys) + end + + it "sets project name space paths" do + expect(cycle_analytics_data) + .to include({ + full_path: project.full_path, + group_path: project.namespace.path, + group_id: project.namespace.id, + request_path: "/#{project.full_path}/-/value_stream_analytics", + milestones_path: "/#{project.full_path}/-/milestones.json", + labels_path: "/#{project.full_path}/-/labels.json" + }) + end + end + end +end diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb index 6d51d85fd64..ef5f6931d02 100644 --- a/spec/helpers/application_settings_helper_spec.rb +++ b/spec/helpers/application_settings_helper_spec.rb @@ -284,4 +284,10 @@ RSpec.describe ApplicationSettingsHelper do end end end + + describe '#sidekiq_job_limiter_modes_for_select' do + subject { helper.sidekiq_job_limiter_modes_for_select } + + it { is_expected.to eq([%w(Track track), %w(Compress compress)]) } + end end diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index c48d609836d..efcb8125f68 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -92,6 +92,30 @@ RSpec.describe BlobHelper do end end + describe "#relative_raw_path" do + include FakeBlobHelpers + + let_it_be(:project) { create(:project) } + + before do + assign(:project, project) + end + + [ + %w[/file.md /-/raw/main/], + %w[/test/file.md /-/raw/main/test/], + %w[/another/test/file.md /-/raw/main/another/test/] + ].each do |file_path, expected_path| + it "pointing from '#{file_path}' to '#{expected_path}'" do + blob = fake_blob(path: file_path) + assign(:blob, blob) + assign(:id, "main#{blob.path}") + assign(:path, blob.path) + + expect(helper.parent_dir_raw_path).to eq "/#{project.full_path}#{expected_path}" + end + end + end context 'viewer related' do include FakeBlobHelpers diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb index 3183a0a2394..874937bc4ce 100644 --- a/spec/helpers/ci/pipeline_editor_helper_spec.rb +++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb @@ -42,7 +42,6 @@ RSpec.describe Ci::PipelineEditorHelper do "ci-config-path": project.ci_config_path_or_default, "ci-examples-help-page-path" => help_page_path('ci/examples/index'), "ci-help-page-path" => help_page_path('ci/index'), - "commit-sha" => project.commit.sha, "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => 'foo', "initial-branch-name" => nil, @@ -69,7 +68,6 @@ RSpec.describe Ci::PipelineEditorHelper do "ci-config-path": project.ci_config_path_or_default, "ci-examples-help-page-path" => help_page_path('ci/examples/index'), "ci-help-page-path" => help_page_path('ci/index'), - "commit-sha" => '', "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => 'foo', "initial-branch-name" => nil, @@ -97,10 +95,7 @@ RSpec.describe Ci::PipelineEditorHelper do end it 'returns correct values' do - latest_feature_sha = project.repository.commit('feature').sha - expect(pipeline_editor_data['initial-branch-name']).to eq('feature') - expect(pipeline_editor_data['commit-sha']).to eq(latest_feature_sha) end end end diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb index 40927d44e24..0f15f8be0a9 100644 --- a/spec/helpers/ci/runners_helper_spec.rb +++ b/spec/helpers/ci/runners_helper_spec.rb @@ -88,6 +88,19 @@ RSpec.describe Ci::RunnersHelper do end end + describe '#group_runners_data_attributes' do + let(:group) { create(:group) } + + it 'returns group data to render a runner list' do + data = group_runners_data_attributes(group) + + expect(data[:registration_token]).to eq(group.runners_token) + expect(data[:group_id]).to eq(group.id) + expect(data[:group_full_path]).to eq(group.full_path) + expect(data[:runner_install_help_page]).to eq('https://docs.gitlab.com/runner/install/') + end + end + describe '#toggle_shared_runners_settings_data' do let_it_be(:group) { create(:group) } diff --git a/spec/helpers/environment_helper_spec.rb b/spec/helpers/environment_helper_spec.rb index 0eecae32cc1..49937a3b53a 100644 --- a/spec/helpers/environment_helper_spec.rb +++ b/spec/helpers/environment_helper_spec.rb @@ -43,7 +43,6 @@ RSpec.describe EnvironmentHelper do external_url: environment.external_url, can_update_environment: true, can_destroy_environment: true, - can_read_environment: true, can_stop_environment: true, can_admin_environment: true, environment_metrics_path: environment_metrics_path(environment), diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index 42da1cb71f1..825d5236b5d 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -19,18 +19,6 @@ RSpec.describe GroupsHelper do end end - describe '#group_dependency_proxy_image_prefix' do - let_it_be(:group) { build_stubbed(:group, path: 'GroupWithUPPERcaseLetters') } - - it 'converts uppercase letters to lowercase' do - expect(group_dependency_proxy_image_prefix(group)).to end_with("/groupwithuppercaseletters#{DependencyProxy::URL_SUFFIX}") - end - - it 'removes the protocol' do - expect(group_dependency_proxy_image_prefix(group)).not_to include('http') - end - end - describe '#group_lfs_status' do let_it_be_with_reload(:group) { create(:group) } let_it_be_with_reload(:project) { create(:project, namespace_id: group.id) } @@ -267,61 +255,6 @@ RSpec.describe GroupsHelper do end end - describe '#group_sidebar_links' do - let_it_be(:group) { create(:group, :public) } - let_it_be(:user) { create(:user) } - - before do - group.add_owner(user) - allow(helper).to receive(:current_user) { user } - allow(helper).to receive(:can?) { |*args| Ability.allowed?(*args) } - helper.instance_variable_set(:@group, group) - end - - it 'returns all the expected links' do - links = [ - :overview, :activity, :issues, :labels, :milestones, :merge_requests, - :runners, :group_members, :settings - ] - - expect(helper.group_sidebar_links).to include(*links) - end - - it 'excludes runners when the user cannot admin the group' do - expect(helper).to receive(:current_user) { user } - # TODO Proper policies, such as `read_group_runners, should be implemented per - # See https://gitlab.com/gitlab-org/gitlab/-/issues/334802 - expect(helper).to receive(:can?).twice.with(user, :admin_group, group) { false } - - expect(helper.group_sidebar_links).not_to include(:runners) - end - - it 'excludes runners when the feature "runner_list_group_view_vue_ui" is disabled' do - stub_feature_flags(runner_list_group_view_vue_ui: false) - - expect(helper.group_sidebar_links).not_to include(:runners) - end - - it 'excludes settings when the user can admin the group' do - expect(helper).to receive(:current_user) { user } - expect(helper).to receive(:can?).twice.with(user, :admin_group, group) { false } - - expect(helper.group_sidebar_links).not_to include(:settings) - end - - it 'excludes cross project features when the user cannot read cross project' do - cross_project_features = [:activity, :issues, :labels, :milestones, - :merge_requests] - - allow(Ability).to receive(:allowed?).and_call_original - cross_project_features.each do |feature| - expect(Ability).to receive(:allowed?).with(user, "read_group_#{feature}".to_sym, group) { false } - end - - expect(helper.group_sidebar_links).not_to include(*cross_project_features) - end - end - describe '#parent_group_options' do let_it_be(:current_user) { create(:user) } let_it_be(:group) { create(:group, name: 'group') } @@ -442,67 +375,6 @@ RSpec.describe GroupsHelper do end end - describe '#show_invite_banner?' do - let_it_be(:current_user) { create(:user) } - let_it_be_with_refind(:group) { create(:group) } - let_it_be(:subgroup) { create(:group, parent: group) } - let_it_be(:users) { [current_user, create(:user)] } - - before do - allow(helper).to receive(:current_user) { current_user } - allow(helper).to receive(:can?).with(current_user, :admin_group, group).and_return(can_admin_group) - allow(helper).to receive(:can?).with(current_user, :admin_group, subgroup).and_return(can_admin_group) - users.take(group_members_count).each { |user| group.add_guest(user) } - end - - using RSpec::Parameterized::TableSyntax - - where(:can_admin_group, :group_members_count, :expected_result) do - true | 1 | true - false | 1 | false - true | 2 | false - false | 2 | false - end - - with_them do - context 'for a parent group' do - subject { helper.show_invite_banner?(group) } - - context 'when the group was just created' do - before do - flash[:notice] = "Group #{group.name} was successfully created" - end - - it { is_expected.to be_falsey } - end - - context 'when no flash message' do - it 'returns the expected result' do - expect(subject).to eq(expected_result) - end - end - end - - context 'for a subgroup' do - subject { helper.show_invite_banner?(subgroup) } - - context 'when the subgroup was just created' do - before do - flash[:notice] = "Group #{subgroup.name} was successfully created" - end - - it { is_expected.to be_falsey } - end - - context 'when no flash message' do - it 'returns the expected result' do - expect(subject).to eq(expected_result) - end - end - end - end - end - describe '#render_setting_to_allow_project_access_token_creation?' do let_it_be(:current_user) { create(:user) } let_it_be(:parent) { create(:group) } @@ -541,4 +413,10 @@ RSpec.describe GroupsHelper do expect(helper.can_admin_group_member?(group)).to be(false) end end + + describe '#localized_jobs_to_be_done_choices' do + it 'has a translation for all `jobs_to_be_done` values' do + expect(localized_jobs_to_be_done_choices.keys).to match_array(NamespaceSetting.jobs_to_be_dones.keys) + end + end end diff --git a/spec/helpers/issuables_description_templates_helper_spec.rb b/spec/helpers/issuables_description_templates_helper_spec.rb index 638dd201fc8..55649e9087a 100644 --- a/spec/helpers/issuables_description_templates_helper_spec.rb +++ b/spec/helpers/issuables_description_templates_helper_spec.rb @@ -56,32 +56,17 @@ RSpec.describe IssuablesDescriptionTemplatesHelper, :clean_gitlab_redis_cache do let(:templates) do { "" => [ - { name: "another_issue_template", id: "another_issue_template" }, - { name: "custom_issue_template", id: "custom_issue_template" } + { name: "another_issue_template", id: "another_issue_template", project_id: project.id }, + { name: "custom_issue_template", id: "custom_issue_template", project_id: project.id } ] } end - it 'returns project templates only' do + it 'returns project templates' do expect(helper.issuable_templates_names(Issue.new)).to eq(%w[another_issue_template custom_issue_template]) end end - context 'without matching project templates' do - let(:templates) do - { - "Project Templates" => [ - { name: "another_issue_template", id: "another_issue_template", project_id: non_existing_record_id }, - { name: "custom_issue_template", id: "custom_issue_template", project_id: non_existing_record_id } - ] - } - end - - it 'returns empty array' do - expect(helper.issuable_templates_names(Issue.new)).to eq([]) - end - end - context 'when there are not templates in the project' do let(:templates) { {} } diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index ecaee03eeea..3eb3c73cfcc 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -123,7 +123,7 @@ RSpec.describe IssuablesHelper do end describe '#issuables_state_counter_text' do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } describe 'state text' do context 'when number of issuables can be generated' do @@ -159,6 +159,38 @@ RSpec.describe IssuablesHelper do .to eq('<span>All</span>') end end + + context 'when count is over the threshold' do + let_it_be(:group) { create(:group) } + + before do + allow(helper).to receive(:issuables_count_for_state).and_return(1100) + allow(helper).to receive(:parent).and_return(group) + stub_const("Gitlab::IssuablesCountForState::THRESHOLD", 1000) + end + + context 'when feature flag cached_issues_state_count is disabled' do + before do + stub_feature_flags(cached_issues_state_count: false) + end + + 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>') + end + end + + context 'when feature flag cached_issues_state_count is enabled' do + before do + stub_feature_flags(cached_issues_state_count: true) + end + + 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>') + end + end + end end end @@ -285,7 +317,8 @@ RSpec.describe IssuablesHelper do initialDescriptionText: 'issue text', initialTaskStatus: '0 of 0 tasks completed', issueType: 'issue', - iid: issue.iid.to_s + iid: issue.iid.to_s, + isHidden: false } expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data)) end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index 9cf3808ab72..f5f26d306fb 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -284,7 +284,7 @@ RSpec.describe IssuesHelper do iid: issue.iid, is_issue_author: 'false', issue_type: 'issue', - new_issue_path: new_project_issue_path(project), + new_issue_path: new_project_issue_path(project, { issue: { description: "Related to \##{issue.iid}.\n\n" } }), project_path: project.full_path, report_abuse_path: new_abuse_report_path(user_id: issue.author.id, ref_url: issue_url(issue)), submit_as_spam_path: mark_as_spam_project_issue_path(project, issue) @@ -310,21 +310,21 @@ RSpec.describe IssuesHelper do can_bulk_update: 'true', can_edit: 'true', can_import_issues: 'true', - email: current_user&.notification_email, + email: current_user&.notification_email_or_default, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), empty_state_svg_path: '#', export_csv_path: export_csv_project_issues_path(project), - has_project_issues: project_issues(project).exists?.to_s, + full_path: project.full_path, + has_any_issues: project_issues(project).exists?.to_s, import_csv_issues_path: '#', initial_email: project.new_issuable_address(current_user, 'issue'), + is_project: 'true', is_signed_in: current_user.present?.to_s, - issues_path: project_issues_path(project), jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), markdown_help_path: help_page_path('user/markdown'), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.id }), project_import_jira_path: project_import_jira_path(project), - project_path: project.full_path, quick_actions_help_path: help_page_path('user/project/quick_actions'), reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'), rss_path: '#', @@ -332,11 +332,11 @@ RSpec.describe IssuesHelper do sign_in_path: new_user_session_path } - expect(helper.issues_list_data(project, current_user, finder)).to include(expected) + expect(helper.project_issues_list_data(project, current_user, finder)).to include(expected) end end - describe '#issues_list_data' do + describe '#project_issues_list_data' do context 'when user is signed in' do it_behaves_like 'issues list data' do let(:current_user) { double.as_null_object } @@ -350,6 +350,33 @@ RSpec.describe IssuesHelper do end end + describe '#group_issues_list_data' do + let(:group) { create(:group) } + let(:current_user) { double.as_null_object } + let(:issues) { [] } + + it 'returns expected result' do + allow(helper).to receive(:current_user).and_return(current_user) + allow(helper).to receive(:can?).and_return(true) + allow(helper).to receive(:image_path).and_return('#') + allow(helper).to receive(:url_for).and_return('#') + + expected = { + autocomplete_award_emojis_path: autocomplete_award_emojis_path, + calendar_path: '#', + empty_state_svg_path: '#', + full_path: group.full_path, + has_any_issues: issues.to_a.any?.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) + end + end + describe '#issue_manual_ordering_class' do context 'when sorting by relative position' do before do @@ -410,4 +437,55 @@ RSpec.describe IssuesHelper do end end end + + describe '#issue_hidden?' do + context 'when issue is hidden' do + let_it_be(:banned_user) { build(:user, :banned) } + let_it_be(:hidden_issue) { build(:issue, author: banned_user) } + + context 'when `ban_user_feature_flag` feature flag is enabled' do + it 'returns `true`' do + expect(helper.issue_hidden?(hidden_issue)).to eq(true) + end + end + + context 'when `ban_user_feature_flag` feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it 'returns `false`' do + expect(helper.issue_hidden?(hidden_issue)).to eq(false) + end + end + end + + context 'when issue is not hidden' do + it 'returns `false`' do + expect(helper.issue_hidden?(issue)).to eq(false) + end + end + end + + describe '#hidden_issue_icon' do + let_it_be(:banned_user) { build(:user, :banned) } + let_it_be(:hidden_issue) { build(:issue, author: banned_user) } + let_it_be(:mock_svg) { '<svg></svg>'.html_safe } + + before do + allow(helper).to receive(:sprite_icon).and_return(mock_svg) + end + + context 'when issue is hidden' do + it 'returns icon with tooltip' do + expect(helper.hidden_issue_icon(hidden_issue)).to eq("<span class=\"has-tooltip\" title=\"This issue is hidden because its author has been banned\">#{mock_svg}</span>") + end + end + + context 'when issue is not hidden' do + it 'returns `nil`' do + expect(helper.hidden_issue_icon(issue)).to be_nil + end + end + end end diff --git a/spec/helpers/learn_gitlab_helper_spec.rb b/spec/helpers/learn_gitlab_helper_spec.rb index cf0d329c36f..1159fd96d59 100644 --- a/spec/helpers/learn_gitlab_helper_spec.rb +++ b/spec/helpers/learn_gitlab_helper_spec.rb @@ -53,7 +53,7 @@ RSpec.describe LearnGitlabHelper do end end - describe '.learn_gitlab_experiment_enabled?' do + describe '.learn_gitlab_enabled?' do using RSpec::Parameterized::TableSyntax let_it_be(:user) { create(:user) } @@ -61,19 +61,16 @@ RSpec.describe LearnGitlabHelper do let(:params) { { namespace_id: project.namespace.to_param, project_id: project } } - subject { helper.learn_gitlab_experiment_enabled?(project) } + subject { helper.learn_gitlab_enabled?(project) } - where(:experiment_a, :experiment_b, :onboarding, :learn_gitlab_available, :result) do - true | false | true | true | true - false | true | true | true | true - false | false | true | true | false - true | true | true | false | false - true | true | false | true | false + where(:onboarding, :learn_gitlab_available, :result) do + true | true | true + true | false | false + false | true | false end with_them do before do - stub_experiment_for_subject(learn_gitlab_a: experiment_a, learn_gitlab_b: experiment_b) allow(OnboardingProgress).to receive(:onboarding?).with(project.namespace).and_return(onboarding) allow_next(LearnGitlab::Project, user).to receive(:available?).and_return(learn_gitlab_available) end @@ -88,10 +85,6 @@ RSpec.describe LearnGitlabHelper do end context 'when not signed in' do - before do - stub_experiment_for_subject(learn_gitlab_a: true, learn_gitlab_b: true) - end - it { is_expected.to eq(false) } end end @@ -106,41 +99,4 @@ RSpec.describe LearnGitlabHelper do expect(sections.values.map { |section| section.keys }).to eq([[:svg]] * 3) end end - - describe '.learn_gitlab_experiment_tracking_category' do - using RSpec::Parameterized::TableSyntax - - let_it_be(:user) { create(:user) } - - subject { helper.learn_gitlab_experiment_tracking_category } - - where(:experiment_a, :experiment_b, :result) do - false | false | nil - false | true | 'Growth::Activation::Experiment::LearnGitLabB' - true | false | 'Growth::Conversion::Experiment::LearnGitLabA' - true | true | 'Growth::Conversion::Experiment::LearnGitLabA' - end - - with_them do - before do - stub_experiment_for_subject(learn_gitlab_a: experiment_a, learn_gitlab_b: experiment_b) - end - - context 'when signed in' do - before do - sign_in(user) - end - - it { is_expected.to eq(result) } - end - end - - context 'when not signed in' do - before do - stub_experiment_for_subject(learn_gitlab_a: true, learn_gitlab_b: true) - end - - it { is_expected.to eq(nil) } - end - end end diff --git a/spec/helpers/nav/new_dropdown_helper_spec.rb b/spec/helpers/nav/new_dropdown_helper_spec.rb index 03b9c538225..64f4d5ff797 100644 --- a/spec/helpers/nav/new_dropdown_helper_spec.rb +++ b/spec/helpers/nav/new_dropdown_helper_spec.rb @@ -51,7 +51,7 @@ RSpec.describe Nav::NewDropdownHelper do title: 'Invite members', href: expected_href, data: { - track_event: 'click_link', + track_action: 'click_link', track_label: 'test_tracking_label', track_property: :invite_members_new_dropdown } @@ -99,12 +99,12 @@ RSpec.describe Nav::NewDropdownHelper do it 'has project menu item' do expect(subject[:menu_sections]).to eq( expected_menu_section( - title: 'GitLab', + title: _('GitLab'), menu_item: ::Gitlab::Nav::TopNavMenuItem.build( id: 'general_new_project', title: 'New project/repository', href: '/projects/new', - data: { track_event: 'click_link_new_project', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_project_link' } + data: { track_action: 'click_link_new_project', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_project_link' } ) ) ) @@ -117,12 +117,12 @@ RSpec.describe Nav::NewDropdownHelper do it 'has group menu item' do expect(subject[:menu_sections]).to eq( expected_menu_section( - title: 'GitLab', + title: _('GitLab'), menu_item: ::Gitlab::Nav::TopNavMenuItem.build( id: 'general_new_group', title: 'New group', href: '/groups/new', - data: { track_event: 'click_link_new_group', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown' } ) ) ) @@ -135,12 +135,12 @@ RSpec.describe Nav::NewDropdownHelper do it 'has new snippet menu item' do expect(subject[:menu_sections]).to eq( expected_menu_section( - title: 'GitLab', + title: _('GitLab'), menu_item: ::Gitlab::Nav::TopNavMenuItem.build( id: 'general_new_snippet', title: 'New snippet', href: '/-/snippets/new', - data: { track_event: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_snippet_link' } + data: { track_action: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_snippet_link' } ) ) ) @@ -178,7 +178,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'new_project', title: 'New project/repository', href: "/projects/new?namespace_id=#{group.id}", - data: { track_event: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' } ) ) ) @@ -196,7 +196,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'new_subgroup', title: 'New subgroup', href: "/groups/new?parent_id=#{group.id}", - data: { track_event: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' } ) ) ) @@ -245,7 +245,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'new_issue', title: 'New issue', href: "/#{project.path_with_namespace}/-/issues/new", - data: { track_event: 'click_link_new_issue', track_label: 'plus_menu_dropdown', qa_selector: 'new_issue_link' } + data: { track_action: 'click_link_new_issue', track_label: 'plus_menu_dropdown', qa_selector: 'new_issue_link' } ) ) ) @@ -263,7 +263,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'new_mr', title: 'New merge request', href: "/#{merge_project.path_with_namespace}/-/merge_requests/new", - data: { track_event: 'click_link_new_mr', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_mr', track_label: 'plus_menu_dropdown' } ) ) ) @@ -281,7 +281,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'new_snippet', title: 'New snippet', href: "/#{project.path_with_namespace}/-/snippets/new", - data: { track_event: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' } ) ) ) diff --git a/spec/helpers/nav/top_nav_helper_spec.rb b/spec/helpers/nav/top_nav_helper_spec.rb index 4d6da258536..da7e5d5dce2 100644 --- a/spec/helpers/nav/top_nav_helper_spec.rb +++ b/spec/helpers/nav/top_nav_helper_spec.rb @@ -142,7 +142,7 @@ RSpec.describe Nav::TopNavHelper do expected_primary = ::Gitlab::Nav::TopNavMenuItem.build( css_class: 'qa-projects-dropdown', data: { - track_event: 'click_dropdown', + track_action: 'click_dropdown', track_label: 'projects_dropdown' }, icon: 'project', @@ -248,7 +248,7 @@ RSpec.describe Nav::TopNavHelper do expected_primary = ::Gitlab::Nav::TopNavMenuItem.build( css_class: 'qa-groups-dropdown', data: { - track_event: 'click_dropdown', + track_action: 'click_dropdown', track_label: 'groups_dropdown' }, icon: 'group', diff --git a/spec/helpers/notify_helper_spec.rb b/spec/helpers/notify_helper_spec.rb index e2a7a212b1b..a4193444528 100644 --- a/spec/helpers/notify_helper_spec.rb +++ b/spec/helpers/notify_helper_spec.rb @@ -55,4 +55,53 @@ RSpec.describe NotifyHelper do def reference_link(entity, url) "<a href=\"#{url}\">#{entity.to_reference}</a>" end + + describe '#invited_join_url' do + let_it_be(:member) { create(:project_member) } + + let(:token) { '_token_' } + + context 'when invite_email_preview_text is enabled', :experiment do + before do + stub_experiments(invite_email_preview_text: :control) + end + + it 'has correct params' do + expect(helper.invited_join_url(token, member)) + .to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_preview_text&invite_type=initial_email") + end + + context 'when invite_email_from is enabled' do + before do + stub_experiments(invite_email_from: :control) + end + + it 'has correct params' do + expect(helper.invited_join_url(token, member)) + .to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_from&invite_type=initial_email") + end + end + end + + context 'when invite_email_from is enabled' do + before do + stub_experiments(invite_email_from: :control) + end + + it 'has correct params' do + expect(helper.invited_join_url(token, member)) + .to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_from&invite_type=initial_email") + end + end + + context 'when invite_email_preview_text is disabled' do + before do + stub_feature_flags(invite_email_preview_text: false) + end + + it 'has correct params' do + expect(helper.invited_join_url(token, member)).to eq("http://test.host/-/invites/#{token}?invite_type=initial_email") + end + end + end end diff --git a/spec/helpers/packages_helper_spec.rb b/spec/helpers/packages_helper_spec.rb index bc60c582ff8..06c6cccd488 100644 --- a/spec/helpers/packages_helper_spec.rb +++ b/spec/helpers/packages_helper_spec.rb @@ -223,21 +223,41 @@ RSpec.describe PackagesHelper do describe '#package_details_data' do let_it_be(:package) { create(:package) } + let(:expected_result) do + { + package_id: package.id, + can_delete: 'true', + project_name: project.name, + group_list_url: '' + } + end + before do allow(helper).to receive(:current_user) { project.owner } allow(helper).to receive(:can?) { true } end - it 'when use_presenter is true populate the package key' do - result = helper.package_details_data(project, package, true) + context 'in a project without a group' do + it 'populates presenter data' do + result = helper.package_details_data(project, package) - expect(result[:package]).not_to be_nil + expect(result).to match(hash_including(expected_result)) + end end - it 'when use_presenter is false the package key is nil' do - result = helper.package_details_data(project, package, false) + context 'in a project with a group' do + let_it_be(:group) { create(:group) } + let_it_be(:project_with_group) { create(:project, group: group) } - expect(result[:package]).to be_nil + it 'populates presenter data' do + result = helper.package_details_data(project_with_group, package) + expected = expected_result.merge({ + group_list_url: group_packages_path(project_with_group.group), + project_name: project_with_group.name + }) + + expect(result).to match(hash_including(expected)) + end end end end diff --git a/spec/helpers/profiles_helper_spec.rb b/spec/helpers/profiles_helper_spec.rb index 2ea832f95dc..c3a3c2a0178 100644 --- a/spec/helpers/profiles_helper_spec.rb +++ b/spec/helpers/profiles_helper_spec.rb @@ -13,7 +13,8 @@ RSpec.describe ProfilesHelper do private_email = user.private_commit_email emails = [ - ["Use a private email - #{private_email}", Gitlab::PrivateCommitEmail::TOKEN], + [s_('Use primary email (%{email})') % { email: user.email }, ''], + [s_("Profiles|Use a private email - %{email}").html_safe % { email: private_email }, Gitlab::PrivateCommitEmail::TOKEN], user.email, confirmed_email1.email, confirmed_email2.email @@ -23,20 +24,6 @@ RSpec.describe ProfilesHelper do end end - describe '#selected_commit_email' do - let(:user) { create(:user) } - - it 'returns main email when commit email attribute is nil' do - expect(helper.selected_commit_email(user)).to eq(user.email) - end - - it 'returns DB stored commit_email' do - user.update!(commit_email: Gitlab::PrivateCommitEmail::TOKEN) - - expect(helper.selected_commit_email(user)).to eq(Gitlab::PrivateCommitEmail::TOKEN) - end - end - describe '#email_provider_label' do it "returns nil for users without external email" do user = create(:user) @@ -152,6 +139,22 @@ RSpec.describe ProfilesHelper do end end + describe '#middle_dot_divider_classes' do + using RSpec::Parameterized::TableSyntax + + where(:stacking, :breakpoint, :expected) do + nil | nil | %w(gl-mb-3 gl-display-inline-block middle-dot-divider) + true | nil | %w(gl-mb-3 middle-dot-divider-sm gl-display-block gl-sm-display-inline-block) + nil | :sm | %w(gl-mb-3 gl-display-inline-block middle-dot-divider-sm) + end + + with_them do + it 'returns CSS classes needed to render the middle dot divider' do + expect(helper.middle_dot_divider_classes(stacking, breakpoint)).to eq expected + end + end + end + def stub_cas_omniauth_provider provider = OpenStruct.new( 'name' => 'cas3', diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 4dac4403f70..85b572d3f68 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -498,7 +498,7 @@ RSpec.describe ProjectsHelper do context 'user has a configured commit email' do before do confirmed_email = create(:email, :confirmed, user: user) - user.update!(commit_email: confirmed_email) + user.update!(commit_email: confirmed_email.email) end it 'returns the commit email' do diff --git a/spec/helpers/recaptcha_helper_spec.rb b/spec/helpers/recaptcha_helper_spec.rb index e7f9ba5b73a..8ad91a0a217 100644 --- a/spec/helpers/recaptcha_helper_spec.rb +++ b/spec/helpers/recaptcha_helper_spec.rb @@ -14,7 +14,7 @@ RSpec.describe RecaptchaHelper, type: :helper do it 'returns false' do stub_application_setting(recaptcha_enabled: false) - expect(helper.show_recaptcha_sign_up?).to be(false) + expect(helper.show_recaptcha_sign_up?).to be_falsey end end @@ -22,7 +22,7 @@ RSpec.describe RecaptchaHelper, type: :helper do it 'returns true' do stub_application_setting(recaptcha_enabled: true) - expect(helper.show_recaptcha_sign_up?).to be(true) + expect(helper.show_recaptcha_sign_up?).to be_truthy end end end diff --git a/spec/helpers/routing/pseudonymization_helper_spec.rb b/spec/helpers/routing/pseudonymization_helper_spec.rb new file mode 100644 index 00000000000..10563502555 --- /dev/null +++ b/spec/helpers/routing/pseudonymization_helper_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' + +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(:issue) { create(:issue, project: project) } + + let(:merge_request) { create(:merge_request, source_project: project) } + + before do + stub_feature_flags(mask_page_urls: true) + allow(helper).to receive(:group).and_return(group) + allow(helper).to receive(:project).and_return(project) + end + + shared_examples 'masked url' do + it 'generates masked page url' do + expect(helper.masked_page_url).to eq(masked_url) + end + end + + describe 'when url has params to mask' do + context 'with controller for MR' do + let(:masked_url) { "http://test.host/namespace:#{group.id}/project:#{project.id}/-/merge_requests/#{merge_request.id}" } + + before do + allow(Rails.application.routes).to receive(:recognize_path).and_return({ + controller: "projects/merge_requests", + action: "show", + namespace_id: group.name, + project_id: project.name, + id: merge_request.id.to_s + }) + end + + it_behaves_like 'masked url' + end + + context 'with controller for issue' do + let(:masked_url) { "http://test.host/namespace:#{group.id}/project:#{project.id}/-/issues/#{issue.id}" } + + before do + allow(Rails.application.routes).to receive(:recognize_path).and_return({ + controller: "projects/issues", + action: "show", + namespace_id: group.name, + project_id: project.name, + id: issue.id.to_s + }) + end + + it_behaves_like 'masked url' + end + + context 'with controller for groups with subgroups and project' do + let(:masked_url) { "http://test.host/namespace:#{subgroup.id}/project:#{project.id}"} + + before do + allow(helper).to receive(:group).and_return(subgroup) + allow(helper.project).to receive(:namespace).and_return(subgroup) + allow(Rails.application.routes).to receive(:recognize_path).and_return({ + controller: 'projects', + action: 'show', + namespace_id: subgroup.name, + id: project.name + }) + end + + it_behaves_like 'masked url' + end + + context 'with controller for groups and subgroups' do + let(:masked_url) { "http://test.host/namespace:#{subgroup.id}"} + + before do + allow(helper).to receive(:group).and_return(subgroup) + allow(Rails.application.routes).to receive(:recognize_path).and_return({ + controller: 'groups', + action: 'show', + id: subgroup.name + }) + end + + it_behaves_like 'masked url' + end + + context 'with controller for blob with file path' do + let(:masked_url) { "http://test.host/namespace:#{group.id}/project:#{project.id}/-/blob/:repository_path" } + + before do + allow(Rails.application.routes).to receive(:recognize_path).and_return({ + controller: 'projects/blob', + action: 'show', + namespace_id: group.name, + project_id: project.name, + id: 'master/README.md' + }) + end + + it_behaves_like 'masked url' + end + + context 'with non identifiable controller' do + let(:masked_url) { "http://test.host/dashboard/issues?assignee_username=root" } + + 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_behaves_like 'masked url' + end + end + + describe 'when url has no params to mask' do + let(:root_url) { 'http://test.host' } + + context 'returns root url' do + it 'masked_page_url' do + expect(helper.masked_page_url).to eq(root_url) + end + end + end + + describe 'when feature flag is disabled' do + before do + stub_feature_flags(mask_page_urls: false) + end + + it 'returns nil' do + expect(helper.masked_page_url).to be_nil + end + end +end diff --git a/spec/helpers/user_callouts_helper_spec.rb b/spec/helpers/user_callouts_helper_spec.rb index 5ef1e9d4daf..794ff5ee945 100644 --- a/spec/helpers/user_callouts_helper_spec.rb +++ b/spec/helpers/user_callouts_helper_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" RSpec.describe UserCalloutsHelper do - let_it_be(:user) { create(:user) } + let_it_be(:user, refind: true) { create(:user) } before do allow(helper).to receive(:current_user).and_return(user) @@ -202,4 +202,95 @@ RSpec.describe UserCalloutsHelper do it { is_expected.to be false } end end + + describe '.show_invite_banner?' do + let_it_be(:group) { create(:group) } + + subject { helper.show_invite_banner?(group) } + + context 'when user has the admin ability for the group' do + before do + group.add_owner(user) + end + + context 'when the invite_members_banner has not been dismissed' do + it { is_expected.to eq(true) } + + context 'when a user has dismissed this banner via cookies already' do + before do + helper.request.cookies["invite_#{group.id}_#{user.id}"] = 'true' + end + + it { is_expected.to eq(false) } + + it 'creates the callout from cookie', :aggregate_failures do + expect { subject }.to change { Users::GroupCallout.count }.by(1) + expect(Users::GroupCallout.last).to have_attributes(group_id: group.id, + feature_name: described_class::INVITE_MEMBERS_BANNER) + end + end + + context 'when the group was just created' do + before do + flash[:notice] = "Group #{group.name} was successfully created" + end + + it { is_expected.to eq(false) } + end + + context 'with concerning multiple members' do + let_it_be(:user_2) { create(:user) } + + context 'on current group' do + before do + group.add_guest(user_2) + end + + it { is_expected.to eq(false) } + end + + context 'on current group that is a subgroup' do + let_it_be(:subgroup) { create(:group, parent: group) } + + subject { helper.show_invite_banner?(subgroup) } + + context 'with only one user on parent and this group' do + it { is_expected.to eq(true) } + end + + context 'when another user is on this group' do + before do + subgroup.add_guest(user_2) + end + + it { is_expected.to eq(false) } + end + + context 'when another user is on the parent group' do + before do + group.add_guest(user_2) + end + + it { is_expected.to eq(false) } + end + end + end + end + + context 'when the invite_members_banner has been dismissed' do + before do + create(:group_callout, + user: user, + group: group, + feature_name: described_class::INVITE_MEMBERS_BANNER) + end + + it { is_expected.to eq(false) } + end + end + + context 'when user does not have admin ability for the group' do + it { is_expected.to eq(false) } + end + end end diff --git a/spec/initializers/validate_database_config_spec.rb b/spec/initializers/validate_database_config_spec.rb new file mode 100644 index 00000000000..99e4a4b36ee --- /dev/null +++ b/spec/initializers/validate_database_config_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'validate database config' do + include RakeHelpers + include StubENV + + let(:rails_configuration) { Rails::Application::Configuration.new(Rails.root) } + let(:ar_configurations) { ActiveRecord::DatabaseConfigurations.new(rails_configuration.database_configuration) } + + subject do + load Rails.root.join('config/initializers/validate_database_config.rb') + end + + before do + # The `AS::ConfigurationFile` calls `read` in `def initialize` + # thus we cannot use `expect_next_instance_of` + # rubocop:disable RSpec/AnyInstanceOf + expect_any_instance_of(ActiveSupport::ConfigurationFile) + .to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml) + # rubocop:enable RSpec/AnyInstanceOf + + allow(Rails.application).to receive(:config).and_return(rails_configuration) + allow(ActiveRecord::Base).to receive(:configurations).and_return(ar_configurations) + end + + shared_examples 'with SKIP_DATABASE_CONFIG_VALIDATION=true' do + before do + stub_env('SKIP_DATABASE_CONFIG_VALIDATION', 'true') + end + + it 'does not raise exception' do + expect { subject }.not_to raise_error + end + end + + context 'when config/database.yml is valid' do + context 'uses legacy syntax' do + let(:database_yml) do + <<-EOS + production: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + EOS + end + + it 'validates configuration with a warning' do + expect(main_object).to receive(:warn).with /uses a deprecated syntax for/ + + expect { subject }.not_to raise_error + end + + it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true' + end + + context 'uses new syntax' do + let(:database_yml) do + <<-EOS + production: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + EOS + end + + it 'validates configuration without errors and warnings' do + expect(main_object).not_to receive(:warn) + + expect { subject }.not_to raise_error + end + end + end + + context 'when config/database.yml is invalid' do + context 'uses unknown connection name' do + let(:database_yml) do + <<-EOS + production: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + + another: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + EOS + end + + it 'raises exception' do + expect { subject }.to raise_error /This installation of GitLab uses unsupported database names/ + end + + it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true' + end + + context 'uses replica configuration' do + let(:database_yml) do + <<-EOS + production: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + replica: true + EOS + end + + it 'raises exception' do + expect { subject }.to raise_error /with 'replica: true' parameter in/ + end + + it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true' + end + + context 'main is not a first entry' do + let(:database_yml) do + <<-EOS + production: + ci: + adapter: postgresql + encoding: unicode + database: gitlabhq_production_ci + username: git + password: "secure password" + host: localhost + replica: true + + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + replica: true + EOS + end + + it 'raises exception' do + expect { subject }.to raise_error /The `main:` database needs to be defined as a first configuration item/ + end + + it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true' + end + end +end diff --git a/spec/javascripts/.eslintrc.yml b/spec/javascripts/.eslintrc.yml deleted file mode 100644 index b863156b57c..00000000000 --- a/spec/javascripts/.eslintrc.yml +++ /dev/null @@ -1,39 +0,0 @@ ---- -env: - jasmine: true -extends: plugin:jasmine/recommended -globals: - appendLoadFixtures: false - appendLoadStyleFixtures: false - appendSetFixtures: false - appendSetStyleFixtures: false - getJSONFixture: false - loadFixtures: false - loadJSONFixtures: false - loadStyleFixtures: false - preloadFixtures: false - preloadStyleFixtures: false - readFixtures: false - sandbox: false - setFixtures: false - setStyleFixtures: false - spyOnDependency: false - spyOnEvent: false - ClassSpecHelper: false -plugins: - - jasmine -rules: - func-names: off - jasmine/no-suite-dupes: - - warn - - branch - jasmine/no-spec-dupes: - - warn - - branch - prefer-arrow-callback: off - import/no-unresolved: - - error - - ignore: - - 'fixtures/blob' - # Temporarily disabled to facilitate an upgrade to eslint-plugin-jasmine - jasmine/prefer-toHaveBeenCalledWith: off diff --git a/spec/javascripts/fixtures/.gitignore b/spec/javascripts/fixtures/.gitignore deleted file mode 100644 index d6b7ef32c84..00000000000 --- a/spec/javascripts/fixtures/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/spec/javascripts/lib/utils/mock_data.js b/spec/javascripts/lib/utils/mock_data.js deleted file mode 100644 index f1358986f2a..00000000000 --- a/spec/javascripts/lib/utils/mock_data.js +++ /dev/null @@ -1 +0,0 @@ -export * from '../../../frontend/lib/utils/mock_data'; diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js deleted file mode 100644 index be14d2ee7e7..00000000000 --- a/spec/javascripts/test_bundle.js +++ /dev/null @@ -1,145 +0,0 @@ -/* eslint-disable - jasmine/no-global-setup, no-underscore-dangle, no-console -*/ - -import { config as testUtilsConfig } from '@vue/test-utils'; -import jasmineDiff from 'jasmine-diff'; -import $ from 'jquery'; -import 'core-js/features/set-immediate'; -import 'vendor/jasmine-jquery'; -import '~/commons'; -import Vue from 'vue'; -import { getDefaultAdapter } from '~/lib/utils/axios_utils'; -import Translate from '~/vue_shared/translate'; - -import { FIXTURES_PATH, TEST_HOST } from './test_constants'; - -// Tech debt issue TBD -testUtilsConfig.logModifiedComponents = false; - -const isHeadlessChrome = /\bHeadlessChrome\//.test(navigator.userAgent); -Vue.config.devtools = !isHeadlessChrome; -Vue.config.productionTip = false; - -let hasVueWarnings = false; -Vue.config.warnHandler = (msg, vm, trace) => { - // The following workaround is necessary, so we are able to use setProps from Vue test utils - // see https://github.com/vuejs/vue-test-utils/issues/631#issuecomment-421108344 - const currentStack = new Error().stack; - const isInVueTestUtils = currentStack - .split('\n') - .some((line) => line.startsWith(' at VueWrapper.setProps (')); - if (isInVueTestUtils) { - return; - } - - hasVueWarnings = true; - fail(`${msg}${trace}`); -}; - -let hasVueErrors = false; -Vue.config.errorHandler = function (err) { - hasVueErrors = true; - fail(err); -}; - -Vue.use(Translate); - -// enable test fixtures -jasmine.getFixtures().fixturesPath = FIXTURES_PATH; -jasmine.getJSONFixtures().fixturesPath = FIXTURES_PATH; - -beforeAll(() => { - jasmine.addMatchers( - jasmineDiff(jasmine, { - colors: window.__karma__.config.color, - inline: window.__karma__.config.color, - }), - ); -}); - -// globalize common libraries -window.$ = $; -window.jQuery = window.$; - -// stub expected globals -window.gl = window.gl || {}; -window.gl.TEST_HOST = TEST_HOST; -window.gon = window.gon || {}; -window.gon.test_env = true; -window.gon.ee = process.env.IS_EE; -gon.relative_url_root = ''; - -let hasUnhandledPromiseRejections = false; - -window.addEventListener('unhandledrejection', (event) => { - hasUnhandledPromiseRejections = true; - console.error('Unhandled promise rejection:'); - console.error(event.reason.stack || event.reason); -}); - -let longRunningTestTimeoutHandle; - -beforeEach((done) => { - longRunningTestTimeoutHandle = setTimeout(() => { - done.fail('Test is running too long!'); - }, 4000); - done(); -}); - -afterEach(() => { - clearTimeout(longRunningTestTimeoutHandle); -}); - -const axiosDefaultAdapter = getDefaultAdapter(); - -// render all of our tests -const testContexts = [require.context('spec', true, /_spec$/)]; - -if (process.env.IS_EE) { - testContexts.push(require.context('ee_spec', true, /_spec$/)); -} - -testContexts.forEach((context) => { - context.keys().forEach((path) => { - try { - context(path); - } catch (err) { - console.log(err); - console.error('[GL SPEC RUNNER ERROR] Unable to load spec: ', path); - describe('Test bundle', function () { - it(`includes '${path}'`, function () { - expect(err).toBeNull(); - }); - }); - } - }); -}); - -describe('test errors', () => { - beforeAll((done) => { - if (hasUnhandledPromiseRejections || hasVueWarnings || hasVueErrors) { - setTimeout(done, 1000); - } else { - done(); - } - }); - - it('has no unhandled Promise rejections', () => { - expect(hasUnhandledPromiseRejections).toBe(false); - }); - - it('has no Vue warnings', () => { - expect(hasVueWarnings).toBe(false); - }); - - it('has no Vue error', () => { - expect(hasVueErrors).toBe(false); - }); - - it('restores axios adapter after mocking', () => { - if (getDefaultAdapter() !== axiosDefaultAdapter) { - fail('axios adapter is not restored! Did you forget a restore() on MockAdapter?'); - } - }); -}); diff --git a/spec/javascripts/test_constants.js b/spec/javascripts/test_constants.js deleted file mode 100644 index de7b3a0e80c..00000000000 --- a/spec/javascripts/test_constants.js +++ /dev/null @@ -1 +0,0 @@ -export * from '../frontend/__helpers__/test_constants'; diff --git a/spec/lib/api/entities/clusters/agent_authorization_spec.rb b/spec/lib/api/entities/clusters/agent_authorization_spec.rb new file mode 100644 index 00000000000..101a8af4ac4 --- /dev/null +++ b/spec/lib/api/entities/clusters/agent_authorization_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +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 + ) + end +end diff --git a/spec/lib/backup/gitaly_backup_spec.rb b/spec/lib/backup/gitaly_backup_spec.rb index a48a1752eff..7797bd12f0e 100644 --- a/spec/lib/backup/gitaly_backup_spec.rb +++ b/spec/lib/backup/gitaly_backup_spec.rb @@ -131,8 +131,19 @@ RSpec.describe Backup::GitalyBackup do context 'parallel option set' do let(:parallel) { 3 } - it 'does not pass parallel option through' do - expect(Open3).to receive(:popen2).with(ENV, anything, 'restore', '-path', anything).and_call_original + it 'passes parallel option through' do + expect(Open3).to receive(:popen2).with(ENV, anything, 'restore', '-path', anything, '-parallel', '3').and_call_original + + subject.start(:restore) + subject.wait + end + end + + context 'parallel_storage option set' 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 subject.start(:restore) subject.wait diff --git a/spec/lib/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb index 2cc1bf41d18..32eea82cfdf 100644 --- a/spec/lib/backup/manager_spec.rb +++ b/spec/lib/backup/manager_spec.rb @@ -432,6 +432,77 @@ RSpec.describe Backup::Manager do end end + context 'with AWS with server side encryption' do + let(:connection) { ::Fog::Storage.new(Gitlab.config.backup.upload.connection.symbolize_keys) } + let(:encryption_key) { nil } + let(:encryption) { nil } + let(:storage_options) { nil } + + before do + stub_backup_setting( + upload: { + connection: { + provider: 'AWS', + aws_access_key_id: 'AWS_ACCESS_KEY_ID', + aws_secret_access_key: 'AWS_SECRET_ACCESS_KEY' + }, + remote_directory: 'directory', + multipart_chunk_size: Gitlab.config.backup.upload.multipart_chunk_size, + encryption: encryption, + encryption_key: encryption_key, + storage_options: storage_options, + storage_class: nil + } + ) + + connection.directories.create(key: Gitlab.config.backup.upload.remote_directory) + end + + context 'with SSE-S3 without using storage_options' do + let(:encryption) { 'AES256' } + + it 'sets encryption attributes' do + result = subject.upload + + expect(result.key).to be_present + expect(result.encryption).to eq('AES256') + expect(result.encryption_key).to be_nil + expect(result.kms_key_id).to be_nil + end + end + + context 'with SSE-C (customer-provided keys) options' do + let(:encryption) { 'AES256' } + let(:encryption_key) { SecureRandom.hex } + + it 'sets encryption attributes' do + result = subject.upload + + expect(result.key).to be_present + expect(result.encryption).to eq(encryption) + expect(result.encryption_key).to eq(encryption_key) + expect(result.kms_key_id).to be_nil + end + end + + context 'with SSE-KMS options' do + let(:storage_options) do + { + server_side_encryption: 'aws:kms', + server_side_encryption_kms_key_id: 'arn:aws:kms:12345' + } + end + + it 'sets encryption attributes' do + result = subject.upload + + expect(result.key).to be_present + expect(result.encryption).to eq('aws:kms') + expect(result.kms_key_id).to eq('arn:aws:kms:12345') + end + end + end + context 'with Google provider' do before do stub_backup_setting( diff --git a/spec/lib/banzai/filter/audio_link_filter_spec.rb b/spec/lib/banzai/filter/audio_link_filter_spec.rb index 4198a50e980..71e069eb29f 100644 --- a/spec/lib/banzai/filter/audio_link_filter_spec.rb +++ b/spec/lib/banzai/filter/audio_link_filter_spec.rb @@ -25,18 +25,14 @@ RSpec.describe Banzai::Filter::AudioLinkFilter do it 'replaces the image tag with an audio tag' do container = filter(image).children.first - expect(container.name).to eq 'div' - expect(container['class']).to eq 'audio-container' + expect(container.name).to eq 'span' + expect(container['class']).to eq 'media-container audio-container' - audio, paragraph = container.children + audio, link = container.children expect(audio.name).to eq 'audio' expect(audio['src']).to eq src - expect(paragraph.name).to eq 'p' - - link = paragraph.children.first - expect(link.name).to eq 'a' expect(link['href']).to eq src expect(link['target']).to eq '_blank' @@ -105,15 +101,13 @@ RSpec.describe Banzai::Filter::AudioLinkFilter do image = %(<img src="#{proxy_src}" data-canonical-src="#{canonical_src}"/>) container = filter(image).children.first - expect(container['class']).to eq 'audio-container' + expect(container['class']).to eq 'media-container audio-container' - audio, paragraph = container.children + audio, link = container.children expect(audio['src']).to eq proxy_src expect(audio['data-canonical-src']).to eq canonical_src - link = paragraph.children.first - expect(link['href']).to eq proxy_src end end diff --git a/spec/lib/banzai/filter/video_link_filter_spec.rb b/spec/lib/banzai/filter/video_link_filter_spec.rb index ec954aa9163..a0b0ba309f5 100644 --- a/spec/lib/banzai/filter/video_link_filter_spec.rb +++ b/spec/lib/banzai/filter/video_link_filter_spec.rb @@ -25,20 +25,16 @@ RSpec.describe Banzai::Filter::VideoLinkFilter do it 'replaces the image tag with a video tag' do container = filter(image).children.first - expect(container.name).to eq 'div' - expect(container['class']).to eq 'video-container' + expect(container.name).to eq 'span' + expect(container['class']).to eq 'media-container video-container' - video, paragraph = container.children + video, link = container.children expect(video.name).to eq 'video' expect(video['src']).to eq src expect(video['width']).to eq "400" expect(video['preload']).to eq 'metadata' - expect(paragraph.name).to eq 'p' - - link = paragraph.children.first - expect(link.name).to eq 'a' expect(link['href']).to eq src expect(link['target']).to eq '_blank' @@ -107,15 +103,13 @@ RSpec.describe Banzai::Filter::VideoLinkFilter do image = %(<img src="#{proxy_src}" data-canonical-src="#{canonical_src}"/>) container = filter(image).children.first - expect(container['class']).to eq 'video-container' + expect(container['class']).to eq 'media-container video-container' - video, paragraph = container.children + video, link = container.children expect(video['src']).to eq proxy_src expect(video['data-canonical-src']).to eq canonical_src - link = paragraph.children.first - expect(link['href']).to eq proxy_src end end diff --git a/spec/lib/banzai/reference_parser/base_parser_spec.rb b/spec/lib/banzai/reference_parser/base_parser_spec.rb index 095500cdc53..4701caa0667 100644 --- a/spec/lib/banzai/reference_parser/base_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/base_parser_spec.rb @@ -247,15 +247,15 @@ RSpec.describe Banzai::ReferenceParser::BaseParser do end end - it 'returns referenceable and visible objects, alongside nodes that are referenceable but not visible' do - expect(subject.gather_references(nodes)).to match( - visible: contain_exactly(6, 8, 10), - not_visible: match_array(nodes.select { |n| n.id.even? && n.id <= 5 }) - ) + it 'returns referenceable and visible objects, alongside all and visible nodes' do + referenceable = nodes.select { |n| n.id.even? } + visible = nodes.select { |n| [6, 8, 10].include?(n.id) } + + expect_gathered_references(subject.gather_references(nodes), [6, 8, 10], referenceable, visible) end it 'is always empty if the input is empty' do - expect(subject.gather_references([])) .to match(visible: be_empty, not_visible: be_empty) + expect_gathered_references(subject.gather_references([]), [], [], []) end end diff --git a/spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb b/spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb index 4610da7cbe6..576e629d271 100644 --- a/spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb @@ -19,7 +19,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedGroupParser do it 'returns empty array' do link['data-group'] = project.group.id.to_s - expect_gathered_references(subject.gather_references([link]), [], 1) + expect_gathered_references(subject.gather_references([link]), [], [link], []) end end @@ -30,7 +30,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedGroupParser do end it 'returns groups' do - expect_gathered_references(subject.gather_references([link]), [group], 0) + expect_gathered_references(subject.gather_references([link]), [group], [link], [link]) end end @@ -38,7 +38,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedGroupParser do it 'returns an empty Array' do link['data-group'] = 'test-non-existing' - expect_gathered_references(subject.gather_references([link]), [], 1) + expect_gathered_references(subject.gather_references([link]), [], [link], []) end end end diff --git a/spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb b/spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb index 7eb58ee40d3..983407addce 100644 --- a/spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb @@ -19,7 +19,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedProjectParser do it 'returns empty Array' do link['data-project'] = project.id.to_s - expect_gathered_references(subject.gather_references([link]), [], 1) + expect_gathered_references(subject.gather_references([link]), [], [link], []) end end @@ -30,7 +30,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedProjectParser do end it 'returns an Array of referenced projects' do - expect_gathered_references(subject.gather_references([link]), [project], 0) + expect_gathered_references(subject.gather_references([link]), [project], [link], [link]) end end @@ -38,7 +38,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedProjectParser do it 'returns an empty Array' do link['data-project'] = 'inexisting-project-id' - expect_gathered_references(subject.gather_references([link]), [], 1) + expect_gathered_references(subject.gather_references([link]), [], [link], []) end end end diff --git a/spec/lib/banzai/reference_parser/mentioned_user_parser_spec.rb b/spec/lib/banzai/reference_parser/mentioned_user_parser_spec.rb index 4be07866db1..f117d796dad 100644 --- a/spec/lib/banzai/reference_parser/mentioned_user_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/mentioned_user_parser_spec.rb @@ -22,7 +22,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedUserParser do end it 'returns empty list of users' do - expect_gathered_references(subject.gather_references([link]), [], 0) + expect_gathered_references(subject.gather_references([link]), [], [link], [link]) end end end @@ -35,7 +35,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedUserParser do end it 'returns empty list of users' do - expect_gathered_references(subject.gather_references([link]), [], 0) + expect_gathered_references(subject.gather_references([link]), [], [link], [link]) end end end @@ -44,7 +44,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedUserParser do it 'returns an Array of users' do link['data-user'] = user.id.to_s - expect_gathered_references(subject.gather_references([link]), [user], 0) + expect_gathered_references(subject.gather_references([link]), [user], [link], [link]) end end end diff --git a/spec/lib/banzai/reference_parser/project_parser_spec.rb b/spec/lib/banzai/reference_parser/project_parser_spec.rb index 6358a04f12a..2c0b6c417b0 100644 --- a/spec/lib/banzai/reference_parser/project_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/project_parser_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Banzai::ReferenceParser::ProjectParser do it 'returns an Array of projects' do link['data-project'] = project.id.to_s - expect_gathered_references(subject.gather_references([link]), [project], 0) + expect_gathered_references(subject.gather_references([link]), [project], [link], [link]) end end @@ -25,7 +25,7 @@ RSpec.describe Banzai::ReferenceParser::ProjectParser do it 'returns an empty Array' do link['data-project'] = '' - expect_gathered_references(subject.gather_references([link]), [], 1) + expect_gathered_references(subject.gather_references([link]), [], [link], []) end end @@ -35,7 +35,7 @@ RSpec.describe Banzai::ReferenceParser::ProjectParser do link['data-project'] = private_project.id.to_s - expect_gathered_references(subject.gather_references([link]), [], 1) + expect_gathered_references(subject.gather_references([link]), [], [link], []) end it 'returns an Array when authorized' do @@ -43,7 +43,7 @@ RSpec.describe Banzai::ReferenceParser::ProjectParser do link['data-project'] = private_project.id.to_s - expect_gathered_references(subject.gather_references([link]), [private_project], 0) + expect_gathered_references(subject.gather_references([link]), [private_project], [link], [link]) end end end diff --git a/spec/lib/bulk_imports/groups/pipelines/entity_finisher_spec.rb b/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb index b97aeb435b9..c1a9ea7b7e2 100644 --- a/spec/lib/bulk_imports/groups/pipelines/entity_finisher_spec.rb +++ b/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BulkImports::Groups::Pipelines::EntityFinisher do +RSpec.describe BulkImports::Common::Pipelines::EntityFinisher do it 'updates the entity status to finished' do entity = create(:bulk_import_entity, :started) pipeline_tracker = create(:bulk_import_tracker, entity: entity) diff --git a/spec/lib/bulk_imports/groups/graphql/get_projects_query_spec.rb b/spec/lib/bulk_imports/groups/graphql/get_projects_query_spec.rb new file mode 100644 index 00000000000..1a7c5a4993c --- /dev/null +++ b/spec/lib/bulk_imports/groups/graphql/get_projects_query_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Graphql::GetProjectsQuery do + describe '#variables' do + it 'returns valid variables based on entity information' do + tracker = create(:bulk_import_tracker) + context = BulkImports::Pipeline::Context.new(tracker) + + query = GraphQL::Query.new( + GitlabSchema, + described_class.to_s, + variables: described_class.variables(context) + ) + result = GitlabSchema.static_validator.validate(query) + + expect(result[:errors]).to be_empty + end + + context 'with invalid variables' do + it 'raises an error' do + expect { GraphQL::Query.new(GitlabSchema, described_class.to_s, variables: 'invalid') }.to raise_error(ArgumentError) + end + end + end + + describe '#data_path' do + it 'returns data path' do + expected = %w[data group projects nodes] + + expect(described_class.data_path).to eq(expected) + end + end + + describe '#page_info_path' do + it 'returns pagination information path' do + expected = %w[data group projects page_info] + + expect(described_class.page_info_path).to eq(expected) + end + end +end diff --git a/spec/lib/bulk_imports/groups/pipelines/project_entities_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/project_entities_pipeline_spec.rb new file mode 100644 index 00000000000..5b6c93e695f --- /dev/null +++ b/spec/lib/bulk_imports/groups/pipelines/project_entities_pipeline_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Pipelines::ProjectEntitiesPipeline do + let_it_be(:user) { create(:user) } + let_it_be(:destination_group) { create(:group) } + + let_it_be(:entity) do + create( + :bulk_import_entity, + group: destination_group, + destination_namespace: destination_group.full_path + ) + end + + let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } + + let(:extracted_data) do + BulkImports::Pipeline::ExtractedData.new(data: { + 'name' => 'project', + 'full_path' => 'group/project' + }) + end + + subject { described_class.new(context) } + + describe '#run' do + before do + allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor| + allow(extractor).to receive(:extract).and_return(extracted_data) + end + + destination_group.add_owner(user) + end + + it 'creates project entity' do + expect { subject.run }.to change(BulkImports::Entity, :count).by(1) + + project_entity = BulkImports::Entity.last + + expect(project_entity.source_type).to eq('project_entity') + expect(project_entity.source_full_path).to eq('group/project') + expect(project_entity.destination_name).to eq('project') + expect(project_entity.destination_namespace).to eq(destination_group.full_path) + end + end + + describe 'pipeline parts' do + it { expect(described_class).to include_module(BulkImports::Pipeline) } + it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) } + + it 'has extractors' do + expect(described_class.get_extractor).to eq( + klass: BulkImports::Common::Extractors::GraphqlExtractor, + options: { + query: BulkImports::Groups::Graphql::GetProjectsQuery + } + ) + end + + it 'has transformers' do + expect(described_class.transformers).to contain_exactly( + { klass: BulkImports::Common::Transformers::ProhibitedAttributesTransformer, options: nil } + ) + end + end +end diff --git a/spec/lib/bulk_imports/stage_spec.rb b/spec/lib/bulk_imports/groups/stage_spec.rb index 4398b00e7e9..81c0ffc14d4 100644 --- a/spec/lib/bulk_imports/stage_spec.rb +++ b/spec/lib/bulk_imports/groups/stage_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' -RSpec.describe BulkImports::Stage do +RSpec.describe BulkImports::Groups::Stage do let(:pipelines) do [ [0, BulkImports::Groups::Pipelines::GroupPipeline], @@ -19,18 +19,21 @@ RSpec.describe BulkImports::Stage do 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::Groups::Pipelines::EntityFinisher) + expect(described_class.pipelines.last.last).to eq(BulkImports::Common::Pipelines::EntityFinisher) end - end - describe '.pipeline_exists?' do - it 'returns true when the given pipeline name exists in the pipelines list' do - expect(described_class.pipeline_exists?(BulkImports::Groups::Pipelines::GroupPipeline)).to eq(true) - expect(described_class.pipeline_exists?('BulkImports::Groups::Pipelines::GroupPipeline')).to eq(true) + it 'includes project entities pipeline' do + stub_feature_flags(bulk_import_projects: true) + + expect(described_class.pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline]) end - it 'returns false when the given pipeline name exists in the pipelines list' do - expect(described_class.pipeline_exists?('BulkImports::Groups::Pipelines::InexistentPipeline')).to eq(false) + 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) + end end end end diff --git a/spec/lib/bulk_imports/projects/pipelines/project_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/project_pipeline_spec.rb new file mode 100644 index 00000000000..c53c0849931 --- /dev/null +++ b/spec/lib/bulk_imports/projects/pipelines/project_pipeline_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Projects::Pipelines::ProjectPipeline do + describe '#run' 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(:entity) do + create( + :bulk_import_entity, + source_type: :project_entity, + 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(:project_data) do + { + 'visibility' => 'private', + 'created_at' => 10.days.ago, + 'archived' => false, + 'shared_runners_enabled' => true, + 'container_registry_enabled' => true, + 'only_allow_merge_if_pipeline_succeeds' => true, + 'only_allow_merge_if_all_discussions_are_resolved' => true, + 'request_access_enabled' => true, + 'printing_merge_request_link_enabled' => true, + 'remove_source_branch_after_merge' => true, + 'autoclose_referenced_issues' => true, + 'suggestion_commit_message' => 'message', + 'wiki_enabled' => true + } + end + + subject(:project_pipeline) { described_class.new(context) } + + before 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 + + group.add_owner(user) + end + + it 'imports new project into destination group', :aggregate_failures do + expect { project_pipeline.run }.to change { Project.count }.by(1) + + project_path = 'my-destination-project' + imported_project = Project.find_by_path(project_path) + + expect(imported_project).not_to be_nil + expect(imported_project.group).to eq(group) + expect(imported_project.suggestion_commit_message).to eq('message') + expect(imported_project.archived?).to eq(project_data['archived']) + expect(imported_project.shared_runners_enabled?).to eq(project_data['shared_runners_enabled']) + expect(imported_project.container_registry_enabled?).to eq(project_data['container_registry_enabled']) + expect(imported_project.only_allow_merge_if_pipeline_succeeds?).to eq(project_data['only_allow_merge_if_pipeline_succeeds']) + expect(imported_project.only_allow_merge_if_all_discussions_are_resolved?).to eq(project_data['only_allow_merge_if_all_discussions_are_resolved']) + expect(imported_project.request_access_enabled?).to eq(project_data['request_access_enabled']) + expect(imported_project.printing_merge_request_link_enabled?).to eq(project_data['printing_merge_request_link_enabled']) + expect(imported_project.remove_source_branch_after_merge?).to eq(project_data['remove_source_branch_after_merge']) + expect(imported_project.autoclose_referenced_issues?).to eq(project_data['autoclose_referenced_issues']) + expect(imported_project.wiki_enabled?).to eq(project_data['wiki_enabled']) + end + end + + describe 'pipeline parts' do + it { expect(described_class).to include_module(BulkImports::Pipeline) } + it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) } + + it 'has extractors' do + expect(described_class.get_extractor) + .to eq( + klass: BulkImports::Common::Extractors::GraphqlExtractor, + options: { query: BulkImports::Projects::Graphql::GetProjectQuery } + ) + end + + it 'has transformers' do + expect(described_class.transformers) + .to contain_exactly( + { klass: BulkImports::Common::Transformers::ProhibitedAttributesTransformer, options: nil }, + { klass: BulkImports::Projects::Transformers::ProjectAttributesTransformer, options: nil } + ) + end + end +end diff --git a/spec/lib/bulk_imports/projects/stage_spec.rb b/spec/lib/bulk_imports/projects/stage_spec.rb new file mode 100644 index 00000000000..428812a34ef --- /dev/null +++ b/spec/lib/bulk_imports/projects/stage_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Projects::Stage do + let(:pipelines) do + [ + [0, BulkImports::Projects::Pipelines::ProjectPipeline], + [1, BulkImports::Common::Pipelines::EntityFinisher] + ] + end + + describe '.pipelines' do + it 'list all the pipelines with their stage number, ordered by stage' do + expect(described_class.pipelines).to eq(pipelines) + end + end +end diff --git a/spec/lib/bulk_imports/projects/transformers/project_attributes_transformer_spec.rb b/spec/lib/bulk_imports/projects/transformers/project_attributes_transformer_spec.rb new file mode 100644 index 00000000000..822bb9a5605 --- /dev/null +++ b/spec/lib/bulk_imports/projects/transformers/project_attributes_transformer_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Projects::Transformers::ProjectAttributesTransformer do + describe '#transform' do + let_it_be(:user) { create(:user) } + let_it_be(:destination_group) { create(:group) } + let_it_be(:project) { create(:project, name: 'My Source Project') } + let_it_be(:bulk_import) { create(:bulk_import, user: user) } + + let_it_be(:entity) do + create( + :bulk_import_entity, + source_type: :project_entity, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'Destination Project Name', + destination_namespace: destination_group.full_path + ) + end + + let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } + + let(:data) do + { + 'name' => 'source_name', + 'visibility' => 'private' + } + end + + subject(:transformed_data) { described_class.new.transform(context, data) } + + it 'transforms name to destination name' do + expect(transformed_data[:name]).to eq(entity.destination_name) + end + + it 'adds path as parameterized name' do + expect(transformed_data[:path]).to eq(entity.destination_name.parameterize) + end + + it 'transforms visibility level' do + visibility = data['visibility'] + + expect(transformed_data).not_to have_key(:visibility) + expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel.string_options[visibility]) + end + + it 'adds import type' do + expect(transformed_data[:import_type]).to eq(described_class::PROJECT_IMPORT_TYPE) + end + + describe 'namespace_id' do + context 'when destination namespace is present' do + it 'adds namespace_id' do + expect(transformed_data[:namespace_id]).to eq(destination_group.id) + end + end + + context 'when destination namespace is blank' do + it 'does not add namespace_id key' do + entity = create( + :bulk_import_entity, + source_type: :project_entity, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'Destination Project Name', + destination_namespace: '' + ) + + context = double(entity: entity) + + expect(described_class.new.transform(context, data)).not_to have_key(:namespace_id) + end + end + end + + it 'converts all keys to symbols' do + expect(transformed_data.keys).to contain_exactly(:name, :path, :import_type, :visibility_level, :namespace_id) + end + end +end diff --git a/spec/lib/error_tracking/collector/dsn_spec.rb b/spec/lib/error_tracking/collector/dsn_spec.rb new file mode 100644 index 00000000000..af55e6f20ec --- /dev/null +++ b/spec/lib/error_tracking/collector/dsn_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ErrorTracking::Collector::Dsn do + describe '.build__url' do + let(:gitlab) do + double( + protocol: 'https', + https: true, + host: 'gitlab.example.com', + port: '4567', + relative_url_root: nil + ) + end + + subject { described_class.build_url('abcdef1234567890', 778) } + + it 'returns a valid URL' do + allow(Settings).to receive(:gitlab).and_return(gitlab) + allow(Settings).to receive(:gitlab_on_standard_port?).and_return(false) + + is_expected.to eq('https://abcdef1234567890@gitlab.example.com:4567/api/v4/error_tracking/collector/778') + end + end +end diff --git a/spec/lib/gitlab/action_cable/request_store_callbacks_spec.rb b/spec/lib/gitlab/action_cable/request_store_callbacks_spec.rb new file mode 100644 index 00000000000..3b73252709c --- /dev/null +++ b/spec/lib/gitlab/action_cable/request_store_callbacks_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::ActionCable::RequestStoreCallbacks do + describe '.wrapper' do + it 'enables RequestStore in the inner block' do + expect(RequestStore.active?).to eq(false) + + described_class.wrapper.call( + nil, + lambda do + expect(RequestStore.active?).to eq(true) + end + ) + + expect(RequestStore.active?).to eq(false) + end + end +end diff --git a/spec/lib/gitlab/background_migration/backfill_projects_with_coverage_spec.rb b/spec/lib/gitlab/background_migration/backfill_projects_with_coverage_spec.rb new file mode 100644 index 00000000000..49056154744 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_projects_with_coverage_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillProjectsWithCoverage, schema: 20210818185845 do + let(:projects) { table(:projects) } + let(:project_ci_feature_usages) { table(:project_ci_feature_usages) } + let(:ci_pipelines) { table(:ci_pipelines) } + let(:ci_daily_build_group_report_results) { table(:ci_daily_build_group_report_results) } + let(:group) { table(:namespaces).create!(name: 'user', path: 'user') } + let(:project_1) { projects.create!(namespace_id: group.id) } + let(:project_2) { projects.create!(namespace_id: group.id) } + let(:pipeline_1) { ci_pipelines.create!(project_id: project_1.id, source: 13) } + let(:pipeline_2) { ci_pipelines.create!(project_id: project_1.id, source: 13) } + let(:pipeline_3) { ci_pipelines.create!(project_id: project_2.id, source: 13) } + let(:pipeline_4) { ci_pipelines.create!(project_id: project_2.id, source: 13) } + + subject { described_class.new } + + describe '#perform' do + before do + ci_daily_build_group_report_results.create!( + id: 1, + project_id: project_1.id, + date: 4.days.ago, + last_pipeline_id: pipeline_1.id, + ref_path: 'main', + group_name: 'rspec', + data: { coverage: 95.0 }, + default_branch: true, + group_id: group.id + ) + + ci_daily_build_group_report_results.create!( + id: 2, + project_id: project_1.id, + date: 3.days.ago, + last_pipeline_id: pipeline_2.id, + ref_path: 'main', + group_name: 'rspec', + data: { coverage: 95.0 }, + default_branch: true, + group_id: group.id + ) + + ci_daily_build_group_report_results.create!( + id: 3, + project_id: project_2.id, + date: 2.days.ago, + last_pipeline_id: pipeline_3.id, + ref_path: 'main', + group_name: 'rspec', + data: { coverage: 95.0 }, + default_branch: true, + group_id: group.id + ) + + ci_daily_build_group_report_results.create!( + id: 4, + project_id: project_2.id, + date: 1.day.ago, + last_pipeline_id: pipeline_4.id, + ref_path: 'test_branch', + group_name: 'rspec', + data: { coverage: 95.0 }, + default_branch: false, + group_id: group.id + ) + + stub_const("#{described_class}::INSERT_DELAY_SECONDS", 0) + end + + it 'creates entries per project and default_branch combination in the given range', :aggregate_failures do + subject.perform(1, 4, 2) + + entries = project_ci_feature_usages.order('project_id ASC, default_branch DESC') + + expect(entries.count).to eq(3) + expect(entries[0]).to have_attributes(project_id: project_1.id, feature: 1, default_branch: true) + expect(entries[1]).to have_attributes(project_id: project_2.id, feature: 1, default_branch: true) + expect(entries[2]).to have_attributes(project_id: project_2.id, feature: 1, default_branch: false) + end + + context 'when an entry for the project and default branch combination already exists' do + before do + subject.perform(1, 4, 2) + end + + it 'does not create a new entry' do + expect { subject.perform(1, 4, 2) }.not_to change { project_ci_feature_usages.count } + end + end + end +end diff --git a/spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb b/spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb new file mode 100644 index 00000000000..a111007a984 --- /dev/null +++ b/spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::ExtractProjectTopicsIntoSeparateTable, schema: 20210730104800 do + it 'correctly extracts project topics into separate table' do + namespaces = table(:namespaces) + projects = table(:projects) + taggings = table(:taggings) + tags = table(:tags) + project_topics = table(:project_topics) + topics = table(:topics) + + namespace = namespaces.create!(name: 'foo', path: 'foo') + project = projects.create!(namespace_id: namespace.id) + tag_1 = tags.create!(name: 'Topic1') + tag_2 = tags.create!(name: 'Topic2') + tag_3 = tags.create!(name: 'Topic3') + topic_3 = topics.create!(name: 'Topic3') + tagging_1 = taggings.create!(taggable_type: 'Project', taggable_id: project.id, context: 'topics', tag_id: tag_1.id) + tagging_2 = taggings.create!(taggable_type: 'Project', taggable_id: project.id, context: 'topics', tag_id: tag_2.id) + other_tagging = taggings.create!(taggable_type: 'Other', taggable_id: project.id, context: 'topics', tag_id: tag_1.id) + tagging_3 = taggings.create!(taggable_type: 'Project', taggable_id: project.id, context: 'topics', tag_id: tag_3.id) + tagging_4 = taggings.create!(taggable_type: 'Project', taggable_id: -1, context: 'topics', tag_id: tag_1.id) + tagging_5 = taggings.create!(taggable_type: 'Project', taggable_id: project.id, context: 'topics', tag_id: -1) + + subject.perform(tagging_1.id, tagging_5.id) + + # Tagging records + expect { tagging_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { tagging_2.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { other_tagging.reload }.not_to raise_error(ActiveRecord::RecordNotFound) + expect { tagging_3.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { tagging_4.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { tagging_5.reload }.to raise_error(ActiveRecord::RecordNotFound) + + # Topic records + topic_1 = topics.find_by(name: 'Topic1') + topic_2 = topics.find_by(name: 'Topic2') + expect(topics.all).to contain_exactly(topic_1, topic_2, topic_3) + + # ProjectTopic records + expect(project_topics.all.map(&:topic_id)).to contain_exactly(topic_1.id, topic_2.id, topic_3.id) + end +end diff --git a/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb b/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb index 496ce151032..91e8dcdf880 100644 --- a/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb +++ b/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb @@ -91,6 +91,18 @@ RSpec.describe Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers d end describe '#perform' do + it 'skips jobs that have already been completed' do + Gitlab::Database::BackgroundMigrationJob.create!( + class_name: 'MigrateMergeRequestDiffCommitUsers', + arguments: [1, 10], + status: :succeeded + ) + + expect(migration).not_to receive(:get_data_to_update) + + migration.perform(1, 10) + end + it 'migrates the data in the range' do commits.create!( merge_request_diff_id: diff.id, 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 906a6a747c9..815dc2e73e5 100644 --- a/spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb +++ b/spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::BackgroundMigration::MigratePagesMetadata, schema: 201909 subject(:migrate_pages_metadata) { described_class.new } - describe '#perform_on_relation' do + describe '#perform' do let(:namespaces) { table(:namespaces) } let(:builds) { table(:ci_builds) } let(:pages_metadata) { table(:project_pages_metadata) } @@ -23,9 +23,9 @@ RSpec.describe Gitlab::BackgroundMigration::MigratePagesMetadata, schema: 201909 not_migrated_no_pages = projects.create!(namespace_id: namespace.id, name: 'Not Migrated No Pages') project_not_in_relation_scope = projects.create!(namespace_id: namespace.id, name: 'Other') - projects_relation = projects.where(id: [not_migrated_with_pages, not_migrated_no_pages, migrated]) + ids = [not_migrated_no_pages.id, not_migrated_with_pages.id, migrated.id] - migrate_pages_metadata.perform_on_relation(projects_relation) + migrate_pages_metadata.perform(ids.min, ids.max) expect(pages_metadata.find_by_project_id(not_migrated_with_pages.id).deployed).to eq(true) expect(pages_metadata.find_by_project_id(not_migrated_no_pages.id).deployed).to eq(false) @@ -33,12 +33,4 @@ RSpec.describe Gitlab::BackgroundMigration::MigratePagesMetadata, schema: 201909 expect(pages_metadata.find_by_project_id(project_not_in_relation_scope.id)).to be_nil end end - - describe '#perform' do - it 'creates relation and delegates to #perform_on_relation' do - expect(migrate_pages_metadata).to receive(:perform_on_relation).with(projects.where(id: 3..5)) - - migrate_pages_metadata.perform(3, 5) - end - end end diff --git a/spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb b/spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb new file mode 100644 index 00000000000..f2fb2ab6b6e --- /dev/null +++ b/spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::StealMigrateMergeRequestDiffCommitUsers do + let(:migration) { described_class.new } + + describe '#perform' do + it 'processes the background migration' do + spy = instance_spy( + Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers + ) + + allow(Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers) + .to receive(:new) + .and_return(spy) + + expect(spy).to receive(:perform).with(1, 4) + expect(migration).to receive(:schedule_next_job) + + migration.perform(1, 4) + end + end + + describe '#schedule_next_job' do + it 'schedules the next job in ascending order' do + Gitlab::Database::BackgroundMigrationJob.create!( + class_name: 'MigrateMergeRequestDiffCommitUsers', + arguments: [10, 20] + ) + + Gitlab::Database::BackgroundMigrationJob.create!( + class_name: 'MigrateMergeRequestDiffCommitUsers', + arguments: [40, 50] + ) + + expect(BackgroundMigrationWorker) + .to receive(:perform_in) + .with(5.minutes, 'StealMigrateMergeRequestDiffCommitUsers', [10, 20]) + + migration.schedule_next_job + end + + it 'does not schedule any new jobs when there are none' do + expect(BackgroundMigrationWorker).not_to receive(:perform_in) + + migration.schedule_next_job + end + end +end diff --git a/spec/lib/gitlab/changelog/config_spec.rb b/spec/lib/gitlab/changelog/config_spec.rb index a464c1e57e5..c410ba4d116 100644 --- a/spec/lib/gitlab/changelog/config_spec.rb +++ b/spec/lib/gitlab/changelog/config_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::Changelog::Config do + include ProjectForksHelper + let(:project) { build_stubbed(:project) } describe '.from_git' do @@ -13,7 +15,7 @@ RSpec.describe Gitlab::Changelog::Config do expect(described_class) .to receive(:from_hash) - .with(project, 'date_format' => '%Y') + .with(project, { 'date_format' => '%Y' }, nil) described_class.from_git(project) end @@ -33,12 +35,25 @@ RSpec.describe Gitlab::Changelog::Config do describe '.from_hash' do it 'sets the configuration according to a Hash' do + user1 = create(:user) + user2 = create(:user) + user3 = create(:user) + group = create(:group, path: 'group') + group2 = create(:group, path: 'group-path') + group.add_developer(user1) + group.add_developer(user2) + group2.add_developer(user3) + config = described_class.from_hash( project, - 'date_format' => 'foo', - 'template' => 'bar', - 'categories' => { 'foo' => 'bar' }, - 'tag_regex' => 'foo' + { + 'date_format' => 'foo', + 'template' => 'bar', + 'categories' => { 'foo' => 'bar' }, + 'tag_regex' => 'foo', + 'include_groups' => %w[group group-path non-existent-group] + }, + user1 ) expect(config.date_format).to eq('foo') @@ -47,6 +62,7 @@ RSpec.describe Gitlab::Changelog::Config do expect(config.categories).to eq({ 'foo' => 'bar' }) expect(config.tag_regex).to eq('foo') + expect(config.always_credit_user_ids).to match_array([user1.id, user2.id, user3.id]) end it 'raises Error when the categories are not a Hash' do @@ -66,20 +82,33 @@ RSpec.describe Gitlab::Changelog::Config do end describe '#contributor?' do - it 'returns true if a user is a contributor' do - user = build_stubbed(:author) + let(:project) { create(:project, :public, :repository) } - allow(project.team).to receive(:contributor?).with(user).and_return(true) - - expect(described_class.new(project).contributor?(user)).to eq(true) - end + context 'when user is a member of project' do + let(:user) { create(:user) } - it "returns true if a user isn't a contributor" do - user = build_stubbed(:author) + before do + project.add_developer(user) + end - allow(project.team).to receive(:contributor?).with(user).and_return(false) + it { expect(described_class.new(project).contributor?(user)).to eq(false) } + end - expect(described_class.new(project).contributor?(user)).to eq(false) + context 'when user has at least one merge request merged into default_branch' do + let(:contributor) { create(:user) } + let(:user_without_access) { create(:user) } + let(:user_fork) { fork_project(project, contributor, repository: true) } + + before do + create(:merge_request, :merged, + author: contributor, + target_project: project, + source_project: user_fork, + target_branch: project.default_branch.to_s) + end + + it { expect(described_class.new(project).contributor?(contributor)).to eq(true) } + it { expect(described_class.new(project).contributor?(user_without_access)).to eq(false) } end end @@ -107,4 +136,55 @@ RSpec.describe Gitlab::Changelog::Config do expect(config.format_date(time)).to eq('2021-01-05') end end + + describe '#always_credit_author?' do + let_it_be(:group_member) { create(:user) } + let_it_be(:non_group_member) { create(:user) } + let_it_be(:group) { create(:group, :private, path: 'group') } + + before do + group.add_developer(group_member) + end + + context 'when include_groups is defined' do + context 'when user generating changelog has access to group' do + it 'returns whether author should always be credited' do + config = described_class.from_hash( + project, + { 'include_groups' => ['group'] }, + group_member + ) + + expect(config.always_credit_author?(group_member)).to eq(true) + expect(config.always_credit_author?(non_group_member)).to eq(false) + end + end + + context 'when user generating changelog has no access to group' do + it 'always returns false' do + config = described_class.from_hash( + project, + { 'include_groups' => ['group'] }, + non_group_member + ) + + expect(config.always_credit_author?(group_member)).to eq(false) + expect(config.always_credit_author?(non_group_member)).to eq(false) + end + end + end + + context 'when include_groups is not defined' do + it 'always returns false' do + config = described_class.from_hash( + project, + {}, + group_member + ) + + expect(config.always_credit_author?(group_member)).to eq(false) + expect(config.always_credit_author?(non_group_member)).to eq(false) + end + end + end end diff --git a/spec/lib/gitlab/changelog/release_spec.rb b/spec/lib/gitlab/changelog/release_spec.rb index f95244d6750..d8434821640 100644 --- a/spec/lib/gitlab/changelog/release_spec.rb +++ b/spec/lib/gitlab/changelog/release_spec.rb @@ -94,6 +94,30 @@ RSpec.describe Gitlab::Changelog::Release do end end + context 'when the author should always be credited' do + it 'includes the author' do + allow(config).to receive(:contributor?).with(author).and_return(false) + allow(config).to receive(:always_credit_author?).with(author).and_return(true) + + release.add_entry( + title: 'Entry title', + commit: commit, + category: 'fixed', + author: author + ) + + expect(release.to_markdown).to eq(<<~OUT) + ## 1.0.0 (2021-01-05) + + ### fixed (1 change) + + - [Entry title](#{commit.to_reference(full: true)}) \ + by #{author.to_reference(full: true)} + + OUT + end + end + context 'when a category has no entries' do it "isn't included in the output" do config.categories['kittens'] = 'Kittens' diff --git a/spec/lib/gitlab/chat/command_spec.rb b/spec/lib/gitlab/chat/command_spec.rb index 89c693daaa0..d99c07d1fa3 100644 --- a/spec/lib/gitlab/chat/command_spec.rb +++ b/spec/lib/gitlab/chat/command_spec.rb @@ -44,7 +44,7 @@ RSpec.describe Gitlab::Chat::Command do let(:pipeline) { command.create_pipeline } before do - stub_ci_pipeline_yaml_file(gitlab_ci_yaml) + stub_ci_pipeline_to_return_yaml_file project.add_developer(chat_name.user) end diff --git a/spec/lib/gitlab/checks/changes_access_spec.rb b/spec/lib/gitlab/checks/changes_access_spec.rb index 4a74dfcec34..633c4baa931 100644 --- a/spec/lib/gitlab/checks/changes_access_spec.rb +++ b/spec/lib/gitlab/checks/changes_access_spec.rb @@ -8,53 +8,35 @@ RSpec.describe Gitlab::Checks::ChangesAccess do subject { changes_access } describe '#validate!' do - shared_examples '#validate!' do - before do - allow(project).to receive(:lfs_enabled?).and_return(true) - end - - context 'without failed checks' do - it "doesn't raise an error" do - expect { subject.validate! }.not_to raise_error - end - - it 'calls lfs checks' do - expect_next_instance_of(Gitlab::Checks::LfsCheck) do |instance| - expect(instance).to receive(:validate!) - end + before do + allow(project).to receive(:lfs_enabled?).and_return(true) + end - subject.validate! - end + context 'without failed checks' do + it "doesn't raise an error" do + expect { subject.validate! }.not_to raise_error end - context 'when time limit was reached' do - it 'raises a TimeoutError' do - logger = Gitlab::Checks::TimedLogger.new(start_time: timeout.ago, timeout: timeout) - access = described_class.new(changes, - project: project, - user_access: user_access, - protocol: protocol, - logger: logger) - - expect { access.validate! }.to raise_error(Gitlab::Checks::TimedLogger::TimeoutError) + it 'calls lfs checks' do + expect_next_instance_of(Gitlab::Checks::LfsCheck) do |instance| + expect(instance).to receive(:validate!) end - end - end - context 'with batched commits enabled' do - before do - stub_feature_flags(changes_batch_commits: true) + subject.validate! end - - it_behaves_like '#validate!' end - context 'with batched commits disabled' do - before do - stub_feature_flags(changes_batch_commits: false) - end + context 'when time limit was reached' do + it 'raises a TimeoutError' do + logger = Gitlab::Checks::TimedLogger.new(start_time: timeout.ago, timeout: timeout) + access = described_class.new(changes, + project: project, + user_access: user_access, + protocol: protocol, + logger: logger) - it_behaves_like '#validate!' + expect { access.validate! }.to raise_error(Gitlab::Checks::TimedLogger::TimeoutError) + end end end @@ -192,6 +174,101 @@ RSpec.describe Gitlab::Checks::ChangesAccess do end end + describe '#single_change_accesses' do + let(:commits_for) { {} } + let(:expected_accesses) { [] } + + shared_examples '#single_change_access' do + before do + commits_for.each do |id, commits| + expect(subject) + .to receive(:commits_for) + .with(id) + .and_return(commits) + end + end + + it 'returns an array of SingleChangeAccess' do + # Commits are wrapped in a Gitlab::Lazy and thus need to be resolved + # first such that we can directly compare types. + actual_accesses = subject.single_change_accesses + .each { |access| access.instance_variable_set(:@commits, access.commits.to_a) } + + expect(actual_accesses).to match_array(expected_accesses) + end + end + + context 'with no changes' do + let(:changes) { [] } + + it_behaves_like '#single_change_access' + end + + context 'with a single change and no new commits' do + let(:commits_for) { { 'new' => [] } } + let(:changes) do + [ + { oldrev: 'old', newrev: 'new', ref: 'refs/heads/branch' } + ] + end + + let(:expected_accesses) do + [ + have_attributes(oldrev: 'old', newrev: 'new', ref: 'refs/heads/branch', commits: []) + ] + end + + it_behaves_like '#single_change_access' + end + + context 'with a single change and new commits' do + let(:commits_for) { { 'new' => [create_commit('new', [])] } } + let(:changes) do + [ + { oldrev: 'old', newrev: 'new', ref: 'refs/heads/branch' } + ] + end + + let(:expected_accesses) do + [ + have_attributes(oldrev: 'old', newrev: 'new', ref: 'refs/heads/branch', commits: [create_commit('new', [])]) + ] + end + + it_behaves_like '#single_change_access' + end + + context 'with multiple changes' do + let(:commits_for) do + { + 'a' => [create_commit('a', [])], + 'c' => [create_commit('c', [])], + 'd' => [] + } + end + + let(:changes) do + [ + { newrev: 'a', ref: 'refs/heads/a' }, + { oldrev: 'b', ref: 'refs/heads/b' }, + { oldrev: 'a', newrev: 'c', ref: 'refs/heads/c' }, + { newrev: 'd', ref: 'refs/heads/d' } + ] + end + + let(:expected_accesses) do + [ + have_attributes(newrev: 'a', ref: 'refs/heads/a', commits: [create_commit('a', [])]), + have_attributes(oldrev: 'b', ref: 'refs/heads/b', commits: []), + have_attributes(oldrev: 'a', newrev: 'c', ref: 'refs/heads/c', commits: [create_commit('c', [])]), + have_attributes(newrev: 'd', ref: 'refs/heads/d', commits: []) + ] + end + + it_behaves_like '#single_change_access' + end + end + def create_commit(id, parent_ids) Gitlab::Git::Commit.new(project.repository, { id: id, diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index 5b47d3a3922..0bb26babfc0 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -169,6 +169,22 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do it { expect(entry).to be_valid } end end + + context 'when rules are used' do + let(:config) { { script: 'ls', cache: { key: 'test' }, rules: rules } } + + let(:rules) do + [ + { if: '$CI_PIPELINE_SOURCE == "schedule"', when: 'never' }, + [ + { if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }, + { if: '$CI_PIPELINE_SOURCE == "merge_request_event"' } + ] + ] + end + + it { expect(entry).to be_valid } + end end context 'when entry value is not correct' do @@ -485,6 +501,70 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do end end end + + context 'when invalid rules are used' do + let(:config) { { script: 'ls', cache: { key: 'test' }, rules: rules } } + + context 'with rules nested more than max allowed levels' do + let(:sample_rule) { { if: '$THIS == "other"', when: 'always' } } + + let(:rules) do + [ + { if: '$THIS == "that"', when: 'always' }, + [ + { if: '$SKIP', when: 'never' }, + [ + sample_rule, + [ + sample_rule, + [ + sample_rule, + [ + sample_rule, + [ + sample_rule, + [ + sample_rule, + [ + sample_rule, + [ + sample_rule, + [ + sample_rule, + [ + sample_rule, + [sample_rule] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + end + + it { expect(entry).not_to be_valid } + end + + context 'with rules with invalid keys' do + let(:rules) do + [ + { invalid_key: 'invalid' }, + [ + { if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }, + { if: '$CI_PIPELINE_SOURCE == "merge_request_event"' } + ] + ] + end + + it { expect(entry).not_to be_valid } + end + end end end @@ -618,6 +698,29 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do end end end + + context 'when job is using tags' do + context 'when limit is reached' do + let(:tags) { Array.new(100) { |i| "tag-#{i}" } } + let(:config) { { tags: tags, script: 'test' } } + + it 'returns error', :aggregate_failures do + expect(entry).not_to be_valid + expect(entry.errors) + .to include "tags config must be less than the limit of #{Gitlab::Ci::Config::Entry::Tags::TAGS_LIMIT} tags" + end + end + + context 'when limit is not reached' do + let(:config) { { tags: %w[tag1 tag2], script: 'test' } } + + it 'returns a valid entry', :aggregate_failures do + expect(entry).to be_valid + expect(entry.errors).to be_empty + expect(entry.tags).to eq(%w[tag1 tag2]) + end + end + end end describe '#manual_action?' do diff --git a/spec/lib/gitlab/ci/config/entry/rules_spec.rb b/spec/lib/gitlab/ci/config/entry/rules_spec.rb index 91252378541..cfec33003e4 100644 --- a/spec/lib/gitlab/ci/config/entry/rules_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/rules_spec.rb @@ -53,7 +53,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules do let(:config) do [ { if: '$THIS == "that"', when: 'always' }, - [{ if: '$SKIP', when: 'never' }] + [{ if: '$SKIP', when: 'never' }, { if: '$THIS == "other"', when: 'always' }] ] end @@ -64,11 +64,11 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules do let(:config) do [ { if: '$THIS == "that"', when: 'always' }, - [{ if: '$SKIP', when: 'never' }, [{ if: '$THIS == "other"', when: 'aways' }]] + [{ if: '$SKIP', when: 'never' }, [{ if: '$THIS == "other"', when: 'always' }]] ] end - it { is_expected.not_to be_valid } + it { is_expected.to be_valid } end end @@ -119,7 +119,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules do context 'with rules nested more than one level' do let(:first_rule) { { if: '$THIS == "that"', when: 'always' } } let(:second_rule) { { if: '$SKIP', when: 'never' } } - let(:third_rule) { { if: '$THIS == "other"', when: 'aways' } } + let(:third_rule) { { if: '$THIS == "other"', when: 'always' } } let(:config) do [ diff --git a/spec/lib/gitlab/ci/config/entry/tags_spec.rb b/spec/lib/gitlab/ci/config/entry/tags_spec.rb new file mode 100644 index 00000000000..79317de373b --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/tags_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Config::Entry::Tags do + let(:entry) { described_class.new(config) } + + describe 'validation' do + context 'when tags config value is correct' do + let(:config) { %w[tag1 tag2] } + + describe '#value' do + it 'returns tags configuration' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is not correct' do + describe '#errors' do + context 'when tags config is not an array of strings' do + let(:config) { [1, 2] } + + it 'reports error' do + expect(entry.errors) + .to include 'tags config should be an array of strings' + end + end + + context 'when tags limit is reached' do + let(:config) { Array.new(50) {|i| "tag-#{i}" } } + + context 'when ci_build_tags_limit is enabled' do + before do + stub_feature_flags(ci_build_tags_limit: true) + end + + it 'reports error' do + expect(entry.errors) + .to include "tags config must be less than the limit of #{described_class::TAGS_LIMIT} tags" + end + end + + context 'when ci_build_tags_limit is disabled' do + before do + stub_feature_flags(ci_build_tags_limit: false) + end + + it 'does not report an error' do + expect(entry.errors).to be_empty + end + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/cron_parser_spec.rb b/spec/lib/gitlab/ci/cron_parser_spec.rb index 15293429354..4017accb462 100644 --- a/spec/lib/gitlab/ci/cron_parser_spec.rb +++ b/spec/lib/gitlab/ci/cron_parser_spec.rb @@ -297,4 +297,65 @@ RSpec.describe Gitlab::Ci::CronParser do it { is_expected.to eq(true) } end end + + describe '.parse_natural', :aggregate_failures do + let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'day', duration: 1 }) } + let(:time) { Time.parse('Mon, 30 Aug 2021 06:29:44.067132000 UTC +00:00') } + let(:hours) { Fugit::Cron.parse(cron_line).hours } + let(:minutes) { Fugit::Cron.parse(cron_line).minutes } + let(:weekdays) { Fugit::Cron.parse(cron_line).weekdays.first } + let(:months) { Fugit::Cron.parse(cron_line).months } + + context 'when repeat cycle is day' do + it 'generates daily cron expression', :aggregate_failures do + expect(hours).to include time.hour + expect(minutes).to include time.min + end + end + + context 'when repeat cycle is week' do + let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'week', duration: 1 }) } + + it 'generates weekly cron expression', :aggregate_failures do + expect(hours).to include time.hour + expect(minutes).to include time.min + expect(weekdays).to include time.wday + end + end + + context 'when repeat cycle is month' do + let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'month', duration: 3 }) } + + it 'generates monthly cron expression', :aggregate_failures do + expect(minutes).to include time.min + expect(months).to include time.month + end + + context 'when an unsupported duration is specified' do + subject { described_class.parse_natural_with_timestamp(time, { unit: 'month', duration: 7 }) } + + it 'raises an exception' do + expect { subject }.to raise_error(NotImplementedError, 'The cadence {:unit=>"month", :duration=>7} is not supported') + end + end + end + + context 'when repeat cycle is year' do + let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'year', duration: 1 }) } + + it 'generates yearly cron expression', :aggregate_failures do + expect(hours).to include time.hour + expect(minutes).to include time.min + expect(months).to include time.month + end + end + + context 'when the repeat cycle is not implemented' do + subject { described_class.parse_natural_with_timestamp(time, { unit: 'quarterly', duration: 1 }) } + + it 'raises an exception' do + expect { subject }.to raise_error(NotImplementedError, 'The cadence unit quarterly is not implemented') + end + end + end end diff --git a/spec/lib/gitlab/ci/parsers/security/common_spec.rb b/spec/lib/gitlab/ci/parsers/security/common_spec.rb index c6387bf615b..c49673f5a4a 100644 --- a/spec/lib/gitlab/ci/parsers/security/common_spec.rb +++ b/spec/lib/gitlab/ci/parsers/security/common_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -# TODO remove duplication from spec/lib/gitlab/ci/parsers/security/common_spec.rb and spec/lib/gitlab/ci/parsers/security/common_spec.rb -# See https://gitlab.com/gitlab-org/gitlab/-/issues/336589 require 'spec_helper' RSpec.describe Gitlab::Ci::Parsers::Security::Common do @@ -15,11 +13,18 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do # The path 'yarn.lock' was initially used by DependencyScanning, it is okay for SAST locations to use it, but this could be made better let(:location) { ::Gitlab::Ci::Reports::Security::Locations::Sast.new(file_path: 'yarn.lock', start_line: 1, end_line: 1) } let(:tracking_data) { nil } + let(:vulnerability_flags_data) do + [ + ::Gitlab::Ci::Reports::Security::Flag.new(type: 'flagged-as-likely-false-positive', origin: 'post analyzer X', description: 'static string to sink'), + ::Gitlab::Ci::Reports::Security::Flag.new(type: 'flagged-as-likely-false-positive', origin: 'post analyzer Y', description: 'integer to sink') + ] + end before do allow_next_instance_of(described_class) do |parser| allow(parser).to receive(:create_location).and_return(location) allow(parser).to receive(:tracking_data).and_return(tracking_data) + allow(parser).to receive(:create_flags).and_return(vulnerability_flags_data) end artifact.each_blob { |blob| described_class.parse!(blob, report, vulnerability_finding_signatures_enabled) } @@ -233,6 +238,17 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do end end + describe 'parsing flags' do + it 'returns flags object for each finding' do + flags = report.findings.first.flags + + expect(flags).to contain_exactly( + have_attributes(type: 'flagged-as-likely-false-positive', origin: 'post analyzer X', description: 'static string to sink'), + have_attributes(type: 'flagged-as-likely-false-positive', origin: 'post analyzer Y', description: 'integer to sink') + ) + end + end + describe 'parsing links' do it 'returns links object for each finding', :aggregate_failures do links = report.findings.flat_map(&:links) diff --git a/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb b/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb index f434ffd12bf..951e0576a58 100644 --- a/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb +++ b/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb @@ -6,7 +6,8 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do using RSpec::Parameterized::TableSyntax where(:report_type, :expected_errors, :valid_data) do - :sast | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] } + 'sast' | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] } + :sast | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] } :secret_detection | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] } end diff --git a/spec/lib/gitlab/ci/pipeline/chain/build/associations_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/build/associations_spec.rb index 5fa414f5bd1..32c92724f62 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/build/associations_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/build/associations_spec.rb @@ -3,10 +3,16 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Pipeline::Chain::Build::Associations do - let(:project) { create(:project, :repository) } - let(:user) { create(:user, developer_projects: [project]) } + let_it_be_with_reload(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user, developer_projects: [project]) } + let(:pipeline) { Ci::Pipeline.new } - let(:step) { described_class.new(pipeline, command) } + let(:bridge) { nil } + + let(:variables_attributes) do + [{ key: 'first', secret_value: 'world' }, + { key: 'second', secret_value: 'second_world' }] + end let(:command) do Gitlab::Ci::Pipeline::Chain::Command.new( @@ -20,7 +26,26 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Build::Associations do merge_request: nil, project: project, current_user: user, - bridge: bridge) + bridge: bridge, + variables_attributes: variables_attributes) + end + + let(:step) { described_class.new(pipeline, command) } + + shared_examples 'breaks the chain' do + it 'returns true' do + step.perform! + + expect(step.break?).to be true + end + end + + shared_examples 'does not break the chain' do + it 'returns false' do + step.perform! + + expect(step.break?).to be false + end end context 'when a bridge is passed in to the pipeline creation' do @@ -37,26 +62,83 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Build::Associations do ) end - it 'never breaks the chain' do - step.perform! - - expect(step.break?).to eq(false) - end + it_behaves_like 'does not break the chain' end context 'when a bridge is not passed in to the pipeline creation' do - let(:bridge) { nil } - it 'leaves the source pipeline empty' do step.perform! expect(pipeline.source_pipeline).to be_nil end - it 'never breaks the chain' do + it_behaves_like 'does not break the chain' + end + + it 'sets pipeline variables' do + step.perform! + + expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) }) + .to eq variables_attributes.map(&:with_indifferent_access) + end + + context 'when project setting restrict_user_defined_variables is enabled' do + before do + project.update!(restrict_user_defined_variables: true) + end + + context 'when user is developer' do + it_behaves_like 'breaks the chain' + + it 'returns an error on variables_attributes', :aggregate_failures do + step.perform! + + expect(pipeline.errors.full_messages).to eq(['Insufficient permissions to set pipeline variables']) + expect(pipeline.variables).to be_empty + end + + context 'when variables_attributes is not specified' do + let(:variables_attributes) { nil } + + it_behaves_like 'does not break the chain' + + it 'assigns empty variables' do + step.perform! + + expect(pipeline.variables).to be_empty + end + end + end + + context 'when user is maintainer' do + before do + project.add_maintainer(user) + end + + it_behaves_like 'does not break the chain' + + it 'assigns variables_attributes' do + step.perform! + + expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) }) + .to eq variables_attributes.map(&:with_indifferent_access) + end + end + end + + context 'with duplicate pipeline variables' do + let(:variables_attributes) do + [{ key: 'first', secret_value: 'world' }, + { key: 'first', secret_value: 'second_world' }] + end + + it_behaves_like 'breaks the chain' + + it 'returns an error for variables_attributes' do step.perform! - expect(step.break?).to eq(false) + expect(pipeline.errors.full_messages).to eq(['Duplicate variable name: first']) + expect(pipeline.variables).to be_empty end end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb index 7771289abe6..dca2204f544 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb @@ -8,11 +8,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Build do let(:pipeline) { Ci::Pipeline.new } - let(:variables_attributes) do - [{ key: 'first', secret_value: 'world' }, - { key: 'second', secret_value: 'second_world' }] - end - let(:command) do Gitlab::Ci::Pipeline::Chain::Command.new( source: :push, @@ -24,100 +19,26 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Build do schedule: nil, merge_request: nil, project: project, - current_user: user, - variables_attributes: variables_attributes) + current_user: user) end let(:step) { described_class.new(pipeline, command) } - shared_examples 'builds pipeline' do - it 'builds a pipeline with the expected attributes' do - step.perform! - - expect(pipeline.sha).not_to be_empty - expect(pipeline.sha).to eq project.commit.id - expect(pipeline.ref).to eq 'master' - expect(pipeline.tag).to be false - expect(pipeline.user).to eq user - expect(pipeline.project).to eq project - end - end - - shared_examples 'breaks the chain' do - it 'returns true' do - step.perform! - - expect(step.break?).to be true - end - end - - shared_examples 'does not break the chain' do - it 'returns false' do - step.perform! - - expect(step.break?).to be false - end - end - - before do - stub_ci_pipeline_yaml_file(gitlab_ci_yaml) - end - - it_behaves_like 'does not break the chain' - it_behaves_like 'builds pipeline' - - it 'sets pipeline variables' do + it 'does not break the chain' do step.perform! - expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) }) - .to eq variables_attributes.map(&:with_indifferent_access) + expect(step.break?).to be false end - context 'when project setting restrict_user_defined_variables is enabled' do - before do - project.update!(restrict_user_defined_variables: true) - end - - context 'when user is developer' do - it_behaves_like 'breaks the chain' - it_behaves_like 'builds pipeline' - - it 'returns an error on variables_attributes', :aggregate_failures do - step.perform! - - expect(pipeline.errors.full_messages).to eq(['Insufficient permissions to set pipeline variables']) - expect(pipeline.variables).to be_empty - end - - context 'when variables_attributes is not specified' do - let(:variables_attributes) { nil } - - it_behaves_like 'does not break the chain' - it_behaves_like 'builds pipeline' - - it 'assigns empty variables' do - step.perform! - - expect(pipeline.variables).to be_empty - end - end - end - - context 'when user is maintainer' do - before do - project.add_maintainer(user) - end - - it_behaves_like 'does not break the chain' - it_behaves_like 'builds pipeline' - - it 'assigns variables_attributes' do - step.perform! + it 'builds a pipeline with the expected attributes' do + step.perform! - expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) }) - .to eq variables_attributes.map(&:with_indifferent_access) - end - end + expect(pipeline.sha).not_to be_empty + expect(pipeline.sha).to eq project.commit.id + expect(pipeline.ref).to eq 'master' + expect(pipeline.tag).to be false + expect(pipeline.user).to eq user + expect(pipeline.project).to eq project end it 'returns a valid pipeline' do diff --git a/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb index 2727f2603cd..27a5abf988c 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb @@ -44,6 +44,14 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::CancelPendingPipelines do expect(build_statuses(pipeline)).to contain_exactly('pending') end + it 'cancels the builds with 2 queries to avoid query timeout' do + second_query_regex = /WHERE "ci_pipelines"\."id" = \d+ AND \(NOT EXISTS/ + recorder = ActiveRecord::QueryRecorder.new { perform } + second_query = recorder.occurrences.keys.filter { |occ| occ =~ second_query_regex } + + expect(second_query).to be_one + end + context 'when the previous pipeline has a child pipeline' do let(:child_pipeline) { create(:ci_pipeline, child_of: prev_pipeline) } diff --git a/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb index c22a0e23794..0d78ce3440a 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb @@ -341,4 +341,40 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Command do end end end + + describe '#observe_step_duration' do + context 'when ci_pipeline_creation_step_duration_tracking is enabled' do + it 'adds the duration to the step duration histogram' do + histogram = double(:histogram) + duration = 1.hour + + expect(::Gitlab::Ci::Pipeline::Metrics).to receive(:pipeline_creation_step_duration_histogram) + .and_return(histogram) + expect(histogram).to receive(:observe) + .with({ step: 'Gitlab::Ci::Pipeline::Chain::Build' }, duration.seconds) + + described_class.new.observe_step_duration( + Gitlab::Ci::Pipeline::Chain::Build, + duration + ) + end + end + + context 'when ci_pipeline_creation_step_duration_tracking is disabled' do + before do + stub_feature_flags(ci_pipeline_creation_step_duration_tracking: false) + end + + it 'does nothing' do + duration = 1.hour + + expect(::Gitlab::Ci::Pipeline::Metrics).not_to receive(:pipeline_creation_step_duration_histogram) + + described_class.new.observe_step_duration( + Gitlab::Ci::Pipeline::Chain::Build, + duration + ) + end + end + end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb index 42ec9ab6f5d..e0d656f456e 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb @@ -92,6 +92,27 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do expect(pipeline.pipeline_config.content).to eq(config_content_result) expect(command.config_content).to eq(config_content_result) end + + context 'when path specifies a refname' do + let(:ci_config_path) { 'path/to/.gitlab-ci.yml@another-group/another-repo:refname' } + let(:config_content_result) do + <<~EOY + --- + include: + - project: another-group/another-repo + file: path/to/.gitlab-ci.yml + ref: refname + EOY + end + + it 'builds root config including the path and refname to another repository' do + subject.perform! + + expect(pipeline.config_source).to eq 'external_project_source' + expect(pipeline.pipeline_config.content).to eq(config_content_result) + expect(command.config_content).to eq(config_content_result) + end + end end context 'when config is defined in the default .gitlab-ci.yml' do diff --git a/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb index 83d47ae6819..e8eb3333b88 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb @@ -8,8 +8,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Sequence do let(:pipeline) { build_stubbed(:ci_pipeline) } let(:command) { Gitlab::Ci::Pipeline::Chain::Command.new(project: project) } - let(:first_step) { spy('first step') } - let(:second_step) { spy('second step') } + let(:first_step) { spy('first step', name: 'FirstStep') } + let(:second_step) { spy('second step', name: 'SecondStep') } let(:sequence) { [first_step, second_step] } let(:histogram) { spy('prometheus metric') } @@ -61,6 +61,17 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Sequence do expect(histogram).to have_received(:observe) end + it 'adds step sequence duration to duration histogram' do + expect(command.metrics) + .to receive(:pipeline_creation_step_duration_histogram) + .twice + .and_return(histogram) + expect(histogram).to receive(:observe).with({ step: 'FirstStep' }, any_args).ordered + expect(histogram).to receive(:observe).with({ step: 'SecondStep' }, any_args).ordered + + subject.build! + end + it 'records pipeline size by pipeline source in a histogram' do allow(command.metrics) .to receive(:pipeline_size_histogram) diff --git a/spec/lib/gitlab/ci/pipeline/metrics_spec.rb b/spec/lib/gitlab/ci/pipeline/metrics_spec.rb new file mode 100644 index 00000000000..83b969ff3c4 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/metrics_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Ci::Pipeline::Metrics do + describe '.pipeline_creation_step_duration_histogram' do + around do |example| + described_class.clear_memoization(:pipeline_creation_step_histogram) + + example.run + + described_class.clear_memoization(:pipeline_creation_step_histogram) + end + + it 'adds the step to the step duration histogram' do + expect(::Gitlab::Metrics).to receive(:histogram) + .with( + :gitlab_ci_pipeline_creation_step_duration_seconds, + 'Duration of each pipeline creation step', + { step: nil }, + [0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 15.0, 20.0, 50.0, 240.0] + ) + + described_class.pipeline_creation_step_duration_histogram + 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 58938251ca1..0c28515b574 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -490,12 +490,21 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do end context 'when job belongs to a resource group' do - let(:attributes) { { name: 'rspec', ref: 'master', resource_group_key: 'iOS' } } + let(:resource_group) { 'iOS' } + let(:attributes) { { name: 'rspec', ref: 'master', resource_group_key: resource_group, environment: 'production' }} it 'returns a job with resource group' do expect(subject.resource_group).not_to be_nil expect(subject.resource_group.key).to eq('iOS') end + + context 'when resource group has $CI_ENVIRONMENT_NAME in it' do + let(:resource_group) { 'test/$CI_ENVIRONMENT_NAME' } + + it 'expands environment name' do + expect(subject.resource_group.key).to eq('test/production') + end + end end end @@ -1140,16 +1149,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do it 'does not have errors' do expect(subject.errors).to be_empty end - - context 'when ci_same_stage_job_needs FF is disabled' do - before do - stub_feature_flags(ci_same_stage_job_needs: false) - end - - it 'has errors' do - expect(subject.errors).to contain_exactly("'rspec' job needs 'build' job, but 'build' is not in any previous stage") - end - end end context 'when using 101 needs' do diff --git a/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb index 3424e7d03a3..5d8a9358e10 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb @@ -34,10 +34,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Pipeline do described_class.new(seed_context, stages_attributes) end - before do - stub_feature_flags(ci_same_stage_job_needs: false) - end - describe '#stages' do it 'returns the stage resources' do stages = seed.stages diff --git a/spec/lib/gitlab/ci/reports/security/flag_spec.rb b/spec/lib/gitlab/ci/reports/security/flag_spec.rb new file mode 100644 index 00000000000..27f83694ac2 --- /dev/null +++ b/spec/lib/gitlab/ci/reports/security/flag_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::Security::Flag do + subject(:security_flag) { described_class.new(type: 'flagged-as-likely-false-positive', origin: 'post analyzer X', description: 'static string to sink') } + + describe '#initialize' do + context 'when all params are given' do + it 'initializes an instance' do + expect { subject }.not_to raise_error + + expect(subject).to have_attributes( + type: 'flagged-as-likely-false-positive', + origin: 'post analyzer X', + description: 'static string to sink' + ) + end + end + + describe '#to_hash' do + it 'returns expected hash' do + expect(security_flag.to_hash).to eq( + { + flag_type: :false_positive, + origin: 'post analyzer X', + description: 'static string to sink' + } + ) + end + end + end +end diff --git a/spec/lib/gitlab/ci/trace/backoff_spec.rb b/spec/lib/gitlab/ci/trace/backoff_spec.rb new file mode 100644 index 00000000000..0fb7e81c6c5 --- /dev/null +++ b/spec/lib/gitlab/ci/trace/backoff_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Trace::Backoff do + using RSpec::Parameterized::TableSyntax + + subject(:backoff) { described_class.new(archival_attempts) } + + it 'keeps the MAX_ATTEMPTS limit in sync' do + expect(Ci::BuildTraceMetadata::MAX_ATTEMPTS).to eq(5) + end + + it 'keeps the Redis TTL limit in sync' do + expect(Ci::BuildTraceChunks::RedisBase::CHUNK_REDIS_TTL).to eq(7.days) + end + + describe '#value' do + where(:archival_attempts, :result) do + 1 | 9.6 + 2 | 19.2 + 3 | 28.8 + 4 | 38.4 + 5 | 48.0 + end + + with_them do + subject { backoff.value } + + it { is_expected.to eq(result.hours) } + end + end + + describe '#value_with_jitter' do + where(:archival_attempts, :min_value, :max_value) do + 1 | 9.6 | 13.6 + 2 | 19.2 | 23.2 + 3 | 28.8 | 32.8 + 4 | 38.4 | 42.4 + 5 | 48.0 | 52.0 + end + + with_them do + subject { backoff.value_with_jitter } + + it { is_expected.to be_in(min_value.hours..max_value.hours) } + end + end + + it 'all retries are happening under the 7 days limit' do + backoff_total = 1.upto(Ci::BuildTraceMetadata::MAX_ATTEMPTS).sum do |attempt| + backoff = described_class.new(attempt) + expect(backoff).to receive(:rand) + .with(described_class::MAX_JITTER_VALUE) + .and_return(described_class::MAX_JITTER_VALUE) + + backoff.value_with_jitter + end + + expect(backoff_total).to be < Ci::BuildTraceChunks::RedisBase::CHUNK_REDIS_TTL + end +end diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb index 69f56871740..1a31b2dad56 100644 --- a/spec/lib/gitlab/ci/trace_spec.rb +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -130,4 +130,18 @@ RSpec.describe Gitlab::Ci::Trace, :clean_gitlab_redis_shared_state, factory_defa end end end + + describe '#can_attempt_archival_now?' do + it 'creates the record and returns true' do + expect(trace.can_attempt_archival_now?).to be_truthy + end + end + + describe '#increment_archival_attempts!' do + it 'creates the record and increments its value' do + expect { trace.increment_archival_attempts! } + .to change { build.reload.trace_metadata&.archival_attempts }.from(nil).to(1) + .and change { build.reload.trace_metadata&.last_archival_attempt_at } + end + end end diff --git a/spec/lib/gitlab/ci/variables/collection/sort_spec.rb b/spec/lib/gitlab/ci/variables/collection/sort_spec.rb index 01eef673c35..7e4e9602a92 100644 --- a/spec/lib/gitlab/ci/variables/collection/sort_spec.rb +++ b/spec/lib/gitlab/ci/variables/collection/sort_spec.rb @@ -5,20 +5,10 @@ require 'rspec-parameterized' RSpec.describe Gitlab::Ci::Variables::Collection::Sort do describe '#initialize with non-Collection value' do - context 'when FF :variable_inside_variable is disabled' do - subject { Gitlab::Ci::Variables::Collection::Sort.new([]) } + subject { Gitlab::Ci::Variables::Collection::Sort.new([]) } - it 'raises ArgumentError' do - expect { subject }.to raise_error(ArgumentError, /Collection object was expected/) - end - end - - context 'when FF :variable_inside_variable is enabled' do - subject { Gitlab::Ci::Variables::Collection::Sort.new([]) } - - it 'raises ArgumentError' do - expect { subject }.to raise_error(ArgumentError, /Collection object was expected/) - end + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError, /Collection object was expected/) end end @@ -182,5 +172,33 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Sort do expect { subject }.to raise_error(TSort::Cyclic) end end + + context 'with overridden variables' do + let(:variables) do + [ + { key: 'PROJECT_VAR', value: '$SUBGROUP_VAR' }, + { key: 'SUBGROUP_VAR', value: '$TOP_LEVEL_GROUP_NAME' }, + { key: 'SUBGROUP_VAR', value: '$SUB_GROUP_NAME' }, + { key: 'TOP_LEVEL_GROUP_NAME', value: 'top-level-group' }, + { key: 'SUB_GROUP_NAME', value: 'vars-in-vars-subgroup' } + ] + end + + let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) } + + subject do + Gitlab::Ci::Variables::Collection::Sort.new(collection).tsort.map { |v| { v[:key] => v.value } } + end + + it 'preserves relative order of overridden variables' do + is_expected.to eq([ + { 'TOP_LEVEL_GROUP_NAME' => 'top-level-group' }, + { 'SUBGROUP_VAR' => '$TOP_LEVEL_GROUP_NAME' }, + { 'SUB_GROUP_NAME' => 'vars-in-vars-subgroup' }, + { 'SUBGROUP_VAR' => '$SUB_GROUP_NAME' }, + { 'PROJECT_VAR' => '$SUBGROUP_VAR' } + ]) + end + end end end diff --git a/spec/lib/gitlab/ci/variables/collection_spec.rb b/spec/lib/gitlab/ci/variables/collection_spec.rb index abda27f0d6e..7ba98380986 100644 --- a/spec/lib/gitlab/ci/variables/collection_spec.rb +++ b/spec/lib/gitlab/ci/variables/collection_spec.rb @@ -123,17 +123,102 @@ RSpec.describe Gitlab::Ci::Variables::Collection do end describe '#[]' do - variable = { key: 'VAR', value: 'value', public: true, masked: false } + subject { Gitlab::Ci::Variables::Collection.new(variables)[var_name] } - collection = described_class.new([variable]) + shared_examples 'an array access operator' do + context 'for a non-existent variable name' do + let(:var_name) { 'UNKNOWN_VAR' } - it 'returns nil for a non-existent variable name' do - expect(collection['UNKNOWN_VAR']).to be_nil + it 'returns nil' do + is_expected.to be_nil + end + end + + context 'for an existent variable name' do + let(:var_name) { 'VAR' } + + it 'returns the last Item' do + is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection::Item) + expect(subject.to_runner_variable).to eq(variables.last) + end + end + end + + context 'with variable key with single entry' do + let(:variables) do + [ + { key: 'VAR', value: 'value', public: true, masked: false } + ] + end + + it_behaves_like 'an array access operator' + end + + context 'with variable key with multiple entries' do + let(:variables) do + [ + { key: 'VAR', value: 'value', public: true, masked: false }, + { key: 'VAR', value: 'override value', public: true, masked: false } + ] + end + + it_behaves_like 'an array access operator' end + end + + describe '#all' do + subject { described_class.new(variables).all(var_name) } - it 'returns Item for an existent variable name' do - expect(collection['VAR']).to be_an_instance_of(Gitlab::Ci::Variables::Collection::Item) - expect(collection['VAR'].to_runner_variable).to eq(variable) + shared_examples 'a method returning all known variables or nil' do + context 'for a non-existent variable name' do + let(:var_name) { 'UNKNOWN_VAR' } + + it 'returns nil' do + is_expected.to be_nil + end + end + + context 'for an existing variable name' do + let(:var_name) { 'VAR' } + + it 'returns all expected Items' do + is_expected.to eq(expected_variables.map { |v| Gitlab::Ci::Variables::Collection::Item.fabricate(v) }) + end + end + end + + context 'with variable key with single entry' do + let(:variables) do + [ + { key: 'VAR', value: 'value', public: true, masked: false } + ] + end + + it_behaves_like 'a method returning all known variables or nil' do + let(:expected_variables) do + [ + { key: 'VAR', value: 'value', public: true, masked: false } + ] + end + end + end + + context 'with variable key with multiple entries' do + let(:variables) do + [ + { key: 'VAR', value: 'value', public: true, masked: false }, + { key: 'VAR', value: 'override value', public: true, masked: false } + ] + end + + it_behaves_like 'a method returning all known variables or nil' do + let(:expected_variables) do + [ + { key: 'VAR', value: 'value', public: true, masked: false }, + { key: 'VAR', value: 'override value', public: true, masked: false } + ] + end + end end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 49a470f9e01..1591c2e6b60 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -590,14 +590,6 @@ module Gitlab end it_behaves_like 'has warnings and expected error', /build job: need test is not defined in current or prior stages/ - - context 'with ci_same_stage_job_needs FF disabled' do - before do - stub_feature_flags(ci_same_stage_job_needs: false) - end - - it_behaves_like 'has warnings and expected error', /build job: need test is not defined in prior stages/ - end end end end @@ -1809,14 +1801,6 @@ module Gitlab let(:dependencies) { ['deploy'] } it_behaves_like 'returns errors', 'test1 job: dependency deploy is not defined in current or prior stages' - - context 'with ci_same_stage_job_needs FF disabled' do - before do - stub_feature_flags(ci_same_stage_job_needs: false) - end - - it_behaves_like 'returns errors', 'test1 job: dependency deploy is not defined in prior stages' - end end context 'when a job depends on another job that references a not-yet defined stage' do @@ -2053,14 +2037,6 @@ module Gitlab let(:needs) { ['deploy'] } it_behaves_like 'returns errors', 'test1 job: need deploy is not defined in current or prior stages' - - context 'with ci_same_stage_job_needs FF disabled' do - before do - stub_feature_flags(ci_same_stage_job_needs: false) - end - - it_behaves_like 'returns errors', 'test1 job: need deploy is not defined in prior stages' - end end context 'needs and dependencies that are mismatching' do diff --git a/spec/lib/gitlab/config/loader/yaml_spec.rb b/spec/lib/gitlab/config/loader/yaml_spec.rb index 731ee12d7f4..be568a8e5f9 100644 --- a/spec/lib/gitlab/config/loader/yaml_spec.rb +++ b/spec/lib/gitlab/config/loader/yaml_spec.rb @@ -15,6 +15,24 @@ RSpec.describe Gitlab::Config::Loader::Yaml do YAML end + context 'when max yaml size and depth are set in ApplicationSetting' do + let(:yaml_size) { 2.megabytes } + let(:yaml_depth) { 200 } + + before do + stub_application_setting(max_yaml_size_bytes: yaml_size, max_yaml_depth: yaml_depth) + end + + it 'uses ApplicationSetting values rather than the defaults' do + expect(Gitlab::Utils::DeepSize) + .to receive(:new) + .with(any_args, { max_size: yaml_size, max_depth: yaml_depth }) + .and_call_original + + loader.load! + end + end + context 'when yaml syntax is correct' do let(:yml) { 'image: ruby:2.7' } diff --git a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb index b9e0132badb..8053f5261c0 100644 --- a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::CycleAnalytics::StageSummary do - let(:project) { create(:project, :repository) } + let_it_be(:project) { create(:project, :repository) } + let(:options) { { from: 1.day.ago } } let(:args) { { options: options, current_user: user } } let(:user) { create(:user, :admin) } @@ -62,6 +63,8 @@ RSpec.describe Gitlab::CycleAnalytics::StageSummary do end describe "#commits" do + let!(:project) { create(:project, :repository) } + subject { stage_summary.second } context 'when from date is given' do @@ -132,115 +135,5 @@ RSpec.describe Gitlab::CycleAnalytics::StageSummary do end end - describe "#deploys" do - subject { stage_summary.third } - - context 'when from date is given' do - before do - Timecop.freeze(5.days.ago) { create(:deployment, :success, project: project) } - Timecop.freeze(5.days.from_now) { create(:deployment, :success, project: project) } - end - - it "finds the number of deploys made created after the 'from date'" do - expect(subject[:value]).to eq('1') - end - - it 'returns the localized title' do - Gitlab::I18n.with_locale(:ru) do - expect(subject[:title]).to eq(n_('Deploy', 'Deploys', 1)) - end - end - end - - it "doesn't find commits from other projects" do - Timecop.freeze(5.days.from_now) do - create(:deployment, :success, project: create(:project, :repository)) - end - - expect(subject[:value]).to eq('-') - end - - context 'when `to` parameter is given' do - before do - Timecop.freeze(5.days.ago) { create(:deployment, :success, project: project) } - Timecop.freeze(5.days.from_now) { create(:deployment, :success, project: project) } - end - - it "doesn't find any record" do - options[:to] = Time.now - - expect(subject[:value]).to eq('-') - end - - it "finds records created between `from` and `to` range" do - options[:from] = 10.days.ago - options[:to] = 10.days.from_now - - expect(subject[:value]).to eq('2') - end - end - end - - describe '#deployment_frequency' do - subject { stage_summary.fourth[:value] } - - it 'includes the unit: `per day`' do - expect(stage_summary.fourth[:unit]).to eq _('per day') - end - - before do - Timecop.freeze(5.days.ago) { create(:deployment, :success, project: project) } - end - - it 'returns 0.0 when there were deploys but the frequency was too low' do - options[:from] = 30.days.ago - - # 1 deployment over 30 days - # frequency of 0.03, rounded off to 0.0 - expect(subject).to eq('0') - end - - it 'returns `-` when there were no deploys' do - options[:from] = 4.days.ago - - # 0 deployment in the last 4 days - expect(subject).to eq('-') - end - - context 'when `to` is nil' do - it 'includes range until now' do - options[:from] = 6.days.ago - options[:to] = nil - - # 1 deployment over 7 days - expect(subject).to eq('0.1') - end - end - - context 'when `to` is given' do - before do - Timecop.freeze(5.days.from_now) { create(:deployment, :success, project: project, finished_at: Time.zone.now) } - end - - it 'finds records created between `from` and `to` range' do - options[:from] = 10.days.ago - options[:to] = 10.days.from_now - - # 2 deployments over 20 days - expect(subject).to eq('0.1') - end - - context 'when `from` and `to` are within a day' do - it 'returns the number of deployments made on that day' do - freeze_time do - create(:deployment, :success, project: project, finished_at: Time.zone.now) - options[:from] = Time.zone.now.at_beginning_of_day - options[:to] = Time.zone.now.at_end_of_day - - expect(subject).to eq('1') - end - end - end - end - end + it_behaves_like 'deployment metrics examples' end diff --git a/spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb b/spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb index ed15951dfb0..eb16a8ccfa5 100644 --- a/spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb @@ -150,6 +150,23 @@ RSpec.describe Gitlab::Database::AsyncIndexes::MigrationHelpers do migration.prepare_async_index(table_name, 'id') end.not_to change { index_model.where(name: index_name).count } end + + it 'updates definition if changed' do + index = create(:postgres_async_index, table_name: table_name, name: index_name, definition: '...') + + expect do + migration.prepare_async_index(table_name, 'id', name: index_name) + end.to change { index.reload.definition } + end + + it 'does not update definition if not changed' do + definition = "CREATE INDEX CONCURRENTLY \"index_#{table_name}_on_id\" ON \"#{table_name}\" (\"id\")" + index = create(:postgres_async_index, table_name: table_name, name: index_name, definition: definition) + + expect do + migration.prepare_async_index(table_name, 'id', name: index_name) + end.not_to change { index.reload.updated_at } + end end context 'when the async index table does not exist' do diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb index 3207e97a639..a1c2634f59c 100644 --- a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb @@ -234,6 +234,42 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m end end + describe '#retry_failed_jobs!' do + let(:batched_migration) { create(:batched_background_migration, status: 'failed') } + + subject(:retry_failed_jobs) { batched_migration.retry_failed_jobs! } + + context 'when there are failed migration jobs' do + let!(:batched_background_migration_job) { create(:batched_background_migration_job, batched_migration: batched_migration, batch_size: 10, min_value: 6, max_value: 15, status: :failed, attempts: 3) } + + before do + allow_next_instance_of(Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchingStrategy) do |batch_class| + allow(batch_class).to receive(:next_batch).with(anything, anything, batch_min_value: 6, batch_size: 5).and_return([6, 10]) + end + end + + it 'moves the status of the migration to active' do + retry_failed_jobs + + expect(batched_migration.status).to eql 'active' + end + + it 'changes the number of attempts to 0' do + retry_failed_jobs + + expect(batched_background_migration_job.reload.attempts).to be_zero + end + end + + context 'when there are no failed migration jobs' do + it 'moves the status of the migration to active' do + retry_failed_jobs + + expect(batched_migration.status).to eql 'active' + end + end + end + describe '#job_class_name=' do it_behaves_like 'an attr_writer that demodulizes assigned class names', :job_class_name end diff --git a/spec/lib/gitlab/database/connection_spec.rb b/spec/lib/gitlab/database/connection_spec.rb index 5e0e6039afc..7f94d7af4a9 100644 --- a/spec/lib/gitlab/database/connection_spec.rb +++ b/spec/lib/gitlab/database/connection_spec.rb @@ -5,29 +5,14 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Connection do let(:connection) { described_class.new } - describe '#default_pool_size' do - before do - allow(Gitlab::Runtime).to receive(:max_threads).and_return(7) - end - - it 'returns the max thread size plus a fixed headroom of 10' do - expect(connection.default_pool_size).to eq(17) - end - - it 'returns the max thread size plus a DB_POOL_HEADROOM if this env var is present' do - stub_env('DB_POOL_HEADROOM', '7') - - expect(connection.default_pool_size).to eq(14) - end - end - describe '#config' do it 'returns a HashWithIndifferentAccess' do expect(connection.config).to be_an_instance_of(HashWithIndifferentAccess) end it 'returns a default pool size' do - expect(connection.config).to include(pool: connection.default_pool_size) + expect(connection.config) + .to include(pool: Gitlab::Database.default_pool_size) end it 'does not cache its results' do @@ -43,7 +28,7 @@ RSpec.describe Gitlab::Database::Connection do it 'returns the default pool size' do expect(connection).to receive(:config).and_return({ pool: nil }) - expect(connection.pool_size).to eq(connection.default_pool_size) + expect(connection.pool_size).to eq(Gitlab::Database.default_pool_size) end end @@ -129,7 +114,7 @@ RSpec.describe Gitlab::Database::Connection do describe '#db_config_with_default_pool_size' do it 'returns db_config with our default pool size' do - allow(connection).to receive(:default_pool_size).and_return(9) + allow(Gitlab::Database).to receive(:default_pool_size).and_return(9) expect(connection.db_config_with_default_pool_size.pool).to eq(9) end @@ -143,7 +128,7 @@ RSpec.describe Gitlab::Database::Connection do describe '#disable_prepared_statements' do around do |example| - original_config = ::Gitlab::Database.main.config + original_config = connection.scope.connection.pool.db_config example.run @@ -162,6 +147,12 @@ RSpec.describe Gitlab::Database::Connection do expect(connection.scope.connection.prepared_statements).to eq(false) end + it 'retains the connection name' do + connection.disable_prepared_statements + + expect(connection.scope.connection_db_config.name).to eq('main') + end + context 'with dynamic connection pool size' do before do connection.scope.establish_connection(connection.config.merge(pool: 7)) @@ -393,34 +384,28 @@ RSpec.describe Gitlab::Database::Connection do end describe '#cached_column_exists?' do - it 'only retrieves data once' do - expect(connection.scope.connection) - .to receive(:columns) - .once.and_call_original - - 2.times do - expect(connection.cached_column_exists?(:projects, :id)).to be_truthy - expect(connection.cached_column_exists?(:projects, :bogus_column)).to be_falsey + it 'only retrieves the data from the schema cache' do + queries = ActiveRecord::QueryRecorder.new do + 2.times do + expect(connection.cached_column_exists?(:projects, :id)).to be_truthy + expect(connection.cached_column_exists?(:projects, :bogus_column)).to be_falsey + end end + + expect(queries.count).to eq(0) end end describe '#cached_table_exists?' do - it 'only retrieves data once per table' do - expect(connection.scope.connection) - .to receive(:data_source_exists?) - .with(:projects) - .once.and_call_original - - expect(connection.scope.connection) - .to receive(:data_source_exists?) - .with(:bogus_table_name) - .once.and_call_original - - 2.times do - expect(connection.cached_table_exists?(:projects)).to be_truthy - expect(connection.cached_table_exists?(:bogus_table_name)).to be_falsey + it 'only retrieves the data from the schema cache' do + queries = ActiveRecord::QueryRecorder.new do + 2.times do + expect(connection.cached_table_exists?(:projects)).to be_truthy + expect(connection.cached_table_exists?(:bogus_table_name)).to be_falsey + end end + + expect(queries.count).to eq(0) end it 'returns false when database does not exist' do @@ -433,16 +418,14 @@ RSpec.describe Gitlab::Database::Connection do end describe '#exists?' do - it 'returns true if `ActiveRecord::Base.connection` succeeds' do - expect(connection.scope).to receive(:connection) - + it 'returns true if the database exists' do expect(connection.exists?).to be(true) end - it 'returns false if `ActiveRecord::Base.connection` fails' do - expect(connection.scope).to receive(:connection) do - raise ActiveRecord::NoDatabaseError, 'broken' - end + it "returns false if the database doesn't exist" do + expect(connection.scope.connection.schema_cache) + .to receive(:database_version) + .and_raise(ActiveRecord::NoDatabaseError) expect(connection.exists?).to be(false) 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 new file mode 100644 index 00000000000..ebbbafb855f --- /dev/null +++ b/spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +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::Session).to receive(:clear_session) + + described_class.wrapper.call( + nil, + lambda do + expect(Gitlab::Database::LoadBalancing::Session.current.use_primary?).to eq(true) + end + ) + end + + 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::Session).to receive(:clear_session) + + expect do + described_class.wrapper.call(nil, lambda { raise 'test_exception' }) + end.to raise_error('test_exception') + end + 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 new file mode 100644 index 00000000000..6621e6276a5 --- /dev/null +++ b/spec/lib/gitlab/database/load_balancing/configuration_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::LoadBalancing::Configuration do + let(:model) do + config = ActiveRecord::DatabaseConfigurations::HashConfig + .new('main', 'test', configuration_hash) + + double(:model, connection_db_config: config) + end + + describe '.for_model' do + context 'when load balancing is not configured' do + let(:configuration_hash) { {} } + + it 'uses the default settings' do + config = described_class.for_model(model) + + expect(config.hosts).to eq([]) + expect(config.max_replication_difference).to eq(8.megabytes) + expect(config.max_replication_lag_time).to eq(60.0) + expect(config.replica_check_interval).to eq(60.0) + expect(config.service_discovery).to eq( + nameserver: 'localhost', + port: 8600, + record: nil, + record_type: 'A', + interval: 60, + disconnect_timeout: 120, + use_tcp: false + ) + expect(config.pool_size).to eq(Gitlab::Database.default_pool_size) + end + end + + context 'when load balancing is configured' do + let(:configuration_hash) do + { + pool: 4, + load_balancing: { + max_replication_difference: 1, + max_replication_lag_time: 2, + replica_check_interval: 3, + hosts: %w[foo bar], + discover: { + 'record' => 'foo.example.com' + } + } + } + end + + it 'uses the custom configuration settings' do + config = described_class.for_model(model) + + expect(config.hosts).to eq(%w[foo bar]) + expect(config.max_replication_difference).to eq(1) + expect(config.max_replication_lag_time).to eq(2.0) + expect(config.replica_check_interval).to eq(3.0) + expect(config.service_discovery).to eq( + nameserver: 'localhost', + port: 8600, + record: 'foo.example.com', + record_type: 'A', + interval: 60, + disconnect_timeout: 120, + use_tcp: false + ) + expect(config.pool_size).to eq(4) + end + end + + context 'when the load balancing configuration uses strings as the keys' do + let(:configuration_hash) do + { + pool: 4, + load_balancing: { + 'max_replication_difference' => 1, + 'max_replication_lag_time' => 2, + 'replica_check_interval' => 3, + 'hosts' => %w[foo bar], + 'discover' => { + 'record' => 'foo.example.com' + } + } + } + end + + it 'uses the custom configuration settings' do + config = described_class.for_model(model) + + expect(config.hosts).to eq(%w[foo bar]) + expect(config.max_replication_difference).to eq(1) + expect(config.max_replication_lag_time).to eq(2.0) + expect(config.replica_check_interval).to eq(3.0) + expect(config.service_discovery).to eq( + nameserver: 'localhost', + port: 8600, + record: 'foo.example.com', + record_type: 'A', + interval: 60, + disconnect_timeout: 120, + use_tcp: false + ) + expect(config.pool_size).to eq(4) + end + end + end + + describe '#load_balancing_enabled?' do + it 'returns true when hosts are configured' do + config = described_class.new(ActiveRecord::Base, %w[foo bar]) + + expect(config.load_balancing_enabled?).to eq(true) + end + + it 'returns true when a service discovery record is configured' do + config = described_class.new(ActiveRecord::Base) + config.service_discovery[:record] = 'foo' + + expect(config.load_balancing_enabled?).to eq(true) + end + + it 'returns false when no hosts are configured and service discovery is disabled' do + config = described_class.new(ActiveRecord::Base) + + expect(config.load_balancing_enabled?).to eq(false) + end + end + + describe '#service_discovery_enabled?' do + it 'returns true when a record is configured' do + config = described_class.new(ActiveRecord::Base) + config.service_discovery[:record] = 'foo' + + expect(config.service_discovery_enabled?).to eq(true) + end + + it 'returns false when no record is configured' do + config = described_class.new(ActiveRecord::Base) + + expect(config.service_discovery_enabled?).to eq(false) + end + end + + describe '#pool_size' do + context 'when a custom pool size is used' do + let(:configuration_hash) { { pool: 4 } } + + it 'always reads the value from the model configuration' do + config = described_class.new(model) + + expect(config.pool_size).to eq(4) + + # We can't modify `configuration_hash` as it's only used to populate the + # internal hash used by ActiveRecord; instead of it being used as-is. + allow(model.connection_db_config) + .to receive(:configuration_hash) + .and_return({ pool: 42 }) + + expect(config.pool_size).to eq(42) + end + end + + context 'when the pool size is nil' do + let(:configuration_hash) { {} } + + it 'returns the default pool size' do + config = described_class.new(model) + + expect(config.pool_size).to eq(Gitlab::Database.default_pool_size) + end + end + end +end diff --git a/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb b/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb index 0ca99ec9acf..ba2f9485066 100644 --- a/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb @@ -3,7 +3,12 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do - let(:proxy) { described_class.new } + let(:proxy) do + config = Gitlab::Database::LoadBalancing::Configuration + .new(ActiveRecord::Base) + + described_class.new(Gitlab::Database::LoadBalancing::LoadBalancer.new(config)) + end describe '#select' do it 'performs a read' do @@ -35,9 +40,15 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do describe 'using a SELECT FOR UPDATE query' do it 'runs the query on the primary and sticks to it' do arel = double(:arel, locked: true) + session = Gitlab::Database::LoadBalancing::Session.new + + allow(Gitlab::Database::LoadBalancing::Session).to receive(:current) + .and_return(session) + + expect(session).to receive(:write!) expect(proxy).to receive(:write_using_load_balancer) - .with(:select_all, arel, 'foo', [], sticky: true) + .with(:select_all, arel, 'foo', []) proxy.select_all(arel, 'foo') end @@ -58,8 +69,13 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do Gitlab::Database::LoadBalancing::ConnectionProxy::STICKY_WRITES.each do |name| describe "#{name}" do it 'runs the query on the primary and sticks to it' do - expect(proxy).to receive(:write_using_load_balancer) - .with(name, 'foo', sticky: true) + session = Gitlab::Database::LoadBalancing::Session.new + + allow(Gitlab::Database::LoadBalancing::Session).to receive(:current) + .and_return(session) + + expect(session).to receive(:write!) + expect(proxy).to receive(:write_using_load_balancer).with(name, 'foo') proxy.send(name, 'foo') end @@ -108,7 +124,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do # We have an extra test for #transaction here to make sure that nested queries # are also sent to a primary. describe '#transaction' do - let(:session) { double(:session) } + let(:session) { Gitlab::Database::LoadBalancing::Session.new } before do allow(Gitlab::Database::LoadBalancing::Session).to receive(:current) @@ -192,7 +208,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do proxy.foo('foo') end - it 'properly forwards trailing hash arguments' do + it 'properly forwards keyword arguments' do allow(proxy.load_balancer).to receive(:read_write) expect(proxy).to receive(:write_using_load_balancer).and_call_original @@ -217,7 +233,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do proxy.foo('foo') end - it 'properly forwards trailing hash arguments' do + it 'properly forwards keyword arguments' do allow(proxy.load_balancer).to receive(:read) expect(proxy).to receive(:read_using_load_balancer).and_call_original @@ -297,20 +313,12 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do .and_return(session) end - it 'uses but does not stick to the primary when sticking is disabled' do + it 'uses but does not stick to the primary' do expect(proxy.load_balancer).to receive(:read_write).and_yield(connection) expect(connection).to receive(:foo).with('foo') expect(session).not_to receive(:write!) proxy.write_using_load_balancer(:foo, 'foo') end - - it 'sticks to the primary when sticking is enabled' do - expect(proxy.load_balancer).to receive(:read_write).and_yield(connection) - expect(connection).to receive(:foo).with('foo') - expect(session).to receive(:write!) - - proxy.write_using_load_balancer(:foo, 'foo', sticky: true) - end end end diff --git a/spec/lib/gitlab/database/load_balancing/host_list_spec.rb b/spec/lib/gitlab/database/load_balancing/host_list_spec.rb index ad4ca18d5e6..9bb8116c434 100644 --- a/spec/lib/gitlab/database/load_balancing/host_list_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/host_list_spec.rb @@ -4,7 +4,12 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::HostList do let(:db_host) { ActiveRecord::Base.connection_pool.db_config.host } - let(:load_balancer) { double(:load_balancer) } + let(:load_balancer) do + Gitlab::Database::LoadBalancing::LoadBalancer.new( + Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base) + ) + end + let(:host_count) { 2 } let(:hosts) { Array.new(host_count) { Gitlab::Database::LoadBalancing::Host.new(db_host, load_balancer, port: 5432) } } let(:host_list) { described_class.new(hosts) } diff --git a/spec/lib/gitlab/database/load_balancing/host_spec.rb b/spec/lib/gitlab/database/load_balancing/host_spec.rb index f42ac8be1bb..e2011692228 100644 --- a/spec/lib/gitlab/database/load_balancing/host_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/host_spec.rb @@ -3,7 +3,10 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::Host do - let(:load_balancer) { Gitlab::Database::LoadBalancing::LoadBalancer.new } + let(:load_balancer) do + Gitlab::Database::LoadBalancing::LoadBalancer + .new(Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base)) + end let(:host) do Gitlab::Database::LoadBalancing::Host.new('localhost', load_balancer) @@ -274,7 +277,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Host do end it 'returns false when the data is not recent enough' do - diff = Gitlab::Database::LoadBalancing.max_replication_difference * 2 + diff = load_balancer.configuration.max_replication_difference * 2 expect(host) .to receive(:query_and_release) 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 c647f5a8f5d..86fae14b961 100644 --- a/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb @@ -5,7 +5,12 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do let(:conflict_error) { Class.new(RuntimeError) } let(:db_host) { ActiveRecord::Base.connection_pool.db_config.host } - let(:lb) { described_class.new([db_host, db_host]) } + let(:config) do + Gitlab::Database::LoadBalancing::Configuration + .new(ActiveRecord::Base, [db_host, db_host]) + end + + let(:lb) { described_class.new(config) } let(:request_cache) { lb.send(:request_cache) } before do @@ -41,6 +46,19 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do top_error end + describe '#initialize' do + it 'ignores the hosts when the primary_only option is enabled' do + config = Gitlab::Database::LoadBalancing::Configuration + .new(ActiveRecord::Base, [db_host]) + lb = described_class.new(config, primary_only: true) + hosts = lb.host_list.hosts + + expect(hosts.length).to eq(1) + expect(hosts.first) + .to be_instance_of(Gitlab::Database::LoadBalancing::PrimaryHost) + end + end + describe '#read' do it 'yields a connection for a read' do connection = double(:connection) @@ -121,6 +139,19 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do expect { |b| lb.read(&b) } .to yield_with_args(ActiveRecord::Base.retrieve_connection) end + + it 'uses the primary when the primary_only option is enabled' do + config = Gitlab::Database::LoadBalancing::Configuration + .new(ActiveRecord::Base) + lb = described_class.new(config, primary_only: true) + + # When no hosts are configured, we don't want to produce any warnings, as + # they aren't useful/too noisy. + expect(Gitlab::Database::LoadBalancing::Logger).not_to receive(:warn) + + expect { |b| lb.read(&b) } + .to yield_with_args(ActiveRecord::Base.retrieve_connection) + end end describe '#read_write' do @@ -152,8 +183,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do end it 'does not create conflicts with other load balancers when caching hosts' do - lb1 = described_class.new([db_host, db_host], ActiveRecord::Base) - lb2 = described_class.new([db_host, db_host], Ci::CiDatabaseRecord) + ci_config = Gitlab::Database::LoadBalancing::Configuration + .new(Ci::CiDatabaseRecord, [db_host, db_host]) + + lb1 = described_class.new(config) + lb2 = described_class.new(ci_config) host1 = lb1.host host2 = lb2.host @@ -283,6 +317,12 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do expect(lb.connection_error?(error)).to eq(false) end + + it 'returns false for ActiveRecord errors without a cause' do + error = ActiveRecord::RecordNotUnique.new + + expect(lb.connection_error?(error)).to eq(false) + end end describe '#serialization_failure?' do diff --git a/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb b/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb new file mode 100644 index 00000000000..a0e63a7ee4e --- /dev/null +++ b/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::LoadBalancing::PrimaryHost do + let(:load_balancer) do + Gitlab::Database::LoadBalancing::LoadBalancer.new( + Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base) + ) + end + + let(:host) { Gitlab::Database::LoadBalancing::PrimaryHost.new(load_balancer) } + + describe '#connection' do + it 'returns a connection from the pool' do + expect(load_balancer.pool).to receive(:connection) + + host.connection + end + end + + describe '#release_connection' do + it 'does nothing' do + expect(host.release_connection).to be_nil + end + end + + describe '#enable_query_cache!' do + it 'does nothing' do + expect(host.enable_query_cache!).to be_nil + end + end + + describe '#disable_query_cache!' do + it 'does nothing' do + expect(host.disable_query_cache!).to be_nil + end + end + + describe '#query_cache_enabled' do + it 'delegates to the primary connection pool' do + expect(host.query_cache_enabled) + .to eq(load_balancer.pool.query_cache_enabled) + end + end + + describe '#disconnect!' do + it 'does nothing' do + expect(host.disconnect!).to be_nil + end + end + + describe '#offline!' do + it 'does nothing' do + expect(host.offline!).to be_nil + end + end + + describe '#online?' do + it 'returns true' do + expect(host.online?).to eq(true) + end + 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 + end + end + + describe '#caught_up?' do + it 'returns true' do + expect(host.caught_up?('foo')).to eq(true) + end + 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({}) + end + end +end diff --git a/spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb b/spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb index a27341a3324..e9bc465b1c7 100644 --- a/spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb @@ -3,13 +3,18 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do - let(:load_balancer) { Gitlab::Database::LoadBalancing::LoadBalancer.new([]) } + let(:load_balancer) do + Gitlab::Database::LoadBalancing::LoadBalancer.new( + Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base) + ) + end + let(:service) do described_class.new( + load_balancer, nameserver: 'localhost', port: 8600, - record: 'foo', - load_balancer: load_balancer + record: 'foo' ) end @@ -26,11 +31,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do describe ':record_type' do subject do described_class.new( + load_balancer, nameserver: 'localhost', port: 8600, record: 'foo', - record_type: record_type, - load_balancer: load_balancer + record_type: record_type ) end @@ -69,18 +74,69 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do end describe '#perform_service_discovery' do - it 'reports exceptions to Sentry' do - error = StandardError.new + context 'without any failures' do + it 'runs once' do + expect(service) + .to receive(:refresh_if_necessary).once + + expect(service).not_to receive(:sleep) + + expect(Gitlab::ErrorTracking).not_to receive(:track_exception) + + service.perform_service_discovery + end + end + context 'with failures' do + before do + allow(Gitlab::ErrorTracking).to receive(:track_exception) + allow(service).to receive(:sleep) + end + + let(:valid_retry_sleep_duration) { satisfy { |val| described_class::RETRY_DELAY_RANGE.include?(val) } } + + it 'retries service discovery when under the retry limit' do + error = StandardError.new + + expect(service) + .to receive(:refresh_if_necessary) + .and_raise(error).exactly(described_class::MAX_DISCOVERY_RETRIES - 1).times.ordered + + expect(service) + .to receive(:sleep).with(valid_retry_sleep_duration) + .exactly(described_class::MAX_DISCOVERY_RETRIES - 1).times + + expect(service).to receive(:refresh_if_necessary).and_return(45).ordered + + expect(service.perform_service_discovery).to eq(45) + end + + it 'does not retry service discovery after exceeding the limit' do + error = StandardError.new + + expect(service) + .to receive(:refresh_if_necessary) + .and_raise(error).exactly(described_class::MAX_DISCOVERY_RETRIES).times + + expect(service) + .to receive(:sleep).with(valid_retry_sleep_duration) + .exactly(described_class::MAX_DISCOVERY_RETRIES).times + + service.perform_service_discovery + end - expect(service) - .to receive(:refresh_if_necessary) - .and_raise(error) + it 'reports exceptions to Sentry' do + error = StandardError.new + + expect(service) + .to receive(:refresh_if_necessary) + .and_raise(error).exactly(described_class::MAX_DISCOVERY_RETRIES).times - expect(Gitlab::ErrorTracking) - .to receive(:track_exception) - .with(error) + expect(Gitlab::ErrorTracking) + .to receive(:track_exception) + .with(error).exactly(described_class::MAX_DISCOVERY_RETRIES).times - service.perform_service_discovery + service.perform_service_discovery + end end end @@ -133,7 +189,10 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do let(:address_bar) { described_class::Address.new('bar') } let(:load_balancer) do - Gitlab::Database::LoadBalancing::LoadBalancer.new([address_foo]) + Gitlab::Database::LoadBalancing::LoadBalancer.new( + Gitlab::Database::LoadBalancing::Configuration + .new(ActiveRecord::Base, [address_foo]) + ) end before do @@ -166,11 +225,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do describe '#addresses_from_dns' do let(:service) do described_class.new( + load_balancer, nameserver: 'localhost', port: 8600, record: 'foo', - record_type: record_type, - load_balancer: load_balancer + record_type: record_type ) end @@ -224,6 +283,16 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do expect(service.addresses_from_dns).to eq([90, addresses]) end end + + context 'when the resolver returns an empty response' do + let(:packet) { double(:packet, answer: []) } + + let(:record_type) { 'A' } + + it 'raises EmptyDnsResponse' do + expect { service.addresses_from_dns }.to raise_error(Gitlab::Database::LoadBalancing::ServiceDiscovery::EmptyDnsResponse) + end + end end describe '#new_wait_time_for' do @@ -246,7 +315,10 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do describe '#addresses_from_load_balancer' do let(:load_balancer) do - Gitlab::Database::LoadBalancing::LoadBalancer.new(%w[b a]) + Gitlab::Database::LoadBalancing::LoadBalancer.new( + Gitlab::Database::LoadBalancing::Configuration + .new(ActiveRecord::Base, %w[b a]) + ) end it 'returns the ordered host names of the load balancer' do 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 54050a87af0..f683ade978a 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 @@ -58,8 +58,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do it 'does not pass database locations', :aggregate_failures do run_middleware - expect(job['database_replica_location']).to be_nil - expect(job['database_write_location']).to be_nil + expect(job['wal_locations']).to be_nil end include_examples 'job data consistency' @@ -86,11 +85,13 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do end it 'passes database_replica_location' do + expected_location = { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => location } + expect(load_balancer).to receive_message_chain(:host, "database_replica_location").and_return(location) run_middleware - expect(job['database_replica_location']).to eq(location) + expect(job['wal_locations']).to eq(expected_location) end include_examples 'job data consistency' @@ -102,40 +103,56 @@ 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 } + expect(load_balancer).to receive(:primary_write_location).and_return(location) run_middleware - expect(job['database_write_location']).to eq(location) + expect(job['wal_locations']).to eq(expected_location) end include_examples 'job data consistency' end end - shared_examples_for 'database location was already provided' do |provided_database_location, other_location| - shared_examples_for 'does not set database location again' do |use_primary| - before do - allow(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_primary?).and_return(use_primary) - end + context 'when worker cannot be constantized' do + let(:worker_class) { 'ActionMailer::MailDeliveryJob' } + let(:expected_consistency) { :always } - it 'does not set database locations again' do - run_middleware + include_examples 'does not pass database locations' + end - expect(job[provided_database_location]).to eq(old_location) - expect(job[other_location]).to be_nil - end - end + context 'when worker class does not include ApplicationWorker' do + let(:worker_class) { ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper } + let(:expected_consistency) { :always } + + include_examples 'does not pass database locations' + end + context 'database wal location was already provided' do let(:old_location) { '0/D525E3A8' } let(:new_location) { 'AB/12345' } - let(:job) { { "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", provided_database_location => old_location } } + let(:wal_locations) { { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => old_location } } + 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) end + shared_examples_for 'does not set database location again' do |use_primary| + before do + allow(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_primary?).and_return(use_primary) + end + + it 'does not set database locations again' do + run_middleware + + expect(job['wal_locations']).to eq(wal_locations) + end + end + context "when write was performed" do include_examples 'does not set database location again', true end @@ -145,28 +162,6 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do end end - context 'when worker cannot be constantized' do - let(:worker_class) { 'ActionMailer::MailDeliveryJob' } - let(:expected_consistency) { :always } - - include_examples 'does not pass database locations' - end - - context 'when worker class does not include ApplicationWorker' do - let(:worker_class) { ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper } - let(:expected_consistency) { :always } - - include_examples 'does not pass database locations' - end - - context 'database write location was already provided' do - include_examples 'database location was already provided', 'database_write_location', 'database_replica_location' - end - - context 'database replica location was already provided' do - include_examples 'database location was already provided', 'database_replica_location', 'database_write_location' - end - context 'when worker data consistency is :always' do include_context 'data consistency worker class', :always, :load_balancing_for_test_data_consistency_worker 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 14f240cd159..9f23eb0094f 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 @@ -62,9 +62,12 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do include_examples 'load balancing strategy', expected_strategy end - shared_examples_for 'replica is up to date' do |location, expected_strategy| + shared_examples_for 'replica is up to date' do |expected_strategy| + let(:location) {'0/D525E3A8' } + let(:wal_locations) { { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => location } } + it 'does not stick to the primary', :aggregate_failures do - expect(middleware).to receive(:replica_caught_up?).with(location).and_return(true) + expect(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 @@ -85,30 +88,40 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do include_examples 'stick to the primary', 'primary' end - context 'when database replica location is set' do - let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'database_replica_location' => '0/D525E3A8' } } + context 'when database wal location is set' 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) + end + + it_behaves_like 'replica is up to date', 'replica' + end + + context 'when deduplication wal location is set' do + let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'dedup_wal_locations' => wal_locations } } before do - allow(middleware).to receive(:replica_caught_up?).and_return(true) + allow(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', '0/D525E3A8', 'replica' + it_behaves_like 'replica is up to date', 'replica' end - context 'when database primary location is set' do + context 'when legacy wal location is set' do let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'database_write_location' => '0/D525E3A8' } } before do - allow(middleware).to receive(:replica_caught_up?).and_return(true) + allow(load_balancer).to receive(:select_up_to_date_host).with('0/D525E3A8').and_return(true) end - it_behaves_like 'replica is up to date', '0/D525E3A8', 'replica' + it_behaves_like 'replica is up to date', 'replica' end context 'when database location is not set' do let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e' } } - it_behaves_like 'stick to the primary', 'primary_no_wal' + include_examples 'stick to the primary', 'primary_no_wal' end end @@ -167,7 +180,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do replication_lag!(false) end - it_behaves_like 'replica is up to date', '0/D525E3A8', 'replica_retried' + include_examples 'replica is up to date', 'replica_retried' end end end @@ -178,7 +191,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do context 'when replica is not up to date' do before do - allow(middleware).to receive(:replica_caught_up?).and_return(false) + allow(load_balancer).to receive(:select_up_to_date_host).and_return(false) end include_examples 'stick to the primary', 'primary' diff --git a/spec/lib/gitlab/database/load_balancing_spec.rb b/spec/lib/gitlab/database/load_balancing_spec.rb index 6ec8e0516f6..f40ad444081 100644 --- a/spec/lib/gitlab/database/load_balancing_spec.rb +++ b/spec/lib/gitlab/database/load_balancing_spec.rb @@ -40,106 +40,25 @@ RSpec.describe Gitlab::Database::LoadBalancing do end describe '.configuration' do - it 'returns a Hash' do - lb_config = { 'hosts' => %w(foo) } + it 'returns the configuration for the load balancer' do + raw = ActiveRecord::Base.connection_db_config.configuration_hash + cfg = described_class.configuration - original_db_config = Gitlab::Database.main.config - modified_db_config = original_db_config.merge(load_balancing: lb_config) - expect(Gitlab::Database.main).to receive(:config).and_return(modified_db_config) - - expect(described_class.configuration).to eq(lb_config) - end - end - - describe '.max_replication_difference' do - context 'without an explicitly configured value' do - it 'returns the default value' do - allow(described_class) - .to receive(:configuration) - .and_return({}) - - expect(described_class.max_replication_difference).to eq(8.megabytes) - end - end - - context 'with an explicitly configured value' do - it 'returns the configured value' do - allow(described_class) - .to receive(:configuration) - .and_return({ 'max_replication_difference' => 4 }) - - expect(described_class.max_replication_difference).to eq(4) - end - end - end - - describe '.max_replication_lag_time' do - context 'without an explicitly configured value' do - it 'returns the default value' do - allow(described_class) - .to receive(:configuration) - .and_return({}) - - expect(described_class.max_replication_lag_time).to eq(60) - end - end - - context 'with an explicitly configured value' do - it 'returns the configured value' do - allow(described_class) - .to receive(:configuration) - .and_return({ 'max_replication_lag_time' => 4 }) - - expect(described_class.max_replication_lag_time).to eq(4) - end - end - end - - describe '.replica_check_interval' do - context 'without an explicitly configured value' do - it 'returns the default value' do - allow(described_class) - .to receive(:configuration) - .and_return({}) - - expect(described_class.replica_check_interval).to eq(60) - end - end - - context 'with an explicitly configured value' do - it 'returns the configured value' do - allow(described_class) - .to receive(:configuration) - .and_return({ 'replica_check_interval' => 4 }) - - expect(described_class.replica_check_interval).to eq(4) - end - end - end - - describe '.hosts' do - it 'returns a list of hosts' do - allow(described_class) - .to receive(:configuration) - .and_return({ 'hosts' => %w(foo bar baz) }) - - expect(described_class.hosts).to eq(%w(foo bar baz)) - end - end - - describe '.pool_size' do - it 'returns a Fixnum' do - expect(described_class.pool_size).to be_a_kind_of(Integer) + # 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).to receive(:hosts).and_return(%w(foo)) + allow(described_class.configuration) + .to receive(:hosts) + .and_return(%w(foo)) end it 'returns false when no hosts are specified' do - allow(described_class).to receive(:hosts).and_return([]) + allow(described_class.configuration).to receive(:hosts).and_return([]) expect(described_class.enable?).to eq(false) end @@ -163,10 +82,10 @@ RSpec.describe Gitlab::Database::LoadBalancing do end it 'returns true when service discovery is enabled' do - allow(described_class).to receive(:hosts).and_return([]) + allow(described_class.configuration).to receive(:hosts).and_return([]) allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(false) - allow(described_class) + allow(described_class.configuration) .to receive(:service_discovery_enabled?) .and_return(true) @@ -175,17 +94,17 @@ RSpec.describe Gitlab::Database::LoadBalancing do end describe '.configured?' do - it 'returns true when Sidekiq is being used' do - allow(described_class).to receive(:hosts).and_return(%w(foo)) - allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) + 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 - it 'returns true when service discovery is enabled in Sidekiq' do - allow(described_class).to receive(:hosts).and_return([]) - allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) - - allow(described_class) + 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) @@ -193,9 +112,8 @@ RSpec.describe Gitlab::Database::LoadBalancing do end it 'returns false when neither service discovery nor hosts are configured' do - allow(described_class).to receive(:hosts).and_return([]) - - allow(described_class) + allow(described_class.configuration).to receive(:hosts).and_return([]) + allow(described_class.configuration) .to receive(:service_discovery_enabled?) .and_return(false) @@ -204,9 +122,11 @@ RSpec.describe Gitlab::Database::LoadBalancing do end describe '.configure_proxy' do - it 'configures the connection 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=) @@ -214,71 +134,24 @@ RSpec.describe Gitlab::Database::LoadBalancing do end context 'when service discovery is enabled' do - let(:service_discovery) { double(Gitlab::Database::LoadBalancing::ServiceDiscovery) } - it 'runs initial service discovery when configuring the connection proxy' do - allow(described_class) - .to receive(:configuration) - .and_return('discover' => { 'record' => 'foo' }) - - expect(Gitlab::Database::LoadBalancing::ServiceDiscovery).to receive(:new).and_return(service_discovery) - expect(service_discovery).to receive(:perform_service_discovery) - - described_class.configure_proxy - end - end - end + discover = instance_spy(Gitlab::Database::LoadBalancing::ServiceDiscovery) - describe '.active_record_models' do - it 'returns an Array' do - expect(described_class.active_record_models).to be_an_instance_of(Array) - end - end + allow(described_class.configuration) + .to receive(:service_discovery) + .and_return({ record: 'foo' }) - describe '.service_discovery_enabled?' do - it 'returns true if service discovery is enabled' do - allow(described_class) - .to receive(:configuration) - .and_return('discover' => { 'record' => 'foo' }) - - expect(described_class.service_discovery_enabled?).to eq(true) - end + expect(Gitlab::Database::LoadBalancing::ServiceDiscovery) + .to receive(:new) + .with( + an_instance_of(Gitlab::Database::LoadBalancing::LoadBalancer), + an_instance_of(Hash) + ) + .and_return(discover) - it 'returns false if service discovery is disabled' do - expect(described_class.service_discovery_enabled?).to eq(false) - end - end + expect(discover).to receive(:perform_service_discovery) - describe '.service_discovery_configuration' do - context 'when no configuration is provided' do - it 'returns a default configuration Hash' do - expect(described_class.service_discovery_configuration).to eq( - nameserver: 'localhost', - port: 8600, - record: nil, - record_type: 'A', - interval: 60, - disconnect_timeout: 120, - use_tcp: false - ) - end - end - - context 'when configuration is provided' do - it 'returns a Hash including the custom configuration' do - allow(described_class) - .to receive(:configuration) - .and_return('discover' => { 'record' => 'foo', 'record_type' => 'SRV' }) - - expect(described_class.service_discovery_configuration).to eq( - nameserver: 'localhost', - port: 8600, - record: 'foo', - record_type: 'SRV', - interval: 60, - disconnect_timeout: 120, - use_tcp: false - ) + described_class.configure_proxy end end end @@ -292,15 +165,23 @@ RSpec.describe Gitlab::Database::LoadBalancing do end it 'starts service discovery if enabled' do - allow(described_class) + 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(an_instance_of(Hash)) + .with(lb, an_instance_of(Hash)) .and_return(instance) expect(instance) @@ -330,7 +211,13 @@ RSpec.describe Gitlab::Database::LoadBalancing do context 'when the load balancing is configured' do let(:db_host) { ActiveRecord::Base.connection_pool.db_config.host } - let(:proxy) { described_class::ConnectionProxy.new([db_host]) } + let(:config) do + Gitlab::Database::LoadBalancing::Configuration + .new(ActiveRecord::Base, [db_host]) + end + + let(:load_balancer) { described_class::LoadBalancer.new(config) } + let(:proxy) { described_class::ConnectionProxy.new(load_balancer) } context 'when a proxy connection is used' do it 'returns :unknown' do @@ -770,6 +657,16 @@ RSpec.describe Gitlab::Database::LoadBalancing do it 'redirects queries to the right roles' do roles = [] + # If we don't run any queries, the pool may be a NullPool. This can + # result in some tests reporting a role as `:unknown`, even though the + # tests themselves are correct. + # + # To prevent this from happening we simply run a simple query to + # ensure the proper pool type is put in place. The exact query doesn't + # matter, provided it actually runs a query and thus creates a proper + # connection pool. + model.count + subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |event| role = ::Gitlab::Database::LoadBalancing.db_role_for_connection(event.payload[:connection]) roles << role if role.present? 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 new file mode 100644 index 00000000000..708d1be6e00 --- /dev/null +++ b/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers do + let_it_be(:migration) do + ActiveRecord::Migration.new.extend(described_class) + end + + let(:model) do + Class.new(ApplicationRecord) do + self.table_name = 'loose_fk_test_table' + end + end + + before(:all) do + migration.create_table :loose_fk_test_table do |t| + t.timestamps + end + end + + before do + 3.times { model.create! } + end + + context 'when the record deletion tracker trigger is not installed' do + it 'does store record deletions' do + model.delete_all + + expect(LooseForeignKeys::DeletedRecord.count).to eq(0) + end + end + + context 'when the record deletion tracker trigger is installed' do + before do + migration.track_record_deletions(:loose_fk_test_table) + end + + it 'stores the record deletion' do + records = model.all + record_to_be_deleted = records.last + + record_to_be_deleted.delete + + 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') + end + + it 'stores multiple record deletions' do + model.delete_all + + expect(LooseForeignKeys::DeletedRecord.count).to eq(3) + end + end +end diff --git a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb index f132ecbf13b..854e97ef897 100644 --- a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe Gitlab::Database::MigrationHelpers::V2 do include Database::TriggerHelpers + include Database::TableSchemaHelpers let(:migration) do ActiveRecord::Migration.new.extend(described_class) @@ -11,6 +12,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do before do allow(migration).to receive(:puts) + + allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) end shared_examples_for 'Setting up to rename a column' do @@ -218,4 +221,105 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do let(:added_column) { :original } end end + + describe '#create_table' do + let(:table_name) { :test_table } + let(:column_attributes) do + [ + { name: 'id', sql_type: 'bigint', null: false, default: nil }, + { name: 'created_at', sql_type: 'timestamp with time zone', null: false, default: nil }, + { name: 'updated_at', sql_type: 'timestamp with time zone', null: false, default: nil }, + { name: 'some_id', sql_type: 'integer', null: false, default: nil }, + { name: 'active', sql_type: 'boolean', null: false, default: 'true' }, + { name: 'name', sql_type: 'text', null: true, default: nil } + ] + end + + context 'using a limit: attribute on .text' do + it 'creates the table as expected' do + migration.create_table table_name do |t| + t.timestamps_with_timezone + t.integer :some_id, null: false + t.boolean :active, null: false, default: true + t.text :name, limit: 100 + end + + expect_table_columns_to_match(column_attributes, table_name) + expect_check_constraint(table_name, 'check_cda6f69506', 'char_length(name) <= 100') + end + end + end + + describe '#with_lock_retries' do + let(:model) do + ActiveRecord::Migration.new.extend(described_class) + end + + let(:buffer) { StringIO.new } + let(:in_memory_logger) { Gitlab::JsonLogger.new(buffer) } + let(:env) { { 'DISABLE_LOCK_RETRIES' => 'true' } } + + it 'sets the migration class name in the logs' do + model.with_lock_retries(env: env, logger: in_memory_logger) { } + + buffer.rewind + expect(buffer.read).to include("\"class\":\"#{model.class}\"") + end + + where(raise_on_exhaustion: [true, false]) + + with_them do + it 'sets raise_on_exhaustion as requested' do + with_lock_retries = double + expect(Gitlab::Database::WithLockRetries).to receive(:new).and_return(with_lock_retries) + expect(with_lock_retries).to receive(:run).with(raise_on_exhaustion: raise_on_exhaustion) + + model.with_lock_retries(env: env, logger: in_memory_logger, raise_on_exhaustion: raise_on_exhaustion) { } + end + end + + it 'does not raise on exhaustion by default' do + with_lock_retries = double + expect(Gitlab::Database::WithLockRetries).to receive(:new).and_return(with_lock_retries) + expect(with_lock_retries).to receive(:run).with(raise_on_exhaustion: false) + + model.with_lock_retries(env: env, logger: in_memory_logger) { } + end + + it 'defaults to disallowing subtransactions' do + with_lock_retries = double + expect(Gitlab::Database::WithLockRetries).to receive(:new).with(hash_including(allow_savepoints: false)).and_return(with_lock_retries) + expect(with_lock_retries).to receive(:run).with(raise_on_exhaustion: false) + + model.with_lock_retries(env: env, logger: in_memory_logger) { } + end + + context 'when in transaction' do + before do + allow(model).to receive(:transaction_open?).and_return(true) + end + + context 'when lock retries are enabled' do + before do + allow(model).to receive(:enable_lock_retries?).and_return(true) + end + + it 'does not use Gitlab::Database::WithLockRetries and executes the provided block directly' do + expect(Gitlab::Database::WithLockRetries).not_to receive(:new) + + expect(model.with_lock_retries(env: env, logger: in_memory_logger) { :block_result }).to eq(:block_result) + end + end + + context 'when lock retries are not enabled' do + before do + allow(model).to receive(:enable_lock_retries?).and_return(false) + end + + it 'raises an error' do + expect { model.with_lock_retries(env: env, logger: in_memory_logger) { } }.to raise_error /can not be run inside an already open transaction/ + end + end + end + end end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 9f9aef77de7..006f8a39f9c 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -798,13 +798,13 @@ RSpec.describe Gitlab::Database::MigrationHelpers do # This spec runs without an enclosing transaction (:delete truncation method for db_cleaner) context 'when the statement_timeout is already disabled', :delete do before do - ActiveRecord::Base.connection.execute('SET statement_timeout TO 0') + ActiveRecord::Migration.connection.execute('SET statement_timeout TO 0') end after do - # Use ActiveRecord::Base.connection instead of model.execute + # Use ActiveRecord::Migration.connection instead of model.execute # so that this call is not counted below - ActiveRecord::Base.connection.execute('RESET statement_timeout') + ActiveRecord::Migration.connection.execute('RESET statement_timeout') end it 'yields control without disabling the timeout or resetting' do @@ -954,10 +954,11 @@ RSpec.describe Gitlab::Database::MigrationHelpers do let(:trigger_name) { model.rename_trigger_name(:users, :id, :new) } let(:user) { create(:user) } let(:copy_trigger) { double('copy trigger') } + let(:connection) { ActiveRecord::Migration.connection } before do expect(Gitlab::Database::UnidirectionalCopyTrigger).to receive(:on_table) - .with(:users).and_return(copy_trigger) + .with(:users, connection: connection).and_return(copy_trigger) end it 'copies the value to the new column using the type_cast_function', :aggregate_failures do @@ -1300,11 +1301,13 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end describe '#install_rename_triggers' do + let(:connection) { ActiveRecord::Migration.connection } + it 'installs the triggers' do copy_trigger = double('copy trigger') expect(Gitlab::Database::UnidirectionalCopyTrigger).to receive(:on_table) - .with(:users).and_return(copy_trigger) + .with(:users, connection: connection).and_return(copy_trigger) expect(copy_trigger).to receive(:create).with(:old, :new, trigger_name: 'foo') @@ -1313,11 +1316,13 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end describe '#remove_rename_triggers' do + let(:connection) { ActiveRecord::Migration.connection } + it 'removes the function and trigger' do copy_trigger = double('copy trigger') expect(Gitlab::Database::UnidirectionalCopyTrigger).to receive(:on_table) - .with('bar').and_return(copy_trigger) + .with('bar', connection: connection).and_return(copy_trigger) expect(copy_trigger).to receive(:drop).with('foo') @@ -1886,6 +1891,61 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end end + describe '#restore_conversion_of_integer_to_bigint' do + let(:table) { :test_table } + let(:column) { :id } + let(:tmp_column) { model.convert_to_bigint_column(column) } + + before do + model.create_table table, id: false do |t| + t.bigint :id, primary_key: true + t.bigint :build_id, null: false + t.timestamps + end + end + + context 'when the target table does not exist' do + it 'raises an error' do + expect { model.restore_conversion_of_integer_to_bigint(:this_table_is_not_real, column) } + .to raise_error('Table this_table_is_not_real does not exist') + end + end + + context 'when the column to migrate does not exist' do + it 'raises an error' do + expect { model.restore_conversion_of_integer_to_bigint(table, :this_column_is_not_real) } + .to raise_error(ArgumentError, "Column this_column_is_not_real does not exist on #{table}") + end + end + + context 'when a single column is given' do + let(:column_to_convert) { 'id' } + let(:temporary_column) { model.convert_to_bigint_column(column_to_convert) } + + it 'creates the correct columns and installs the trigger' do + expect(model).to receive(:add_column).with(table, temporary_column, :int, default: 0, null: false) + + expect(model).to receive(:install_rename_triggers).with(table, [column_to_convert], [temporary_column]) + + model.restore_conversion_of_integer_to_bigint(table, column_to_convert) + end + end + + context 'when multiple columns are given' do + let(:columns_to_convert) { %i[id build_id] } + let(:temporary_columns) { columns_to_convert.map { |column| model.convert_to_bigint_column(column) } } + + it 'creates the correct columns and installs the trigger' do + expect(model).to receive(:add_column).with(table, temporary_columns[0], :int, default: 0, null: false) + expect(model).to receive(:add_column).with(table, temporary_columns[1], :int, default: 0, null: false) + + expect(model).to receive(:install_rename_triggers).with(table, columns_to_convert, temporary_columns) + + model.restore_conversion_of_integer_to_bigint(table, columns_to_convert) + end + end + end + describe '#revert_initialize_conversion_of_integer_to_bigint' do let(:table) { :test_table } @@ -2139,7 +2199,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do describe '#index_exists_by_name?' do it 'returns true if an index exists' do - ActiveRecord::Base.connection.execute( + ActiveRecord::Migration.connection.execute( 'CREATE INDEX test_index_for_index_exists ON projects (path);' ) @@ -2154,7 +2214,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do context 'when an index with a function exists' do before do - ActiveRecord::Base.connection.execute( + ActiveRecord::Migration.connection.execute( 'CREATE INDEX test_index ON projects (LOWER(path));' ) end @@ -2167,15 +2227,15 @@ RSpec.describe Gitlab::Database::MigrationHelpers do context 'when an index exists for a table with the same name in another schema' do before do - ActiveRecord::Base.connection.execute( + ActiveRecord::Migration.connection.execute( 'CREATE SCHEMA new_test_schema' ) - ActiveRecord::Base.connection.execute( + ActiveRecord::Migration.connection.execute( 'CREATE TABLE new_test_schema.projects (id integer, name character varying)' ) - ActiveRecord::Base.connection.execute( + ActiveRecord::Migration.connection.execute( 'CREATE INDEX test_index_on_name ON new_test_schema.projects (LOWER(name));' ) end @@ -2255,8 +2315,6 @@ RSpec.describe Gitlab::Database::MigrationHelpers do expect(buffer.read).to include("\"class\":\"#{model.class}\"") end - using RSpec::Parameterized::TableSyntax - where(raise_on_exhaustion: [true, false]) with_them do @@ -2276,6 +2334,15 @@ RSpec.describe Gitlab::Database::MigrationHelpers do model.with_lock_retries(env: env, logger: in_memory_logger) { } end + + it 'defaults to allowing subtransactions' do + with_lock_retries = double + + expect(Gitlab::Database::WithLockRetries).to receive(:new).with(hash_including(allow_savepoints: true)).and_return(with_lock_retries) + expect(with_lock_retries).to receive(:run).with(raise_on_exhaustion: false) + + model.with_lock_retries(env: env, logger: in_memory_logger) { } + end end describe '#backfill_iids' do @@ -2401,19 +2468,19 @@ RSpec.describe Gitlab::Database::MigrationHelpers do describe '#check_constraint_exists?' do before do - ActiveRecord::Base.connection.execute( + ActiveRecord::Migration.connection.execute( 'ALTER TABLE projects ADD CONSTRAINT check_1 CHECK (char_length(path) <= 5) NOT VALID' ) - ActiveRecord::Base.connection.execute( + ActiveRecord::Migration.connection.execute( 'CREATE SCHEMA new_test_schema' ) - ActiveRecord::Base.connection.execute( + ActiveRecord::Migration.connection.execute( 'CREATE TABLE new_test_schema.projects (id integer, name character varying)' ) - ActiveRecord::Base.connection.execute( + ActiveRecord::Migration.connection.execute( 'ALTER TABLE new_test_schema.projects ADD CONSTRAINT check_2 CHECK (char_length(name) <= 5)' ) end @@ -2628,6 +2695,10 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end describe '#remove_check_constraint' do + before do + allow(model).to receive(:transaction_open?).and_return(false) + end + it 'removes the constraint' do drop_sql = /ALTER TABLE test_table\s+DROP CONSTRAINT IF EXISTS check_name/ diff --git a/spec/lib/gitlab/database/migration_spec.rb b/spec/lib/gitlab/database/migration_spec.rb new file mode 100644 index 00000000000..287e738c24e --- /dev/null +++ b/spec/lib/gitlab/database/migration_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::Migration do + describe '.[]' do + context 'version: 1.0' do + subject { described_class[1.0] } + + it 'inherits from ActiveRecord::Migration[6.1]' do + expect(subject.superclass).to eq(ActiveRecord::Migration[6.1]) + end + + it 'includes migration helpers version 2' do + expect(subject.included_modules).to include(Gitlab::Database::MigrationHelpers::V2) + end + + it 'includes LockRetriesConcern' do + expect(subject.included_modules).to include(Gitlab::Database::Migration::LockRetriesConcern) + end + end + + context 'unknown version' do + it 'raises an error' do + expect { described_class[0] }.to raise_error(ArgumentError, /Unknown migration version/) + end + end + end + + describe '.current_version' do + it 'includes current ActiveRecord migration class' do + # This breaks upon Rails upgrade. In that case, we'll add a new version in Gitlab::Database::Migration::MIGRATION_CLASSES, + # bump .current_version and leave existing migrations and already defined versions of Gitlab::Database::Migration + # untouched. + expect(described_class[described_class.current_version].superclass).to eq(ActiveRecord::Migration::Current) + end + end + + describe Gitlab::Database::Migration::LockRetriesConcern do + subject { class_def.new } + + context 'when not explicitly called' do + let(:class_def) do + Class.new do + include Gitlab::Database::Migration::LockRetriesConcern + end + end + + it 'does not disable lock retries by default' do + expect(subject.enable_lock_retries?).not_to be_truthy + end + end + + context 'when explicitly disabled' do + let(:class_def) do + Class.new do + include Gitlab::Database::Migration::LockRetriesConcern + + enable_lock_retries! + end + end + + it 'does not disable lock retries by default' do + expect(subject.enable_lock_retries?).to be_truthy + end + end + end +end diff --git a/spec/lib/gitlab/database/migrations/lock_retry_mixin_spec.rb b/spec/lib/gitlab/database/migrations/lock_retry_mixin_spec.rb new file mode 100644 index 00000000000..076fb9e8215 --- /dev/null +++ b/spec/lib/gitlab/database/migrations/lock_retry_mixin_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::Database::Migrations::LockRetryMixin do + describe Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigrationProxyLockRetries do + let(:migration) { double } + let(:return_value) { double } + let(:class_def) do + Class.new do + include Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigrationProxyLockRetries + + attr_reader :migration + + def initialize(migration) + @migration = migration + end + end + end + + describe '#enable_lock_retries?' do + subject { class_def.new(migration).enable_lock_retries? } + + it 'delegates to #migration' do + expect(migration).to receive(:enable_lock_retries?).and_return(return_value) + + result = subject + + expect(result).to eq(return_value) + end + end + + describe '#migration_class' do + subject { class_def.new(migration).migration_class } + + it 'retrieves actual migration class from #migration' do + expect(migration).to receive(:class).and_return(return_value) + + result = subject + + expect(result).to eq(return_value) + end + end + end + + describe Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigratorLockRetries do + let(:class_def) do + Class.new do + attr_reader :receiver + + def initialize(receiver) + @receiver = receiver + end + + def ddl_transaction(migration, &block) + receiver.ddl_transaction(migration, &block) + end + + def use_transaction?(migration) + receiver.use_transaction?(migration) + end + end.prepend(Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigratorLockRetries) + end + + subject { class_def.new(receiver) } + + before do + allow(migration).to receive(:migration_class).and_return('TestClass') + allow(receiver).to receive(:ddl_transaction) + end + + context 'with transactions disabled' do + let(:migration) { double('migration', enable_lock_retries?: false) } + let(:receiver) { double('receiver', use_transaction?: false)} + + it 'calls super method' do + p = proc { } + + expect(receiver).to receive(:ddl_transaction).with(migration, &p) + + subject.ddl_transaction(migration, &p) + end + end + + context 'with transactions enabled, but lock retries disabled' do + let(:receiver) { double('receiver', use_transaction?: true)} + let(:migration) { double('migration', enable_lock_retries?: false) } + + it 'calls super method' do + p = proc { } + + expect(receiver).to receive(:ddl_transaction).with(migration, &p) + + subject.ddl_transaction(migration, &p) + end + end + + context 'with transactions enabled and lock retries enabled' do + let(:receiver) { double('receiver', use_transaction?: true)} + let(:migration) { double('migration', enable_lock_retries?: true) } + + it 'calls super method' do + p = proc { } + + expect(receiver).not_to receive(:ddl_transaction) + expect_next_instance_of(Gitlab::Database::WithLockRetries) do |retries| + expect(retries).to receive(:run).with(raise_on_exhaustion: false, &p) + end + + subject.ddl_transaction(migration, &p) + end + end + end + + describe '.patch!' do + subject { described_class.patch! } + + it 'patches MigrationProxy' do + expect(ActiveRecord::MigrationProxy).to receive(:prepend).with(Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigrationProxyLockRetries) + + subject + end + + it 'patches Migrator' do + expect(ActiveRecord::Migrator).to receive(:prepend).with(Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigratorLockRetries) + + subject + end + end +end diff --git a/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb b/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb index c4fbf53d1c2..27ada12b067 100644 --- a/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb +++ b/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do + let(:connection) { ActiveRecord::Base.connection } + describe '#current_partitions' do subject { described_class.new(model, partitioning_key).current_partitions } @@ -11,7 +13,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do let(:table_name) { :partitioned_test } before do - ActiveRecord::Base.connection.execute(<<~SQL) + connection.execute(<<~SQL) CREATE TABLE #{table_name} (id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at)) PARTITION BY RANGE (created_at); @@ -52,7 +54,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do context 'with existing partitions' do before do - ActiveRecord::Base.connection.execute(<<~SQL) + connection.execute(<<~SQL) CREATE TABLE #{model.table_name} (id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at)) PARTITION BY RANGE (created_at); @@ -113,7 +115,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do context 'without existing partitions' do before do - ActiveRecord::Base.connection.execute(<<~SQL) + connection.execute(<<~SQL) CREATE TABLE #{model.table_name} (id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at)) PARTITION BY RANGE (created_at); @@ -159,7 +161,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do context 'with a regular partition but no catchall (MINVALUE, to) partition' do before do - ActiveRecord::Base.connection.execute(<<~SQL) + connection.execute(<<~SQL) CREATE TABLE #{model.table_name} (id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at)) PARTITION BY RANGE (created_at); @@ -248,6 +250,25 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-05-01', '2020-06-01', partition_name: 'partitioned_test_202005') ) end + + context 'when the retain_non_empty_partitions is true' do + subject { described_class.new(model, partitioning_key, retain_for: 2.months, retain_non_empty_partitions: true).extra_partitions } + + it 'prunes empty partitions' do + expect(subject).to contain_exactly( + Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: 'partitioned_test_000000'), + Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-05-01', '2020-06-01', partition_name: 'partitioned_test_202005') + ) + end + + it 'does not prune non-empty partitions' do + connection.execute("INSERT INTO #{table_name} (created_at) VALUES (('2020-05-15'))") # inserting one record into partitioned_test_202005 + + expect(subject).to contain_exactly( + Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: 'partitioned_test_000000') + ) + end + end end end end diff --git a/spec/lib/gitlab/database/partitioning/multi_database_partition_manager_spec.rb b/spec/lib/gitlab/database/partitioning/multi_database_partition_manager_spec.rb new file mode 100644 index 00000000000..3c94c1bf4ea --- /dev/null +++ b/spec/lib/gitlab/database/partitioning/multi_database_partition_manager_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::Partitioning::MultiDatabasePartitionManager, '#sync_partitions' do + subject(:sync_partitions) { manager.sync_partitions } + + let(:manager) { described_class.new(models) } + let(:models) { [model1, model2] } + + let(:model1) { double('model1', connection: connection1, table_name: 'table1') } + let(:model2) { double('model2', connection: connection1, table_name: 'table2') } + + let(:connection1) { double('connection1') } + let(:connection2) { double('connection2') } + + let(:target_manager_class) { Gitlab::Database::Partitioning::PartitionManager } + let(:target_manager1) { double('partition manager') } + let(:target_manager2) { double('partition manager') } + + before do + allow(manager).to receive(:connection_name).and_return('name') + end + + it 'syncs model partitions, setting up the appropriate connection for each', :aggregate_failures do + expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(model1.connection).and_yield.ordered + expect(target_manager_class).to receive(:new).with(model1).and_return(target_manager1).ordered + expect(target_manager1).to receive(:sync_partitions) + + expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(model2.connection).and_yield.ordered + expect(target_manager_class).to receive(:new).with(model2).and_return(target_manager2).ordered + expect(target_manager2).to receive(:sync_partitions) + + sync_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 3d60457c3a9..8f1f5b5ba1b 100644 --- a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb +++ b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb @@ -12,26 +12,18 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do end end - describe '.register' do - let(:model) { double(partitioning_strategy: nil) } - - it 'remembers registered models' do - expect { described_class.register(model) }.to change { described_class.models }.to include(model) - end - end - context 'creating partitions (mocked)' do - subject(:sync_partitions) { described_class.new(models).sync_partitions } + subject(:sync_partitions) { described_class.new(model).sync_partitions } - let(:models) { [model] } - let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table) } + let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table, connection: connection) } let(:partitioning_strategy) { double(missing_partitions: partitions, extra_partitions: []) } + let(:connection) { ActiveRecord::Base.connection } let(:table) { "some_table" } before do - allow(ActiveRecord::Base.connection).to receive(:table_exists?).and_call_original - allow(ActiveRecord::Base.connection).to receive(:table_exists?).with(table).and_return(true) - allow(ActiveRecord::Base.connection).to receive(:execute).and_call_original + allow(connection).to receive(:table_exists?).and_call_original + allow(connection).to receive(:table_exists?).with(table).and_return(true) + allow(connection).to receive(:execute).and_call_original stub_exclusive_lease(described_class::MANAGEMENT_LEASE_KEY % table, timeout: described_class::LEASE_TIMEOUT) end @@ -44,35 +36,23 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do end it 'creates the partition' do - expect(ActiveRecord::Base.connection).to receive(:execute).with(partitions.first.to_sql) - expect(ActiveRecord::Base.connection).to receive(:execute).with(partitions.second.to_sql) + expect(connection).to receive(:execute).with(partitions.first.to_sql) + expect(connection).to receive(:execute).with(partitions.second.to_sql) sync_partitions end - context 'error handling with 2 models' do - let(:models) do - [ - double(partitioning_strategy: strategy1, table_name: table), - double(partitioning_strategy: strategy2, table_name: table) - ] - end - - let(:strategy1) { double('strategy1', missing_partitions: nil, extra_partitions: []) } - let(:strategy2) { double('strategy2', missing_partitions: partitions, extra_partitions: []) } + context 'when an error occurs during partition management' do + it 'does not raise an error' do + expect(partitioning_strategy).to receive(:missing_partitions).and_raise('this should never happen (tm)') - it 'still creates partitions for the second table' do - expect(strategy1).to receive(:missing_partitions).and_raise('this should never happen (tm)') - expect(ActiveRecord::Base.connection).to receive(:execute).with(partitions.first.to_sql) - expect(ActiveRecord::Base.connection).to receive(:execute).with(partitions.second.to_sql) - - sync_partitions + expect { sync_partitions }.not_to raise_error end end end context 'creating partitions' do - subject(:sync_partitions) { described_class.new([my_model]).sync_partitions } + subject(:sync_partitions) { described_class.new(my_model).sync_partitions } let(:connection) { ActiveRecord::Base.connection } let(:my_model) do @@ -101,15 +81,15 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do context 'detaching partitions (mocked)' do subject(:sync_partitions) { manager.sync_partitions } - let(:manager) { described_class.new(models) } - let(:models) { [model] } - let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table)} + let(:manager) { described_class.new(model) } + let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table, connection: connection) } let(:partitioning_strategy) { double(extra_partitions: extra_partitions, missing_partitions: []) } + let(:connection) { ActiveRecord::Base.connection } let(:table) { "foo" } before do - allow(ActiveRecord::Base.connection).to receive(:table_exists?).and_call_original - allow(ActiveRecord::Base.connection).to receive(:table_exists?).with(table).and_return(true) + allow(connection).to receive(:table_exists?).and_call_original + allow(connection).to receive(:table_exists?).with(table).and_return(true) stub_exclusive_lease(described_class::MANAGEMENT_LEASE_KEY % table, timeout: described_class::LEASE_TIMEOUT) end @@ -131,24 +111,6 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do sync_partitions end - - context 'error handling' do - let(:models) do - [ - double(partitioning_strategy: error_strategy, table_name: table), - model - ] - end - - let(:error_strategy) { double(extra_partitions: nil, missing_partitions: []) } - - it 'still drops partitions for the other model' do - expect(error_strategy).to receive(:extra_partitions).and_raise('injected error!') - extra_partitions.each { |p| expect(manager).to receive(:detach_one_partition).with(p) } - - sync_partitions - end - end end context 'with the partition_pruning feature flag disabled' do @@ -171,7 +133,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do end end - subject { described_class.new([my_model]).sync_partitions } + subject { described_class.new(my_model).sync_partitions } let(:connection) { ActiveRecord::Base.connection } let(:my_model) do @@ -280,11 +242,11 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do it 'creates partitions for the future then drops the oldest one after a month' do # 1 month for the current month, 1 month for the old month that we're retaining data for, headroom expected_num_partitions = (Gitlab::Database::Partitioning::MonthlyStrategy::HEADROOM + 2.months) / 1.month - expect { described_class.new([my_model]).sync_partitions }.to change { num_partitions(my_model) }.from(0).to(expected_num_partitions) + expect { described_class.new(my_model).sync_partitions }.to change { num_partitions(my_model) }.from(0).to(expected_num_partitions) travel 1.month - expect { described_class.new([my_model]).sync_partitions }.to change { has_partition(my_model, 2.months.ago.beginning_of_month) }.from(true).to(false).and(change { num_partitions(my_model) }.by(0)) + expect { described_class.new(my_model).sync_partitions }.to change { has_partition(my_model, 2.months.ago.beginning_of_month) }.from(true).to(false).and(change { num_partitions(my_model) }.by(0)) end end end diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers_spec.rb index a524fe681e9..f0e34476cf2 100644 --- a/spec/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers_spec.rb +++ b/spec/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers_spec.rb @@ -27,6 +27,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers before do allow(migration).to receive(:puts) + allow(migration).to receive(:transaction_open?).and_return(false) connection.execute(<<~SQL) CREATE TABLE #{target_table_name} ( @@ -141,5 +142,15 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers .with(source_table_name, target_table_name, options) end end + + context 'when run inside a transaction block' do + it 'raises an error' do + expect(migration).to receive(:transaction_open?).and_return(true) + + expect do + migration.add_concurrent_partitioned_foreign_key(source_table_name, target_table_name, column: column_name) + end.to raise_error(/can not be run inside a transaction/) + end + end end end diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb index c3edc3a0c87..8ab3816529b 100644 --- a/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb +++ b/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb @@ -20,6 +20,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::IndexHelpers do before do allow(migration).to receive(:puts) + allow(migration).to receive(:transaction_open?).and_return(false) connection.execute(<<~SQL) CREATE TABLE #{table_name} ( @@ -127,6 +128,16 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::IndexHelpers do end.to raise_error(ArgumentError, /#{table_name} is not a partitioned table/) end end + + context 'when run inside a transaction block' do + it 'raises an error' do + expect(migration).to receive(:transaction_open?).and_return(true) + + expect do + migration.add_concurrent_partitioned_index(table_name, column_name) + end.to raise_error(/can not be run inside a transaction/) + end + end end describe '#remove_concurrent_partitioned_index_by_name' do @@ -182,5 +193,15 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::IndexHelpers do end.to raise_error(ArgumentError, /#{table_name} is not a partitioned table/) end end + + context 'when run inside a transaction block' do + it 'raises an error' do + expect(migration).to receive(:transaction_open?).and_return(true) + + expect do + migration.remove_concurrent_partitioned_index_by_name(table_name, index_name) + end.to raise_error(/can not be run inside a transaction/) + end + end end end diff --git a/spec/lib/gitlab/database/partitioning_spec.rb b/spec/lib/gitlab/database/partitioning_spec.rb new file mode 100644 index 00000000000..f163b45e01e --- /dev/null +++ b/spec/lib/gitlab/database/partitioning_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::Partitioning do + describe '.sync_partitions' do + let(:partition_manager_class) { described_class::MultiDatabasePartitionManager } + let(:partition_manager) { double('partition manager') } + + context 'when no partitioned models are given' do + it 'calls the partition manager with the registered models' do + expect(partition_manager_class).to receive(:new) + .with(described_class.registered_models) + .and_return(partition_manager) + + expect(partition_manager).to receive(:sync_partitions) + + described_class.sync_partitions + end + end + + context 'when partitioned models are given' do + it 'calls the partition manager with the given models' do + models = ['my special model'] + + expect(partition_manager_class).to receive(:new) + .with(models) + .and_return(partition_manager) + + expect(partition_manager).to receive(:sync_partitions) + + described_class.sync_partitions(models) + end + end + end +end diff --git a/spec/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb b/spec/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb index 40e36bc02e9..8b06f068503 100644 --- a/spec/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb +++ b/spec/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb @@ -26,4 +26,12 @@ RSpec.describe Gitlab::Database::PostgresqlAdapter::DumpSchemaVersionsMixin do instance.dump_schema_information end + + it 'does not call touch_all in production' do + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production')) + + expect(Gitlab::Database::SchemaMigrations).not_to receive(:touch_all) + + instance.dump_schema_information + end end diff --git a/spec/lib/gitlab/database/schema_migrations/context_spec.rb b/spec/lib/gitlab/database/schema_migrations/context_spec.rb index 1f1943d00a3..a79e6706149 100644 --- a/spec/lib/gitlab/database/schema_migrations/context_spec.rb +++ b/spec/lib/gitlab/database/schema_migrations/context_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do describe '#schema_directory' do it 'returns db/schema_migrations' do - expect(context.schema_directory).to eq(File.join(Rails.root, 'db/schema_migrations')) + expect(context.schema_directory).to eq(File.join(Rails.root, described_class.default_schema_migrations_path)) end context 'CI database' do @@ -19,7 +19,7 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do it 'returns a directory path that is database specific' do skip_if_multiple_databases_not_setup - expect(context.schema_directory).to eq(File.join(Rails.root, 'db/schema_migrations')) + expect(context.schema_directory).to eq(File.join(Rails.root, described_class.default_schema_migrations_path)) end end @@ -124,8 +124,4 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do end end end - - def skip_if_multiple_databases_not_setup - skip 'Skipping because multiple databases not set up' unless Gitlab::Database.has_config?(:ci) - end end diff --git a/spec/lib/gitlab/database/shared_model_spec.rb b/spec/lib/gitlab/database/shared_model_spec.rb new file mode 100644 index 00000000000..5d616aeb05f --- /dev/null +++ b/spec/lib/gitlab/database/shared_model_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::SharedModel do + describe 'using an external connection' do + let!(:original_connection) { described_class.connection } + let(:new_connection) { double('connection') } + + it 'overrides the connection for the duration of the block', :aggregate_failures do + expect_original_connection_around do + described_class.using_connection(new_connection) do + expect(described_class.connection).to be(new_connection) + end + end + end + + it 'does not affect connections in other threads', :aggregate_failures do + expect_original_connection_around do + described_class.using_connection(new_connection) do + expect(described_class.connection).to be(new_connection) + + Thread.new do + expect(described_class.connection).not_to be(new_connection) + end.join + end + end + end + + context 'when the block raises an error', :aggregate_failures do + it 're-raises the error, removing the overridden connection' do + expect_original_connection_around do + expect do + described_class.using_connection(new_connection) do + expect(described_class.connection).to be(new_connection) + + raise 'here comes an error!' + end + end.to raise_error(RuntimeError, 'here comes an error!') + end + end + end + + def expect_original_connection_around + # For safety, ensure our original connection is distinct from our double + # This should be the case, but in case of something leaking we should verify + expect(original_connection).not_to be(new_connection) + expect(described_class.connection).to be(original_connection) + + yield + + expect(described_class.connection).to be(original_connection) + end + end +end diff --git a/spec/lib/gitlab/database/transaction/context_spec.rb b/spec/lib/gitlab/database/transaction/context_spec.rb index 65d52b4d099..37cfc841d48 100644 --- a/spec/lib/gitlab/database/transaction/context_spec.rb +++ b/spec/lib/gitlab/database/transaction/context_spec.rb @@ -62,30 +62,32 @@ RSpec.describe Gitlab::Database::Transaction::Context do it { expect(data[:queries]).to eq(['SELECT 1', 'SELECT * FROM users']) } end - describe '#duration' do + describe '#track_backtrace' do before do - subject.set_start_time + subject.track_backtrace(caller) end - it { expect(subject.duration).to be >= 0 } - end + it { expect(data[:backtraces]).to be_a(Array) } + it { expect(data[:backtraces]).to all(be_a(Array)) } + it { expect(data[:backtraces].length).to eq(1) } + it { expect(data[:backtraces][0][0]).to be_a(String) } - context 'when depth is low' do - it 'does not log data upon COMMIT' do - expect(subject).not_to receive(:application_info) + it 'appends the backtrace' do + subject.track_backtrace(caller) - subject.commit + expect(data[:backtraces].length).to eq(2) + expect(subject.backtraces).to be_a(Array) + expect(subject.backtraces).to all(be_a(Array)) + expect(subject.backtraces[1][0]).to be_a(String) end + end - it 'does not log data upon ROLLBACK' do - expect(subject).not_to receive(:application_info) - - subject.rollback + describe '#duration' do + before do + subject.set_start_time end - it '#should_log? returns false' do - expect(subject.should_log?).to be false - end + it { expect(subject.duration).to be >= 0 } end shared_examples 'logs transaction data' do @@ -116,17 +118,9 @@ RSpec.describe Gitlab::Database::Transaction::Context do end end - context 'when depth exceeds threshold' do - before do - subject.set_depth(described_class::LOG_DEPTH_THRESHOLD + 1) - end - - it_behaves_like 'logs transaction data' - end - context 'when savepoints count exceeds threshold' do before do - data[:savepoints] = described_class::LOG_SAVEPOINTS_THRESHOLD + 1 + data[:savepoints] = 1 end it_behaves_like 'logs transaction data' diff --git a/spec/lib/gitlab/database/transaction/observer_spec.rb b/spec/lib/gitlab/database/transaction/observer_spec.rb index 7aa24217dc3..e5cc0106c9b 100644 --- a/spec/lib/gitlab/database/transaction/observer_spec.rb +++ b/spec/lib/gitlab/database/transaction/observer_spec.rb @@ -25,7 +25,7 @@ RSpec.describe Gitlab::Database::Transaction::Observer do User.first expect(transaction_context).to be_a(::Gitlab::Database::Transaction::Context) - expect(context.keys).to match_array(%i(start_time depth savepoints queries)) + expect(context.keys).to match_array(%i(start_time depth savepoints queries backtraces)) expect(context[:depth]).to eq(2) expect(context[:savepoints]).to eq(1) expect(context[:queries].length).to eq(1) @@ -35,6 +35,7 @@ RSpec.describe Gitlab::Database::Transaction::Observer do expect(context[:depth]).to eq(2) expect(context[:savepoints]).to eq(1) expect(context[:releases]).to eq(1) + expect(context[:backtraces].length).to eq(1) end describe '.extract_sql_command' do diff --git a/spec/lib/gitlab/database/with_lock_retries_spec.rb b/spec/lib/gitlab/database/with_lock_retries_spec.rb index 72074f06210..0b960830d89 100644 --- a/spec/lib/gitlab/database/with_lock_retries_spec.rb +++ b/spec/lib/gitlab/database/with_lock_retries_spec.rb @@ -5,7 +5,9 @@ require 'spec_helper' RSpec.describe Gitlab::Database::WithLockRetries do let(:env) { {} } let(:logger) { Gitlab::Database::WithLockRetries::NULL_LOGGER } - let(:subject) { described_class.new(env: env, logger: logger, timing_configuration: timing_configuration) } + 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(:timing_configuration) do [ @@ -66,7 +68,7 @@ RSpec.describe Gitlab::Database::WithLockRetries do WHERE t.relkind = 'r' AND l.mode = 'ExclusiveLock' AND t.relname = '#{Project.table_name}' """ - expect(ActiveRecord::Base.connection.execute(check_exclusive_lock_query).to_a).to be_present + expect(connection.execute(check_exclusive_lock_query).to_a).to be_present end end @@ -95,8 +97,8 @@ RSpec.describe Gitlab::Database::WithLockRetries do lock_fiber.resume end - ActiveRecord::Base.transaction do - ActiveRecord::Base.connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode") + connection.transaction do + connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode") lock_acquired = true end end @@ -114,7 +116,7 @@ RSpec.describe Gitlab::Database::WithLockRetries do context 'setting the idle transaction timeout' do context 'when there is no outer transaction: disable_ddl_transaction! is set in the migration' do it 'does not disable the idle transaction timeout' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) + allow(connection).to receive(:transaction_open?).and_return(false) allow(subject).to receive(:run_block_with_lock_timeout).once.and_raise(ActiveRecord::LockWaitTimeout) allow(subject).to receive(:run_block_with_lock_timeout).once @@ -126,7 +128,7 @@ RSpec.describe Gitlab::Database::WithLockRetries do context 'when there is outer transaction: disable_ddl_transaction! is not set in the migration' do it 'disables the idle transaction timeout so the code can sleep and retry' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(true) + allow(connection).to receive(:transaction_open?).and_return(true) n = 0 allow(subject).to receive(:run_block_with_lock_timeout).twice do @@ -151,7 +153,7 @@ RSpec.describe Gitlab::Database::WithLockRetries do context 'when there is no outer transaction: disable_ddl_transaction! is set in the migration' do it 'does not disable the lock_timeout' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) + allow(connection).to receive(:transaction_open?).and_return(false) allow(subject).to receive(:run_block_with_lock_timeout).once.and_raise(ActiveRecord::LockWaitTimeout) expect(subject).not_to receive(:disable_lock_timeout) @@ -162,7 +164,7 @@ RSpec.describe Gitlab::Database::WithLockRetries do context 'when there is outer transaction: disable_ddl_transaction! is not set in the migration' do it 'disables the lock_timeout' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(true) + allow(connection).to receive(:transaction_open?).and_return(true) allow(subject).to receive(:run_block_with_lock_timeout).once.and_raise(ActiveRecord::LockWaitTimeout) expect(subject).to receive(:disable_lock_timeout) @@ -197,8 +199,8 @@ RSpec.describe Gitlab::Database::WithLockRetries do subject.run(raise_on_exhaustion: true) do lock_attempts += 1 - ActiveRecord::Base.transaction do - ActiveRecord::Base.connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode") + connection.transaction do + connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode") lock_acquired = true end end @@ -212,11 +214,11 @@ RSpec.describe Gitlab::Database::WithLockRetries do context 'when statement timeout is reached' do it 'raises QueryCanceled error' do lock_acquired = false - ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout='100ms'") + connection.execute("SET LOCAL statement_timeout='100ms'") expect do subject.run do - ActiveRecord::Base.connection.execute("SELECT 1 FROM pg_sleep(0.11)") # 110ms + connection.execute("SELECT 1 FROM pg_sleep(0.11)") # 110ms lock_acquired = true end end.to raise_error(ActiveRecord::QueryCanceled) @@ -229,11 +231,11 @@ RSpec.describe Gitlab::Database::WithLockRetries do context 'restore local database variables' do it do - expect { subject.run {} }.not_to change { ActiveRecord::Base.connection.execute("SHOW lock_timeout").to_a } + expect { subject.run {} }.not_to change { connection.execute("SHOW lock_timeout").to_a } end it do - expect { subject.run {} }.not_to change { ActiveRecord::Base.connection.execute("SHOW idle_in_transaction_session_timeout").to_a } + expect { subject.run {} }.not_to change { connection.execute("SHOW idle_in_transaction_session_timeout").to_a } end end @@ -241,10 +243,10 @@ RSpec.describe Gitlab::Database::WithLockRetries do let(:timing_configuration) { [[0.015.seconds, 0.025.seconds], [0.015.seconds, 0.025.seconds]] } # 15ms, 25ms it 'executes `SET LOCAL lock_timeout` using the configured timeout value in milliseconds' do - expect(ActiveRecord::Base.connection).to receive(:execute).with("RESET idle_in_transaction_session_timeout; RESET lock_timeout").and_call_original - expect(ActiveRecord::Base.connection).to receive(:execute).with("SAVEPOINT active_record_1", "TRANSACTION").and_call_original - expect(ActiveRecord::Base.connection).to receive(:execute).with("SET LOCAL lock_timeout TO '15ms'").and_call_original - expect(ActiveRecord::Base.connection).to receive(:execute).with("RELEASE SAVEPOINT active_record_1", "TRANSACTION").and_call_original + expect(connection).to receive(:execute).with("RESET idle_in_transaction_session_timeout; RESET lock_timeout").and_call_original + expect(connection).to receive(:execute).with("SAVEPOINT active_record_1", "TRANSACTION").and_call_original + expect(connection).to receive(:execute).with("SET LOCAL lock_timeout TO '15ms'").and_call_original + expect(connection).to receive(:execute).with("RELEASE SAVEPOINT active_record_1", "TRANSACTION").and_call_original subject.run { } end @@ -256,4 +258,20 @@ RSpec.describe Gitlab::Database::WithLockRetries do subject.run { } end end + + context 'Stop using subtransactions - allow_savepoints: false' do + let(:allow_savepoints) { false } + + it 'prevents running inside already open transaction' do + allow(connection).to receive(:transaction_open?).and_return(true) + + expect { subject.run { } }.to raise_error(/should not run inside already open transaction/) + end + + it 'does not raise the error if not inside open transaction' do + allow(connection).to receive(:transaction_open?).and_return(false) + + expect { subject.run { } }.not_to raise_error + end + end end diff --git a/spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb b/spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb new file mode 100644 index 00000000000..8c3d372cc55 --- /dev/null +++ b/spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter do + subject { described_class.import } + + it_behaves_like 'work item base types importer' +end diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index c67b5af5e3c..a9a8d5e6314 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -15,6 +15,22 @@ RSpec.describe Gitlab::Database do end end + describe '.default_pool_size' do + before do + allow(Gitlab::Runtime).to receive(:max_threads).and_return(7) + end + + it 'returns the max thread size plus a fixed headroom of 10' do + expect(described_class.default_pool_size).to eq(17) + end + + it 'returns the max thread size plus a DB_POOL_HEADROOM if this env var is present' do + stub_env('DB_POOL_HEADROOM', '7') + + expect(described_class.default_pool_size).to eq(14) + end + end + describe '.has_config?' do context 'two tier database config' do before do @@ -139,23 +155,43 @@ RSpec.describe Gitlab::Database do it { expect(described_class.nulls_first_order('column', 'DESC')).to eq 'column DESC NULLS FIRST'} end - describe '.db_config_name' do - it 'returns the db_config name for the connection' do - connection = ActiveRecord::Base.connection + describe '.db_config_for_connection' do + context 'when the regular connection is used' do + it 'returns db_config' do + connection = ActiveRecord::Base.retrieve_connection - 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) + expect(described_class.db_config_for_connection(connection)).to eq(connection.pool.db_config) + end + end + + context 'when the connection is LoadBalancing::ConnectionProxy' do + it 'returns nil' do + lb_config = ::Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base) + lb = ::Gitlab::Database::LoadBalancing::LoadBalancer.new(lb_config) + proxy = ::Gitlab::Database::LoadBalancing::ConnectionProxy.new(lb) + + expect(described_class.db_config_for_connection(proxy)).to be_nil + end end context 'when the pool is a NullPool' do - it 'returns unknown' do + it 'returns nil' do connection = double(:active_record_connection, pool: ActiveRecord::ConnectionAdapters::NullPool.new) - expect(described_class.db_config_name(connection)).to eq('unknown') + expect(described_class.db_config_for_connection(connection)).to be_nil end end end + describe '.db_config_name' do + it 'returns the db_config name for the connection' do + connection = ActiveRecord::Base.connection + + 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) + end + end + describe '#true_value' do it 'returns correct value' do expect(described_class.true_value).to eq "'t'" diff --git a/spec/lib/gitlab/devise_failure_spec.rb b/spec/lib/gitlab/devise_failure_spec.rb deleted file mode 100644 index a452de59795..00000000000 --- a/spec/lib/gitlab/devise_failure_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::DeviseFailure do - let(:env) do - { - 'REQUEST_URI' => 'http://test.host/', - 'HTTP_HOST' => 'test.host', - 'REQUEST_METHOD' => 'GET', - 'warden.options' => { scope: :user }, - 'rack.session' => {}, - 'rack.session.options' => {}, - 'rack.input' => "", - 'warden' => OpenStruct.new(message: nil) - } - end - - let(:response) { described_class.call(env).to_a } - let(:request) { ActionDispatch::Request.new(env) } - - context 'When redirecting' do - it 'sets the expire_after key' do - response - - expect(env['rack.session.options']).to have_key(:expire_after) - end - - it 'returns to the default redirect location' do - expect(response.first).to eq(302) - expect(request.flash[:alert]).to eq('You need to sign in or sign up before continuing.') - expect(response.second['Location']).to eq('http://test.host/users/sign_in') - end - end -end diff --git a/spec/lib/gitlab/diff/highlight_cache_spec.rb b/spec/lib/gitlab/diff/highlight_cache_spec.rb index 9e94a63ea4b..e643b58ee32 100644 --- a/spec/lib/gitlab/diff/highlight_cache_spec.rb +++ b/spec/lib/gitlab/diff/highlight_cache_spec.rb @@ -185,6 +185,15 @@ RSpec.describe Gitlab::Diff::HighlightCache, :clean_gitlab_redis_cache do expect { cache.send(:write_to_redis_hash, diff_hash) } .to change { Gitlab::Redis::Cache.with { |r| r.hgetall(cache_key) } } end + + context 'when diff contains unsupported characters' do + let(:diff_hash) { { 'README' => [{ line_code: nil, rich_text: nil, text: [0xff, 0xfe, 0x0, 0x23].pack("c*"), type: "match", index: 0, old_pos: 17, new_pos: 17 }] } } + + it 'does not update the cache' do + expect { cache.send(:write_to_redis_hash, diff_hash) } + .not_to change { Gitlab::Redis::Cache.with { |r| r.hgetall(cache_key) } } + end + end end describe '#clear' do 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 2ef3b324db8..2916e65528f 100644 --- a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb @@ -353,13 +353,4 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do expect { receiver.execute rescue nil }.not_to change { Issue.count } end end - - def email_fixture(path) - fixture_file(path).gsub('project_id', project.project_id.to_s) - end - - def service_desk_fixture(path, slug: nil, key: 'mykey') - slug ||= project.full_path_slug.to_s - fixture_file(path).gsub('project_slug', slug).gsub('project_key', key) - end end diff --git a/spec/lib/gitlab/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb index 268ac5dcc21..98170ef437c 100644 --- a/spec/lib/gitlab/encoding_helper_spec.rb +++ b/spec/lib/gitlab/encoding_helper_spec.rb @@ -241,7 +241,7 @@ RSpec.describe Gitlab::EncodingHelper do let(:data) { binary_string } let(:kwargs) { {} } - shared_examples 'detects encoding' do + context 'detects encoding' do it { is_expected.to be_a(Hash) } it 'correctly detects the binary' do @@ -264,33 +264,5 @@ RSpec.describe Gitlab::EncodingHelper do end end end - - context 'cached_encoding_detection is enabled' do - before do - stub_feature_flags(cached_encoding_detection: true) - end - - it_behaves_like 'detects encoding' - - context 'cache_key is provided' do - let(:kwargs) do - { cache_key: %w(foo bar) } - end - - it 'uses that cache_key to serve from the cache' do - expect(Rails.cache).to receive(:fetch).with([:detect_binary, CharlockHolmes::VERSION, %w(foo bar)], expires_in: 1.week).and_call_original - - expect(subject[:type]).to eq(:binary) - end - end - end - - context 'cached_encoding_detection is disabled' do - before do - stub_feature_flags(cached_encoding_detection: false) - end - - it_behaves_like 'detects encoding' - end end end diff --git a/spec/lib/gitlab/experimentation/controller_concern_spec.rb b/spec/lib/gitlab/experimentation/controller_concern_spec.rb index 8535d72a61f..1f7b7b90467 100644 --- a/spec/lib/gitlab/experimentation/controller_concern_spec.rb +++ b/spec/lib/gitlab/experimentation/controller_concern_spec.rb @@ -7,10 +7,6 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do before do stub_const('Gitlab::Experimentation::EXPERIMENTS', { - backwards_compatible_test_experiment: { - tracking_category: 'Team', - use_backwards_compatible_subject_index: true - }, test_experiment: { tracking_category: 'Team', rollout_strategy: rollout_strategy @@ -23,7 +19,6 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do allow(Gitlab).to receive(:dev_env_or_com?).and_return(is_gitlab_com) - Feature.enable_percentage_of_time(:backwards_compatible_test_experiment_experiment_percentage, enabled_percentage) Feature.enable_percentage_of_time(:test_experiment_experiment_percentage, enabled_percentage) end @@ -124,24 +119,15 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do end context 'cookie is present' do - using RSpec::Parameterized::TableSyntax - before do cookies.permanent.signed[:experimentation_subject_id] = 'abcd-1234' get :index end - where(:experiment_key, :index_value) do - :test_experiment | 'abcd-1234' - :backwards_compatible_test_experiment | 'abcd1234' - end - - with_them do - it 'calls Gitlab::Experimentation.in_experiment_group?? with the name of the experiment and the calculated experimentation_subject_index based on the uuid' do - expect(Gitlab::Experimentation).to receive(:in_experiment_group?).with(experiment_key, subject: index_value) + it 'calls Gitlab::Experimentation.in_experiment_group? with the name of the experiment and the calculated experimentation_subject_index based on the uuid' do + expect(Gitlab::Experimentation).to receive(:in_experiment_group?).with(:test_experiment, subject: 'abcd-1234') - check_experiment(experiment_key) - end + check_experiment(:test_experiment) end context 'when subject is given' do diff --git a/spec/lib/gitlab/experimentation/experiment_spec.rb b/spec/lib/gitlab/experimentation/experiment_spec.rb index 94dbf1d7e4b..d52ab3a8983 100644 --- a/spec/lib/gitlab/experimentation/experiment_spec.rb +++ b/spec/lib/gitlab/experimentation/experiment_spec.rb @@ -9,7 +9,6 @@ RSpec.describe Gitlab::Experimentation::Experiment do let(:params) do { tracking_category: 'Category1', - use_backwards_compatible_subject_index: true, rollout_strategy: nil } end diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb index c486538a260..c482874b725 100644 --- a/spec/lib/gitlab/experimentation_spec.rb +++ b/spec/lib/gitlab/experimentation_spec.rb @@ -7,10 +7,6 @@ RSpec.describe Gitlab::Experimentation do before do stub_const('Gitlab::Experimentation::EXPERIMENTS', { - backwards_compatible_test_experiment: { - tracking_category: 'Team', - use_backwards_compatible_subject_index: true - }, test_experiment: { tracking_category: 'Team' }, @@ -22,7 +18,6 @@ RSpec.describe Gitlab::Experimentation do skip_feature_flags_yaml_validation skip_default_enabled_yaml_check - Feature.enable_percentage_of_time(:backwards_compatible_test_experiment_experiment_percentage, enabled_percentage) Feature.enable_percentage_of_time(:test_experiment_experiment_percentage, enabled_percentage) allow(Gitlab).to receive(:com?).and_return(true) end @@ -65,97 +60,47 @@ RSpec.describe Gitlab::Experimentation do end describe '.in_experiment_group?' do - context 'with new index calculation' do - let(:enabled_percentage) { 50 } - let(:experiment_subject) { 'z' } # Zlib.crc32('test_experimentz') % 100 = 33 - - subject { described_class.in_experiment_group?(:test_experiment, subject: experiment_subject) } - - context 'when experiment is active' do - context 'when subject is part of the experiment' do - it { is_expected.to eq(true) } - end + let(:enabled_percentage) { 50 } + let(:experiment_subject) { 'z' } # Zlib.crc32('test_experimentz') % 100 = 33 - context 'when subject is not part of the experiment' do - let(:experiment_subject) { 'a' } # Zlib.crc32('test_experimenta') % 100 = 61 + subject { described_class.in_experiment_group?(:test_experiment, subject: experiment_subject) } - it { is_expected.to eq(false) } - end + context 'when experiment is active' do + context 'when subject is part of the experiment' do + it { is_expected.to eq(true) } + end - context 'when subject has a global_id' do - let(:experiment_subject) { double(:subject, to_global_id: 'z') } + context 'when subject is not part of the experiment' do + let(:experiment_subject) { 'a' } # Zlib.crc32('test_experimenta') % 100 = 61 - it { is_expected.to eq(true) } - end + it { is_expected.to eq(false) } + end - context 'when subject is nil' do - let(:experiment_subject) { nil } + context 'when subject has a global_id' do + let(:experiment_subject) { double(:subject, to_global_id: 'z') } - it { is_expected.to eq(false) } - end + it { is_expected.to eq(true) } + end - context 'when subject is an empty string' do - let(:experiment_subject) { '' } + context 'when subject is nil' do + let(:experiment_subject) { nil } - it { is_expected.to eq(false) } - end + it { is_expected.to eq(false) } end - context 'when experiment is not active' do - before do - allow(described_class).to receive(:active?).and_return(false) - end + context 'when subject is an empty string' do + let(:experiment_subject) { '' } it { is_expected.to eq(false) } end end - context 'with backwards compatible index calculation' do - let(:experiment_subject) { 'abcd' } # Digest::SHA1.hexdigest('abcd').hex % 100 = 7 - - subject { described_class.in_experiment_group?(:backwards_compatible_test_experiment, subject: experiment_subject) } - - context 'when experiment is active' do - before do - allow(described_class).to receive(:active?).and_return(true) - end - - context 'when subject is part of the experiment' do - it { is_expected.to eq(true) } - end - - context 'when subject is not part of the experiment' do - let(:experiment_subject) { 'abc' } # Digest::SHA1.hexdigest('abc').hex % 100 = 17 - - it { is_expected.to eq(false) } - end - - context 'when subject has a global_id' do - let(:experiment_subject) { double(:subject, to_global_id: 'abcd') } - - it { is_expected.to eq(true) } - end - - context 'when subject is nil' do - let(:experiment_subject) { nil } - - it { is_expected.to eq(false) } - end - - context 'when subject is an empty string' do - let(:experiment_subject) { '' } - - it { is_expected.to eq(false) } - end + context 'when experiment is not active' do + before do + allow(described_class).to receive(:active?).and_return(false) end - context 'when experiment is not active' do - before do - allow(described_class).to receive(:active?).and_return(false) - end - - it { is_expected.to eq(false) } - end + it { is_expected.to eq(false) } end end diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb index f58bab52cfa..f4dba5e8d58 100644 --- a/spec/lib/gitlab/git/commit_spec.rb +++ b/spec/lib/gitlab/git/commit_spec.rb @@ -364,19 +364,39 @@ RSpec.describe Gitlab::Git::Commit, :seed_helper do end describe '.between' do - subject do - commits = described_class.between(repository, SeedRepo::Commit::PARENT_ID, SeedRepo::Commit::ID) - commits.map { |c| c.id } + let(:limit) { nil } + let(:commit_ids) { commits.map(&:id) } + + subject(:commits) { described_class.between(repository, from, to, limit: limit) } + + context 'requesting a single commit' do + let(:from) { SeedRepo::Commit::PARENT_ID } + let(:to) { SeedRepo::Commit::ID } + + it { expect(commit_ids).to contain_exactly(to) } end - it { is_expected.to contain_exactly(SeedRepo::Commit::ID) } + context 'requesting a commit range' do + let(:from) { 'v1.0.0' } + let(:to) { 'v1.2.0' } - context 'between_uses_list_commits FF disabled' do - before do - stub_feature_flags(between_uses_list_commits: false) + let(:commits_in_range) do + %w[ + 570e7b2abdd848b95f2f578043fc23bd6f6fd24d + 5937ac0a7beb003549fc5fd26fc247adbce4a52e + eb49186cfa5c4338011f5f590fac11bd66c5c631 + ] end - it { is_expected.to contain_exactly(SeedRepo::Commit::ID) } + context 'no limit' do + it { expect(commit_ids).to eq(commits_in_range) } + end + + context 'limited' do + let(:limit) { 2 } + + it { expect(commit_ids).to eq(commits_in_range.last(2)) } + end end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 29e7a1dce1d..9ecd281cce0 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -109,6 +109,32 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RefService, :tag_names end + describe '#tags' do + subject { repository.tags } + + it 'gets tags from GitalyClient' do + expect_next_instance_of(Gitlab::GitalyClient::RefService) do |service| + expect(service).to receive(:tags) + end + + subject + end + + context 'with sorting option' do + subject { repository.tags(sort_by: 'name_asc') } + + it 'gets tags from GitalyClient' do + expect_next_instance_of(Gitlab::GitalyClient::RefService) do |service| + expect(service).to receive(:tags).with(sort_by: 'name_asc') + end + + subject + end + end + + it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RefService, :tags + end + describe '#archive_metadata' do let(:storage_path) { '/tmp' } let(:cache_key) { File.join(repository.gl_repository, SeedRepo::LastCommit::ID) } @@ -936,6 +962,159 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do end end + describe '#new_blobs' do + let(:repository) { mutable_repository } + let(:repository_rugged) { mutable_repository_rugged } + let(:blob) { create_blob('This is a new blob') } + let(:commit) { create_commit('nested/new-blob.txt' => blob) } + + def create_blob(content) + repository_rugged.write(content, :blob) + end + + def create_commit(blobs) + author = { name: 'Test User', email: 'mail@example.com', time: Time.now } + + index = repository_rugged.index + blobs.each do |path, oid| + index.add(path: path, oid: oid, mode: 0100644) + end + + Rugged::Commit.create(repository_rugged, + author: author, + committer: author, + message: "Message", + parents: [], + tree: index.write_tree(repository_rugged)) + end + + subject { repository.new_blobs(newrevs).to_a } + + shared_examples '#new_blobs with revisions' do + before do + expect_next_instance_of(Gitlab::GitalyClient::BlobService) do |service| + expect(service) + .to receive(:list_blobs) + .with(expected_newrevs, + limit: Gitlab::Git::Repository::REV_LIST_COMMIT_LIMIT, + with_paths: true, + dynamic_timeout: nil) + .once + .and_call_original + end + end + + it 'enumerates new blobs' do + expect(subject).to match_array(expected_blobs) + end + + it 'memoizes results' do + expect(subject).to match_array(expected_blobs) + expect(subject).to match_array(expected_blobs) + end + end + + context 'with a single revision' do + let(:newrevs) { commit } + let(:expected_newrevs) { ['--not', '--all', '--not', newrevs] } + let(:expected_blobs) do + [have_attributes(class: Gitlab::Git::Blob, id: blob, path: 'nested/new-blob.txt', size: 18)] + end + + it_behaves_like '#new_blobs with revisions' + end + + context 'with a single-entry array' do + let(:newrevs) { [commit] } + let(:expected_newrevs) { ['--not', '--all', '--not'] + newrevs } + let(:expected_blobs) do + [have_attributes(class: Gitlab::Git::Blob, id: blob, path: 'nested/new-blob.txt', size: 18)] + end + + it_behaves_like '#new_blobs with revisions' + end + + context 'with multiple revisions' do + let(:another_blob) { create_blob('Another blob') } + let(:newrevs) { [commit, create_commit('another_path.txt' => another_blob)] } + let(:expected_newrevs) { ['--not', '--all', '--not'] + newrevs.sort } + let(:expected_blobs) do + [ + have_attributes(class: Gitlab::Git::Blob, id: blob, path: 'nested/new-blob.txt', size: 18), + have_attributes(class: Gitlab::Git::Blob, id: another_blob, path: 'another_path.txt', size: 12) + ] + end + + it_behaves_like '#new_blobs with revisions' + end + + context 'with partially blank revisions' do + let(:newrevs) { [nil, commit, Gitlab::Git::BLANK_SHA] } + let(:expected_newrevs) { ['--not', '--all', '--not', commit] } + let(:expected_blobs) do + [ + have_attributes(class: Gitlab::Git::Blob, id: blob, path: 'nested/new-blob.txt', size: 18) + ] + end + + it_behaves_like '#new_blobs with revisions' + end + + context 'with repeated revisions' do + let(:newrevs) { [commit, commit, commit] } + let(:expected_newrevs) { ['--not', '--all', '--not', commit] } + let(:expected_blobs) do + [ + have_attributes(class: Gitlab::Git::Blob, id: blob, path: 'nested/new-blob.txt', size: 18) + ] + end + + it_behaves_like '#new_blobs with revisions' + end + + context 'with preexisting commits' do + let(:newrevs) { ['refs/heads/master'] } + let(:expected_newrevs) { ['--not', '--all', '--not'] + newrevs } + let(:expected_blobs) { [] } + + it_behaves_like '#new_blobs with revisions' + end + + shared_examples '#new_blobs without revisions' do + before do + expect(Gitlab::GitalyClient::BlobService).not_to receive(:new) + end + + it 'returns an empty array' do + expect(subject).to eq([]) + end + end + + context 'with a single nil newrev' do + let(:newrevs) { nil } + + it_behaves_like '#new_blobs without revisions' + end + + context 'with a single zero newrev' do + let(:newrevs) { Gitlab::Git::BLANK_SHA } + + it_behaves_like '#new_blobs without revisions' + end + + context 'with an empty array' do + let(:newrevs) { [] } + + it_behaves_like '#new_blobs without revisions' + end + + context 'with array containing only empty refs' do + let(:newrevs) { [nil, Gitlab::Git::BLANK_SHA] } + + it_behaves_like '#new_blobs without revisions' + end + end + describe '#new_commits' do let(:repository) { mutable_repository } let(:new_commit) do @@ -1132,28 +1311,6 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do end end - describe '#ref_name_for_sha' do - let(:ref_path) { 'refs/heads' } - let(:sha) { repository.find_branch('master').dereferenced_target.id } - let(:ref_name) { 'refs/heads/master' } - - it 'returns the ref name for the given sha' do - expect(repository.ref_name_for_sha(ref_path, sha)).to eq(ref_name) - end - - it "returns an empty name if the ref doesn't exist" do - expect(repository.ref_name_for_sha(ref_path, "000000")).to eq("") - end - - it "raise an exception if the ref is empty" do - expect { repository.ref_name_for_sha(ref_path, "") }.to raise_error(ArgumentError) - end - - it "raise an exception if the ref is nil" do - expect { repository.ref_name_for_sha(ref_path, nil) }.to raise_error(ArgumentError) - end - end - describe '#branches' do subject { repository.branches } @@ -1732,83 +1889,42 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do end describe '#set_full_path' do - shared_examples '#set_full_path' do - before do - repository_rugged.config["gitlab.fullpath"] = repository_path - end - - context 'is given a path' do - it 'writes it to disk' do - repository.set_full_path(full_path: "not-the/real-path.git") - - config = File.read(File.join(repository_path, "config")) - - expect(config).to include("[gitlab]") - expect(config).to include("fullpath = not-the/real-path.git") - end - end - - context 'it is given an empty path' do - it 'does not write it to disk' do - repository.set_full_path(full_path: "") - - config = File.read(File.join(repository_path, "config")) - - expect(config).to include("[gitlab]") - expect(config).to include("fullpath = #{repository_path}") - end - end + before do + repository_rugged.config["gitlab.fullpath"] = repository_path + end - context 'repository does not exist' do - it 'raises NoRepository and does not call Gitaly WriteConfig' do - repository = Gitlab::Git::Repository.new('default', 'does/not/exist.git', '', 'group/project') + context 'is given a path' do + it 'writes it to disk' do + repository.set_full_path(full_path: "not-the/real-path.git") - expect(repository.gitaly_repository_client).not_to receive(:set_full_path) + config = File.read(File.join(repository_path, "config")) - expect do - repository.set_full_path(full_path: 'foo/bar.git') - end.to raise_error(Gitlab::Git::Repository::NoRepository) - end + expect(config).to include("[gitlab]") + expect(config).to include("fullpath = not-the/real-path.git") end end - context 'with :set_full_path enabled' do - before do - stub_feature_flags(set_full_path: true) - end + context 'it is given an empty path' do + it 'does not write it to disk' do + repository.set_full_path(full_path: "") - it_behaves_like '#set_full_path' - end + config = File.read(File.join(repository_path, "config")) - context 'with :set_full_path disabled' do - before do - stub_feature_flags(set_full_path: false) + expect(config).to include("[gitlab]") + expect(config).to include("fullpath = #{repository_path}") end - - it_behaves_like '#set_full_path' end - end - describe '#set_config' do - let(:repository) { mutable_repository } - let(:entries) do - { - 'test.foo1' => 'bla bla', - 'test.foo2' => 1234, - 'test.foo3' => true - } - end + context 'repository does not exist' do + it 'raises NoRepository and does not call Gitaly WriteConfig' do + repository = Gitlab::Git::Repository.new('default', 'does/not/exist.git', '', 'group/project') - it 'can set config settings' do - expect(repository.set_config(entries)).to be_nil + expect(repository.gitaly_repository_client).not_to receive(:set_full_path) - expect(repository_rugged.config['test.foo1']).to eq('bla bla') - expect(repository_rugged.config['test.foo2']).to eq('1234') - expect(repository_rugged.config['test.foo3']).to eq('true') - end - - after do - entries.keys.each { |k| repository_rugged.config.delete(k) } + expect do + repository.set_full_path(full_path: 'foo/bar.git') + end.to raise_error(Gitlab::Git::Repository::NoRepository) + end end end diff --git a/spec/lib/gitlab/git/tag_spec.rb b/spec/lib/gitlab/git/tag_spec.rb index 79ae47f8a7b..4f56595d7d2 100644 --- a/spec/lib/gitlab/git/tag_spec.rb +++ b/spec/lib/gitlab/git/tag_spec.rb @@ -38,7 +38,7 @@ RSpec.describe Gitlab::Git::Tag, :seed_helper do it { expect(tag.tagger.timezone).to eq("+0200") } end - shared_examples 'signed tag' do + describe 'signed tag' do let(:project) { create(:project, :repository) } let(:tag) { project.repository.find_tag('v1.1.1') } @@ -54,18 +54,6 @@ RSpec.describe Gitlab::Git::Tag, :seed_helper do it { expect(tag.tagger.timezone).to eq("+0100") } end - context 'with :get_tag_signatures enabled' do - it_behaves_like 'signed tag' - end - - context 'with :get_tag_signatures disabled' do - before do - stub_feature_flags(get_tag_signatures: false) - end - - it_behaves_like 'signed tag' - end - it { expect(repository.tags.size).to eq(SeedRepo::Repo::TAGS.size) } end diff --git a/spec/lib/gitlab/git/tree_spec.rb b/spec/lib/gitlab/git/tree_spec.rb index f11d84bd8d3..005f8ecaa3a 100644 --- a/spec/lib/gitlab/git/tree_spec.rb +++ b/spec/lib/gitlab/git/tree_spec.rb @@ -189,12 +189,109 @@ RSpec.describe Gitlab::Git::Tree, :seed_helper do end it_behaves_like :repo do - context 'with pagination parameters' do - let(:pagination_params) { { limit: 3, page_token: nil } } + describe 'Pagination' do + context 'with restrictive limit' do + let(:pagination_params) { { limit: 3, page_token: nil } } + + it 'returns limited paginated list of tree objects' do + expect(entries.count).to eq(3) + expect(cursor.next_cursor).to be_present + end + end + + context 'when limit is equal to number of entries' do + let(:entries_count) { entries.count } + + it 'returns all entries without a cursor' do + result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: entries_count, page_token: nil }) + + expect(cursor).to be_nil + expect(result.entries.count).to eq(entries_count) + end + end + + context 'when limit is 0' do + let(:pagination_params) { { limit: 0, page_token: nil } } + + it 'returns empty result' do + expect(entries).to eq([]) + expect(cursor).to be_nil + end + end + + context 'when limit is missing' do + let(:pagination_params) { { limit: nil, page_token: nil } } + + it 'returns empty result' do + expect(entries).to eq([]) + expect(cursor).to be_nil + end + end + + context 'when limit is negative' do + let(:entries_count) { entries.count } + + it 'returns all entries' do + result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: -1, page_token: nil }) + + expect(result.count).to eq(entries_count) + expect(cursor).to be_nil + end + + context 'when token is provided' do + let(:pagination_params) { { limit: 1000, page_token: nil } } + let(:token) { entries.second.id } + + it 'returns all entries after token' do + result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: -1, page_token: token }) + + expect(result.count).to eq(entries.count - 2) + expect(cursor).to be_nil + end + end + end + + context 'when token does not exist' do + let(:pagination_params) { { limit: 5, page_token: 'aabbccdd' } } + + it 'raises a command error' do + expect { entries }.to raise_error(Gitlab::Git::CommandError, 'could not find starting OID: aabbccdd') + end + end + + context 'when limit is bigger than number of entries' do + let(:pagination_params) { { limit: 1000, page_token: nil } } + + it 'returns only available entries' do + expect(entries.count).to be < 20 + expect(cursor).to be_nil + end + end + + it 'returns all tree entries in specific order during cursor pagination' do + collected_entries = [] + token = nil + + expected_entries = entries + + loop do + result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: 5, page_token: token }) + + collected_entries += result.entries + token = cursor&.next_cursor + + break if token.blank? + end + + expect(collected_entries.map(&:path)).to match_array(expected_entries.map(&:path)) + + expected_order = [ + collected_entries.select(&:dir?).map(&:path), + collected_entries.select(&:file?).map(&:path), + collected_entries.select(&:submodule?).map(&:path) + ].flatten - it 'does not support pagination' do - expect(entries.count).to be >= 10 - expect(cursor).to be_nil + expect(collected_entries.map(&:path)).to eq(expected_order) end end end diff --git a/spec/lib/gitlab/gitaly_client/blob_service_spec.rb b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb index 50078d8c127..f869c66337e 100644 --- a/spec/lib/gitlab/gitaly_client/blob_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb @@ -92,13 +92,14 @@ RSpec.describe Gitlab::GitalyClient::BlobService do describe '#list_blobs' do let(:limit) { 0 } let(:bytes_limit) { 0 } - let(:expected_params) { { revisions: revisions, limit: limit, bytes_limit: bytes_limit } } + let(:with_paths) { false } + let(:expected_params) { { revisions: revisions, limit: limit, bytes_limit: bytes_limit, with_paths: with_paths } } before do ::Gitlab::GitalyClient.clear_stubs! end - subject { client.list_blobs(revisions, limit: limit, bytes_limit: bytes_limit) } + subject { client.list_blobs(revisions, limit: limit, bytes_limit: bytes_limit, with_paths: with_paths) } context 'with a single revision' do let(:revisions) { ['master'] } @@ -147,6 +148,24 @@ RSpec.describe Gitlab::GitalyClient::BlobService do end end + context 'with paths' do + let(:revisions) { ['master'] } + let(:limit) { 10 } + let(:bytes_lmit) { 1024 } + let(:with_paths) { true } + + it 'sends a list_blobs message' do + expect_next_instance_of(Gitaly::BlobService::Stub) do |service| + expect(service) + .to receive(:list_blobs) + .with(gitaly_request_with_params(expected_params), kind_of(Hash)) + .and_return([]) + end + + subject + end + end + context 'with split contents' do let(:revisions) { ['master'] } diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb index a0e2d43cf45..554a91f2bc5 100644 --- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb @@ -311,6 +311,10 @@ RSpec.describe Gitlab::GitalyClient::CommitService do end describe '#list_commits' do + let(:revisions) { 'master' } + let(:reverse) { false } + let(:pagination_params) { nil } + shared_examples 'a ListCommits request' do before do ::Gitlab::GitalyClient.clear_stubs! @@ -318,26 +322,35 @@ RSpec.describe Gitlab::GitalyClient::CommitService do it 'sends a list_commits message' do expect_next_instance_of(Gitaly::CommitService::Stub) do |service| - expect(service) - .to receive(:list_commits) - .with(gitaly_request_with_params(expected_params), kind_of(Hash)) - .and_return([]) + expected_request = gitaly_request_with_params( + Array.wrap(revisions), + reverse: reverse, + pagination_params: pagination_params + ) + + expect(service).to receive(:list_commits).with(expected_request, kind_of(Hash)).and_return([]) end - client.list_commits(revisions) + client.list_commits(revisions, reverse: reverse, pagination_params: pagination_params) end end - context 'with a single revision' do - let(:revisions) { 'master' } - let(:expected_params) { %w[master] } + it_behaves_like 'a ListCommits request' + + context 'with multiple revisions' do + let(:revisions) { %w[master --not --all] } + + it_behaves_like 'a ListCommits request' + end + + context 'with reverse: true' do + let(:reverse) { true } it_behaves_like 'a ListCommits request' end - context 'with multiple revisions' do - let(:revisions) { %w[master --not --all] } - let(:expected_params) { %w[master --not --all] } + context 'with pagination params' do + let(:pagination_params) { { limit: 1, page_token: 'foo' } } it_behaves_like 'a ListCommits request' end diff --git a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb index e19be965e68..d308612ef31 100644 --- a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb @@ -92,6 +92,36 @@ RSpec.describe Gitlab::GitalyClient::RefService do end end + describe '#find_branch' do + it 'sends a find_branch message' do + expect_any_instance_of(Gitaly::RefService::Stub) + .to receive(:find_branch) + .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash)) + .and_return(double(branch: Gitaly::Branch.new(name: 'name', target_commit: build(:gitaly_commit)))) + + client.find_branch('name') + end + end + + describe '#find_tag' do + it 'sends a find_tag message' do + expect_any_instance_of(Gitaly::RefService::Stub) + .to receive(:find_tag) + .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash)) + .and_return(double(tag: Gitaly::Tag.new)) + + client.find_tag('name') + end + + context 'when tag is empty' do + it 'does not send a fing_tag message' do + expect_any_instance_of(Gitaly::RefService::Stub).not_to receive(:find_tag) + + expect(client.find_tag('')).to be_nil + end + end + end + describe '#default_branch_name' do it 'sends a find_default_branch_name message' do expect_any_instance_of(Gitaly::RefService::Stub) @@ -103,16 +133,6 @@ RSpec.describe Gitlab::GitalyClient::RefService do end end - describe '#list_new_blobs' do - it 'raises DeadlineExceeded when timeout is too small' do - newrev = '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' - - expect do - client.list_new_blobs(newrev, dynamic_timeout: 0.001) - end.to raise_error(GRPC::DeadlineExceeded) - end - end - describe '#local_branches' do it 'sends a find_local_branches message' do expect_any_instance_of(Gitaly::RefService::Stub) @@ -154,6 +174,22 @@ RSpec.describe Gitlab::GitalyClient::RefService do client.tags end + + context 'with sorting option' do + it 'sends a correct find_all_tags message' do + expected_sort_by = Gitaly::FindAllTagsRequest::SortBy.new( + key: :REFNAME, + direction: :ASCENDING + ) + + expect_any_instance_of(Gitaly::RefService::Stub) + .to receive(:find_all_tags) + .with(gitaly_request_with_params(sort_by: expected_sort_by), kind_of(Hash)) + .and_return([]) + + client.tags(sort_by: 'name_asc') + end + end end describe '#branch_names_contains_sha' do @@ -189,13 +225,6 @@ RSpec.describe Gitlab::GitalyClient::RefService do end end - describe '#find_ref_name', :seed_helper do - subject { client.find_ref_name(SeedRepo::Commit::ID, 'refs/heads/master') } - - it { is_expected.to be_utf8 } - it { is_expected.to eq('refs/heads/master') } - end - describe '#ref_exists?', :seed_helper do it 'finds the master branch ref' do expect(client.ref_exists?('refs/heads/master')).to eq(true) diff --git a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb index 4b037d3f836..e5502a883b5 100644 --- a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb @@ -195,19 +195,6 @@ RSpec.describe Gitlab::GitalyClient::RepositoryService do end end - describe '#squash_in_progress?' do - let(:squash_id) { 1 } - - it 'sends a repository_squash_in_progress message' do - expect_any_instance_of(Gitaly::RepositoryService::Stub) - .to receive(:is_squash_in_progress) - .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash)) - .and_return(double(in_progress: true)) - - client.squash_in_progress?(squash_id) - end - end - describe '#calculate_checksum' do it 'sends a calculate_checksum message' do expect_any_instance_of(Gitaly::RepositoryService::Stub) diff --git a/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb b/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb index 0af840d2c10..3dc15c7c059 100644 --- a/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb @@ -20,6 +20,7 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNoteImporter do noteable_type: 'MergeRequest', noteable_id: 1, commit_id: '123abc', + original_commit_id: 'original123abc', file_path: 'README.md', diff_hunk: hunk, author: Gitlab::GithubImport::Representation::User @@ -64,13 +65,14 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNoteImporter do LegacyDiffNote.table_name, [ { + discussion_id: anything, noteable_type: 'MergeRequest', noteable_id: merge_request.id, project_id: project.id, author_id: user.id, note: 'Hello', system: false, - commit_id: '123abc', + commit_id: 'original123abc', line_code: note.line_code, type: 'LegacyDiffNote', created_at: created_at, @@ -95,13 +97,14 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNoteImporter do LegacyDiffNote.table_name, [ { + discussion_id: anything, noteable_type: 'MergeRequest', noteable_id: merge_request.id, project_id: project.id, author_id: project.creator_id, note: "*Created by: #{user.username}*\n\nHello", system: false, - commit_id: '123abc', + commit_id: 'original123abc', line_code: note.line_code, type: 'LegacyDiffNote', created_at: created_at, 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 7750e508713..46b9959ff64 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 @@ -12,6 +12,7 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNotesImporter do html_url: 'https://github.com/foo/bar/pull/42', path: 'README.md', commit_id: '123abc', + original_commit_id: 'original123abc', diff_hunk: "@@ -1 +1 @@\n-Hello\n+Hello world", user: double(:user, id: 4, login: 'alice'), body: 'Hello world', diff --git a/spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb new file mode 100644 index 00000000000..8c71d7d0ed7 --- /dev/null +++ b/spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointDiffNotesImporter do + let(:client) { double } + let(:project) { create(:project, import_source: 'github/repo') } + + subject { described_class.new(project, client) } + + it { is_expected.to include_module(Gitlab::GithubImport::ParallelScheduling) } + it { is_expected.to include_module(Gitlab::GithubImport::SingleEndpointNotesImporting) } + it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::DiffNote) } + it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::DiffNoteImporter) } + it { expect(subject.collection_method).to eq(:pull_request_comments) } + it { expect(subject.object_type).to eq(:diff_note) } + it { expect(subject.id_for_already_imported_cache(double(id: 1))).to eq(1) } + + describe '#each_object_to_import', :clean_gitlab_redis_cache do + let(:merge_request) do + create( + :merged_merge_request, + iid: 999, + source_project: project, + target_project: project + ) + end + + let(:note) { double(id: 1) } + let(:page) { double(objects: [note], number: 1) } + + it 'fetches data' do + expect(client) + .to receive(:each_page) + .exactly(:once) # ensure to be cached on the second call + .with(:pull_request_comments, 'github/repo', merge_request.iid, page: 1) + .and_yield(page) + + expect { |b| subject.each_object_to_import(&b) }.to yield_with_args(note) + + subject.each_object_to_import {} + + expect( + Gitlab::Cache::Import::Caching.set_includes?( + "github-importer/merge_request/diff_notes/already-imported/#{project.id}", + merge_request.iid + ) + ).to eq(true) + end + + it 'skips cached pages' do + Gitlab::GithubImport::PageCounter + .new(project, "merge_request/#{merge_request.id}/pull_request_comments") + .set(2) + + expect(client) + .to receive(:each_page) + .exactly(:once) # ensure to be cached on the second call + .with(:pull_request_comments, 'github/repo', merge_request.iid, page: 2) + + subject.each_object_to_import {} + end + + it 'skips cached merge requests' do + Gitlab::Cache::Import::Caching.set_add( + "github-importer/merge_request/diff_notes/already-imported/#{project.id}", + merge_request.iid + ) + + expect(client).not_to receive(:each_page) + + subject.each_object_to_import {} + end + end +end diff --git a/spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb new file mode 100644 index 00000000000..8d8f2730880 --- /dev/null +++ b/spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointIssueNotesImporter do + let(:client) { double } + let(:project) { create(:project, import_source: 'github/repo') } + + subject { described_class.new(project, client) } + + it { is_expected.to include_module(Gitlab::GithubImport::ParallelScheduling) } + it { is_expected.to include_module(Gitlab::GithubImport::SingleEndpointNotesImporting) } + it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::Note) } + it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::NoteImporter) } + it { expect(subject.collection_method).to eq(:issue_comments) } + it { expect(subject.object_type).to eq(:note) } + it { expect(subject.id_for_already_imported_cache(double(id: 1))).to eq(1) } + + describe '#each_object_to_import', :clean_gitlab_redis_cache do + let(:issue) do + create( + :issue, + iid: 999, + project: project + ) + end + + let(:note) { double(id: 1) } + let(:page) { double(objects: [note], number: 1) } + + it 'fetches data' do + expect(client) + .to receive(:each_page) + .exactly(:once) # ensure to be cached on the second call + .with(:issue_comments, 'github/repo', issue.iid, page: 1) + .and_yield(page) + + expect { |b| subject.each_object_to_import(&b) }.to yield_with_args(note) + + subject.each_object_to_import {} + + expect( + Gitlab::Cache::Import::Caching.set_includes?( + "github-importer/issue/notes/already-imported/#{project.id}", + issue.iid + ) + ).to eq(true) + end + + it 'skips cached pages' do + Gitlab::GithubImport::PageCounter + .new(project, "issue/#{issue.id}/issue_comments") + .set(2) + + expect(client) + .to receive(:each_page) + .exactly(:once) # ensure to be cached on the second call + .with(:issue_comments, 'github/repo', issue.iid, page: 2) + + subject.each_object_to_import {} + end + + it 'skips cached merge requests' do + Gitlab::Cache::Import::Caching.set_add( + "github-importer/issue/notes/already-imported/#{project.id}", + issue.iid + ) + + expect(client).not_to receive(:each_page) + + subject.each_object_to_import {} + end + end +end diff --git a/spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb new file mode 100644 index 00000000000..b8282212a90 --- /dev/null +++ b/spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointMergeRequestNotesImporter do + let(:client) { double } + let(:project) { create(:project, import_source: 'github/repo') } + + subject { described_class.new(project, client) } + + it { is_expected.to include_module(Gitlab::GithubImport::ParallelScheduling) } + it { is_expected.to include_module(Gitlab::GithubImport::SingleEndpointNotesImporting) } + it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::Note) } + it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::NoteImporter) } + it { expect(subject.collection_method).to eq(:issue_comments) } + it { expect(subject.object_type).to eq(:note) } + it { expect(subject.id_for_already_imported_cache(double(id: 1))).to eq(1) } + + describe '#each_object_to_import', :clean_gitlab_redis_cache do + let(:merge_request) do + create( + :merge_request, + iid: 999, + source_project: project, + target_project: project + ) + end + + let(:note) { double(id: 1) } + let(:page) { double(objects: [note], number: 1) } + + it 'fetches data' do + expect(client) + .to receive(:each_page) + .exactly(:once) # ensure to be cached on the second call + .with(:issue_comments, 'github/repo', merge_request.iid, page: 1) + .and_yield(page) + + expect { |b| subject.each_object_to_import(&b) }.to yield_with_args(note) + + subject.each_object_to_import {} + + expect( + Gitlab::Cache::Import::Caching.set_includes?( + "github-importer/merge_request/notes/already-imported/#{project.id}", + merge_request.iid + ) + ).to eq(true) + end + + it 'skips cached pages' do + Gitlab::GithubImport::PageCounter + .new(project, "merge_request/#{merge_request.id}/issue_comments") + .set(2) + + expect(client) + .to receive(:each_page) + .exactly(:once) # ensure to be cached on the second call + .with(:issue_comments, 'github/repo', merge_request.iid, page: 2) + + subject.each_object_to_import {} + end + + it 'skips cached merge requests' do + Gitlab::Cache::Import::Caching.set_add( + "github-importer/merge_request/notes/already-imported/#{project.id}", + merge_request.iid + ) + + expect(client).not_to receive(:each_page) + + subject.each_object_to_import {} + end + end +end diff --git a/spec/lib/gitlab/github_import/issuable_finder_spec.rb b/spec/lib/gitlab/github_import/issuable_finder_spec.rb index f009b61ad89..3afd006109b 100644 --- a/spec/lib/gitlab/github_import/issuable_finder_spec.rb +++ b/spec/lib/gitlab/github_import/issuable_finder_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::GithubImport::IssuableFinder, :clean_gitlab_redis_cache do - let(:project) { double(:project, id: 4) } + let(:project) { double(:project, id: 4, group: nil) } let(:issue) do double(:issue, issuable_type: MergeRequest, iid: 1) end @@ -26,15 +26,77 @@ RSpec.describe Gitlab::GithubImport::IssuableFinder, :clean_gitlab_redis_cache d expect { finder.database_id }.to raise_error(TypeError) end + + context 'when group is present' do + context 'when github_importer_single_endpoint_notes_import feature flag is enabled' do + it 'reads cache value with longer timeout' do + project = create(:project, import_url: 'http://t0ken@github.com/user/repo.git') + group = create(:group, projects: [project]) + + stub_feature_flags(github_importer_single_endpoint_notes_import: group) + + expect(Gitlab::Cache::Import::Caching) + .to receive(:read) + .with(anything, timeout: Gitlab::Cache::Import::Caching::LONGER_TIMEOUT) + + described_class.new(project, issue).database_id + end + end + + context 'when github_importer_single_endpoint_notes_import feature flag is disabled' do + it 'reads cache value with default timeout' do + project = double(:project, id: 4, group: create(:group)) + + stub_feature_flags(github_importer_single_endpoint_notes_import: false) + + expect(Gitlab::Cache::Import::Caching) + .to receive(:read) + .with(anything, timeout: Gitlab::Cache::Import::Caching::TIMEOUT) + + described_class.new(project, issue).database_id + end + end + end end describe '#cache_database_id' do it 'caches the ID of a database row' do expect(Gitlab::Cache::Import::Caching) .to receive(:write) - .with('github-import/issuable-finder/4/MergeRequest/1', 10) + .with('github-import/issuable-finder/4/MergeRequest/1', 10, timeout: 86400) finder.cache_database_id(10) end + + context 'when group is present' do + context 'when github_importer_single_endpoint_notes_import feature flag is enabled' do + it 'caches value with longer timeout' do + project = create(:project, import_url: 'http://t0ken@github.com/user/repo.git') + group = create(:group, projects: [project]) + + stub_feature_flags(github_importer_single_endpoint_notes_import: group) + + expect(Gitlab::Cache::Import::Caching) + .to receive(:write) + .with(anything, anything, timeout: Gitlab::Cache::Import::Caching::LONGER_TIMEOUT) + + described_class.new(project, issue).cache_database_id(10) + end + end + + context 'when github_importer_single_endpoint_notes_import feature flag is disabled' do + it 'caches value with default timeout' do + project = double(:project, id: 4, group: create(:group)) + + stub_feature_flags(github_importer_single_endpoint_notes_import: false) + + expect(Gitlab::Cache::Import::Caching) + .to receive(:write) + .with(anything, anything, timeout: Gitlab::Cache::Import::Caching::TIMEOUT) + + described_class.new(project, issue).cache_database_id(10) + end + end + end end end 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 7e540674258..7c24cd0a5db 100644 --- a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb +++ b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb @@ -67,6 +67,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do html_url: 'https://github.com/foo/bar/pull/42', path: 'README.md', commit_id: '123abc', + original_commit_id: 'original123abc', diff_hunk: hunk, user: double(:user, id: 4, login: 'alice'), body: 'Hello world', @@ -99,6 +100,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do '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', @@ -117,6 +119,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do 'noteable_id' => 42, 'file_path' => 'README.md', 'commit_id' => '123abc', + 'original_commit_id' => 'original123abc', 'diff_hunk' => hunk, 'note' => 'Hello world', 'created_at' => created_at.to_s, @@ -145,6 +148,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do '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', diff --git a/spec/lib/gitlab/github_import/sequential_importer_spec.rb b/spec/lib/gitlab/github_import/sequential_importer_spec.rb index a5e89049ed9..3c3f8ff59d0 100644 --- a/spec/lib/gitlab/github_import/sequential_importer_spec.rb +++ b/spec/lib/gitlab/github_import/sequential_importer_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Gitlab::GithubImport::SequentialImporter do describe '#execute' do 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') + 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::GithubImport::Importer::RepositoryImporter) do |instance| diff --git a/spec/lib/gitlab/github_import/user_finder_spec.rb b/spec/lib/gitlab/github_import/user_finder_spec.rb index f81fa3b1e2e..8eb6eedd72d 100644 --- a/spec/lib/gitlab/github_import/user_finder_spec.rb +++ b/spec/lib/gitlab/github_import/user_finder_spec.rb @@ -195,7 +195,7 @@ RSpec.describe Gitlab::GithubImport::UserFinder, :clean_gitlab_redis_cache do expect(Gitlab::Cache::Import::Caching) .to receive(:write) - .with(an_instance_of(String), email) + .with(an_instance_of(String), email, timeout: Gitlab::Cache::Import::Caching::TIMEOUT) finder.email_for_github_username('kittens') end @@ -211,6 +211,16 @@ RSpec.describe Gitlab::GithubImport::UserFinder, :clean_gitlab_redis_cache do expect(finder.email_for_github_username('kittens')).to be_nil end + + it 'shortens the timeout for Email address in cache when an Email address is private/nil from GitHub' do + user = double(:user, email: nil) + expect(client).to receive(:user).with('kittens').and_return(user) + + expect(Gitlab::Cache::Import::Caching) + .to receive(:write).with(an_instance_of(String), nil, timeout: Gitlab::Cache::Import::Caching::SHORTER_TIMEOUT) + + expect(finder.email_for_github_username('kittens')).to be_nil + end end end diff --git a/spec/lib/gitlab/github_import_spec.rb b/spec/lib/gitlab/github_import_spec.rb index 662757f66ad..1ea9f003098 100644 --- a/spec/lib/gitlab/github_import_spec.rb +++ b/spec/lib/gitlab/github_import_spec.rb @@ -3,13 +3,17 @@ require 'spec_helper' RSpec.describe Gitlab::GithubImport do + before do + stub_feature_flags(github_importer_lower_per_page_limit: false) + end + context 'github.com' do - let(:project) { double(:project, import_url: 'http://t0ken@github.com/user/repo.git', id: 1) } + let(:project) { double(:project, import_url: 'http://t0ken@github.com/user/repo.git', id: 1, group: nil) } it 'returns a new Client with a custom token' do expect(described_class::Client) .to receive(:new) - .with('123', host: nil, parallel: true) + .with('123', host: nil, parallel: true, per_page: 100) described_class.new_client_for(project, token: '123') end @@ -23,7 +27,7 @@ RSpec.describe Gitlab::GithubImport do expect(described_class::Client) .to receive(:new) - .with('123', host: nil, parallel: true) + .with('123', host: nil, parallel: true, per_page: 100) described_class.new_client_for(project) end @@ -45,12 +49,12 @@ RSpec.describe Gitlab::GithubImport do end context 'GitHub Enterprise' do - let(:project) { double(:project, import_url: 'http://t0ken@github.another-domain.com/repo-org/repo.git') } + let(:project) { double(:project, import_url: 'http://t0ken@github.another-domain.com/repo-org/repo.git', group: nil) } it 'returns a new Client with a custom token' do expect(described_class::Client) .to receive(:new) - .with('123', host: 'http://github.another-domain.com/api/v3', parallel: true) + .with('123', host: 'http://github.another-domain.com/api/v3', parallel: true, per_page: 100) described_class.new_client_for(project, token: '123') end @@ -64,7 +68,7 @@ RSpec.describe Gitlab::GithubImport do expect(described_class::Client) .to receive(:new) - .with('123', host: 'http://github.another-domain.com/api/v3', parallel: true) + .with('123', host: 'http://github.another-domain.com/api/v3', parallel: true, per_page: 100) described_class.new_client_for(project) end @@ -88,4 +92,37 @@ RSpec.describe Gitlab::GithubImport do expect(described_class.formatted_import_url(project)).to eq('http://github.another-domain.com/api/v3') end end + + describe '.per_page' do + context 'when project group is present' do + context 'when github_importer_lower_per_page_limit is enabled' do + it 'returns lower per page value' do + project = create(:project, import_url: 'http://t0ken@github.com/user/repo.git') + group = create(:group, projects: [project]) + + stub_feature_flags(github_importer_lower_per_page_limit: group) + + expect(described_class.per_page(project)).to eq(Gitlab::GithubImport::Client::LOWER_PER_PAGE) + end + end + + context 'when github_importer_lower_per_page_limit is disabled' do + it 'returns default per page value' do + project = double(:project, import_url: 'http://t0ken@github.com/user/repo.git', id: 1, group: create(:group)) + + stub_feature_flags(github_importer_lower_per_page_limit: false) + + expect(described_class.per_page(project)).to eq(Gitlab::GithubImport::Client::DEFAULT_PER_PAGE) + end + end + end + + context 'when project group is missing' do + it 'returns default per page value' do + project = double(:project, import_url: 'http://t0ken@github.com/user/repo.git', id: 1, group: nil) + + expect(described_class.per_page(project)).to eq(Gitlab::GithubImport::Client::DEFAULT_PER_PAGE) + end + end + end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 2b7138a7a10..614aa55c3c5 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -58,6 +58,7 @@ issues: - test_reports - requirement - incident_management_issuable_escalation_status +- pending_escalations work_item_type: - issues events: @@ -223,6 +224,7 @@ ci_pipelines: - builds - bridges - processables +- generic_commit_statuses - trigger_requests - variables - auto_canceled_by @@ -318,6 +320,7 @@ integrations: - project - service_hook - jira_tracker_data +- zentao_tracker_data - issue_tracker_data - open_project_tracker_data hooks: @@ -354,6 +357,8 @@ project: - taggings - base_tags - topic_taggings +- topics_acts_as_taggable +- project_topics - topics - chat_services - cluster @@ -365,6 +370,7 @@ project: - value_streams - group - namespace +- project_namespace - management_clusters - boards - last_event @@ -395,6 +401,7 @@ project: - teamcity_integration - pushover_integration - jira_integration +- zentao_integration - redmine_integration - youtrack_integration - custom_issue_tracker_integration @@ -583,6 +590,9 @@ project: - timelogs - error_tracking_errors - error_tracking_client_keys +- pending_builds +- security_scans +- ci_feature_usages award_emoji: - awardable - user @@ -673,6 +683,7 @@ boards: - destroyable_lists - milestone - iteration +- iteration_cadence - board_labels - board_assignee - assignee @@ -762,3 +773,5 @@ push_rule: - group bulk_import_export: - group +service_desk_setting: + - file_template_project diff --git a/spec/lib/gitlab/import_export/attributes_permitter_spec.rb b/spec/lib/gitlab/import_export/attributes_permitter_spec.rb index 0c1b1cd74bf..36a831a785c 100644 --- a/spec/lib/gitlab/import_export/attributes_permitter_spec.rb +++ b/spec/lib/gitlab/import_export/attributes_permitter_spec.rb @@ -74,4 +74,73 @@ RSpec.describe Gitlab::ImportExport::AttributesPermitter do expect(subject.permitted_attributes_for(:labels)).to contain_exactly(:title, :description, :type, :priorities) end end + + describe '#permitted_attributes_defined?' do + using RSpec::Parameterized::TableSyntax + + let(:attributes_permitter) { described_class.new } + + where(:relation_name, :permitted_attributes_defined) do + :user | false + :author | false + :ci_cd_settings | false + :issuable_sla | false + :push_rule | false + :metrics_setting | true + :project_badges | true + :pipeline_schedules | true + :error_tracking_setting | true + :auto_devops | true + end + + with_them do + it { expect(attributes_permitter.permitted_attributes_defined?(relation_name)).to eq(permitted_attributes_defined) } + end + 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 + end + end + end end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 77d126e012e..a9efa32f986 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -167,6 +167,7 @@ ProjectMember: - expires_at - ldap - override +- invite_email_success User: - id - username @@ -761,6 +762,7 @@ Board: - group_id - milestone_id - iteration_id +- iteration_cadence_id - weight - name - hide_backlog_list diff --git a/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb b/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb index cd1828791c3..b2a11353d0c 100644 --- a/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb +++ b/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb @@ -130,15 +130,25 @@ RSpec.describe Gitlab::Instrumentation::RedisInterceptor, :clean_gitlab_redis_sh end context 'when report_on_long_redis_durations is enabled' do - it 'tracks an exception and continues' do - expect(Gitlab::ErrorTracking) - .to receive(:track_exception) - .with(an_instance_of(described_class::MysteryRedisDurationError), - command: 'mget', - duration: be > threshold, - timestamp: a_string_matching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{5}/)) + context 'for an instance other than SharedState' do + it 'does nothing' do + expect(Gitlab::ErrorTracking).not_to receive(:track_exception) - Gitlab::Redis::SharedState.with { |r| r.mget('foo', 'foo') { sleep threshold + 0.1 } } + Gitlab::Redis::Queues.with { |r| r.mget('foo', 'foo') { sleep threshold + 0.1 } } + end + end + + context 'for the SharedState instance' do + it 'tracks an exception and continues' do + expect(Gitlab::ErrorTracking) + .to receive(:track_exception) + .with(an_instance_of(described_class::MysteryRedisDurationError), + command: 'mget', + duration: be > threshold, + timestamp: a_string_matching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{5}/)) + + Gitlab::Redis::SharedState.with { |r| r.mget('foo', 'foo') { sleep threshold + 0.1 } } + end end end end diff --git a/spec/lib/gitlab/instrumentation/redis_spec.rb b/spec/lib/gitlab/instrumentation/redis_spec.rb index 6cddf958f2a..ebc2e92a0dd 100644 --- a/spec/lib/gitlab/instrumentation/redis_spec.rb +++ b/spec/lib/gitlab/instrumentation/redis_spec.rb @@ -28,6 +28,13 @@ RSpec.describe Gitlab::Instrumentation::Redis do describe '.payload', :request_store do before do + # If this is the first spec in a spec run that uses Redis, there + # will be an extra SELECT command to choose the right database. We + # don't want to make the spec less precise, so we force that to + # happen (if needed) first, then clear the counts. + Gitlab::Redis::Cache.with { |redis| redis.info } + RequestStore.clear! + Gitlab::Redis::Cache.with { |redis| redis.set('cache-test', 321) } Gitlab::Redis::SharedState.with { |redis| redis.set('shared-state-test', 123) } end diff --git a/spec/lib/gitlab/issuables_count_for_state_spec.rb b/spec/lib/gitlab/issuables_count_for_state_spec.rb index a6170c146ab..cc4ebba863d 100644 --- a/spec/lib/gitlab/issuables_count_for_state_spec.rb +++ b/spec/lib/gitlab/issuables_count_for_state_spec.rb @@ -66,4 +66,106 @@ RSpec.describe Gitlab::IssuablesCountForState do end end end + + context 'when store_in_redis_cache is `true`', :clean_gitlab_redis_cache do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + + let(:cache_options) { { expires_in: 1.hour } } + let(:cache_key) { ['group', group.id, 'issues'] } + let(:threshold) { described_class::THRESHOLD } + let(:states_count) { { opened: 1, closed: 1, all: 2 } } + let(:params) { {} } + + subject { described_class.new(finder, fast_fail: true, store_in_redis_cache: true ) } + + before do + allow(finder).to receive(:count_by_state).and_return(states_count) + allow_next_instance_of(described_class) do |counter| + allow(counter).to receive(:parent_group).and_return(group) + end + end + + shared_examples 'calculating counts without caching' do + it 'does not store in redis store' do + expect(Rails.cache).not_to receive(:read) + expect(finder).to receive(:count_by_state) + expect(Rails.cache).not_to receive(:write) + expect(subject[:all]).to eq(states_count[:all]) + end + end + + context 'with Issues' do + let(:finder) { IssuesFinder.new(user, params) } + + it 'returns -1 for the requested state' do + allow(finder).to receive(:count_by_state).and_raise(ActiveRecord::QueryCanceled) + expect(Rails.cache).not_to receive(:write) + + expect(subject[:all]).to eq(-1) + end + + context 'when parent group is not present' do + let(:group) { nil } + + it_behaves_like 'calculating counts without caching' + end + + context 'when params include search filters' do + let(:parent) { group } + + before do + finder.params[:assignee_username] = [user.username, 'root'] + end + + it_behaves_like 'calculating counts without caching' + end + + context 'when counts are stored in cache' do + before do + allow(Rails.cache).to receive(:read).with(cache_key, cache_options) + .and_return({ opened: 1000, closed: 1000, all: 2000 }) + end + + it 'does not call finder count_by_state' do + expect(finder).not_to receive(:count_by_state) + + expect(subject[:all]).to eq(2000) + end + end + + context 'when cache is empty' do + context 'when state counts are under threshold' do + let(:states_count) { { opened: 1, closed: 1, all: 2 } } + + it 'does not store state counts in cache' do + expect(Rails.cache).to receive(:read).with(cache_key, cache_options) + expect(finder).to receive(:count_by_state) + expect(Rails.cache).not_to receive(:write) + expect(subject[:all]).to eq(states_count[:all]) + end + end + + context 'when state counts are over threshold' do + let(:states_count) do + { opened: threshold + 1, closed: threshold + 1, all: (threshold + 1) * 2 } + end + + it 'stores state counts in cache' do + expect(Rails.cache).to receive(:read).with(cache_key, cache_options) + expect(finder).to receive(:count_by_state) + expect(Rails.cache).to receive(:write).with(cache_key, states_count, cache_options) + + expect(subject[:all]).to eq((threshold + 1) * 2) + end + end + end + end + + context 'with Merge Requests' do + let(:finder) { MergeRequestsFinder.new(user, params) } + + it_behaves_like 'calculating counts without caching' + end + end end diff --git a/spec/lib/gitlab/issues/rebalancing/state_spec.rb b/spec/lib/gitlab/issues/rebalancing/state_spec.rb new file mode 100644 index 00000000000..bdd0dbd365d --- /dev/null +++ b/spec/lib/gitlab/issues/rebalancing/state_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Issues::Rebalancing::State, :clean_gitlab_redis_shared_state do + shared_examples 'issues rebalance caching' do + describe '#track_new_running_rebalance' do + it 'caches a project id to track caching in progress' do + expect { rebalance_caching.track_new_running_rebalance }.to change { rebalance_caching.concurrent_running_rebalances_count }.from(0).to(1) + end + end + + describe '#set and get current_index' do + it 'returns zero as current index when index not cached' do + expect(rebalance_caching.get_current_index).to eq(0) + end + + it 'returns cached current index' do + expect { rebalance_caching.cache_current_index(123) }.to change { rebalance_caching.get_current_index }.from(0).to(123) + end + end + + describe '#set and get current_project' do + it 'returns nil if there is no project_id cached' do + expect(rebalance_caching.get_current_project_id).to be_nil + end + + it 'returns cached current project_id' do + expect { rebalance_caching.cache_current_project_id(456) }.to change { rebalance_caching.get_current_project_id }.from(nil).to('456') + end + end + + describe "#rebalance_in_progress?" do + it 'return zero if no re-balances are running' do + expect(rebalance_caching.concurrent_running_rebalances_count).to eq(0) + end + + it 'return false if no re-balances are running' do + expect(rebalance_caching.rebalance_in_progress?).to be false + end + + it 'return true a re-balance for given project/namespace is running' do + rebalance_caching.track_new_running_rebalance + + expect(rebalance_caching.rebalance_in_progress?).to be true + end + end + + context 'caching issue ids' do + context 'with no issue ids cached' do + it 'returns zero when there are no cached issue ids' do + expect(rebalance_caching.issue_count).to eq(0) + end + + it 'returns empty array when there are no cached issue ids' do + expect(rebalance_caching.get_cached_issue_ids(0, 100)).to eq([]) + end + end + + context 'with cached issue ids' do + before do + generate_and_cache_issues_ids(count: 3) + end + + it 'returns count of cached issue ids' do + expect(rebalance_caching.issue_count).to eq(3) + end + + it 'returns array of issue ids' do + expect(rebalance_caching.get_cached_issue_ids(0, 100)).to eq(%w(1 2 3)) + end + + it 'limits returned values' do + expect(rebalance_caching.get_cached_issue_ids(0, 2)).to eq(%w(1 2)) + end + + context 'when caching duplicate issue_ids' do + before do + generate_and_cache_issues_ids(count: 3, position_offset: 3, position_direction: -1) + end + + it 'does not cache duplicate issues' do + expect(rebalance_caching.issue_count).to eq(3) + end + + it 'returns cached issues with latest scores' do + expect(rebalance_caching.get_cached_issue_ids(0, 100)).to eq(%w(3 2 1)) + end + end + end + end + + context 'when setting expiration' do + context 'when tracking new rebalance' do + it 'returns as expired for non existent key' do + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:concurrent_running_rebalances_key))).to be < 0 + end + end + + it 'has expiration set' do + rebalance_caching.track_new_running_rebalance + + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:concurrent_running_rebalances_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i) + end + end + end + + context 'when setting current index' do + it 'returns as expiring for non existent key' do + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:current_index_key))).to be < 0 + end + end + + it 'has expiration set' do + rebalance_caching.cache_current_index(123) + + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:current_index_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i) + end + end + end + + context 'when setting current project id' do + it 'returns as expired for non existent key' do + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:current_project_key))).to be < 0 + end + end + + it 'has expiration set' do + rebalance_caching.cache_current_project_id(456) + + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:current_project_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i) + end + end + end + + context 'when setting cached issue ids' do + it 'returns as expired for non existent key' do + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:issue_ids_key))).to be < 0 + end + end + + it 'has expiration set' do + generate_and_cache_issues_ids(count: 3) + + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(rebalance_caching.send(:issue_ids_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i) + end + end + end + end + + context 'cleanup cache' do + before do + generate_and_cache_issues_ids(count: 3) + rebalance_caching.cache_current_index(123) + rebalance_caching.cache_current_project_id(456) + rebalance_caching.track_new_running_rebalance + end + + it 'removes cache keys' do + expect(check_existing_keys).to eq(4) + + rebalance_caching.cleanup_cache + + expect(check_existing_keys).to eq(0) + end + end + end + + context 'rebalancing issues in namespace' do + let_it_be(:group) { create(:group, :private) } + let_it_be(:project) { create(:project, namespace: group) } + + subject(:rebalance_caching) { described_class.new(group, group.projects) } + + it { expect(rebalance_caching.send(:rebalanced_container_type)).to eq(described_class::NAMESPACE) } + + it_behaves_like 'issues rebalance caching' + end + + context 'rebalancing issues in a project' do + let_it_be(:project) { create(:project) } + + subject(:rebalance_caching) { described_class.new(project.namespace, Project.where(id: project)) } + + it { expect(rebalance_caching.send(:rebalanced_container_type)).to eq(described_class::PROJECT) } + + it_behaves_like 'issues rebalance caching' + end + + # count - how many issue ids to generate, issue ids will start at 1 + # position_offset - if you'd want to offset generated relative_position for the issue ids, + # relative_position is generated as = issue id * 10 + position_offset + # position_direction - (1) for positive relative_positions, (-1) for negative relative_positions + def generate_and_cache_issues_ids(count:, position_offset: 0, position_direction: 1) + issues = [] + + count.times do |idx| + id = idx + 1 + issues << double(relative_position: position_direction * (id * 10 + position_offset), id: id) + end + + rebalance_caching.cache_issue_ids(issues) + end + + def check_existing_keys + index = 0 + + index += 1 if rebalance_caching.get_current_index > 0 + index += 1 if rebalance_caching.get_current_project_id.present? + index += 1 if rebalance_caching.get_cached_issue_ids(0, 100).present? + index += 1 if rebalance_caching.rebalance_in_progress? + + index + end +end diff --git a/spec/lib/gitlab/kas/client_spec.rb b/spec/lib/gitlab/kas/client_spec.rb index 40e18f58ee4..5b89023cc13 100644 --- a/spec/lib/gitlab/kas/client_spec.rb +++ b/spec/lib/gitlab/kas/client_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe Gitlab::Kas::Client do let_it_be(:project) { create(:project) } + let_it_be(:agent) { create(:cluster_agent, project: project) } describe '#initialize' do context 'kas is not enabled' do @@ -44,6 +45,32 @@ RSpec.describe Gitlab::Kas::Client do expect(token).to receive(:audience=).with(described_class::JWT_AUDIENCE) end + describe '#get_connected_agents' do + let(:stub) { instance_double(Gitlab::Agent::AgentTracker::Rpc::AgentTracker::Stub) } + let(:request) { instance_double(Gitlab::Agent::AgentTracker::Rpc::GetConnectedAgentsRequest) } + let(:response) { double(Gitlab::Agent::AgentTracker::Rpc::GetConnectedAgentsResponse, agents: connected_agents) } + + let(:connected_agents) { [double] } + + subject { described_class.new.get_connected_agents(project: project) } + + before do + expect(Gitlab::Agent::AgentTracker::Rpc::AgentTracker::Stub).to receive(:new) + .with('example.kas.internal', :this_channel_is_insecure, timeout: described_class::TIMEOUT) + .and_return(stub) + + expect(Gitlab::Agent::AgentTracker::Rpc::GetConnectedAgentsRequest).to receive(:new) + .with(project_id: project.id) + .and_return(request) + + expect(stub).to receive(:get_connected_agents) + .with(request, metadata: { 'authorization' => 'bearer test-token' }) + .and_return(response) + end + + it { expect(subject).to eq(connected_agents) } + end + describe '#list_agent_config_files' do let(:stub) { instance_double(Gitlab::Agent::ConfigurationProject::Rpc::ConfigurationProject::Stub) } diff --git a/spec/lib/gitlab/middleware/sidekiq_web_static_spec.rb b/spec/lib/gitlab/middleware/sidekiq_web_static_spec.rb new file mode 100644 index 00000000000..e6815a46a56 --- /dev/null +++ b/spec/lib/gitlab/middleware/sidekiq_web_static_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Middleware::SidekiqWebStatic do + let(:app) { double(:app) } + let(:middleware) { described_class.new(app) } + let(:env) { {} } + + describe '#call' do + before do + env['HTTP_X_SENDFILE_TYPE'] = 'X-Sendfile' + env['PATH_INFO'] = path + end + + context 'with an /admin/sidekiq route' do + let(:path) { '/admin/sidekiq/javascripts/application.js'} + + it 'deletes the HTTP_X_SENDFILE_TYPE header' do + expect(app).to receive(:call) + + middleware.call(env) + + expect(env['HTTP_X_SENDFILE_TYPE']).to be_nil + end + end + + context 'with some static asset route' do + let(:path) { '/assets/test.png' } + + it 'keeps the HTTP_X_SENDFILE_TYPE header' do + expect(app).to receive(:call) + + middleware.call(env) + + expect(env['HTTP_X_SENDFILE_TYPE']).to eq('X-Sendfile') + end + end + end +end diff --git a/spec/lib/gitlab/pagination/cursor_based_keyset_spec.rb b/spec/lib/gitlab/pagination/cursor_based_keyset_spec.rb new file mode 100644 index 00000000000..ac2695977c4 --- /dev/null +++ b/spec/lib/gitlab/pagination/cursor_based_keyset_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::CursorBasedKeyset do + subject { described_class } + + describe '.available_for_type?' do + it 'returns true for Group' do + expect(subject.available_for_type?(Group.all)).to be_truthy + end + + it 'return false for other types of relations' do + expect(subject.available_for_type?(User.all)).to be_falsey + end + end + + describe '.available?' do + let(:request_context) { double('request_context', params: { order_by: order_by, sort: sort }) } + let(:cursor_based_request_context) { Gitlab::Pagination::Keyset::CursorBasedRequestContext.new(request_context) } + + context 'with order-by name asc' do + let(:order_by) { :name } + let(:sort) { :asc } + + it 'returns true for Group' do + expect(subject.available?(cursor_based_request_context, Group.all)).to be_truthy + end + + it 'return false for other types of relations' do + expect(subject.available?(cursor_based_request_context, User.all)).to be_falsey + end + end + + context 'with other order-by columns' do + let(:order_by) { :path } + let(:sort) { :asc } + + it 'returns false for Group' do + expect(subject.available?(cursor_based_request_context, Group.all)).to be_falsey + end + + it 'return false for other types of relations' do + expect(subject.available?(cursor_based_request_context, User.all)).to be_falsey + end + end + end +end diff --git a/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb b/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb index 8a26e153385..dcb8138bdde 100644 --- a/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb +++ b/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb @@ -74,7 +74,7 @@ RSpec.describe Gitlab::Pagination::GitalyKeysetPager do allow(request_context).to receive(:request).and_return(fake_request) allow(project.repository).to receive(:branch_count).and_return(branches.size) - expect(finder).to receive(:execute).with(gitaly_pagination: true).and_return(branches) + expect(finder).to receive(:execute).and_return(branches) expect(request_context).to receive(:header).with('X-Per-Page', '2') expect(request_context).to receive(:header).with('X-Page', '1') expect(request_context).to receive(:header).with('X-Next-Page', '2') @@ -99,6 +99,7 @@ RSpec.describe Gitlab::Pagination::GitalyKeysetPager do before do allow(request_context).to receive(:request).and_return(fake_request) + allow(finder).to receive(:is_a?).with(BranchesFinder) { true } expect(finder).to receive(:execute).with(gitaly_pagination: true).and_return(branches) end diff --git a/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb index 6e9e987f90c..69384e0c501 100644 --- a/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb @@ -185,4 +185,25 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do end end end + + describe "#order_direction_as_sql_string" do + let(:nulls_last_order) do + described_class.new( + attribute_name: :name, + column_expression: Project.arel_table[:name], + order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc), + reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc), + order_direction: :desc, + nullable: :nulls_last, # null values are always last + distinct: false + ) + end + + it { expect(project_name_column.order_direction_as_sql_string).to eq('ASC') } + it { expect(project_name_column.reverse.order_direction_as_sql_string).to eq('DESC') } + it { expect(project_name_lower_column.order_direction_as_sql_string).to eq('DESC') } + it { expect(project_name_lower_column.reverse.order_direction_as_sql_string).to eq('ASC') } + it { expect(nulls_last_order.order_direction_as_sql_string).to eq('DESC NULLS LAST') } + it { expect(nulls_last_order.reverse.order_direction_as_sql_string).to eq('ASC NULLS FIRST') } + end end diff --git a/spec/lib/gitlab/pagination/keyset/cursor_based_request_context_spec.rb b/spec/lib/gitlab/pagination/keyset/cursor_based_request_context_spec.rb new file mode 100644 index 00000000000..79de6f230ec --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/cursor_based_request_context_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::CursorBasedRequestContext do + let(:params) { { per_page: 2, cursor: 'eyJuYW1lIjoiR2l0TGFiIEluc3RhbmNlIiwiaWQiOiI1MiIsIl9rZCI6Im4ifQ==', order_by: :name, sort: :asc } } + let(:request) { double('request', url: 'http://localhost') } + let(:request_context) { double('request_context', header: nil, params: params, request: request) } + + describe '#per_page' do + subject(:per_page) { described_class.new(request_context).per_page } + + it { is_expected.to eq 2 } + end + + describe '#cursor' do + subject(:cursor) { described_class.new(request_context).cursor } + + it { is_expected.to eq 'eyJuYW1lIjoiR2l0TGFiIEluc3RhbmNlIiwiaWQiOiI1MiIsIl9rZCI6Im4ifQ==' } + end + + describe '#order_by' do + subject(:order_by) { described_class.new(request_context).order_by } + + it { is_expected.to eq({ name: :asc }) } + end + + describe '#apply_headers' do + let(:request) { double('request', url: "http://#{Gitlab.config.gitlab.host}/api/v4/projects?per_page=3") } + let(:params) { { per_page: 3 } } + let(:request_context) { double('request_context', header: nil, params: params, request: request) } + let(:cursor_for_next_page) { 'eyJuYW1lIjoiSDVicCIsImlkIjoiMjgiLCJfa2QiOiJuIn0=' } + + subject(:apply_headers) { described_class.new(request_context).apply_headers(cursor_for_next_page) } + + it 'sets Link header with same host/path as the original request' do + orig_uri = URI.parse(request_context.request.url) + + expect(request_context).to receive(:header).once do |name, header| + first_link, _ = /<([^>]+)>; rel="next"/.match(header).captures + + uri = URI.parse(first_link) + + expect(name).to eq('Link') + expect(uri.host).to eq(orig_uri.host) + expect(uri.path).to eq(orig_uri.path) + end + + apply_headers + end + + it 'sets Link header with a cursor to the next page' do + orig_uri = URI.parse(request_context.request.url) + + expect(request_context).to receive(:header).once do |name, header| + first_link, _ = /<([^>]+)>; rel="next"/.match(header).captures + + query = CGI.parse(URI.parse(first_link).query) + + expect(name).to eq('Link') + expect(query.except('cursor')).to eq(CGI.parse(orig_uri.query).except('cursor')) + expect(query['cursor']).to eq([cursor_for_next_page]) + end + + apply_headers + end + end +end diff --git a/spec/lib/gitlab/pagination/keyset/cursor_pager_spec.rb b/spec/lib/gitlab/pagination/keyset/cursor_pager_spec.rb new file mode 100644 index 00000000000..783e728b34c --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/cursor_pager_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::CursorPager do + let(:relation) { Group.all.order(:name, :id) } + let(:per_page) { 3 } + let(:params) { { cursor: nil, per_page: per_page } } + let(:request_context) { double('request_context', params: params) } + let(:cursor_based_request_context) { Gitlab::Pagination::Keyset::CursorBasedRequestContext.new(request_context) } + + before_all do + create_list(:group, 7) + end + + describe '#paginate' do + subject(:paginated_result) { described_class.new(cursor_based_request_context).paginate(relation) } + + it 'returns the limited relation' do + expect(paginated_result).to eq(relation.limit(per_page)) + end + end + + describe '#finalize' do + subject(:finalize) do + service = described_class.new(cursor_based_request_context) + # we need to do this because `finalize` can only be called + # after `paginate` is called. Otherwise the `paginator` object won't be set. + service.paginate(relation) + service.finalize + end + + it 'passes information about next page to request' do + cursor_for_next_page = relation.keyset_paginate(**params).cursor_for_next_page + + expect_next_instance_of(Gitlab::Pagination::Keyset::HeaderBuilder, request_context) do |builder| + expect(builder).to receive(:add_next_page_header).with({ cursor: cursor_for_next_page }) + end + + finalize + end + + context 'when retrieving the last page' do + let(:relation) { Group.where('id > ?', Group.maximum(:id) - per_page).order(:name, :id) } + + it 'does not build information about the next page' do + expect(Gitlab::Pagination::Keyset::HeaderBuilder).not_to receive(:new) + + finalize + end + end + + context 'when retrieving an empty page' do + let(:relation) { Group.where('id > ?', Group.maximum(:id) + 1).order(:name, :id) } + + it 'does not build information about the next page' do + expect(Gitlab::Pagination::Keyset::HeaderBuilder).not_to receive(:new) + + finalize + end + end + end +end diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/array_scope_columns_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/array_scope_columns_spec.rb new file mode 100644 index 00000000000..2cebf0d9473 --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/array_scope_columns_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::ArrayScopeColumns do + let(:columns) { [:relative_position, :id] } + + subject(:array_scope_columns) { described_class.new(columns) } + + it 'builds array column names' do + expect(array_scope_columns.array_aggregated_column_names).to eq(%w[array_cte_relative_position_array array_cte_id_array]) + end + + context 'when no columns are given' do + let(:columns) { [] } + + it { expect { array_scope_columns }.to raise_error /No array columns were given/ } + end +end diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/column_data_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/column_data_spec.rb new file mode 100644 index 00000000000..4f200c9096f --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/column_data_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::ColumnData do + subject(:column_data) { described_class.new('id', 'issue_id', Issue.arel_table) } + + describe '#array_aggregated_column_name' do + it { expect(column_data.array_aggregated_column_name).to eq('issues_id_array') } + end + + describe '#projection' do + it 'returns the Arel projection for the column with a new alias' do + expect(column_data.projection.to_sql).to eq('"issues"."id" AS issue_id') + end + end + + it 'accepts symbols for original_column_name and as' do + column_data = described_class.new(:id, :issue_id, Issue.arel_table) + + expect(column_data.projection.to_sql).to eq('"issues"."id" AS issue_id') + end +end diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/order_by_columns_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/order_by_columns_spec.rb new file mode 100644 index 00000000000..f4fa14e2261 --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/order_by_columns_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::OrderByColumns do + let(:columns) do + [ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :relative_position, + order_expression: Issue.arel_table[:relative_position].desc + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :id, + order_expression: Issue.arel_table[:id].desc + ) + ] + end + + subject(:order_by_columns) { described_class.new(columns, Issue.arel_table) } + + describe '#array_aggregated_column_names' do + it { expect(order_by_columns.array_aggregated_column_names).to eq(%w[issues_relative_position_array issues_id_array]) } + end + + describe '#original_column_names' do + it { expect(order_by_columns.original_column_names).to eq(%w[relative_position id]) } + end + + describe '#cursor_values' do + it 'returns the keyset pagination cursor values from the column arrays as SQL expression' do + expect(order_by_columns.cursor_values('tbl')).to eq({ + "id" => "tbl.issues_id_array[position]", + "relative_position" => "tbl.issues_relative_position_array[position]" + }) + 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 new file mode 100644 index 00000000000..4ce51e37685 --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder do + let_it_be(:two_weeks_ago) { 2.weeks.ago } + let_it_be(:three_weeks_ago) { 3.weeks.ago } + let_it_be(:four_weeks_ago) { 4.weeks.ago } + let_it_be(:five_weeks_ago) { 5.weeks.ago } + + let_it_be(:top_level_group) { create(:group) } + let_it_be(:sub_group_1) { create(:group, parent: top_level_group) } + let_it_be(:sub_group_2) { create(:group, parent: top_level_group) } + let_it_be(:sub_sub_group_1) { create(:group, parent: sub_group_2) } + + let_it_be(:project_1) { create(:project, group: top_level_group) } + let_it_be(:project_2) { create(:project, group: top_level_group) } + + let_it_be(:project_3) { create(:project, group: sub_group_1) } + let_it_be(:project_4) { create(:project, group: sub_group_2) } + + let_it_be(:project_5) { create(:project, group: sub_sub_group_1) } + + let_it_be(:issues) do + [ + create(:issue, project: project_1, created_at: three_weeks_ago, relative_position: 5), + create(:issue, project: project_1, created_at: two_weeks_ago), + create(:issue, project: project_2, created_at: two_weeks_ago, relative_position: 15), + create(:issue, project: project_2, created_at: two_weeks_ago), + create(:issue, project: project_3, created_at: four_weeks_ago), + create(:issue, project: project_4, created_at: five_weeks_ago, relative_position: 10), + create(:issue, project: project_5, created_at: four_weeks_ago) + ] + end + + shared_examples 'correct ordering examples' do + let(:iterator) do + Gitlab::Pagination::Keyset::Iterator.new( + scope: scope.limit(batch_size), + in_operator_optimization_options: in_operator_optimization_options + ) + end + + it 'returns records in correct order' do + all_records = [] + iterator.each_batch(of: batch_size) do |records| + all_records.concat(records) + end + + expect(all_records).to eq(expected_order) + end + end + + context 'when ordering by issues.id DESC' do + let(:scope) { Issue.order(id: :desc) } + let(:expected_order) { issues.sort_by(&:id).reverse } + + let(:in_operator_optimization_options) do + { + array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id), + array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) }, + finder_query: -> (id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) } + } + end + + context 'when iterating records one by one' do + let(:batch_size) { 1 } + + it_behaves_like 'correct ordering examples' + end + + context 'when iterating records with LIMIT 3' do + let(:batch_size) { 3 } + + it_behaves_like 'correct ordering examples' + end + + context 'when loading records at once' do + let(:batch_size) { issues.size + 1 } + + it_behaves_like 'correct ordering examples' + end + end + + context 'when ordering by issues.relative_position DESC NULLS LAST, id DESC' do + let(:scope) { Issue.order(order) } + let(:expected_order) { scope.to_a } + + let(:order) do + # NULLS LAST ordering requires custom Order object for keyset pagination: + # https://docs.gitlab.com/ee/development/database/keyset_pagination.html#complex-order-configuration + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :relative_position, + column_expression: Issue.arel_table[:relative_position], + order_expression: Gitlab::Database.nulls_last_order('relative_position', :desc), + reversed_order_expression: Gitlab::Database.nulls_first_order('relative_position', :asc), + order_direction: :desc, + nullable: :nulls_last, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :id, + order_expression: Issue.arel_table[:id].desc, + nullable: :not_nullable, + distinct: true + ) + ]) + end + + let(:in_operator_optimization_options) do + { + array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id), + array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) }, + finder_query: -> (_relative_position_expression, id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) } + } + end + + context 'when iterating records one by one' do + let(:batch_size) { 1 } + + it_behaves_like 'correct ordering examples' + end + + context 'when iterating records with LIMIT 3' do + let(:batch_size) { 3 } + + it_behaves_like 'correct ordering examples' + end + end + + context 'when ordering by issues.created_at DESC, issues.id ASC' do + let(:scope) { Issue.order(created_at: :desc, id: :asc) } + let(:expected_order) { issues.sort_by { |issue| [issue.created_at.to_f * -1, issue.id] } } + + let(:in_operator_optimization_options) do + { + array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id), + array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) }, + finder_query: -> (_created_at_expression, id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) } + } + end + + context 'when iterating records one by one' do + let(:batch_size) { 1 } + + it_behaves_like 'correct ordering examples' + end + + context 'when iterating records with LIMIT 3' do + let(:batch_size) { 3 } + + it_behaves_like 'correct ordering examples' + end + + context 'when loading records at once' do + let(:batch_size) { issues.size + 1 } + + it_behaves_like 'correct ordering examples' + end + end + + context 'pagination support' do + let(:scope) { Issue.order(id: :desc) } + let(:expected_order) { issues.sort_by(&:id).reverse } + + let(:options) do + { + scope: scope, + array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id), + array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) }, + finder_query: -> (id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) } + } + end + + context 'offset pagination' do + subject(:optimized_scope) { described_class.new(**options).execute } + + it 'paginates the scopes' do + first_page = optimized_scope.page(1).per(2) + expect(first_page).to eq(expected_order[0...2]) + + second_page = optimized_scope.page(2).per(2) + expect(second_page).to eq(expected_order[2...4]) + + third_page = optimized_scope.page(3).per(2) + expect(third_page).to eq(expected_order[4...6]) + end + end + + context 'keyset pagination' do + def paginator(cursor = nil) + scope.keyset_paginate(cursor: cursor, per_page: 2, keyset_order_options: options) + end + + it 'paginates correctly' do + first_page = paginator.records + expect(first_page).to eq(expected_order[0...2]) + + cursor_for_page_2 = paginator.cursor_for_next_page + + second_page = paginator(cursor_for_page_2).records + expect(second_page).to eq(expected_order[2...4]) + + cursor_for_page_3 = paginator(cursor_for_page_2).cursor_for_next_page + + third_page = paginator(cursor_for_page_3).records + expect(third_page).to eq(expected_order[4...6]) + end + end + end + + it 'raises error when unsupported scope is passed' do + scope = Issue.order(Issue.arel_table[:id].lower.desc) + + options = { + scope: scope, + array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id), + array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) }, + finder_query: -> (id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) } + } + + expect { described_class.new(**options).execute }.to raise_error(/The order on the scope does not support keyset pagination/) + end +end diff --git a/spec/lib/gitlab/pagination/keyset/order_spec.rb b/spec/lib/gitlab/pagination/keyset/order_spec.rb index b867dd533e0..3c14d91fdfd 100644 --- a/spec/lib/gitlab/pagination/keyset/order_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/order_spec.rb @@ -538,6 +538,47 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do end it_behaves_like 'cursor attribute examples' + + context 'with projections' do + context 'when additional_projections is empty' do + let(:scope) { Project.select(:id, :namespace_id) } + + subject(:sql) { order.apply_cursor_conditions(scope, { id: '100' }).to_sql } + + it 'has correct projections' do + is_expected.to include('SELECT "projects"."id", "projects"."namespace_id" FROM "projects"') + end + end + + context 'when there are additional_projections' do + let(:order) do + order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'created_at_field', + column_expression: Project.arel_table[:created_at], + order_expression: Project.arel_table[:created_at].desc, + order_direction: :desc, + distinct: false, + add_to_projections: true + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: Project.arel_table[:id].desc + ) + ]) + + order + end + + let(:scope) { Project.select(:id, :namespace_id).reorder(order) } + + subject(:sql) { order.apply_cursor_conditions(scope).to_sql } + + it 'has correct projections' do + is_expected.to include('SELECT "projects"."id", "projects"."namespace_id", "projects"."created_at" AS created_at_field FROM "projects"') + end + end + end end end end diff --git a/spec/lib/gitlab/pagination/offset_pagination_spec.rb b/spec/lib/gitlab/pagination/offset_pagination_spec.rb index f8d50fbc517..ffecbb06ff8 100644 --- a/spec/lib/gitlab/pagination/offset_pagination_spec.rb +++ b/spec/lib/gitlab/pagination/offset_pagination_spec.rb @@ -82,7 +82,7 @@ RSpec.describe Gitlab::Pagination::OffsetPagination do context 'when the api_kaminari_count_with_limit feature flag is enabled' do before do - stub_feature_flags(api_kaminari_count_with_limit: true) + stub_feature_flags(api_kaminari_count_with_limit: true, lower_relation_max_count_limit: false) end context 'when resources count is less than MAX_COUNT_LIMIT' do @@ -120,6 +120,41 @@ RSpec.describe Gitlab::Pagination::OffsetPagination do end end + context 'when lower_relation_max_count_limit FF is enabled' do + before do + stub_feature_flags(lower_relation_max_count_limit: true) + end + + it_behaves_like 'paginated response' + it_behaves_like 'response with pagination headers' + + context 'when limit is met' do + before do + stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_NEW_LOWER_LIMIT", 2) + end + + it_behaves_like 'paginated response' + + it 'does not return the X-Total and X-Total-Pages headers' do + expect_no_header('X-Total') + expect_no_header('X-Total-Pages') + expect_header('X-Per-Page', '2') + expect_header('X-Page', '1') + expect_header('X-Next-Page', '2') + expect_header('X-Prev-Page', '') + + expect_header('Link', anything) do |_key, val| + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first")) + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="next")) + expect(val).not_to include('rel="last"') + expect(val).not_to include('rel="prev"') + end + + subject.paginate(resource) + end + end + end + it 'does not return the total headers when excluding them' do expect_no_header('X-Total') expect_no_header('X-Total-Pages') diff --git a/spec/lib/gitlab/patch/legacy_database_config_spec.rb b/spec/lib/gitlab/patch/legacy_database_config_spec.rb new file mode 100644 index 00000000000..e6c0bdbf360 --- /dev/null +++ b/spec/lib/gitlab/patch/legacy_database_config_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Patch::LegacyDatabaseConfig do + it 'module is included' do + expect(Rails::Application::Configuration).to include(described_class) + end + + describe 'config/database.yml' do + let(:configuration) { Rails::Application::Configuration.new(Rails.root) } + + before do + # The `AS::ConfigurationFile` calls `read` in `def initialize` + # thus we cannot use `expect_next_instance_of` + # rubocop:disable RSpec/AnyInstanceOf + expect_any_instance_of(ActiveSupport::ConfigurationFile) + .to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml) + # rubocop:enable RSpec/AnyInstanceOf + end + + shared_examples 'hash containing main: connection name' do + it 'returns a hash containing only main:' do + database_configuration = configuration.database_configuration + + expect(database_configuration).to match( + "production" => { "main" => a_hash_including("adapter") }, + "development" => { "main" => a_hash_including("adapter" => "postgresql") }, + "test" => { "main" => a_hash_including("adapter" => "postgresql") } + ) + end + end + + context 'when a new syntax is used' do + let(:database_yml) do + <<-EOS + production: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + + development: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_development + username: postgres + password: "secure password" + host: localhost + variables: + statement_timeout: 15s + + test: &test + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_test + username: postgres + password: + host: localhost + prepared_statements: false + variables: + statement_timeout: 15s + EOS + end + + include_examples 'hash containing main: connection name' + + it 'configuration is not legacy one' do + configuration.database_configuration + + expect(configuration.uses_legacy_database_config).to eq(false) + end + end + + context 'when a legacy syntax is used' do + let(:database_yml) do + <<-EOS + production: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + + development: + adapter: postgresql + encoding: unicode + database: gitlabhq_development + username: postgres + password: "secure password" + host: localhost + variables: + statement_timeout: 15s + + test: &test + adapter: postgresql + encoding: unicode + database: gitlabhq_test + username: postgres + password: + host: localhost + prepared_statements: false + variables: + statement_timeout: 15s + EOS + end + + include_examples 'hash containing main: connection name' + + it 'configuration is legacy' do + configuration.database_configuration + + expect(configuration.uses_legacy_database_config).to eq(true) + end + end + end +end diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index d343634fb92..aa13660deb4 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -468,6 +468,7 @@ RSpec.describe Gitlab::PathRegex do end let_it_be(:git_paths) { container_paths.map { |path| path + '.git' } } + let_it_be(:git_lfs_paths) { git_paths.flat_map { |path| [path + '/info/lfs/', path + '/gitlab-lfs/'] } } let_it_be(:snippet_paths) { container_paths.grep(%r{snippets/\d}) } let_it_be(:wiki_git_paths) { (container_paths - snippet_paths).map { |path| path + '.wiki.git' } } let_it_be(:invalid_git_paths) { invalid_paths.map { |path| path + '.git' } } @@ -498,6 +499,15 @@ RSpec.describe Gitlab::PathRegex do end end + describe '.repository_git_lfs_route_regex' do + subject { %r{\A#{described_class.repository_git_lfs_route_regex}\z} } + + it 'matches the expected paths' do + expect_route_match(git_lfs_paths) + expect_no_route_match(container_paths + invalid_paths + git_paths + invalid_git_paths) + end + end + describe '.repository_wiki_git_route_regex' do subject { %r{\A#{described_class.repository_wiki_git_route_regex}\z} } diff --git a/spec/lib/gitlab/rack_attack/request_spec.rb b/spec/lib/gitlab/rack_attack/request_spec.rb new file mode 100644 index 00000000000..3be7ec17e45 --- /dev/null +++ b/spec/lib/gitlab/rack_attack/request_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::RackAttack::Request do + describe 'FILES_PATH_REGEX' do + subject { described_class::FILES_PATH_REGEX } + + it { is_expected.to match('/api/v4/projects/1/repository/files/README') } + it { is_expected.to match('/api/v4/projects/1/repository/files/README?ref=master') } + it { is_expected.to match('/api/v4/projects/1/repository/files/README/blame') } + it { is_expected.to match('/api/v4/projects/1/repository/files/README/raw') } + 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 +end diff --git a/spec/lib/gitlab/rack_attack_spec.rb b/spec/lib/gitlab/rack_attack_spec.rb index 788d2eac61f..8f03905e08d 100644 --- a/spec/lib/gitlab/rack_attack_spec.rb +++ b/spec/lib/gitlab/rack_attack_spec.rb @@ -10,12 +10,19 @@ RSpec.describe Gitlab::RackAttack, :aggregate_failures do let(:throttles) do { - throttle_unauthenticated: Gitlab::Throttle.unauthenticated_options, - throttle_authenticated_api: Gitlab::Throttle.authenticated_api_options, + throttle_unauthenticated_api: Gitlab::Throttle.options(:api, authenticated: false), + throttle_authenticated_api: Gitlab::Throttle.options(:api, authenticated: true), + throttle_unauthenticated_web: Gitlab::Throttle.unauthenticated_web_options, + throttle_authenticated_web: Gitlab::Throttle.authenticated_web_options, throttle_product_analytics_collector: { limit: 100, period: 60 }, - throttle_unauthenticated_protected_paths: Gitlab::Throttle.unauthenticated_options, - throttle_authenticated_protected_paths_api: Gitlab::Throttle.authenticated_api_options, - throttle_authenticated_protected_paths_web: Gitlab::Throttle.authenticated_web_options + throttle_unauthenticated_protected_paths: Gitlab::Throttle.protected_paths_options, + throttle_authenticated_protected_paths_api: Gitlab::Throttle.protected_paths_options, + throttle_authenticated_protected_paths_web: Gitlab::Throttle.protected_paths_options, + throttle_unauthenticated_packages_api: Gitlab::Throttle.options(:packages_api, authenticated: false), + throttle_authenticated_packages_api: Gitlab::Throttle.options(:packages_api, authenticated: true), + throttle_authenticated_git_lfs: Gitlab::Throttle.throttle_authenticated_git_lfs_options, + throttle_unauthenticated_files_api: Gitlab::Throttle.options(:files_api, authenticated: false), + throttle_authenticated_files_api: Gitlab::Throttle.options(:files_api, authenticated: true) } end @@ -84,6 +91,15 @@ RSpec.describe Gitlab::RackAttack, :aggregate_failures do end end + it 'enables dry-runs for `throttle_unauthenticated_api` and `throttle_unauthenticated_web` when selecting `throttle_unauthenticated`' do + stub_env('GITLAB_THROTTLE_DRY_RUN', 'throttle_unauthenticated') + + described_class.configure(fake_rack_attack) + + expect(fake_rack_attack).to have_received(:track).with('throttle_unauthenticated_api', throttles[:throttle_unauthenticated_api]) + expect(fake_rack_attack).to have_received(:track).with('throttle_unauthenticated_web', throttles[:throttle_unauthenticated_web]) + end + context 'user allowlist' do subject { described_class.user_allowlist } diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index f6e69aa6533..177e9d346b6 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -332,14 +332,59 @@ RSpec.describe Gitlab::ReferenceExtractor do it 'returns visible references of given type' do expect(subject.references(:issue)).to eq([issue]) end + end - it 'does not increase stateful_not_visible_counter' do - expect { subject.references(:issue) }.not_to change { subject.stateful_not_visible_counter } - end + it 'does not return any references' do + expect(subject.references(:issue)).to be_empty + end + end + + describe '#all_visible?' do + let_it_be(:user) { create(:user) } + let_it_be(:project2) { create(:project) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:issue2) { create(:issue, project: project2) } + + let(:text) { "Ref. #{issue.to_reference} and #{issue2.to_reference(project)}" } + + subject { described_class.new(project, user) } + + before do + subject.analyze(text) end - it 'increases stateful_not_visible_counter' do - expect { subject.references(:issue) }.to change { subject.stateful_not_visible_counter }.by(1) + it 'returns true if no references were parsed yet' do + expect(subject.all_visible?).to be_truthy + end + + context 'when references was already called' do + let(:membership) { [] } + + before do + membership.each { |p| p.add_developer(user) } + + subject.references(:issue) + end + + it 'returns false' do + expect(subject.all_visible?).to be_falsey + end + + context 'when user can access only some references' do + let(:membership) { [project] } + + it 'returns false' do + expect(subject.all_visible?).to be_falsey + end + end + + context 'when user can access all references' do + let(:membership) { [project, project2] } + + it 'returns true' do + expect(subject.all_visible?).to be_truthy + end + end end end end diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index c1c97e87a4c..f1b4e50b1eb 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -924,4 +924,25 @@ RSpec.describe Gitlab::Regex do it { is_expected.not_to match('/api/v4/groups/1234/packages/debian/dists/stable/Release.gpg') } it { is_expected.not_to match('/api/v4/groups/1234/packages/debian/pool/compon/a/pkg/file.name') } end + + describe '.composer_package_version_regex' do + subject { described_class.composer_package_version_regex } + + it { is_expected.to match('v1.2.3') } + it { is_expected.to match('v1.2.x') } + it { is_expected.to match('v1.2.X') } + it { is_expected.to match('1.2.3') } + it { is_expected.to match('1') } + it { is_expected.to match('v1') } + it { is_expected.to match('1.2') } + it { is_expected.to match('v1.2') } + it { is_expected.not_to match('1.2.3-beta') } + it { is_expected.not_to match('1.2.x-beta') } + it { is_expected.not_to match('1.2.X-beta') } + it { is_expected.not_to match('1.2.3-alpha.3') } + it { is_expected.not_to match('1./2.3') } + it { is_expected.not_to match('v1./2.3') } + it { is_expected.not_to match('../../../../../1.2.3') } + it { is_expected.not_to match('%2e%2e%2f1.2.3') } + end end diff --git a/spec/lib/gitlab/repository_cache/preloader_spec.rb b/spec/lib/gitlab/repository_cache/preloader_spec.rb new file mode 100644 index 00000000000..8c6618c9f8f --- /dev/null +++ b/spec/lib/gitlab/repository_cache/preloader_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::RepositoryCache::Preloader, :use_clean_rails_redis_caching do + let(:projects) { create_list(:project, 2, :repository) } + let(:repositories) { projects.map(&:repository) } + + describe '#preload' do + context 'when the values are already cached' do + before do + # Warm the cache but use a different model so they are not memoized + repos = Project.id_in(projects).order(:id).map(&:repository) + + allow(repos[0].head_tree).to receive(:readme_path).and_return('README.txt') + allow(repos[1].head_tree).to receive(:readme_path).and_return('README.md') + + repos.map(&:exists?) + repos.map(&:readme_path) + end + + it 'prevents individual cache reads for cached methods' do + expect(Rails.cache).to receive(:read_multi).once.and_call_original + + described_class.new(repositories).preload( + %i[exists? readme_path] + ) + + expect(Rails.cache).not_to receive(:read) + expect(Rails.cache).not_to receive(:write) + + expect(repositories[0].exists?).to eq(true) + expect(repositories[0].readme_path).to eq('README.txt') + + expect(repositories[1].exists?).to eq(true) + expect(repositories[1].readme_path).to eq('README.md') + end + end + + context 'when values are not cached' do + it 'reads and writes from cache individually' do + described_class.new(repositories).preload( + %i[exists? has_visible_content?] + ) + + expect(Rails.cache).to receive(:read).exactly(4).times + expect(Rails.cache).to receive(:write).exactly(4).times + + repositories.each(&:exists?) + repositories.each(&:has_visible_content?) + end + end + end +end diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index b8972f28889..27d65e14347 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -148,13 +148,13 @@ RSpec.describe Gitlab::SearchResults do end end - it 'includes merge requests from source and target projects' do + it 'does not include merge requests from source projects' do forked_project = fork_project(project, user) merge_request_2 = create(:merge_request, target_project: project, source_project: forked_project, title: 'foo') results = described_class.new(user, 'foo', Project.where(id: forked_project.id)) - expect(results.objects('merge_requests')).to include merge_request_2 + expect(results.objects('merge_requests')).not_to include merge_request_2 end describe '#merge_requests' do diff --git a/spec/lib/gitlab/seeder_spec.rb b/spec/lib/gitlab/seeder_spec.rb new file mode 100644 index 00000000000..877461a7064 --- /dev/null +++ b/spec/lib/gitlab/seeder_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Seeder do + describe '.quiet' do + it 'disables mail deliveries' do + expect(ActionMailer::Base.perform_deliveries).to eq(true) + + described_class.quiet do + expect(ActionMailer::Base.perform_deliveries).to eq(false) + end + + expect(ActionMailer::Base.perform_deliveries).to eq(true) + end + + it 'disables new note notifications' do + note = create(:note_on_issue) + + notification_service = NotificationService.new + + expect(notification_service).to receive(:send_new_note_notifications).twice + + notification_service.new_note(note) + + described_class.quiet do + expect(notification_service.new_note(note)).to eq(nil) + end + + notification_service.new_note(note) + end + end +end diff --git a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb b/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb index 3dd5ac8ee6c..e818b03cf75 100644 --- a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb +++ b/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb @@ -48,6 +48,18 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do cli.run(%w(*)) end + it 'raises an error when the arguments contain newlines' do + invalid_arguments = [ + ["foo\n"], + ["foo\r"], + %W[foo b\nar] + ] + + invalid_arguments.each do |arguments| + expect { cli.run(arguments) }.to raise_error(described_class::CommandError) + end + end + context 'with --negate flag' do it 'starts Sidekiq workers for all queues in all_queues.yml except the ones in argv' do expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(['baz']) 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 d67cb95f483..cc69a11f7f8 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 @@ -9,7 +9,14 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi described_class.new(job, queue) end - let(:job) { { 'class' => 'AuthorizedProjectsWorker', 'args' => [1], 'jid' => '123' } } + let(:wal_locations) do + { + main: '0/D525E3A8', + ci: 'AB/12345' + } + end + + let(:job) { { 'class' => 'AuthorizedProjectsWorker', 'args' => [1], 'jid' => '123', 'wal_locations' => wal_locations } } let(:queue) { 'authorized_projects' } let(:idempotency_key) do @@ -74,13 +81,39 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi context 'when there was no job in the queue yet' do it { expect(duplicate_job.check!).to eq('123') } - it "adds a key with ttl set to #{described_class::DUPLICATE_KEY_TTL}" do + it "adds a idempotency key with ttl set to #{described_class::DUPLICATE_KEY_TTL}" do expect { duplicate_job.check! } .to change { read_idempotency_key_with_ttl(idempotency_key) } .from([nil, -2]) .to(['123', be_within(1).of(described_class::DUPLICATE_KEY_TTL)]) end + context 'when wal locations is not empty' do + it "adds a existing wal locations key with ttl set to #{described_class::DUPLICATE_KEY_TTL}" do + expect { duplicate_job.check! } + .to change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :main)) } + .from([nil, -2]) + .to([wal_locations[:main], be_within(1).of(described_class::DUPLICATE_KEY_TTL)]) + .and change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :ci)) } + .from([nil, -2]) + .to([wal_locations[:ci], be_within(1).of(described_class::DUPLICATE_KEY_TTL)]) + end + 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 "does not change the existing wal locations key's TTL" do + expect { duplicate_job.check! } + .to not_change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :main)) } + .from([nil, -2]) + .and not_change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :ci)) } + .from([nil, -2]) + end + end + it "adds the idempotency key to the jobs payload" do expect { duplicate_job.check! }.to change { job['idempotency_key'] }.from(nil).to(idempotency_key) end @@ -89,6 +122,9 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi context 'when there was already a job with same arguments in the same queue' do before do set_idempotency_key(idempotency_key, 'existing-key') + wal_locations.each do |config_name, location| + set_idempotency_key(existing_wal_location_key(idempotency_key, config_name), location) + end end it { expect(duplicate_job.check!).to eq('existing-key') } @@ -99,6 +135,14 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi .from(['existing-key', -1]) end + it "does not change the existing wal locations key's TTL" do + expect { duplicate_job.check! } + .to not_change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :main)) } + .from([wal_locations[:main], -1]) + .and not_change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :ci)) } + .from([wal_locations[:ci], -1]) + end + it 'sets the existing jid' do duplicate_job.check! @@ -107,6 +151,117 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi end end + describe '#update_latest_wal_location!' do + let(:offset) { '1024' } + + before do + allow(duplicate_job).to receive(:pg_wal_lsn_diff).with(:main).and_return(offset) + allow(duplicate_job).to receive(:pg_wal_lsn_diff).with(:ci).and_return(offset) + end + + shared_examples 'updates wal location' do + it 'updates a wal location to redis with an offset' do + expect { duplicate_job.update_latest_wal_location! } + .to change { read_range_from_redis(wal_location_key(idempotency_key, :main)) } + .from(existing_wal_with_offset[:main]) + .to(new_wal_with_offset[:main]) + .and change { read_range_from_redis(wal_location_key(idempotency_key, :ci)) } + .from(existing_wal_with_offset[:ci]) + .to(new_wal_with_offset[:ci]) + end + 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 "doesn't call Sidekiq.redis" do + expect(Sidekiq).not_to receive(:redis) + + duplicate_job.update_latest_wal_location! + end + + it "doesn't update a wal location to redis with an offset" do + expect { duplicate_job.update_latest_wal_location! } + .to not_change { read_range_from_redis(wal_location_key(idempotency_key, :main)) } + .from([]) + .and not_change { read_range_from_redis(wal_location_key(idempotency_key, :ci)) } + .from([]) + end + end + + context "when the key doesn't exists in redis" do + include_examples 'updates wal location' do + let(:existing_wal_with_offset) { { main: [], ci: [] } } + let(:new_wal_with_offset) { wal_locations.transform_values { |v| [v, offset] } } + end + end + + context "when the key exists in redis" do + let(:existing_offset) { '1023'} + let(:existing_wal_locations) do + { + main: '0/D525E3NM', + ci: 'AB/111112' + } + end + + before do + rpush_to_redis_key(wal_location_key(idempotency_key, :main), existing_wal_locations[:main], existing_offset) + rpush_to_redis_key(wal_location_key(idempotency_key, :ci), existing_wal_locations[:ci], existing_offset) + end + + context "when the new offset is bigger then the existing one" do + include_examples 'updates wal location' do + let(:existing_wal_with_offset) { existing_wal_locations.transform_values { |v| [v, existing_offset] } } + let(:new_wal_with_offset) { wal_locations.transform_values { |v| [v, offset] } } + end + end + + context "when the old offset is not bigger then the existing one" do + let(:existing_offset) { offset } + + it "does not update a wal location to redis with an offset" do + expect { duplicate_job.update_latest_wal_location! } + .to not_change { read_range_from_redis(wal_location_key(idempotency_key, :main)) } + .from([existing_wal_locations[:main], existing_offset]) + .and not_change { read_range_from_redis(wal_location_key(idempotency_key, :ci)) } + .from([existing_wal_locations[:ci], existing_offset]) + end + end + end + end + + describe '#latest_wal_locations' do + context 'when job was deduplicated and wal locations were already persisted' do + before do + rpush_to_redis_key(wal_location_key(idempotency_key, :main), wal_locations[:main], 1024) + rpush_to_redis_key(wal_location_key(idempotency_key, :ci), wal_locations[:ci], 1024) + end + + it { expect(duplicate_job.latest_wal_locations).to eq(wal_locations) } + end + + context 'when job is not deduplication and wal locations were not persisted' do + it { expect(duplicate_job.latest_wal_locations).to be_empty } + 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 "doesn't call Sidekiq.redis" do + expect(Sidekiq).not_to receive(:redis) + + duplicate_job.latest_wal_locations + end + + it { expect(duplicate_job.latest_wal_locations).to eq({}) } + end + end + describe '#delete!' do context "when we didn't track the definition" do it { expect { duplicate_job.delete! }.not_to raise_error } @@ -115,14 +270,79 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi context 'when the key exists in redis' do before do set_idempotency_key(idempotency_key, 'existing-jid') + wal_locations.each do |config_name, location| + set_idempotency_key(existing_wal_location_key(idempotency_key, config_name), location) + set_idempotency_key(wal_location_key(idempotency_key, config_name), location) + end end shared_examples 'deleting the duplicate job' do - it 'removes the key from redis' do - expect { duplicate_job.delete! } - .to change { read_idempotency_key_with_ttl(idempotency_key) } - .from(['existing-jid', -1]) - .to([nil, -2]) + shared_examples 'deleting keys from redis' do |key_name| + it "removes the #{key_name} from redis" do + expect { duplicate_job.delete! } + .to change { read_idempotency_key_with_ttl(key) } + .from([from_value, -1]) + .to([nil, -2]) + end + end + + shared_examples 'does not delete key from redis' do |key_name| + it "does not remove the #{key_name} from redis" do + expect { duplicate_job.delete! } + .to not_change { read_idempotency_key_with_ttl(key) } + .from([from_value, -1]) + end + end + + it_behaves_like 'deleting keys from redis', 'idempotent key' do + let(:key) { idempotency_key } + let(:from_value) { 'existing-jid' } + end + + it_behaves_like 'deleting keys from redis', 'existing wal location keys for main database' do + let(:key) { existing_wal_location_key(idempotency_key, :main) } + let(:from_value) { wal_locations[:main] } + end + + it_behaves_like 'deleting keys from redis', 'existing wal location keys for ci database' do + let(:key) { existing_wal_location_key(idempotency_key, :ci) } + let(:from_value) { wal_locations[:ci] } + end + + it_behaves_like 'deleting keys from redis', 'latest wal location keys for main database' do + let(:key) { wal_location_key(idempotency_key, :main) } + let(:from_value) { wal_locations[:main] } + end + + it_behaves_like 'deleting keys from redis', 'latest wal location keys for ci database' do + let(:key) { wal_location_key(idempotency_key, :ci) } + let(:from_value) { wal_locations[:ci] } + 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_behaves_like 'does not delete key from redis', 'latest wal location keys for main database' do + let(:key) { existing_wal_location_key(idempotency_key, :main) } + let(:from_value) { wal_locations[:main] } + end + + it_behaves_like 'does not delete key from redis', 'latest wal location keys for ci database' do + let(:key) { existing_wal_location_key(idempotency_key, :ci) } + let(:from_value) { wal_locations[:ci] } + end + + it_behaves_like 'does not delete key from redis', 'latest wal location keys for main database' do + let(:key) { wal_location_key(idempotency_key, :main) } + let(:from_value) { wal_locations[:main] } + end + + it_behaves_like 'does not delete key from redis', 'latest wal location keys for ci database' do + let(:key) { wal_location_key(idempotency_key, :ci) } + let(:from_value) { wal_locations[:ci] } + end end end @@ -254,10 +474,22 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi end end + def existing_wal_location_key(idempotency_key, config_name) + "#{idempotency_key}:#{config_name}:existing_wal_location" + end + + def wal_location_key(idempotency_key, config_name) + "#{idempotency_key}:#{config_name}:wal_location" + end + def set_idempotency_key(key, value = '1') Sidekiq.redis { |r| r.set(key, value) } end + def rpush_to_redis_key(key, wal, offset) + Sidekiq.redis { |r| r.rpush(key, [wal, offset]) } + end + def read_idempotency_key_with_ttl(key) Sidekiq.redis do |redis| redis.pipelined do |p| @@ -266,4 +498,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi end end end + + def read_range_from_redis(key) + Sidekiq.redis do |redis| + redis.lrange(key, 0, -1) + end + end end diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb index b3d463b6f6b..9772255fc50 100644 --- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb @@ -7,6 +7,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies::UntilExecut describe '#perform' do let(:proc) { -> {} } + before do + allow(fake_duplicate_job).to receive(:latest_wal_locations).and_return( {} ) + end + it 'deletes the lock after executing' do expect(proc).to receive(:call).ordered expect(fake_duplicate_job).to receive(:delete!).ordered diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb index d45b6c5fcd1..c4045b8c63b 100644 --- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb @@ -7,6 +7,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies::UntilExecut describe '#perform' do let(:proc) { -> {} } + before do + allow(fake_duplicate_job).to receive(:latest_wal_locations).and_return( {} ) + end + it 'deletes the lock before executing' do expect(fake_duplicate_job).to receive(:delete!).ordered expect(proc).to receive(:call).ordered diff --git a/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb b/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb index 440eca10a88..abbfb9cd9fa 100644 --- a/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do +RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator, :aggregate_failures do let(:base_payload) do { "class" => "ARandomWorker", @@ -31,10 +31,35 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do end before do + # Settings aren't in the database in specs, but stored in memory, this is fine + # for these tests. + allow(Gitlab::CurrentSettings).to receive(:current_application_settings?).and_return(true) stub_const("TestSizeLimiterWorker", worker_class) end describe '#initialize' do + context 'configuration from application settings' do + let(:validator) { described_class.new(worker_class, job_payload) } + + it 'has the right defaults' do + expect(validator.mode).to eq(described_class::COMPRESS_MODE) + expect(validator.compression_threshold).to eq(described_class::DEFAULT_COMPRESSION_THRESHOLD_BYTES) + expect(validator.size_limit).to eq(described_class::DEFAULT_SIZE_LIMIT) + end + + it 'allows configuration through application settings' do + stub_application_setting( + sidekiq_job_limiter_mode: 'track', + sidekiq_job_limiter_compression_threshold_bytes: 1, + sidekiq_job_limiter_limit_bytes: 2 + ) + + expect(validator.mode).to eq(described_class::TRACK_MODE) + expect(validator.compression_threshold).to eq(1) + expect(validator.size_limit).to eq(2) + end + end + context 'when the input mode is valid' do it 'does not log a warning message' do expect(::Sidekiq.logger).not_to receive(:warn) @@ -58,7 +83,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do it 'defaults to track mode' do expect(::Sidekiq.logger).not_to receive(:warn) - validator = described_class.new(TestSizeLimiterWorker, job_payload) + validator = described_class.new(TestSizeLimiterWorker, job_payload, mode: nil) expect(validator.mode).to eql('track') end @@ -74,10 +99,12 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do end context 'when the size input is invalid' do - it 'defaults to 0 and logs a warning message' do + it 'logs a warning message' do expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter limit: -1') - described_class.new(TestSizeLimiterWorker, job_payload, size_limit: -1) + validator = described_class.new(TestSizeLimiterWorker, job_payload, size_limit: -1) + + expect(validator.size_limit).to be(0) end end @@ -85,9 +112,9 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do it 'defaults to 0' do expect(::Sidekiq.logger).not_to receive(:warn) - validator = described_class.new(TestSizeLimiterWorker, job_payload) + validator = described_class.new(TestSizeLimiterWorker, job_payload, size_limit: nil) - expect(validator.size_limit).to be(0) + expect(validator.size_limit).to be(described_class::DEFAULT_SIZE_LIMIT) end end @@ -258,6 +285,22 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do end end + context 'when job size is bigger than compression threshold and size limit is 0' do + let(:size_limit) { 0 } + let(:args) { { a: 'a' * 300 } } + let(:job) { job_payload(args) } + + it 'does not raise an exception and compresses the arguments' do + expect(::Gitlab::SidekiqMiddleware::SizeLimiter::Compressor).to receive(:compress).with( + job, Sidekiq.dump_json(args) + ).and_return('a' * 40) + + expect do + validate.call(TestSizeLimiterWorker, job) + end.not_to raise_error + end + end + context 'when the job was already compressed' do let(:job) do job_payload({ a: 'a' * 10 }) @@ -275,7 +318,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do let(:args) { { a: 'a' * 3000 } } let(:job) { job_payload(args) } - it 'does not raise an exception' do + it 'raises an exception' do expect(::Gitlab::SidekiqMiddleware::SizeLimiter::Compressor).to receive(:compress).with( job, Sidekiq.dump_json(args) ).and_return('a' * 60) @@ -284,24 +327,46 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do validate.call(TestSizeLimiterWorker, job) end.to raise_error(Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError) end + + it 'does not raise an exception when the worker allows big payloads' do + worker_class.big_payload! + + expect(::Gitlab::SidekiqMiddleware::SizeLimiter::Compressor).to receive(:compress).with( + job, Sidekiq.dump_json(args) + ).and_return('a' * 60) + + expect do + validate.call(TestSizeLimiterWorker, job) + end.not_to raise_error + end end end end - describe '#validate!' do - context 'when calling SizeLimiter.validate!' do - let(:validate) { ->(worker_clas, job) { described_class.validate!(worker_class, job) } } + describe '.validate!' do + let(:validate) { ->(worker_class, job) { described_class.validate!(worker_class, job) } } + it_behaves_like 'validate limit job payload size' do before do - stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_MODE', mode) - stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES', size_limit) - stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_COMPRESSION_THRESHOLD_BYTES', compression_threshold) + stub_application_setting( + sidekiq_job_limiter_mode: mode, + sidekiq_job_limiter_compression_threshold_bytes: compression_threshold, + sidekiq_job_limiter_limit_bytes: size_limit + ) end + end - it_behaves_like 'validate limit job payload size' + it "skips background migrations" do + expect(described_class).not_to receive(:new) + + described_class::EXEMPT_WORKER_NAMES.each do |class_name| + validate.call(class_name.constantize, job_payload) + end end + end - context 'when creating an instance with the related ENV variables' do + describe '#validate!' do + context 'when creating an instance with the related configuration variables' do let(:validate) do ->(worker_clas, job) do described_class.new(worker_class, job).validate! @@ -309,9 +374,11 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do end before do - stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_MODE', mode) - stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES', size_limit) - stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_COMPRESSION_THRESHOLD_BYTES', compression_threshold) + stub_application_setting( + sidekiq_job_limiter_mode: mode, + sidekiq_job_limiter_compression_threshold_bytes: compression_threshold, + sidekiq_job_limiter_limit_bytes: size_limit + ) end it_behaves_like 'validate limit job payload size' diff --git a/spec/lib/gitlab/sidekiq_middleware_spec.rb b/spec/lib/gitlab/sidekiq_middleware_spec.rb index 5e4e79e818e..8285cf960d2 100644 --- a/spec/lib/gitlab/sidekiq_middleware_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware_spec.rb @@ -66,12 +66,12 @@ RSpec.describe Gitlab::SidekiqMiddleware do ::Gitlab::SidekiqMiddleware::BatchLoader, ::Labkit::Middleware::Sidekiq::Server, ::Gitlab::SidekiqMiddleware::InstrumentationLogger, - ::Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, ::Gitlab::SidekiqMiddleware::AdminMode::Server, ::Gitlab::SidekiqVersioning::Middleware, ::Gitlab::SidekiqStatus::ServerMiddleware, ::Gitlab::SidekiqMiddleware::WorkerContext::Server, - ::Gitlab::SidekiqMiddleware::DuplicateJobs::Server + ::Gitlab::SidekiqMiddleware::DuplicateJobs::Server, + ::Gitlab::Database::LoadBalancing::SidekiqServerMiddleware ] end @@ -177,12 +177,12 @@ RSpec.describe Gitlab::SidekiqMiddleware do [ ::Gitlab::SidekiqMiddleware::WorkerContext::Client, ::Labkit::Middleware::Sidekiq::Client, + ::Gitlab::Database::LoadBalancing::SidekiqClientMiddleware, ::Gitlab::SidekiqMiddleware::DuplicateJobs::Client, ::Gitlab::SidekiqStatus::ClientMiddleware, ::Gitlab::SidekiqMiddleware::AdminMode::Client, ::Gitlab::SidekiqMiddleware::SizeLimiter::Client, - ::Gitlab::SidekiqMiddleware::ClientMetrics, - ::Gitlab::Database::LoadBalancing::SidekiqClientMiddleware + ::Gitlab::SidekiqMiddleware::ClientMetrics ] end diff --git a/spec/lib/gitlab/sidekiq_queue_spec.rb b/spec/lib/gitlab/sidekiq_queue_spec.rb index 2ab32657f0e..5e91282612e 100644 --- a/spec/lib/gitlab/sidekiq_queue_spec.rb +++ b/spec/lib/gitlab/sidekiq_queue_spec.rb @@ -4,15 +4,15 @@ require 'spec_helper' RSpec.describe Gitlab::SidekiqQueue, :clean_gitlab_redis_queues do around do |example| - Sidekiq::Queue.new('authorized_projects').clear + Sidekiq::Queue.new('default').clear Sidekiq::Testing.disable!(&example) - Sidekiq::Queue.new('authorized_projects').clear + Sidekiq::Queue.new('default').clear end - def add_job(user, args) + def add_job(args, user:, klass: 'AuthorizedProjectsWorker') Sidekiq::Client.push( - 'class' => 'AuthorizedProjectsWorker', - 'queue' => 'authorized_projects', + 'class' => klass, + 'queue' => 'default', 'args' => args, 'meta.user' => user.username ) @@ -20,13 +20,13 @@ RSpec.describe Gitlab::SidekiqQueue, :clean_gitlab_redis_queues do describe '#drop_jobs!' do shared_examples 'queue processing' do - let(:sidekiq_queue) { described_class.new('authorized_projects') } + let(:sidekiq_queue) { described_class.new('default') } let_it_be(:sidekiq_queue_user) { create(:user) } before do - add_job(create(:user), [1]) - add_job(sidekiq_queue_user, [2]) - add_job(sidekiq_queue_user, [3]) + add_job([1], user: create(:user)) + add_job([2], user: sidekiq_queue_user, klass: 'MergeWorker') + add_job([3], user: sidekiq_queue_user) end context 'when the queue is not processed in time' do @@ -68,11 +68,19 @@ RSpec.describe Gitlab::SidekiqQueue, :clean_gitlab_redis_queues do end end + context 'when there are jobs matching the class name' do + include_examples 'queue processing' do + let(:search_metadata) { { user: sidekiq_queue_user.username, worker_class: 'AuthorizedProjectsWorker' } } + let(:timeout_deleted) { 1 } + let(:no_timeout_deleted) { 1 } + end + end + context 'when there are no valid metadata keys passed' do it 'raises NoMetadataError' do - add_job(create(:user), [1]) + add_job([1], user: create(:user)) - expect { described_class.new('authorized_projects').drop_jobs!({ username: 'sidekiq_queue_user' }, timeout: 1) } + expect { described_class.new('default').drop_jobs!({ username: 'sidekiq_queue_user' }, timeout: 1) } .to raise_error(described_class::NoMetadataError) end end diff --git a/spec/lib/gitlab/tracking/snowplow_schema_validation_spec.rb b/spec/lib/gitlab/tracking/snowplow_schema_validation_spec.rb new file mode 100644 index 00000000000..32c601ae47d --- /dev/null +++ b/spec/lib/gitlab/tracking/snowplow_schema_validation_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Snowplow Schema Validation' do + context 'snowplow events definition' do + shared_examples 'matches schema' do + it 'conforms schema json' do + paths = Dir[Rails.root.join(yaml_path)] + + events = paths.each_with_object([]) do |path, metrics| + metrics.push( + YAML.safe_load(File.read(path), aliases: true) + ) + end + + expect(events).to all match_schema(Rails.root.join('config/events/schema.json')) + end + end + + describe 'matches the schema for CE' do + let(:yaml_path) { 'config/events/*.yml' } + + it_behaves_like 'matches schema' + end + + describe 'matches the schema for EE' do + let(:yaml_path) { 'ee/config/events/*.yml' } + + it_behaves_like 'matches schema' + end + end +end diff --git a/spec/lib/gitlab/tracking/standard_context_spec.rb b/spec/lib/gitlab/tracking/standard_context_spec.rb index a0fb6a270a5..ca7a6b6b1c3 100644 --- a/spec/lib/gitlab/tracking/standard_context_spec.rb +++ b/spec/lib/gitlab/tracking/standard_context_spec.rb @@ -87,8 +87,26 @@ RSpec.describe Gitlab::Tracking::StandardContext do end 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 user id' do + expect(snowplow_context.to_json[:data].keys).not_to include(:user_id) + end + + it 'contains namespace and project ids' do + expect(snowplow_context.to_json[:data].keys).to include(:project_id, :namespace_id) + end + + it 'accepts just project id as integer' do + expect { described_class.new(project: 1).to_context }.not_to raise_error + end + + context 'without add_namespace_and_project_to_snowplow_tracking feature' do + before 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) + end end end end diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb index 994316f38ee..02e66458f46 100644 --- a/spec/lib/gitlab/tracking_spec.rb +++ b/spec/lib/gitlab/tracking_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Gitlab::Tracking do described_class.instance_variable_set("@snowplow", nil) end - describe '.snowplow_options' do + describe '.options' do it 'returns useful client options' do expected_fields = { namespace: 'gl', @@ -22,13 +22,13 @@ RSpec.describe Gitlab::Tracking do linkClickTracking: true } - expect(subject.snowplow_options(nil)).to match(expected_fields) + expect(subject.options(nil)).to match(expected_fields) end it 'when feature flag is disabled' do stub_feature_flags(additional_snowplow_tracking: false) - expect(subject.snowplow_options(nil)).to include( + expect(subject.options(nil)).to include( formTracking: false, linkClickTracking: false ) @@ -47,7 +47,7 @@ RSpec.describe Gitlab::Tracking do it "delegates to #{klass} destination" do other_context = double(:context) - project = double(:project) + project = build_stubbed(:project) user = double(:user) expect(Gitlab::Tracking::StandardContext) diff --git a/spec/lib/gitlab/url_builder_spec.rb b/spec/lib/gitlab/url_builder_spec.rb index b359eb422d7..8e372ba795b 100644 --- a/spec/lib/gitlab/url_builder_spec.rb +++ b/spec/lib/gitlab/url_builder_spec.rb @@ -69,6 +69,27 @@ RSpec.describe Gitlab::UrlBuilder do end end + context 'when passing a compare' do + # NOTE: The Compare requires an actual repository, which isn't available + # with the `build_stubbed` strategy used by the table tests above + let_it_be(:compare) { create(:compare) } + let_it_be(:project) { compare.project } + + it 'returns the full URL' do + expect(subject.build(compare)).to eq("#{Gitlab.config.gitlab.url}/#{project.full_path}/-/compare/#{compare.base_commit_sha}...#{compare.head_commit_sha}") + end + + it 'returns only the path if only_path is given' do + expect(subject.build(compare, only_path: true)).to eq("/#{project.full_path}/-/compare/#{compare.base_commit_sha}...#{compare.head_commit_sha}") + end + + it 'returns an empty string for missing project' do + expect(compare).to receive(:project).and_return(nil) + + expect(subject.build(compare)).to eq('') + end + end + context 'when passing a commit without a project' do let(:commit) { build_stubbed(:commit) } diff --git a/spec/lib/gitlab/usage/metric_definition_spec.rb b/spec/lib/gitlab/usage/metric_definition_spec.rb index 1ae8a0881ef..6406c0b5458 100644 --- a/spec/lib/gitlab/usage/metric_definition_spec.rb +++ b/spec/lib/gitlab/usage/metric_definition_spec.rb @@ -9,7 +9,8 @@ RSpec.describe Gitlab::Usage::MetricDefinition do value_type: 'string', product_category: 'collection', product_stage: 'growth', - status: 'data_available', + status: 'active', + milestone: '14.1', default_generation: 'generation_1', key_path: 'uuid', product_group: 'group::product analytics', @@ -64,6 +65,7 @@ RSpec.describe Gitlab::Usage::MetricDefinition do :value_type | nil :value_type | 'test' :status | nil + :milestone | nil :data_category | nil :key_path | nil :product_group | nil @@ -127,9 +129,7 @@ RSpec.describe Gitlab::Usage::MetricDefinition do where(:status, :skip_validation?) do 'deprecated' | true 'removed' | true - 'data_available' | false - 'implemented' | false - 'not_used' | false + 'active' | false end with_them do @@ -191,7 +191,8 @@ RSpec.describe Gitlab::Usage::MetricDefinition do value_type: 'string', product_category: 'collection', product_stage: 'growth', - status: 'data_available', + status: 'active', + milestone: '14.1', default_generation: 'generation_1', key_path: 'counter.category.event', product_group: 'group::product analytics', diff --git a/spec/lib/gitlab/usage/metric_spec.rb b/spec/lib/gitlab/usage/metric_spec.rb index d83f59e4a7d..ea8d1a135a6 100644 --- a/spec/lib/gitlab/usage/metric_spec.rb +++ b/spec/lib/gitlab/usage/metric_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Gitlab::Usage::Metric do product_group: "group::plan", product_category: "issue_tracking", value_type: "number", - status: "data_available", + status: "active", time_frame: "all", data_source: "database", instrumentation_class: "CountIssuesMetric", diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/service_ping_features_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/service_ping_features_metric_spec.rb new file mode 100644 index 00000000000..40e9b962878 --- /dev/null +++ b/spec/lib/gitlab/usage/metrics/instrumentations/service_ping_features_metric_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Usage::Metrics::Instrumentations::ServicePingFeaturesMetric do + using RSpec::Parameterized::TableSyntax + + where(:usage_ping_features_enabled, :expected_value) do + true | true + false | false + end + + with_them do + before do + stub_application_setting(usage_ping_features_enabled: usage_ping_features_enabled) + end + + it_behaves_like 'a correct instrumented metric value', { time_frame: 'none' } + 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 d4148b57348..4996b0a0089 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 @@ -77,11 +77,22 @@ RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do let(:project_id) { 1 } let(:config_source) { :repository_source } - Dir.glob(File.join('lib', 'gitlab', 'ci', 'templates', '**'), base: Rails.root) do |template| + described_class.ci_templates.each do |template| next if described_class::TEMPLATE_TO_EVENT.key?(template) - it "does not track #{template}" do - expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to(receive(:track_event)) + 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 + + 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) described_class.track_unique_project_event(project_id: project_id, template: template, config_source: config_source) end diff --git a/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb b/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb index a1dee442131..c4a84445a01 100644 --- a/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'Code review events' do code_review_events = Gitlab::UsageDataCounters::HLLRedisCounter.events_for_category("code_review") - exceptions = %w[i_code_review_mr_diffs i_code_review_mr_single_file_diffs] + exceptions = %w[i_code_review_mr_diffs i_code_review_mr_single_file_diffs i_code_review_total_suggestions_applied i_code_review_total_suggestions_added] code_review_aggregated_events += exceptions expect(code_review_events - code_review_aggregated_events).to be_empty 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 887759014f5..427dd4a205e 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 @@ -462,6 +462,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s allow(described_class).to receive(:known_events).and_return(known_events) allow(described_class).to receive(:categories).and_return(%w(category1 category2)) + stub_const('Gitlab::UsageDataCounters::HLLRedisCounter::CATEGORIES_FOR_TOTALS', %w(category1 category2)) + described_class.track_event('event1_slot', values: entity1, time: 2.days.ago) described_class.track_event('event2_slot', values: entity2, time: 2.days.ago) described_class.track_event('event2_slot', values: entity3, time: 2.weeks.ago) diff --git a/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb index 041fc2f20a8..cd3388701fe 100644 --- a/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb @@ -206,18 +206,32 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl end describe '.track_add_suggestion_action' do - subject { described_class.track_add_suggestion_action(user: user) } + subject { described_class.track_add_suggestion_action(note: note) } + + before do + note.suggestions << build(:suggestion, id: 1, note: note) + end it_behaves_like 'a tracked merge request unique event' do - let(:action) { described_class::MR_ADD_SUGGESTION_ACTION } + let(:action) { described_class::MR_USER_ADD_SUGGESTION_ACTION } + end + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_TOTAL_ADD_SUGGESTION_ACTION } end end describe '.track_apply_suggestion_action' do - subject { described_class.track_apply_suggestion_action(user: user) } + subject { described_class.track_apply_suggestion_action(user: user, suggestions: suggestions) } + + let(:suggestions) { [build(:suggestion, id: 1, note: note)] } + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_USER_APPLY_SUGGESTION_ACTION } + end it_behaves_like 'a tracked merge request unique event' do - let(:action) { described_class::MR_APPLY_SUGGESTION_ACTION } + let(:action) { described_class::MR_TOTAL_APPLY_SUGGESTION_ACTION } end end @@ -394,4 +408,12 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl let(:action) { described_class::MR_RESOLVE_CONFLICT_ACTION } end end + + describe '.track_resolve_thread_in_issue_action' do + subject { described_class.track_resolve_thread_in_issue_action(user: user) } + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_RESOLVE_THREAD_IN_ISSUE_ACTION } + end + end end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 5d85ad5ad01..a70b68a181f 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -1089,6 +1089,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(subject[:settings][:collected_data_categories]).to eq(expected_value) end + + it 'gathers service_ping_features_enabled' do + expect(subject[:settings][:service_ping_features_enabled]).to eq(Gitlab::CurrentSettings.usage_ping_features_enabled) + end end end @@ -1279,9 +1283,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do subject { described_class.redis_hll_counters } let(:categories) { ::Gitlab::UsageDataCounters::HLLRedisCounter.categories } - let(:ineligible_total_categories) do - %w[source_code ci_secrets_management incident_management_alerts snippets terraform incident_management_oncall secure network_policies] - end context 'with redis_hll_tracking feature enabled' do it 'has all known_events' do @@ -1296,7 +1297,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do metrics = keys.map { |key| "#{key}_weekly" } + keys.map { |key| "#{key}_monthly" } - if ineligible_total_categories.exclude?(category) + if ::Gitlab::UsageDataCounters::HLLRedisCounter::CATEGORIES_FOR_TOTALS.include?(category) metrics.append("#{category}_total_unique_counts_weekly", "#{category}_total_unique_counts_monthly") end diff --git a/spec/lib/gitlab/x509/tag_spec.rb b/spec/lib/gitlab/x509/tag_spec.rb index be120aaf16a..f52880cfc52 100644 --- a/spec/lib/gitlab/x509/tag_spec.rb +++ b/spec/lib/gitlab/x509/tag_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Gitlab::X509::Tag do let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } let(:project) { create(:project, :repository) } - shared_examples 'signed tag' do + describe 'signed tag' do let(:tag) { project.repository.find_tag('v1.1.1') } let(:certificate_attributes) do { @@ -33,24 +33,10 @@ RSpec.describe Gitlab::X509::Tag do it { expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) } end - shared_examples 'unsigned tag' do + describe 'unsigned tag' do let(:tag) { project.repository.find_tag('v1.0.0') } it { expect(signature).to be_nil } end - - context 'with :get_tag_signatures enabled' do - it_behaves_like 'signed tag' - it_behaves_like 'unsigned tag' - end - - context 'with :get_tag_signatures disabled' do - before do - stub_feature_flags(get_tag_signatures: false) - end - - it_behaves_like 'signed tag' - it_behaves_like 'unsigned tag' - end end end diff --git a/spec/lib/gitlab/zentao/client_spec.rb b/spec/lib/gitlab/zentao/client_spec.rb new file mode 100644 index 00000000000..e3a335c1e89 --- /dev/null +++ b/spec/lib/gitlab/zentao/client_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Zentao::Client do + subject(:integration) { described_class.new(zentao_integration) } + + let(:zentao_integration) { create(:zentao_integration) } + let(:mock_get_products_url) { integration.send(:url, "products/#{zentao_integration.zentao_product_xid}") } + + describe '#new' do + context 'if integration is nil' do + let(:zentao_integration) { nil } + + it 'raises ConfigError' do + expect { integration }.to raise_error(described_class::ConfigError) + end + end + + context 'integration is provided' do + it 'is initialized successfully' do + expect { integration }.not_to raise_error + end + end + end + + describe '#fetch_product' do + let(:mock_headers) do + { + headers: { + 'Content-Type' => 'application/json', + 'Token' => zentao_integration.api_token + } + } + end + + context 'with valid product' do + let(:mock_response) { { 'id' => zentao_integration.zentao_product_xid } } + + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: mock_response.to_json) + end + + it 'fetches the product' do + expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq mock_response + end + end + + context 'with invalid product' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 404, body: {}.to_json) + end + + it 'fetches the empty product' do + expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq({}) + end + end + + context 'with invalid response' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: '[invalid json}') + end + + it 'fetches the empty product' do + expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq({}) + end + end + end + + describe '#ping' do + let(:mock_headers) do + { + headers: { + 'Content-Type' => 'application/json', + 'Token' => zentao_integration.api_token + } + } + end + + context 'with valid resource' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: { 'deleted' => '0' }.to_json) + end + + it 'responds with success' do + expect(integration.ping[:success]).to eq true + end + end + + context 'with deleted resource' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: { 'deleted' => '1' }.to_json) + end + + it 'responds with unsuccess' do + expect(integration.ping[:success]).to eq false + end + end + end +end diff --git a/spec/lib/marginalia_spec.rb b/spec/lib/marginalia_spec.rb index dd57cd7980e..3f39d969dbd 100644 --- a/spec/lib/marginalia_spec.rb +++ b/spec/lib/marginalia_spec.rb @@ -42,7 +42,8 @@ RSpec.describe 'Marginalia spec' do { "application" => "test", "endpoint_id" => "MarginaliaTestController#first_user", - "correlation_id" => correlation_id + "correlation_id" => correlation_id, + "db_config_name" => "main" } end @@ -51,6 +52,29 @@ RSpec.describe 'Marginalia spec' do expect(recorded.log.last).to include("#{component}:#{value}") end end + + context 'when using CI database' do + let(:component_map) do + { + "application" => "test", + "endpoint_id" => "MarginaliaTestController#first_user", + "correlation_id" => correlation_id, + "db_config_name" => "ci" + } + end + + before do |example| + skip_if_multiple_databases_not_setup + + allow(User).to receive(:connection) { Ci::CiDatabaseRecord.connection } + end + + it 'generates a query that includes the component and value' do + component_map.each do |component, value| + expect(recorded.log.last).to include("#{component}:#{value}") + end + end + end end describe 'for Sidekiq worker jobs' do @@ -79,7 +103,8 @@ RSpec.describe 'Marginalia spec' do "application" => "sidekiq", "endpoint_id" => "MarginaliaTestJob", "correlation_id" => sidekiq_job['correlation_id'], - "jid" => sidekiq_job['jid'] + "jid" => sidekiq_job['jid'], + "db_config_name" => "main" } end @@ -100,9 +125,10 @@ RSpec.describe 'Marginalia spec' do let(:component_map) do { - "application" => "sidekiq", - "endpoint_id" => "ActionMailer::MailDeliveryJob", - "jid" => delivery_job.job_id + "application" => "sidekiq", + "endpoint_id" => "ActionMailer::MailDeliveryJob", + "jid" => delivery_job.job_id, + "db_config_name" => "main" } end diff --git a/spec/lib/object_storage/config_spec.rb b/spec/lib/object_storage/config_spec.rb index 0ead2a1d269..21b8a44b3d6 100644 --- a/spec/lib/object_storage/config_spec.rb +++ b/spec/lib/object_storage/config_spec.rb @@ -188,6 +188,7 @@ RSpec.describe ObjectStorage::Config do end context 'with SSE-KMS enabled' do + it { expect(subject.aws_server_side_encryption_enabled?).to be true } it { expect(subject.server_side_encryption).to eq('AES256') } it { expect(subject.server_side_encryption_kms_key_id).to eq('arn:aws:12345') } it { expect(subject.fog_attributes.keys).to match_array(%w(x-amz-server-side-encryption x-amz-server-side-encryption-aws-kms-key-id)) } @@ -196,6 +197,7 @@ RSpec.describe ObjectStorage::Config do context 'with only server side encryption enabled' do let(:storage_options) { { server_side_encryption: 'AES256' } } + it { expect(subject.aws_server_side_encryption_enabled?).to be true } it { expect(subject.server_side_encryption).to eq('AES256') } it { expect(subject.server_side_encryption_kms_key_id).to be_nil } it { expect(subject.fog_attributes).to eq({ 'x-amz-server-side-encryption' => 'AES256' }) } @@ -204,6 +206,7 @@ RSpec.describe ObjectStorage::Config do context 'without encryption enabled' do let(:storage_options) { {} } + it { expect(subject.aws_server_side_encryption_enabled?).to be false } it { expect(subject.server_side_encryption).to be_nil } it { expect(subject.server_side_encryption_kms_key_id).to be_nil } it { expect(subject.fog_attributes).to eq({}) } @@ -215,6 +218,5 @@ RSpec.describe ObjectStorage::Config do end it { expect(subject.enabled?).to be false } - it { expect(subject.fog_attributes).to eq({}) } end end diff --git a/spec/lib/sidebars/menu_spec.rb b/spec/lib/sidebars/menu_spec.rb index 1db80351e45..eb6a68f1afd 100644 --- a/spec/lib/sidebars/menu_spec.rb +++ b/spec/lib/sidebars/menu_spec.rb @@ -198,4 +198,27 @@ RSpec.describe Sidebars::Menu do end end end + + describe '#link' do + let(:foo_path) { '/foo_path'} + + let(:foo_menu) do + ::Sidebars::MenuItem.new( + title: 'foo', + link: foo_path, + active_routes: {}, + item_id: :foo + ) + end + + it 'returns first visible menu item link' do + menu.add_item(foo_menu) + + expect(menu.link).to eq foo_path + end + + it 'returns nil if there are no visible menu items' do + expect(menu.link).to be_nil + end + end end diff --git a/spec/lib/sidebars/projects/menus/learn_gitlab_menu_spec.rb b/spec/lib/sidebars/projects/menus/learn_gitlab_menu_spec.rb index 231e5a850c2..36a76e70a48 100644 --- a/spec/lib/sidebars/projects/menus/learn_gitlab_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/learn_gitlab_menu_spec.rb @@ -4,15 +4,13 @@ require 'spec_helper' RSpec.describe Sidebars::Projects::Menus::LearnGitlabMenu do let_it_be(:project) { build(:project) } - let_it_be(:experiment_enabled) { true } - let_it_be(:tracking_category) { 'Growth::Activation::Experiment::LearnGitLabB' } + let_it_be(:learn_gitlab_enabled) { true } let(:context) do Sidebars::Projects::Context.new( current_user: nil, container: project, - learn_gitlab_experiment_enabled: experiment_enabled, - learn_gitlab_experiment_tracking_category: tracking_category + learn_gitlab_enabled: learn_gitlab_enabled ) end @@ -27,7 +25,6 @@ RSpec.describe Sidebars::Projects::Menus::LearnGitlabMenu do { class: 'home', data: { - track_property: tracking_category, track_label: 'learn_gitlab' } } @@ -46,7 +43,7 @@ RSpec.describe Sidebars::Projects::Menus::LearnGitlabMenu do end context 'when learn gitlab experiment is disabled' do - let(:experiment_enabled) { false } + let(:learn_gitlab_enabled) { false } it 'returns false' do expect(subject.render?).to eq false @@ -62,7 +59,7 @@ RSpec.describe Sidebars::Projects::Menus::LearnGitlabMenu do end context 'when learn gitlab experiment is disabled' do - let(:experiment_enabled) { false } + let(:learn_gitlab_enabled) { false } it 'returns false' do expect(subject.has_pill?).to eq false diff --git a/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb b/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb index 381842be5ab..77efe99aaa9 100644 --- a/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb @@ -49,25 +49,6 @@ RSpec.describe Sidebars::Projects::Menus::MonitorMenu do end end - describe '#link' do - let(:foo_path) { '/foo_path'} - - let(:foo_menu) do - ::Sidebars::MenuItem.new( - title: 'foo', - link: foo_path, - active_routes: {}, - item_id: :foo - ) - end - - it 'returns first visible item link' do - subject.insert_element_before(subject.renderable_items, subject.renderable_items.first.item_id, foo_menu) - - expect(subject.link).to eq foo_path - end - end - context 'Menu items' do subject { described_class.new(context).renderable_items.index { |e| e.item_id == item_id } } diff --git a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb index 9b79614db20..3079c781d73 100644 --- a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb @@ -158,5 +158,31 @@ RSpec.describe Sidebars::Projects::Menus::SettingsMenu do end end end + + describe 'Usage Quotas' do + let(:item_id) { :usage_quotas } + + describe 'with project_storage_ui feature flag enabled' do + before do + stub_feature_flags(project_storage_ui: true) + end + + specify { is_expected.not_to be_nil } + + describe 'when the user does not have access' do + let(:user) { nil } + + specify { is_expected.to be_nil } + end + end + + describe 'with project_storage_ui feature flag disabled' do + before do + stub_feature_flags(project_storage_ui: false) + end + + specify { is_expected.to be_nil } + end + end end end diff --git a/spec/lib/system_check/incoming_email_check_spec.rb b/spec/lib/system_check/incoming_email_check_spec.rb new file mode 100644 index 00000000000..710702b93fc --- /dev/null +++ b/spec/lib/system_check/incoming_email_check_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SystemCheck::IncomingEmailCheck do + before do + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production')) + end + + describe '#multi_check' do + context 'when incoming e-mail is disabled' do + before do + stub_incoming_email_setting(enabled: false) + end + + it 'does not run any checks' do + expect(SystemCheck).not_to receive(:run) + + subject.multi_check + end + end + + context 'when incoming e-mail is enabled for IMAP' do + before do + stub_incoming_email_setting(enabled: true) + end + + it 'runs IMAP and mailroom checks' do + expect(SystemCheck).to receive(:run).with('Reply by email', [ + SystemCheck::IncomingEmail::ImapAuthenticationCheck, + SystemCheck::IncomingEmail::InitdConfiguredCheck, + SystemCheck::IncomingEmail::MailRoomRunningCheck + ]) + + subject.multi_check + end + end + + context 'when incoming e-mail is enabled for Microsoft Graph' do + before do + stub_incoming_email_setting(enabled: true, inbox_method: 'microsoft_graph') + end + + it 'runs mailroom checks' do + expect(SystemCheck).to receive(:run).with('Reply by email', [ + SystemCheck::IncomingEmail::InitdConfiguredCheck, + SystemCheck::IncomingEmail::MailRoomRunningCheck + ]) + + subject.multi_check + end + end + end +end diff --git a/spec/mailers/emails/in_product_marketing_spec.rb b/spec/mailers/emails/in_product_marketing_spec.rb index 74354630ade..99beef92dea 100644 --- a/spec/mailers/emails/in_product_marketing_spec.rb +++ b/spec/mailers/emails/in_product_marketing_spec.rb @@ -23,7 +23,7 @@ RSpec.describe Emails::InProductMarketing do it 'sends to the right user with a link to unsubscribe' do aggregate_failures do - expect(subject).to deliver_to(user.notification_email) + expect(subject).to deliver_to(user.notification_email_or_default) expect(subject).to have_body_text(profile_notifications_url) end end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 8272b5d64c1..f39037cf744 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -71,7 +71,7 @@ RSpec.describe Notify do it 'is sent to the assignee as the author' do aggregate_failures do expect_sender(current_user) - expect(subject).to deliver_to(recipient.notification_email) + expect(subject).to deliver_to(recipient.notification_email_or_default) end end end @@ -710,7 +710,7 @@ RSpec.describe Notify do it 'contains all the useful information' do to_emails = subject.header[:to].addrs.map(&:address) - expect(to_emails).to eq([recipient.notification_email]) + expect(to_emails).to eq([recipient.notification_email_or_default]) is_expected.to have_subject "Request to join the #{project.full_name} project" is_expected.to have_body_text project.full_name @@ -800,8 +800,7 @@ RSpec.describe Notify do is_expected.to have_body_text project_member.invite_token is_expected.to have_link('Join now', href: invite_url(project_member.invite_token, - invite_type: Emails::Members::INITIAL_INVITE, - experiment_name: 'invite_email_preview_text')) + invite_type: Emails::Members::INITIAL_INVITE)) is_expected.to have_content("#{inviter.name} invited you to join the") is_expected.to have_content('Project details') is_expected.to have_content("What's it about?") @@ -818,13 +817,54 @@ RSpec.describe Notify do is_expected.to have_body_text project_member.invite_token is_expected.to have_link('Join now', href: invite_url(project_member.invite_token, - invite_type: Emails::Members::INITIAL_INVITE, - experiment_name: 'invite_email_preview_text')) + invite_type: Emails::Members::INITIAL_INVITE)) is_expected.to have_content('Project details') is_expected.to have_content("What's it about?") end end + context 'with invite_email_preview_text enabled', :experiment do + before do + stub_experiments(invite_email_preview_text: :control) + end + + it 'has the correct invite_url with params' do + is_expected.to have_link('Join now', + href: invite_url(project_member.invite_token, + invite_type: Emails::Members::INITIAL_INVITE, + experiment_name: 'invite_email_preview_text')) + end + + it 'tracks the sent invite' do + expect(experiment(:invite_email_preview_text)).to track(:assignment) + .with_context(actor: project_member) + .on_next_instance + + invite_email.deliver_now + end + end + + context 'with invite_email_from enabled', :experiment do + before do + stub_experiments(invite_email_from: :control) + end + + it 'has the correct invite_url with params' do + is_expected.to have_link('Join now', + href: invite_url(project_member.invite_token, + invite_type: Emails::Members::INITIAL_INVITE, + experiment_name: 'invite_email_from')) + end + + it 'tracks the sent invite' do + expect(experiment(:invite_email_from)).to track(:assignment) + .with_context(actor: project_member) + .on_next_instance + + invite_email.deliver_now + end + end + context 'when invite email sent is tracked', :snowplow do it 'tracks the sent invite' do invite_email.deliver_now @@ -838,15 +878,15 @@ RSpec.describe Notify do end end - context 'when on gitlab.com' do + context 'when mailgun events are enabled' do before do - allow(Gitlab).to receive(:dev_env_or_com?).and_return(true) + stub_application_setting(mailgun_events_enabled: true) end it 'has custom headers' do aggregate_failures do - expect(subject).to have_header('X-Mailgun-Tag', 'invite_email') - expect(subject).to have_header('X-Mailgun-Variables', { 'invite_token' => project_member.invite_token }.to_json) + expect(subject).to have_header('X-Mailgun-Tag', ::Members::Mailgun::INVITE_EMAIL_TAG) + expect(subject).to have_header('X-Mailgun-Variables', { ::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY => project_member.invite_token }.to_json) end end end @@ -1007,7 +1047,7 @@ RSpec.describe Notify do it 'is sent to the given recipient as the author' do aggregate_failures do expect_sender(note_author) - expect(subject).to deliver_to(recipient.notification_email) + expect(subject).to deliver_to(recipient.notification_email_or_default) end end @@ -1164,7 +1204,7 @@ RSpec.describe Notify do it 'is sent to the given recipient as the author' do aggregate_failures do expect_sender(note_author) - expect(subject).to deliver_to(recipient.notification_email) + expect(subject).to deliver_to(recipient.notification_email_or_default) end end @@ -1301,7 +1341,7 @@ RSpec.describe Notify do it 'contains all the useful information' do to_emails = subject.header[:to].addrs.map(&:address) - expect(to_emails).to eq([recipient.notification_email]) + expect(to_emails).to eq([recipient.notification_email_or_default]) is_expected.to have_subject "Request to join the #{group.name} group" is_expected.to have_body_text group.name diff --git a/spec/migrations/20210804150320_create_base_work_item_types_spec.rb b/spec/migrations/20210804150320_create_base_work_item_types_spec.rb index 535472f5931..9ba29637e00 100644 --- a/spec/migrations/20210804150320_create_base_work_item_types_spec.rb +++ b/spec/migrations/20210804150320_create_base_work_item_types_spec.rb @@ -6,7 +6,17 @@ require_migration!('create_base_work_item_types') RSpec.describe CreateBaseWorkItemTypes, :migration do let!(:work_item_types) { table(:work_item_types) } + after(:all) do + # Make sure base types are recreated after running the migration + # because migration specs are not run in a transaction + WorkItem::Type.delete_all + Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter.import + end + it 'creates default data' do + # Need to delete all as base types are seeded before entire test suite + WorkItem::Type.delete_all + reversible_migration do |migration| migration.before -> { # Depending on whether the migration has been run before, diff --git a/spec/migrations/20210818185845_backfill_projects_with_coverage_spec.rb b/spec/migrations/20210818185845_backfill_projects_with_coverage_spec.rb new file mode 100644 index 00000000000..d87f952b5da --- /dev/null +++ b/spec/migrations/20210818185845_backfill_projects_with_coverage_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('backfill_projects_with_coverage') + +RSpec.describe BackfillProjectsWithCoverage do + let(:projects) { table(:projects) } + let(:ci_pipelines) { table(:ci_pipelines) } + let(:ci_daily_build_group_report_results) { table(:ci_daily_build_group_report_results) } + let(:group) { table(:namespaces).create!(name: 'user', path: 'user') } + let(:project_1) { projects.create!(namespace_id: group.id) } + let(:project_2) { projects.create!(namespace_id: group.id) } + let(:pipeline_1) { ci_pipelines.create!(project_id: project_1.id) } + let(:pipeline_2) { ci_pipelines.create!(project_id: project_2.id) } + let(:pipeline_3) { ci_pipelines.create!(project_id: project_2.id) } + + describe '#up' do + before do + stub_const("#{described_class}::BATCH_SIZE", 2) + stub_const("#{described_class}::SUB_BATCH_SIZE", 1) + + ci_daily_build_group_report_results.create!( + id: 1, + project_id: project_1.id, + date: 3.days.ago, + last_pipeline_id: pipeline_1.id, + ref_path: 'main', + group_name: 'rspec', + data: { coverage: 95.0 }, + default_branch: true, + group_id: group.id + ) + + ci_daily_build_group_report_results.create!( + id: 2, + project_id: project_2.id, + date: 2.days.ago, + last_pipeline_id: pipeline_2.id, + ref_path: 'main', + group_name: 'rspec', + data: { coverage: 95.0 }, + default_branch: true, + group_id: group.id + ) + + ci_daily_build_group_report_results.create!( + id: 3, + project_id: project_2.id, + date: 1.day.ago, + last_pipeline_id: pipeline_3.id, + ref_path: 'test_branch', + group_name: 'rspec', + data: { coverage: 95.0 }, + default_branch: false, + group_id: group.id + ) + 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, 1, 2, 1) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, 3, 3, 1) + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + end + end + end + end +end diff --git a/spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb b/spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb new file mode 100644 index 00000000000..b1751216732 --- /dev/null +++ b/spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('drop_temporary_columns_and_triggers_for_ci_builds_runner_session') + +RSpec.describe DropTemporaryColumnsAndTriggersForCiBuildsRunnerSession, :migration do + let(:ci_builds_runner_session_table) { table(:ci_builds_runner_session) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(ci_builds_runner_session_table.column_names).to include('build_id_convert_to_bigint') + } + + migration.after -> { + ci_builds_runner_session_table.reset_column_information + expect(ci_builds_runner_session_table.column_names).not_to include('build_id_convert_to_bigint') + } + end + end +end diff --git a/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb b/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb new file mode 100644 index 00000000000..c23110750c3 --- /dev/null +++ b/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('upsert_base_work_item_types') + +RSpec.describe UpsertBaseWorkItemTypes, :migration do + let!(:work_item_types) { table(:work_item_types) } + + after(:all) do + # Make sure base types are recreated after running the migration + # because migration specs are not run in a transaction + WorkItem::Type.delete_all + Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter.import + end + + context 'when no default types exist' do + it 'creates default data' do + # Need to delete all as base types are seeded before entire test suite + WorkItem::Type.delete_all + + expect(work_item_types.count).to eq(0) + + reversible_migration do |migration| + migration.before -> { + # Depending on whether the migration has been run before, + # the size could be 4, or 0, so we don't set any expectations + # as we don't delete base types on migration reverse + } + + migration.after -> { + expect(work_item_types.count).to eq(4) + expect(work_item_types.all.pluck(:base_type)).to match_array(WorkItem::Type.base_types.values) + } + end + end + end + + context 'when default types already exist' do + it 'does not create default types again' do + expect(work_item_types.all.pluck(:base_type)).to match_array(WorkItem::Type.base_types.values) + + reversible_migration do |migration| + migration.before -> { + expect(work_item_types.all.pluck(:base_type)).to match_array(WorkItem::Type.base_types.values) + } + + migration.after -> { + expect(work_item_types.count).to eq(4) + expect(work_item_types.all.pluck(:base_type)).to match_array(WorkItem::Type.base_types.values) + } + end + end + end +end diff --git a/spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb b/spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb new file mode 100644 index 00000000000..1b35982c41d --- /dev/null +++ b/spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('drop_temporary_columns_and_triggers_for_ci_build_needs') + +RSpec.describe DropTemporaryColumnsAndTriggersForCiBuildNeeds do + let(:ci_build_needs_table) { table(:ci_build_needs) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(ci_build_needs_table.column_names).to include('build_id_convert_to_bigint') + } + + migration.after -> { + ci_build_needs_table.reset_column_information + expect(ci_build_needs_table.column_names).not_to include('build_id_convert_to_bigint') + } + end + end +end diff --git a/spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb b/spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb new file mode 100644 index 00000000000..8d46ba7eb58 --- /dev/null +++ b/spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('drop_temporary_columns_and_triggers_for_ci_build_trace_chunks') + +RSpec.describe DropTemporaryColumnsAndTriggersForCiBuildTraceChunks do + let(:ci_build_trace_chunks_table) { table(:ci_build_trace_chunks) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(ci_build_trace_chunks_table.column_names).to include('build_id_convert_to_bigint') + } + + migration.after -> { + ci_build_trace_chunks_table.reset_column_information + expect(ci_build_trace_chunks_table.column_names).not_to include('build_id_convert_to_bigint') + } + end + end +end diff --git a/spec/migrations/active_record/schema_spec.rb b/spec/migrations/active_record/schema_spec.rb index 4a505c51a16..042b5710dce 100644 --- a/spec/migrations/active_record/schema_spec.rb +++ b/spec/migrations/active_record/schema_spec.rb @@ -7,7 +7,7 @@ require 'spec_helper' RSpec.describe ActiveRecord::Schema, schema: :latest do let(:all_migrations) do - migrations_directories = %w[db/migrate db/post_migrate].map { |path| Rails.root.join(path).to_s } + migrations_directories = Rails.application.paths["db/migrate"].paths.map(&:to_s) migrations_paths = migrations_directories.map { |path| File.join(path, '*') } migrations = Dir[*migrations_paths] - migrations_directories diff --git a/spec/migrations/add_default_project_approval_rules_vuln_allowed_spec.rb b/spec/migrations/add_default_project_approval_rules_vuln_allowed_spec.rb new file mode 100644 index 00000000000..057e95eb158 --- /dev/null +++ b/spec/migrations/add_default_project_approval_rules_vuln_allowed_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe AddDefaultProjectApprovalRulesVulnAllowed do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:namespace) { namespaces.create!(name: 'namespace', path: 'namespace') } + let(:project) { projects.create!(name: 'project', path: 'project', namespace_id: namespace.id) } + let(:approval_project_rules) { table(:approval_project_rules) } + + it 'updates records when vulnerabilities_allowed is nil' do + records_to_migrate = 10 + + records_to_migrate.times do |i| + approval_project_rules.create!(name: "rule #{i}", project_id: project.id) + end + + expect { migrate! } + .to change { approval_project_rules.where(vulnerabilities_allowed: nil).count } + .from(records_to_migrate) + .to(0) + end + + it 'defaults vulnerabilities_allowed to 0' do + approval_project_rule = approval_project_rules.create!(name: "new rule", project_id: project.id) + + expect(approval_project_rule.vulnerabilities_allowed).to be_nil + + migrate! + + expect(approval_project_rule.reload.vulnerabilities_allowed).to eq(0) + end +end diff --git a/spec/migrations/add_triggers_to_integrations_type_new_spec.rb b/spec/migrations/add_triggers_to_integrations_type_new_spec.rb index 07845715a52..01af5884170 100644 --- a/spec/migrations/add_triggers_to_integrations_type_new_spec.rb +++ b/spec/migrations/add_triggers_to_integrations_type_new_spec.rb @@ -8,6 +8,18 @@ RSpec.describe AddTriggersToIntegrationsTypeNew do let(:migration) { described_class.new } let(:integrations) { table(:integrations) } + # This matches Gitlab::Integrations::StiType at the time the trigger was added + let(:namespaced_integrations) do + %w[ + Asana Assembla Bamboo Bugzilla Buildkite Campfire Confluence CustomIssueTracker Datadog + Discord DroneCi EmailsOnPush Ewm ExternalWiki Flowdock HangoutsChat Irker Jenkins Jira Mattermost + MattermostSlashCommands MicrosoftTeams MockCi MockMonitoring Packagist PipelinesEmail Pivotaltracker + Prometheus Pushover Redmine Slack SlackSlashCommands Teamcity UnifyCircuit WebexTeams Youtrack + + Github GitlabSlackApplication + ] + end + describe '#up' do before do migrate! @@ -15,7 +27,7 @@ RSpec.describe AddTriggersToIntegrationsTypeNew do describe 'INSERT trigger' do it 'sets `type_new` to the transformed `type` class name' do - Gitlab::Integrations::StiType.namespaced_integrations.each do |type| + namespaced_integrations.each do |type| integration = integrations.create!(type: "#{type}Service") expect(integration.reload).to have_attributes( diff --git a/spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb b/spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb new file mode 100644 index 00000000000..1a64de8d0db --- /dev/null +++ b/spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! +# require Rails.root.join('db', 'post_migrate', '20210825193652_backfill_candence_id_for_boards_scoped_to_iteration.rb') + +RSpec.describe BackfillCadenceIdForBoardsScopedToIteration, :migration do + let(:projects) { table(:projects) } + let(:namespaces) { table(:namespaces) } + let(:iterations_cadences) { table(:iterations_cadences) } + let(:boards) { table(:boards) } + + let!(:group) { namespaces.create!(name: 'group1', path: 'group1', type: 'Group') } + let!(:cadence) { iterations_cadences.create!(title: 'group cadence', group_id: group.id, start_date: Time.current) } + let!(:project) { projects.create!(name: 'gitlab1', path: 'gitlab1', namespace_id: group.id, visibility_level: 0) } + let!(:project_board1) { boards.create!(name: 'Project Dev1', project_id: project.id) } + let!(:project_board2) { boards.create!(name: 'Project Dev2', project_id: project.id, iteration_id: -4) } + let!(:project_board3) { boards.create!(name: 'Project Dev3', project_id: project.id, iteration_id: -4) } + let!(:project_board4) { boards.create!(name: 'Project Dev4', project_id: project.id, iteration_id: -4) } + + let!(:group_board1) { boards.create!(name: 'Group Dev1', group_id: group.id) } + let!(:group_board2) { boards.create!(name: 'Group Dev2', group_id: group.id, iteration_id: -4) } + let!(:group_board3) { boards.create!(name: 'Group Dev3', group_id: group.id, iteration_id: -4) } + let!(:group_board4) { boards.create!(name: 'Group Dev4', group_id: group.id, iteration_id: -4) } + + describe '#up' do + it 'schedules background migrations' do + Sidekiq::Testing.fake! do + freeze_time do + described_class.new.up + + migration = described_class::MIGRATION + + expect(migration).to be_scheduled_delayed_migration(2.minutes, 'group', 'up', group_board2.id, group_board4.id) + expect(migration).to be_scheduled_delayed_migration(2.minutes, 'project', 'up', project_board2.id, project_board4.id) + expect(BackgroundMigrationWorker.jobs.size).to eq 2 + end + end + end + + context 'in batches' do + before do + stub_const('BackfillCadenceIdForBoardsScopedToIteration::BATCH_SIZE', 2) + end + + it 'schedules background migrations' do + Sidekiq::Testing.fake! do + freeze_time do + described_class.new.up + + migration = described_class::MIGRATION + + expect(migration).to be_scheduled_delayed_migration(2.minutes, 'group', 'up', group_board2.id, group_board3.id) + expect(migration).to be_scheduled_delayed_migration(4.minutes, 'group', 'up', group_board4.id, group_board4.id) + expect(migration).to be_scheduled_delayed_migration(2.minutes, 'project', 'up', project_board2.id, project_board3.id) + expect(migration).to be_scheduled_delayed_migration(4.minutes, 'project', 'up', project_board4.id, project_board4.id) + expect(BackgroundMigrationWorker.jobs.size).to eq 4 + end + end + end + end + end + + describe '#down' do + let!(:project_board1) { boards.create!(name: 'Project Dev1', project_id: project.id) } + let!(:project_board2) { boards.create!(name: 'Project Dev2', project_id: project.id, iteration_cadence_id: cadence.id) } + let!(:project_board3) { boards.create!(name: 'Project Dev3', project_id: project.id, iteration_id: -4, iteration_cadence_id: cadence.id) } + let!(:project_board4) { boards.create!(name: 'Project Dev4', project_id: project.id, iteration_id: -4, iteration_cadence_id: cadence.id) } + + let!(:group_board1) { boards.create!(name: 'Group Dev1', group_id: group.id) } + let!(:group_board2) { boards.create!(name: 'Group Dev2', group_id: group.id, iteration_cadence_id: cadence.id) } + let!(:group_board3) { boards.create!(name: 'Group Dev3', group_id: group.id, iteration_id: -4, iteration_cadence_id: cadence.id) } + let!(:group_board4) { boards.create!(name: 'Group Dev4', group_id: group.id, iteration_id: -4, iteration_cadence_id: cadence.id) } + + it 'schedules background migrations' do + Sidekiq::Testing.fake! do + freeze_time do + described_class.new.down + + migration = described_class::MIGRATION + + expect(migration).to be_scheduled_delayed_migration(2.minutes, 'none', 'down', project_board2.id, group_board4.id) + expect(BackgroundMigrationWorker.jobs.size).to eq 1 + end + end + end + + context 'in batches' do + before do + stub_const('BackfillCadenceIdForBoardsScopedToIteration::BATCH_SIZE', 2) + end + + it 'schedules background migrations' do + Sidekiq::Testing.fake! do + freeze_time do + described_class.new.down + + migration = described_class::MIGRATION + + expect(migration).to be_scheduled_delayed_migration(2.minutes, 'none', 'down', project_board2.id, project_board3.id) + expect(migration).to be_scheduled_delayed_migration(4.minutes, 'none', 'down', project_board4.id, group_board2.id) + expect(migration).to be_scheduled_delayed_migration(6.minutes, 'none', 'down', group_board3.id, group_board4.id) + expect(BackgroundMigrationWorker.jobs.size).to eq 3 + end + end + end + end + end +end diff --git a/spec/migrations/backfill_stage_event_hash_spec.rb b/spec/migrations/backfill_stage_event_hash_spec.rb new file mode 100644 index 00000000000..cecaddcd3d4 --- /dev/null +++ b/spec/migrations/backfill_stage_event_hash_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe BackfillStageEventHash, schema: 20210730103808 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:labels) { table(:labels) } + let(:group_stages) { table(:analytics_cycle_analytics_group_stages) } + let(:project_stages) { table(:analytics_cycle_analytics_project_stages) } + let(:group_value_streams) { table(:analytics_cycle_analytics_group_value_streams) } + let(:project_value_streams) { table(:analytics_cycle_analytics_project_value_streams) } + let(:stage_event_hashes) { table(:analytics_cycle_analytics_stage_event_hashes) } + + let(:issue_created) { 1 } + let(:issue_closed) { 3 } + let(:issue_label_removed) { 9 } + let(:unknown_stage_event) { -1 } + + let(:namespace) { namespaces.create!(name: 'ns', path: 'ns', type: 'Group') } + let(:project) { projects.create!(name: 'project', path: 'project', namespace_id: namespace.id) } + let(:group_label) { labels.create!(title: 'label', type: 'GroupLabel', group_id: namespace.id) } + let(:group_value_stream) { group_value_streams.create!(name: 'group vs', group_id: namespace.id) } + let(:project_value_stream) { project_value_streams.create!(name: 'project vs', project_id: project.id) } + + let(:group_stage_1) do + group_stages.create!( + name: 'stage 1', + group_id: namespace.id, + start_event_identifier: issue_created, + end_event_identifier: issue_closed, + group_value_stream_id: group_value_stream.id + ) + end + + let(:group_stage_2) do + group_stages.create!( + name: 'stage 2', + group_id: namespace.id, + start_event_identifier: issue_created, + end_event_identifier: issue_label_removed, + end_event_label_id: group_label.id, + group_value_stream_id: group_value_stream.id + ) + end + + let(:project_stage_1) do + project_stages.create!( + name: 'stage 1', + project_id: project.id, + start_event_identifier: issue_created, + end_event_identifier: issue_closed, + project_value_stream_id: project_value_stream.id + ) + end + + let(:invalid_group_stage) do + group_stages.create!( + name: 'stage 3', + group_id: namespace.id, + start_event_identifier: issue_created, + end_event_identifier: unknown_stage_event, + group_value_stream_id: group_value_stream.id + ) + end + + describe '#up' do + it 'populates stage_event_hash_id column' do + group_stage_1 + group_stage_2 + project_stage_1 + + migrate! + + group_stage_1.reload + group_stage_2.reload + project_stage_1.reload + + expect(group_stage_1.stage_event_hash_id).not_to be_nil + expect(group_stage_2.stage_event_hash_id).not_to be_nil + expect(project_stage_1.stage_event_hash_id).not_to be_nil + + expect(stage_event_hashes.count).to eq(2) # group_stage_1 and project_stage_1 has the same hash + end + + it 'runs without problem without stages' do + expect { migrate! }.not_to raise_error + end + + context 'when invalid event identifier is discovered' do + it 'removes the stage' do + group_stage_1 + invalid_group_stage + + expect { migrate! }.not_to change { group_stage_1 } + + expect(group_stages.find_by_id(invalid_group_stage.id)).to eq(nil) + end + end + end +end diff --git a/spec/migrations/cleanup_remaining_orphan_invites_spec.rb b/spec/migrations/cleanup_remaining_orphan_invites_spec.rb new file mode 100644 index 00000000000..0eb1f5a578a --- /dev/null +++ b/spec/migrations/cleanup_remaining_orphan_invites_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! 'cleanup_remaining_orphan_invites' + +RSpec.describe CleanupRemainingOrphanInvites, :migration do + def create_member(**extra_attributes) + defaults = { + access_level: 10, + source_id: 1, + source_type: "Project", + notification_level: 0, + type: 'ProjectMember' + } + + table(:members).create!(defaults.merge(extra_attributes)) + end + + def create_user(**extra_attributes) + defaults = { projects_limit: 0 } + table(:users).create!(defaults.merge(extra_attributes)) + end + + describe '#up', :aggregate_failures do + it 'removes invite tokens for accepted records' do + record1 = create_member(invite_token: 'foo', user_id: nil) + record2 = create_member(invite_token: 'foo2', user_id: create_user(username: 'foo', email: 'foo@example.com').id) + record3 = create_member(invite_token: nil, user_id: create_user(username: 'bar', email: 'bar@example.com').id) + + migrate! + + expect(table(:members).find(record1.id).invite_token).to eq 'foo' + expect(table(:members).find(record2.id).invite_token).to eq nil + expect(table(:members).find(record3.id).invite_token).to eq nil + end + end +end diff --git a/spec/migrations/disable_job_token_scope_when_unused_spec.rb b/spec/migrations/disable_job_token_scope_when_unused_spec.rb new file mode 100644 index 00000000000..d969c98aa0f --- /dev/null +++ b/spec/migrations/disable_job_token_scope_when_unused_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe DisableJobTokenScopeWhenUnused do + let(:ci_cd_settings) { table(:project_ci_cd_settings) } + let(:links) { table(:ci_job_token_project_scope_links) } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + + let(:namespace) { namespaces.create!(name: 'test', path: 'path', type: 'Group') } + + let(:project_with_used_scope) { projects.create!(namespace_id: namespace.id) } + let!(:used_scope_settings) { ci_cd_settings.create!(project_id: project_with_used_scope.id, job_token_scope_enabled: true) } + let(:target_project) { projects.create!(namespace_id: namespace.id) } + let!(:link) { links.create!(source_project_id: project_with_used_scope.id, target_project_id: target_project.id) } + + let(:project_with_unused_scope) { projects.create!(namespace_id: namespace.id) } + let!(:unused_scope_settings) { ci_cd_settings.create!(project_id: project_with_unused_scope.id, job_token_scope_enabled: true) } + + let(:project_with_disabled_scope) { projects.create!(namespace_id: namespace.id) } + let!(:disabled_scope_settings) { ci_cd_settings.create!(project_id: project_with_disabled_scope.id, job_token_scope_enabled: false) } + + describe '#up' do + it 'sets job_token_scope_enabled to false for projects not having job token scope configured' do + migrate! + + expect(unused_scope_settings.reload.job_token_scope_enabled).to be_falsey + end + + it 'keeps the scope enabled for projects that are using it' do + migrate! + + expect(used_scope_settings.reload.job_token_scope_enabled).to be_truthy + end + + it 'keeps the scope disabled for projects having it disabled' do + migrate! + + expect(disabled_scope_settings.reload.job_token_scope_enabled).to be_falsey + end + end +end diff --git a/spec/migrations/remove_duplicate_dast_site_tokens_spec.rb b/spec/migrations/remove_duplicate_dast_site_tokens_spec.rb new file mode 100644 index 00000000000..fed9941b2a4 --- /dev/null +++ b/spec/migrations/remove_duplicate_dast_site_tokens_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe RemoveDuplicateDastSiteTokens do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:dast_site_tokens) { table(:dast_site_tokens) } + let!(:namespace) { namespaces.create!(id: 1, name: 'group', path: 'group') } + let!(:project1) { projects.create!(id: 1, namespace_id: namespace.id, path: 'project1') } + # create non duplicate dast site token + let!(:dast_site_token1) { dast_site_tokens.create!(project_id: project1.id, url: 'https://gitlab.com', token: SecureRandom.uuid) } + + context 'when duplicate dast site tokens exists' do + # create duplicate dast site token + let_it_be(:duplicate_url) { 'https://about.gitlab.com' } + + let!(:project2) { projects.create!(id: 2, namespace_id: namespace.id, path: 'project2') } + let!(:dast_site_token2) { dast_site_tokens.create!(project_id: project2.id, url: duplicate_url, token: SecureRandom.uuid) } + let!(:dast_site_token3) { dast_site_tokens.create!(project_id: project2.id, url: 'https://temp_url.com', token: SecureRandom.uuid) } + let!(:dast_site_token4) { dast_site_tokens.create!(project_id: project2.id, url: 'https://other_temp_url.com', token: SecureRandom.uuid) } + + before 'update URL to bypass uniqueness validation' do + dast_site_tokens.where(project_id: 2).update_all(url: duplicate_url) + end + + describe 'migration up' do + it 'does remove duplicated dast site tokens' do + expect(dast_site_tokens.count).to eq(4) + expect(dast_site_tokens.where(project_id: 2, url: duplicate_url).size).to eq(3) + + migrate! + + expect(dast_site_tokens.count).to eq(2) + expect(dast_site_tokens.where(project_id: 2, url: duplicate_url).size).to eq(1) + end + end + end + + context 'when duplicate dast site tokens does not exists' do + before do + dast_site_tokens.create!(project_id: 1, url: 'https://about.gitlab.com/handbook', token: SecureRandom.uuid) + end + + describe 'migration up' do + it 'does remove duplicated dast site tokens' do + expect { migrate! }.not_to change(dast_site_tokens, :count) + end + end + end +end diff --git a/spec/migrations/remove_duplicate_dast_site_tokens_with_same_token_spec.rb b/spec/migrations/remove_duplicate_dast_site_tokens_with_same_token_spec.rb new file mode 100644 index 00000000000..57d677af5cf --- /dev/null +++ b/spec/migrations/remove_duplicate_dast_site_tokens_with_same_token_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe RemoveDuplicateDastSiteTokensWithSameToken do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:dast_site_tokens) { table(:dast_site_tokens) } + let!(:namespace) { namespaces.create!(id: 1, name: 'group', path: 'group') } + let!(:project1) { projects.create!(id: 1, namespace_id: namespace.id, path: 'project1') } + # create non duplicate dast site token + let!(:dast_site_token1) { dast_site_tokens.create!(project_id: project1.id, url: 'https://gitlab.com', token: SecureRandom.uuid) } + + context 'when duplicate dast site tokens exists' do + # create duplicate dast site token + let_it_be(:duplicate_token) { 'duplicate_token' } + let_it_be(:other_duplicate_token) { 'other_duplicate_token' } + + let!(:project2) { projects.create!(id: 2, namespace_id: namespace.id, path: 'project2') } + let!(:dast_site_token2) { dast_site_tokens.create!(project_id: project2.id, url: 'https://gitlab2.com', token: duplicate_token) } + let!(:dast_site_token3) { dast_site_tokens.create!(project_id: project2.id, url: 'https://gitlab3.com', token: duplicate_token) } + let!(:dast_site_token4) { dast_site_tokens.create!(project_id: project2.id, url: 'https://gitlab4.com', token: duplicate_token) } + + let!(:project3) { projects.create!(id: 3, namespace_id: namespace.id, path: 'project3') } + let!(:dast_site_token5) { dast_site_tokens.create!(project_id: project3.id, url: 'https://gitlab2.com', token: other_duplicate_token) } + let!(:dast_site_token6) { dast_site_tokens.create!(project_id: project3.id, url: 'https://gitlab3.com', token: other_duplicate_token) } + let!(:dast_site_token7) { dast_site_tokens.create!(project_id: project3.id, url: 'https://gitlab4.com', token: other_duplicate_token) } + + describe 'migration up' do + it 'does remove duplicated dast site tokens with the same token' do + expect(dast_site_tokens.count).to eq(7) + expect(dast_site_tokens.where(token: duplicate_token).size).to eq(3) + + migrate! + + expect(dast_site_tokens.count).to eq(3) + expect(dast_site_tokens.where(token: duplicate_token).size).to eq(1) + end + end + end + + context 'when duplicate dast site tokens do not exist' do + let!(:dast_site_token5) { dast_site_tokens.create!(project_id: 1, url: 'https://gitlab5.com', token: SecureRandom.uuid) } + + describe 'migration up' do + it 'does not remove any dast site tokens' do + expect { migrate! }.not_to change(dast_site_tokens, :count) + end + end + end +end diff --git a/spec/migrations/replace_external_wiki_triggers_spec.rb b/spec/migrations/replace_external_wiki_triggers_spec.rb new file mode 100644 index 00000000000..392ef76c5ba --- /dev/null +++ b/spec/migrations/replace_external_wiki_triggers_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe ReplaceExternalWikiTriggers do + let(:migration) { described_class.new } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:integrations) { table(:integrations) } + + before do + @namespace = namespaces.create!(name: 'foo', path: 'foo') + @project = projects.create!(namespace_id: @namespace.id) + end + + def create_external_wiki_integration(**attrs) + attrs.merge!(type_info) + + integrations.create!(**attrs) + end + + def has_external_wiki + !!@project.reload.has_external_wiki + end + + shared_examples 'external wiki triggers' do + describe 'INSERT trigger' do + it 'sets `has_external_wiki` to true when active external wiki integration is inserted' do + expect do + create_external_wiki_integration(active: true, project_id: @project.id) + end.to change { has_external_wiki }.to(true) + end + + it 'does not set `has_external_wiki` to true when integration is for a different project' do + different_project = projects.create!(namespace_id: @namespace.id) + + expect do + create_external_wiki_integration(active: true, project_id: different_project.id) + end.not_to change { has_external_wiki } + end + + it 'does not set `has_external_wiki` to true when inactive external wiki integration is inserted' do + expect do + create_external_wiki_integration(active: false, project_id: @project.id) + end.not_to change { has_external_wiki } + end + + it 'does not set `has_external_wiki` to true when active other service is inserted' do + expect do + integrations.create!(type_new: 'Integrations::MyService', type: 'MyService', active: true, project_id: @project.id) + end.not_to change { has_external_wiki } + end + end + + describe 'UPDATE trigger' do + it 'sets `has_external_wiki` to true when `ExternalWikiService` is made active' do + service = create_external_wiki_integration(active: false, project_id: @project.id) + + expect do + service.update!(active: true) + end.to change { has_external_wiki }.to(true) + end + + it 'sets `has_external_wiki` to false when integration is made inactive' do + service = create_external_wiki_integration(active: true, project_id: @project.id) + + expect do + service.update!(active: false) + end.to change { has_external_wiki }.to(false) + end + + it 'does not change `has_external_wiki` when integration is for a different project' do + different_project = projects.create!(namespace_id: @namespace.id) + service = create_external_wiki_integration(active: false, project_id: different_project.id) + + expect do + service.update!(active: true) + end.not_to change { has_external_wiki } + end + end + + describe 'DELETE trigger' do + it 'sets `has_external_wiki` to false when integration is deleted' do + service = create_external_wiki_integration(active: true, project_id: @project.id) + + expect do + service.delete + end.to change { has_external_wiki }.to(false) + end + + it 'does not change `has_external_wiki` when integration is for a different project' do + different_project = projects.create!(namespace_id: @namespace.id) + service = create_external_wiki_integration(active: true, project_id: different_project.id) + + expect do + service.delete + end.not_to change { has_external_wiki } + end + end + end + + describe '#up' do + before do + migrate! + end + + context 'when integrations are created with the new STI value' do + let(:type_info) { { type_new: 'Integrations::ExternalWiki' } } + + it_behaves_like 'external wiki triggers' + end + + context 'when integrations are created with the old STI value' do + let(:type_info) { { type: 'ExternalWikiService' } } + + it_behaves_like 'external wiki triggers' + end + end + + describe '#down' do + before do + migration.up + migration.down + end + + let(:type_info) { { type: 'ExternalWikiService' } } + + it_behaves_like 'external wiki triggers' + end +end diff --git a/spec/migrations/set_default_job_token_scope_true_spec.rb b/spec/migrations/set_default_job_token_scope_true_spec.rb new file mode 100644 index 00000000000..e7c77357318 --- /dev/null +++ b/spec/migrations/set_default_job_token_scope_true_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe SetDefaultJobTokenScopeTrue, schema: 20210819153805 do + let(:ci_cd_settings) { table(:project_ci_cd_settings) } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + + let(:namespace) { namespaces.create!(name: 'test', path: 'path', type: 'Group') } + let(:project) { projects.create!(namespace_id: namespace.id) } + + describe '#up' do + it 'sets the job_token_scope_enabled default to true' do + described_class.new.up + + settings = ci_cd_settings.create!(project_id: project.id) + + expect(settings.job_token_scope_enabled).to be_truthy + end + end + + describe '#down' do + it 'sets the job_token_scope_enabled default to false' do + described_class.new.down + + settings = ci_cd_settings.create!(project_id: project.id) + + expect(settings.job_token_scope_enabled).to be_falsey + end + end +end diff --git a/spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb b/spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb new file mode 100644 index 00000000000..1fd19ee42b4 --- /dev/null +++ b/spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! 'slice_merge_request_diff_commit_migrations' + +RSpec.describe SliceMergeRequestDiffCommitMigrations, :migration do + let(:migration) { described_class.new } + + describe '#up' do + context 'when there are no jobs to process' do + it 'does nothing' do + expect(migration).not_to receive(:migrate_in) + expect(Gitlab::Database::BackgroundMigrationJob).not_to receive(:create!) + + migration.up + end + end + + context 'when there are pending jobs' do + let!(:job1) do + Gitlab::Database::BackgroundMigrationJob.create!( + class_name: described_class::MIGRATION_CLASS, + arguments: [1, 10_001] + ) + end + + let!(:job2) do + Gitlab::Database::BackgroundMigrationJob.create!( + class_name: described_class::MIGRATION_CLASS, + arguments: [10_001, 20_001] + ) + end + + it 'marks the old jobs as finished' do + migration.up + + job1.reload + job2.reload + + expect(job1).to be_succeeded + expect(job2).to be_succeeded + end + + it 'the jobs are slices into smaller ranges' do + migration.up + + new_jobs = Gitlab::Database::BackgroundMigrationJob + .for_migration_class(described_class::MIGRATION_CLASS) + .pending + .to_a + + expect(new_jobs.map(&:arguments)).to eq([ + [1, 5_001], + [5_001, 10_001], + [10_001, 15_001], + [15_001, 20_001] + ]) + end + + it 'schedules a background migration for the first job' do + expect(migration) + .to receive(:migrate_in) + .with(1.hour, described_class::STEAL_MIGRATION_CLASS, [1, 5_001]) + + migration.up + end + end + end +end diff --git a/spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb b/spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb new file mode 100644 index 00000000000..3ad0b5a93c2 --- /dev/null +++ b/spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! 'steal_merge_request_diff_commit_users_migration' + +RSpec.describe StealMergeRequestDiffCommitUsersMigration, :migration do + let(:migration) { described_class.new } + + describe '#up' do + it 'schedules a job if there are pending jobs' do + Gitlab::Database::BackgroundMigrationJob.create!( + class_name: 'MigrateMergeRequestDiffCommitUsers', + arguments: [10, 20] + ) + + expect(migration) + .to receive(:migrate_in) + .with(1.hour, 'StealMigrateMergeRequestDiffCommitUsers', [10, 20]) + + migration.up + end + + it 'does not schedule any jobs when all jobs have been completed' do + expect(migration).not_to receive(:migrate_in) + + migration.up + end + end +end diff --git a/spec/migrations/update_integrations_trigger_type_new_on_insert_spec.rb b/spec/migrations/update_integrations_trigger_type_new_on_insert_spec.rb new file mode 100644 index 00000000000..41cf35b40f4 --- /dev/null +++ b/spec/migrations/update_integrations_trigger_type_new_on_insert_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe UpdateIntegrationsTriggerTypeNewOnInsert do + let(:migration) { described_class.new } + let(:integrations) { table(:integrations) } + + shared_examples 'transforms known types' do + # This matches Gitlab::Integrations::StiType at the time the original trigger + # was added in db/migrate/20210721135638_add_triggers_to_integrations_type_new.rb + let(:namespaced_integrations) do + %w[ + Asana Assembla Bamboo Bugzilla Buildkite Campfire Confluence CustomIssueTracker Datadog + Discord DroneCi EmailsOnPush Ewm ExternalWiki Flowdock HangoutsChat Irker Jenkins Jira Mattermost + MattermostSlashCommands MicrosoftTeams MockCi MockMonitoring Packagist PipelinesEmail Pivotaltracker + Prometheus Pushover Redmine Slack SlackSlashCommands Teamcity UnifyCircuit WebexTeams Youtrack + + Github GitlabSlackApplication + ] + end + + it 'sets `type_new` to the transformed `type` class name' do + namespaced_integrations.each do |type| + integration = integrations.create!(type: "#{type}Service") + + expect(integration.reload).to have_attributes( + type: "#{type}Service", + type_new: "Integrations::#{type}" + ) + end + end + end + + describe '#up' do + before do + migrate! + end + + describe 'INSERT trigger with dynamic mapping' do + it_behaves_like 'transforms known types' + + it 'transforms unknown types if it ends in "Service"' do + integration = integrations.create!(type: 'AcmeService') + + expect(integration.reload).to have_attributes( + type: 'AcmeService', + type_new: 'Integrations::Acme' + ) + end + + it 'ignores "Service" occurring elsewhere in the type' do + integration = integrations.create!(type: 'ServiceAcmeService') + + expect(integration.reload).to have_attributes( + type: 'ServiceAcmeService', + type_new: 'Integrations::ServiceAcme' + ) + end + + it 'copies unknown types if it does not end with "Service"' do + integration = integrations.create!(type: 'Integrations::Acme') + + expect(integration.reload).to have_attributes( + type: 'Integrations::Acme', + type_new: 'Integrations::Acme' + ) + end + end + end + + describe '#down' do + before do + migration.up + migration.down + end + + describe 'INSERT trigger with static mapping' do + it_behaves_like 'transforms known types' + + it 'ignores types that are already namespaced' do + integration = integrations.create!(type: 'Integrations::Asana') + + expect(integration.reload).to have_attributes( + type: 'Integrations::Asana', + type_new: nil + ) + end + + it 'ignores types that are unknown' do + integration = integrations.create!(type: 'FooBar') + + expect(integration.reload).to have_attributes( + type: 'FooBar', + type_new: nil + ) + end + end + end +end diff --git a/spec/migrations/update_minimum_password_length_spec.rb b/spec/migrations/update_minimum_password_length_spec.rb index 02254ba1343..e40d090fd77 100644 --- a/spec/migrations/update_minimum_password_length_spec.rb +++ b/spec/migrations/update_minimum_password_length_spec.rb @@ -13,7 +13,7 @@ RSpec.describe UpdateMinimumPasswordLength do before do stub_const('ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH', 10) - allow(Devise.password_length).to receive(:min).and_return(12) + allow(Devise).to receive(:password_length).and_return(12..20) end it 'correctly migrates minimum_password_length' do diff --git a/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb b/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb new file mode 100644 index 00000000000..3e6d4ebd0a2 --- /dev/null +++ b/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Analytics::CycleAnalytics::IssueStageEvent do + it { is_expected.to validate_presence_of(:stage_event_hash_id) } + it { is_expected.to validate_presence_of(:issue_id) } + 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) } +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 new file mode 100644 index 00000000000..244c5c70286 --- /dev/null +++ b/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Analytics::CycleAnalytics::MergeRequestStageEvent do + it { is_expected.to validate_presence_of(:stage_event_hash_id) } + it { is_expected.to validate_presence_of(:merge_request_id) } + 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) } +end diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb index f9a05c720a3..efb92ddaea0 100644 --- a/spec/models/application_record_spec.rb +++ b/spec/models/application_record_spec.rb @@ -39,7 +39,7 @@ RSpec.describe ApplicationRecord do let(:suggestion_attributes) { attributes_for(:suggestion).merge!(note_id: note.id) } - shared_examples '.safe_find_or_create_by' do + describe '.safe_find_or_create_by' do it 'creates the suggestion avoiding race conditions' do existing_suggestion = double(:Suggestion) @@ -63,7 +63,7 @@ RSpec.describe ApplicationRecord do end end - shared_examples '.safe_find_or_create_by!' do + describe '.safe_find_or_create_by!' do it 'creates a record using safe_find_or_create_by' do expect(Suggestion.safe_find_or_create_by!(suggestion_attributes)) .to be_a(Suggestion) @@ -88,24 +88,6 @@ RSpec.describe ApplicationRecord do .to raise_error(ActiveRecord::RecordNotFound) end end - - context 'when optimized_safe_find_or_create_by is enabled' do - before do - stub_feature_flags(optimized_safe_find_or_create_by: true) - end - - it_behaves_like '.safe_find_or_create_by' - it_behaves_like '.safe_find_or_create_by!' - end - - context 'when optimized_safe_find_or_create_by is disabled' do - before do - stub_feature_flags(optimized_safe_find_or_create_by: false) - end - - it_behaves_like '.safe_find_or_create_by' - it_behaves_like '.safe_find_or_create_by!' - end end describe '.underscore' do @@ -164,6 +146,23 @@ RSpec.describe ApplicationRecord do end end end + + # rubocop:disable Database/MultipleDatabases + it 'increments a counter when a transaction is created in ActiveRecord' do + expect(described_class.connection.transaction_open?).to be false + + expect(::Gitlab::Database::Metrics) + .to receive(:subtransactions_increment) + .with('ActiveRecord::Base') + .once + + ActiveRecord::Base.transaction do + ActiveRecord::Base.transaction(requires_new: true) do + expect(ActiveRecord::Base.connection.transaction_open?).to be true + end + end + end + # rubocop:enable Database/MultipleDatabases end describe '.with_fast_read_statement_timeout' do @@ -236,4 +235,46 @@ RSpec.describe ApplicationRecord do end end end + + describe '.default_select_columns' do + shared_examples_for 'selects identically to the default' do + it 'generates the same sql as the default' do + expected_sql = test_model.all.to_sql + generated_sql = test_model.all.select(test_model.default_select_columns).to_sql + + expect(expected_sql).to eq(generated_sql) + end + end + + before do + ApplicationRecord.connection.execute(<<~SQL) + create table tests ( + id bigserial primary key not null, + ignore_me text + ) + SQL + end + context 'without an ignored column' do + let(:test_model) do + Class.new(ApplicationRecord) do + self.table_name = 'tests' + end + end + + it_behaves_like 'selects identically to the default' + end + + context 'with an ignored column' do + let(:test_model) do + Class.new(ApplicationRecord) do + include IgnorableColumns + self.table_name = 'tests' + + ignore_columns :ignore_me, remove_after: '2100-01-01', remove_with: '99.12' + end + end + + it_behaves_like 'selects identically to the default' + end + end end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index e9c5ffef210..3e264867703 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -7,6 +7,8 @@ RSpec.describe ApplicationSetting do subject(:setting) { described_class.create_from_defaults } + it_behaves_like 'sanitizable', :application_setting, %i[default_branch_name] + it { include(CacheableAttributes) } it { include(ApplicationSettingImplementation) } it { expect(described_class.current_without_cache).to eq(described_class.last) } @@ -79,12 +81,19 @@ RSpec.describe ApplicationSetting do 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) } it { is_expected.to validate_numericality_of(:max_artifacts_size).only_integer.is_greater_than(0) } + it { is_expected.to validate_presence_of(:max_yaml_size_bytes) } + it { is_expected.to validate_numericality_of(:max_yaml_size_bytes).only_integer.is_greater_than(0) } + it { is_expected.to validate_presence_of(:max_yaml_depth) } + it { is_expected.to validate_numericality_of(:max_yaml_depth).only_integer.is_greater_than(0) } it { is_expected.to validate_presence_of(:max_pages_size) } it 'ensures max_pages_size is an integer greater than 0 (or equal to 0 to indicate unlimited/maximum)' do is_expected.to validate_numericality_of(:max_pages_size).only_integer.is_greater_than_or_equal_to(0) .is_less_than(::Gitlab::Pages::MAX_SIZE / 1.megabyte) end + it { is_expected.to validate_presence_of(:jobs_per_stage_page_size) } + it { is_expected.to validate_numericality_of(:jobs_per_stage_page_size).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.not_to allow_value(7).for(:minimum_password_length) } it { is_expected.not_to allow_value(129).for(:minimum_password_length) } it { is_expected.not_to allow_value(nil).for(:minimum_password_length) } @@ -921,6 +930,8 @@ RSpec.describe ApplicationSetting do context 'throttle_* settings' do where(:throttle_setting) do %i[ + throttle_unauthenticated_api_requests_per_period + throttle_unauthenticated_api_period_in_seconds throttle_unauthenticated_requests_per_period throttle_unauthenticated_period_in_seconds throttle_authenticated_api_requests_per_period @@ -931,6 +942,12 @@ RSpec.describe ApplicationSetting do throttle_unauthenticated_packages_api_period_in_seconds throttle_authenticated_packages_api_requests_per_period throttle_authenticated_packages_api_period_in_seconds + throttle_unauthenticated_files_api_requests_per_period + throttle_unauthenticated_files_api_period_in_seconds + throttle_authenticated_files_api_requests_per_period + throttle_authenticated_files_api_period_in_seconds + throttle_authenticated_git_lfs_requests_per_period + throttle_authenticated_git_lfs_period_in_seconds ] end @@ -942,6 +959,20 @@ RSpec.describe ApplicationSetting do it { is_expected.not_to allow_value(nil).for(throttle_setting) } end end + + context 'sidekiq job limiter settings' do + it 'has the right defaults', :aggregate_failures do + expect(setting.sidekiq_job_limiter_mode).to eq('compress') + expect(setting.sidekiq_job_limiter_compression_threshold_bytes) + .to eq(Gitlab::SidekiqMiddleware::SizeLimiter::Validator::DEFAULT_COMPRESSION_THRESHOLD_BYTES) + expect(setting.sidekiq_job_limiter_limit_bytes) + .to eq(Gitlab::SidekiqMiddleware::SizeLimiter::Validator::DEFAULT_SIZE_LIMIT) + end + + it { is_expected.to allow_value('track').for(:sidekiq_job_limiter_mode) } + it { is_expected.to validate_numericality_of(:sidekiq_job_limiter_compression_threshold_bytes).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.to validate_numericality_of(:sidekiq_job_limiter_limit_bytes).only_integer.is_greater_than_or_equal_to(0) } + end end context 'restrict creating duplicates' do diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb index 11a3e53dd16..c1cbe61885f 100644 --- a/spec/models/bulk_imports/entity_spec.rb +++ b/spec/models/bulk_imports/entity_spec.rb @@ -154,4 +154,57 @@ RSpec.describe BulkImports::Entity, type: :model do expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed') end end + + describe '#pipelines' do + context 'when entity is group' do + it 'returns group pipelines' do + entity = build(:bulk_import_entity, :group_entity) + + expect(entity.pipelines.flatten).to include(BulkImports::Groups::Pipelines::GroupPipeline) + end + end + + context 'when entity is project' do + it 'returns project pipelines' do + entity = build(:bulk_import_entity, :project_entity) + + expect(entity.pipelines.flatten).to include(BulkImports::Projects::Pipelines::ProjectPipeline) + end + end + end + + describe '#create_pipeline_trackers!' do + context 'when entity is group' do + it 'creates trackers for group entity' 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.map(&:pipeline_name)).to include(BulkImports::Groups::Pipelines::GroupPipeline.to_s) + end + end + + context 'when entity is project' do + it 'creates trackers for project entity' 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.map(&:pipeline_name)).to include(BulkImports::Projects::Pipelines::ProjectPipeline.to_s) + end + end + end + + describe '#pipeline_exists?' do + let_it_be(:entity) { create(:bulk_import_entity, :group_entity) } + + it 'returns true when the given pipeline name exists in the pipelines list' do + expect(entity.pipeline_exists?(BulkImports::Groups::Pipelines::GroupPipeline)).to eq(true) + expect(entity.pipeline_exists?('BulkImports::Groups::Pipelines::GroupPipeline')).to eq(true) + end + + it 'returns false when the given pipeline name exists in the pipelines list' do + expect(entity.pipeline_exists?('BulkImports::Groups::Pipelines::InexistentPipeline')).to eq(false) + end + end end diff --git a/spec/models/bulk_imports/tracker_spec.rb b/spec/models/bulk_imports/tracker_spec.rb index 0f00aeb9c1d..7f0a7d4f1ae 100644 --- a/spec/models/bulk_imports/tracker_spec.rb +++ b/spec/models/bulk_imports/tracker_spec.rb @@ -66,7 +66,7 @@ RSpec.describe BulkImports::Tracker, type: :model do describe '#pipeline_class' do it 'returns the pipeline class' do - pipeline_class = BulkImports::Stage.pipelines.first[1] + pipeline_class = BulkImports::Groups::Stage.pipelines.first[1] tracker = create(:bulk_import_tracker, pipeline_name: pipeline_class) expect(tracker.pipeline_class).to eq(pipeline_class) @@ -77,7 +77,7 @@ RSpec.describe BulkImports::Tracker, type: :model do expect { tracker.pipeline_class } .to raise_error( - NameError, + BulkImports::Error, "'InexistingPipeline' is not a valid BulkImport Pipeline" ) end diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb index db956b26b6b..6dd3c40f228 100644 --- a/spec/models/ci/bridge_spec.rb +++ b/spec/models/ci/bridge_spec.rb @@ -74,18 +74,18 @@ RSpec.describe Ci::Bridge do it "schedules downstream pipeline creation when the status is #{status}" do bridge.status = status - expect(bridge).to receive(:schedule_downstream_pipeline!) - bridge.enqueue! + + expect(::Ci::CreateCrossProjectPipelineWorker.jobs.last['args']).to eq([bridge.id]) end end it "schedules downstream pipeline creation when the status is waiting for resource" do bridge.status = :waiting_for_resource - expect(bridge).to receive(:schedule_downstream_pipeline!) - bridge.enqueue_waiting_for_resource! + + expect(::Ci::CreateCrossProjectPipelineWorker.jobs.last['args']).to eq([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 26abc98656e..1e06d566c80 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1307,7 +1307,9 @@ RSpec.describe Ci::Build do shared_examples_for 'avoid deadlock' do it 'executes UPDATE in the right order' do - recorded = ActiveRecord::QueryRecorder.new { subject } + recorded = with_cross_database_modification_prevented do + ActiveRecord::QueryRecorder.new { subject } + end index_for_build = recorded.log.index { |l| l.include?("UPDATE \"ci_builds\"") } index_for_deployment = recorded.log.index { |l| l.include?("UPDATE \"deployments\"") } @@ -1322,7 +1324,9 @@ RSpec.describe Ci::Build do it_behaves_like 'avoid deadlock' it 'transits deployment status to running' do - subject + with_cross_database_modification_prevented do + subject + end expect(deployment).to be_running end @@ -1340,7 +1344,9 @@ RSpec.describe Ci::Build do it_behaves_like 'calling proper BuildFinishedWorker' it 'transits deployment status to success' do - subject + with_cross_database_modification_prevented do + subject + end expect(deployment).to be_success end @@ -1353,7 +1359,9 @@ RSpec.describe Ci::Build do it_behaves_like 'calling proper BuildFinishedWorker' it 'transits deployment status to failed' do - subject + with_cross_database_modification_prevented do + subject + end expect(deployment).to be_failed end @@ -1365,7 +1373,9 @@ RSpec.describe Ci::Build do it_behaves_like 'avoid deadlock' it 'transits deployment status to skipped' do - subject + with_cross_database_modification_prevented do + subject + end expect(deployment).to be_skipped end @@ -1378,7 +1388,9 @@ RSpec.describe Ci::Build do it_behaves_like 'calling proper BuildFinishedWorker' it 'transits deployment status to canceled' do - subject + with_cross_database_modification_prevented do + subject + end expect(deployment).to be_canceled end @@ -2632,6 +2644,10 @@ RSpec.describe Ci::Build do value: "#{Gitlab.host_with_port}/#{project.namespace.root_ancestor.path.downcase}#{DependencyProxy::URL_SUFFIX}", public: true, masked: false }, + { key: 'CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX', + value: "#{Gitlab.host_with_port}/#{project.namespace.full_path.downcase}#{DependencyProxy::URL_SUFFIX}", + public: true, + masked: false }, { key: 'CI_API_V4_URL', value: 'http://localhost/api/v4', public: true, masked: false }, { key: 'CI_PIPELINE_IID', value: pipeline.iid.to_s, public: true, masked: false }, { key: 'CI_PIPELINE_SOURCE', value: pipeline.source, public: true, masked: false }, @@ -5243,4 +5259,33 @@ RSpec.describe Ci::Build do expect(described_class.with_coverage_regex).to eq([build_with_coverage_regex]) end end + + describe '#ensure_trace_metadata!' do + it 'delegates to Ci::BuildTraceMetadata' do + expect(Ci::BuildTraceMetadata) + .to receive(:find_or_upsert_for!) + .with(build.id) + + build.ensure_trace_metadata! + end + end + + describe '#doom!' do + subject { build.doom! } + + let_it_be(:build) { create(:ci_build, :queued) } + + it 'updates status and failure_reason', :aggregate_failures do + subject + + expect(build.status).to eq("failed") + expect(build.failure_reason).to eq("data_integrity_failure") + end + + it 'drops associated pending build' do + subject + + expect(build.reload.queuing_entry).not_to be_present + end + end end diff --git a/spec/models/ci/build_trace_chunks/fog_spec.rb b/spec/models/ci/build_trace_chunks/fog_spec.rb index 21dab6fad60..bbf04ef9430 100644 --- a/spec/models/ci/build_trace_chunks/fog_spec.rb +++ b/spec/models/ci/build_trace_chunks/fog_spec.rb @@ -107,37 +107,22 @@ RSpec.describe Ci::BuildTraceChunks::Fog do let(:model) { create(:ci_build_trace_chunk, :fog_with_data, initial_data: initial_data) } let(:data) { data_store.data(model) } - context 'when ci_job_trace_force_encode is enabled' do - it 'appends ASCII data' do - data_store.append_data(model, +'hello world', 4) + it 'appends ASCII data' do + data_store.append_data(model, +'hello world', 4) - expect(data.encoding).to eq(Encoding::ASCII_8BIT) - expect(data.force_encoding(Encoding::UTF_8)).to eq('😺hello world') - end - - it 'appends UTF-8 data' do - data_store.append_data(model, +'Résumé', 4) - - expect(data.encoding).to eq(Encoding::ASCII_8BIT) - expect(data.force_encoding(Encoding::UTF_8)).to eq("😺Résumé") - end - - context 'when initial data is UTF-8' do - let(:initial_data) { +'😺' } + expect(data.encoding).to eq(Encoding::ASCII_8BIT) + expect(data.force_encoding(Encoding::UTF_8)).to eq('😺hello world') + end - it 'appends ASCII data' do - data_store.append_data(model, +'hello world', 4) + it 'appends UTF-8 data' do + data_store.append_data(model, +'Résumé', 4) - expect(data.encoding).to eq(Encoding::ASCII_8BIT) - expect(data.force_encoding(Encoding::UTF_8)).to eq('😺hello world') - end - end + expect(data.encoding).to eq(Encoding::ASCII_8BIT) + expect(data.force_encoding(Encoding::UTF_8)).to eq("😺Résumé") end - context 'when ci_job_trace_force_encode is disabled' do - before do - stub_feature_flags(ci_job_trace_force_encode: false) - end + context 'when initial data is UTF-8' do + let(:initial_data) { +'😺' } it 'appends ASCII data' do data_store.append_data(model, +'hello world', 4) @@ -145,11 +130,6 @@ RSpec.describe Ci::BuildTraceChunks::Fog do expect(data.encoding).to eq(Encoding::ASCII_8BIT) expect(data.force_encoding(Encoding::UTF_8)).to eq('😺hello world') end - - it 'throws an exception when appending UTF-8 data' do - expect(Gitlab::ErrorTracking).to receive(:track_and_raise_exception).and_call_original - expect { data_store.append_data(model, +'Résumé', 4) }.to raise_exception(Encoding::CompatibilityError) - end end end diff --git a/spec/models/ci/build_trace_metadata_spec.rb b/spec/models/ci/build_trace_metadata_spec.rb index 42b9d5d34b6..5e4645c5dc4 100644 --- a/spec/models/ci/build_trace_metadata_spec.rb +++ b/spec/models/ci/build_trace_metadata_spec.rb @@ -7,4 +7,128 @@ RSpec.describe Ci::BuildTraceMetadata do it { is_expected.to belong_to(:trace_artifact) } it { is_expected.to validate_presence_of(:build) } + it { is_expected.to validate_presence_of(:archival_attempts) } + + describe '#can_attempt_archival_now?' do + let(:metadata) do + build(:ci_build_trace_metadata, + archival_attempts: archival_attempts, + last_archival_attempt_at: last_archival_attempt_at) + end + + subject { metadata.can_attempt_archival_now? } + + context 'when archival_attempts is over the limit' do + let(:archival_attempts) { described_class::MAX_ATTEMPTS + 1 } + let(:last_archival_attempt_at) {} + + it { is_expected.to be_falsey } + end + + context 'when last_archival_attempt_at is not set' do + let(:archival_attempts) { described_class::MAX_ATTEMPTS } + let(:last_archival_attempt_at) {} + + it { is_expected.to be_truthy } + end + + context 'when last_archival_attempt_at is set' do + let(:archival_attempts) { described_class::MAX_ATTEMPTS } + let(:last_archival_attempt_at) { 6.days.ago } + + it { is_expected.to be_truthy } + end + + context 'when last_archival_attempt_at is too close' do + let(:archival_attempts) { described_class::MAX_ATTEMPTS } + let(:last_archival_attempt_at) { 1.hour.ago } + + it { is_expected.to be_falsey } + end + end + + describe '#archival_attempts_available?' do + let(:metadata) do + build(:ci_build_trace_metadata, archival_attempts: archival_attempts) + end + + subject { metadata.archival_attempts_available? } + + context 'when archival_attempts is over the limit' do + let(:archival_attempts) { described_class::MAX_ATTEMPTS + 1 } + + it { is_expected.to be_falsey } + end + + context 'when archival_attempts is at the limit' do + let(:archival_attempts) { described_class::MAX_ATTEMPTS } + + it { is_expected.to be_truthy } + end + end + + describe '#increment_archival_attempts!' do + let_it_be(:metadata) do + create(:ci_build_trace_metadata, + archival_attempts: 2, + last_archival_attempt_at: 1.day.ago) + end + + it 'increments the attempts' do + expect { metadata.increment_archival_attempts! } + .to change { metadata.reload.archival_attempts } + end + + it 'updates the last_archival_attempt_at timestamp' do + expect { metadata.increment_archival_attempts! } + .to change { metadata.reload.last_archival_attempt_at } + end + end + + describe '#track_archival!' do + let(:trace_artifact) { create(:ci_job_artifact) } + let(:metadata) { create(:ci_build_trace_metadata) } + + it 'stores the artifact id and timestamp' do + expect(metadata.trace_artifact_id).to be_nil + + metadata.track_archival!(trace_artifact.id) + metadata.reload + + expect(metadata.trace_artifact_id).to eq(trace_artifact.id) + expect(metadata.archived_at).to be_like_time(Time.current) + end + end + + describe '.find_or_upsert_for!' do + let_it_be(:build) { create(:ci_build) } + + subject(:execute) do + described_class.find_or_upsert_for!(build.id) + end + + it 'creates a new record' do + metadata = execute + + expect(metadata).to be_a(described_class) + expect(metadata.id).to eq(build.id) + expect(metadata.archival_attempts).to eq(0) + end + + context 'with existing records' do + before do + create(:ci_build_trace_metadata, + build: build, + archival_attempts: described_class::MAX_ATTEMPTS) + end + + it 'returns the existing record' do + metadata = execute + + expect(metadata).to be_a(described_class) + expect(metadata.id).to eq(build.id) + expect(metadata.archival_attempts).to eq(described_class::MAX_ATTEMPTS) + end + end + end end diff --git a/spec/models/ci/pending_build_spec.rb b/spec/models/ci/pending_build_spec.rb index 0518c9a1652..ad711f5622f 100644 --- a/spec/models/ci/pending_build_spec.rb +++ b/spec/models/ci/pending_build_spec.rb @@ -34,6 +34,47 @@ RSpec.describe Ci::PendingBuild do end end end + + describe '.for_tags' do + subject(:pending_builds) { described_class.for_tags(tag_ids) } + + let_it_be(:pending_build_with_tags) { create(:ci_pending_build, tag_ids: [1, 2]) } + let_it_be(:pending_build_without_tags) { create(:ci_pending_build) } + + context 'when tag_ids match pending builds' do + let(:tag_ids) { [1, 2] } + + it 'returns matching pending builds' do + expect(pending_builds).to contain_exactly(pending_build_with_tags, pending_build_without_tags) + end + end + + context 'when tag_ids does not match pending builds' do + let(:tag_ids) { [non_existing_record_id] } + + it 'returns matching pending builds without tags' do + expect(pending_builds).to contain_exactly(pending_build_without_tags) + end + end + + context 'when tag_ids is not provided' do + context 'with a nil value' do + let(:tag_ids) { nil } + + it 'returns matching pending builds without tags' do + expect(pending_builds).to contain_exactly(pending_build_without_tags) + end + end + + context 'with an empty array' do + let(:tag_ids) { [] } + + it 'returns matching pending builds without tags' do + expect(pending_builds).to contain_exactly(pending_build_without_tags) + end + end + end + end end describe '.upsert_from_build!' do @@ -58,7 +99,11 @@ RSpec.describe Ci::PendingBuild do end end - context 'when project does not have shared runner' do + context 'when project does not have shared runners enabled' do + before do + project.shared_runners_enabled = false + end + it 'sets instance_runners_enabled to false' do described_class.upsert_from_build!(build) @@ -69,6 +114,10 @@ RSpec.describe Ci::PendingBuild do context 'when project has shared runner' do let_it_be(:runner) { create(:ci_runner, :instance) } + before do + project.shared_runners_enabled = true + end + context 'when ci_pending_builds_maintain_shared_runners_data is enabled' do it 'sets instance_runners_enabled to true' do described_class.upsert_from_build!(build) @@ -113,5 +162,65 @@ RSpec.describe Ci::PendingBuild do end end end + + context 'when build has tags' do + let!(:build) { create(:ci_build, :tags) } + + subject(:ci_pending_build) { described_class.last } + + context 'when ci_pending_builds_maintain_tags_data is enabled' do + it 'sets tag_ids' do + described_class.upsert_from_build!(build) + + expect(ci_pending_build.tag_ids).to eq(build.tags_ids) + end + end + + context 'when ci_pending_builds_maintain_tags_data is disabled' do + before do + stub_feature_flags(ci_pending_builds_maintain_tags_data: false) + end + + it 'does not set tag_ids' do + described_class.upsert_from_build!(build) + + expect(ci_pending_build.tag_ids).to be_empty + end + end + end + + context 'when a build project is nested in a subgroup' do + let(:group) { create(:group, :with_hierarchy, depth: 2, children: 1) } + let(:project) { create(:project, namespace: group.descendants.first) } + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:build) { create(:ci_build, :created, pipeline: pipeline) } + + subject { described_class.last } + + context 'when build can be picked by a group runner' do + before do + project.group_runners_enabled = true + end + + it 'denormalizes namespace traversal ids' do + described_class.upsert_from_build!(build) + + expect(subject.namespace_traversal_ids).not_to be_empty + expect(subject.namespace_traversal_ids).to eq [group.id, project.namespace.id] + end + end + + context 'when build can not be picked by a group runner' do + before do + project.group_runners_enabled = false + end + + it 'creates an empty namespace traversal ids array' do + described_class.upsert_from_build!(build) + + expect(subject.namespace_traversal_ids).to be_empty + end + end + end end end diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb index 8de3ebb18b9..c7e1fe91b1e 100644 --- a/spec/models/ci/pipeline_schedule_spec.rb +++ b/spec/models/ci/pipeline_schedule_spec.rb @@ -107,31 +107,24 @@ RSpec.describe Ci::PipelineSchedule do describe '#set_next_run_at' do using RSpec::Parameterized::TableSyntax - where(:worker_cron, :schedule_cron, :plan_limit, :ff_enabled, :now, :result) do - '0 1 2 3 *' | '0 1 * * *' | nil | true | Time.zone.local(2021, 3, 2, 1, 0) | Time.zone.local(2022, 3, 2, 1, 0) - '0 1 2 3 *' | '0 1 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 3, 2, 1, 0) | Time.zone.local(2022, 3, 2, 1, 0) - '0 1 2 3 *' | '0 1 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | false | Time.zone.local(2021, 3, 2, 1, 0) | Time.zone.local(2022, 3, 2, 1, 0) - '*/5 * * * *' | '*/1 * * * *' | nil | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 5) - '*/5 * * * *' | '*/1 * * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0) - '*/5 * * * *' | '*/1 * * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | false | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 5) - '*/5 * * * *' | '*/1 * * * *' | (1.day.in_minutes / 10).to_i | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 10) - '*/5 * * * *' | '*/1 * * * *' | 200 | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 10) - '*/5 * * * *' | '*/1 * * * *' | 200 | false | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 5) - '*/5 * * * *' | '0 * * * *' | nil | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 5) - '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 10).to_i | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0) - '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 10).to_i | false | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 5) - '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0) - '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 2.hours.in_minutes).to_i | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 5) - '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0) - '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 10).to_i | true | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0) - '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 8).to_i | true | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0) - '*/5 * * * *' | '0 1 1 * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 1, 1, 0) | Time.zone.local(2021, 6, 1, 1, 0) - '*/9 * * * *' | '0 1 1 * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 1, 1, 9) | Time.zone.local(2021, 6, 1, 1, 0) - '*/9 * * * *' | '0 1 1 * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | false | Time.zone.local(2021, 5, 1, 1, 9) | Time.zone.local(2021, 6, 1, 1, 9) - '*/5 * * * *' | '59 14 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 1, 15, 0) | Time.zone.local(2021, 5, 2, 15, 0) - '*/5 * * * *' | '59 14 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | false | Time.zone.local(2021, 5, 1, 15, 0) | Time.zone.local(2021, 5, 2, 15, 0) - '*/5 * * * *' | '45 21 1 2 *' | (1.day.in_minutes / 5).to_i | true | Time.zone.local(2021, 2, 1, 21, 45) | Time.zone.local(2022, 2, 1, 21, 45) - '*/5 * * * *' | '45 21 1 2 *' | (1.day.in_minutes / 5).to_i | false | Time.zone.local(2021, 2, 1, 21, 45) | Time.zone.local(2022, 2, 1, 21, 50) + where(:worker_cron, :schedule_cron, :plan_limit, :now, :result) do + '0 1 2 3 *' | '0 1 * * *' | nil | Time.zone.local(2021, 3, 2, 1, 0) | Time.zone.local(2022, 3, 2, 1, 0) + '0 1 2 3 *' | '0 1 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 3, 2, 1, 0) | Time.zone.local(2022, 3, 2, 1, 0) + '*/5 * * * *' | '*/1 * * * *' | nil | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 5) + '*/5 * * * *' | '*/1 * * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0) + '*/5 * * * *' | '*/1 * * * *' | (1.day.in_minutes / 10).to_i | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 10) + '*/5 * * * *' | '*/1 * * * *' | 200 | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 10) + '*/5 * * * *' | '0 * * * *' | nil | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 5) + '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 10).to_i | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0) + '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0) + '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 2.hours.in_minutes).to_i | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 5) + '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0) + '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 10).to_i | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0) + '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 8).to_i | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0) + '*/5 * * * *' | '0 1 1 * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 1, 1, 0) | Time.zone.local(2021, 6, 1, 1, 0) + '*/9 * * * *' | '0 1 1 * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 1, 1, 9) | Time.zone.local(2021, 6, 1, 1, 0) + '*/5 * * * *' | '59 14 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 1, 15, 0) | Time.zone.local(2021, 5, 2, 15, 0) + '*/5 * * * *' | '45 21 1 2 *' | (1.day.in_minutes / 5).to_i | Time.zone.local(2021, 2, 1, 21, 45) | Time.zone.local(2022, 2, 1, 21, 45) end with_them do @@ -143,7 +136,6 @@ RSpec.describe Ci::PipelineSchedule do end create(:plan_limits, :default_plan, ci_daily_pipeline_schedule_triggers: plan_limit) if plan_limit - stub_feature_flags(ci_daily_limit_for_pipeline_schedules: false) unless ff_enabled # Setting this here to override initial save with the current time pipeline_schedule.next_run_at = now diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index da89eccc3b2..1007d64438f 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -183,6 +183,28 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end end + describe '.where_not_sha' do + let_it_be(:pipeline) { create(:ci_pipeline, sha: 'abcx') } + let_it_be(:pipeline_2) { create(:ci_pipeline, sha: 'abc') } + + let(:sha) { 'abc' } + + subject { described_class.where_not_sha(sha) } + + it 'returns the pipeline without the specified sha' do + is_expected.to contain_exactly(pipeline) + end + + context 'when argument is array' do + let(:sha) { %w[abc abcx] } + + it 'returns the pipelines without the specified shas' do + pipeline_3 = create(:ci_pipeline, sha: 'abcy') + is_expected.to contain_exactly(pipeline_3) + end + end + end + describe '.for_source_sha' do subject { described_class.for_source_sha(source_sha) } @@ -2015,16 +2037,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do it 'returns external pull request modified paths' do expect(pipeline.modified_paths).to match(external_pull_request.modified_paths) end - - context 'when the FF ci_modified_paths_of_external_prs is disabled' do - before do - stub_feature_flags(ci_modified_paths_of_external_prs: false) - end - - it 'returns nil' do - expect(pipeline.modified_paths).to be_nil - end - end end end @@ -4524,51 +4536,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do subject(:reset_bridge) { pipeline.reset_source_bridge!(project.owner) } - # This whole block will be removed by https://gitlab.com/gitlab-org/gitlab/-/issues/329194 - # It contains some duplicate checks. - context 'when the FF ci_reset_bridge_with_subsequent_jobs is disabled' do - before do - stub_feature_flags(ci_reset_bridge_with_subsequent_jobs: false) - end - - context 'when the pipeline is a child pipeline and the bridge is depended' do - let!(:parent_pipeline) { create(:ci_pipeline) } - let!(:bridge) { create_bridge(parent_pipeline, pipeline, true) } - - it 'marks source bridge as pending' do - reset_bridge - - expect(bridge.reload).to be_pending - end - - context 'when the parent pipeline has subsequent jobs after the bridge' do - let!(:after_bridge_job) { create(:ci_build, :skipped, pipeline: parent_pipeline, stage_idx: bridge.stage_idx + 1) } - - it 'does not touch subsequent jobs of the bridge' do - reset_bridge - - expect(after_bridge_job.reload).to be_skipped - end - end - - context 'when the parent pipeline has a dependent upstream pipeline' do - let(:upstream_pipeline) { create(:ci_pipeline, project: create(:project)) } - let!(:upstream_bridge) { create_bridge(upstream_pipeline, parent_pipeline, true) } - - let(:upstream_upstream_pipeline) { create(:ci_pipeline, project: create(:project)) } - let!(:upstream_upstream_bridge) { create_bridge(upstream_upstream_pipeline, upstream_pipeline, true) } - - it 'marks all source bridges as pending' do - reset_bridge - - expect(bridge.reload).to be_pending - expect(upstream_bridge.reload).to be_pending - expect(upstream_upstream_bridge.reload).to be_pending - end - end - end - end - context 'when the pipeline is a child pipeline and the bridge is depended' do let!(:parent_pipeline) { create(:ci_pipeline) } let!(:bridge) { create_bridge(parent_pipeline, pipeline, true) } diff --git a/spec/models/ci/pipeline_variable_spec.rb b/spec/models/ci/pipeline_variable_spec.rb index 04fcaab4c2d..4e8d49585d0 100644 --- a/spec/models/ci/pipeline_variable_spec.rb +++ b/spec/models/ci/pipeline_variable_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Ci::PipelineVariable do it_behaves_like "CI variable" - it { is_expected.to validate_uniqueness_of(:key).scoped_to(:pipeline_id) } + it { is_expected.to validate_presence_of(:key) } describe '#hook_attrs' do let(:variable) { create(:ci_pipeline_variable, key: 'foo', value: 'bar') } diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index ffc8ab4cf8b..31e854c852e 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -531,6 +531,10 @@ RSpec.describe Ci::Runner do it 'can handle builds' do expect(runner.can_pick?(build)).to be_truthy end + + it 'knows namespace id it is assigned to' do + expect(runner.namespace_ids).to eq [group.id] + end end end diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb index ea7a55480a8..f9df84e8ff4 100644 --- a/spec/models/clusters/agent_spec.rb +++ b/spec/models/clusters/agent_spec.rb @@ -9,6 +9,10 @@ RSpec.describe Clusters::Agent do it { is_expected.to belong_to(:project).class_name('::Project') } it { is_expected.to have_many(:agent_tokens).class_name('Clusters::AgentToken') } it { is_expected.to have_many(:last_used_agent_tokens).class_name('Clusters::AgentToken') } + it { is_expected.to have_many(:group_authorizations).class_name('Clusters::Agents::GroupAuthorization') } + it { is_expected.to have_many(:authorized_groups).through(:group_authorizations) } + it { is_expected.to have_many(:project_authorizations).class_name('Clusters::Agents::ProjectAuthorization') } + it { is_expected.to have_many(:authorized_projects).through(:project_authorizations).class_name('::Project') } it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_length_of(:name).is_at_most(63) } diff --git a/spec/models/clusters/agents/group_authorization_spec.rb b/spec/models/clusters/agents/group_authorization_spec.rb new file mode 100644 index 00000000000..2a99fb26e3f --- /dev/null +++ b/spec/models/clusters/agents/group_authorization_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::GroupAuthorization do + it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required } + it { is_expected.to belong_to(:group).class_name('::Group').required } + + it { expect(described_class).to validate_jsonb_schema(['config']) } +end diff --git a/spec/models/clusters/agents/implicit_authorization_spec.rb b/spec/models/clusters/agents/implicit_authorization_spec.rb new file mode 100644 index 00000000000..69aa55a350e --- /dev/null +++ b/spec/models/clusters/agents/implicit_authorization_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::ImplicitAuthorization do + let_it_be(:agent) { create(:cluster_agent) } + + subject { described_class.new(agent: agent) } + + 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).to be_nil } +end diff --git a/spec/models/clusters/agents/project_authorization_spec.rb b/spec/models/clusters/agents/project_authorization_spec.rb new file mode 100644 index 00000000000..134c70739ac --- /dev/null +++ b/spec/models/clusters/agents/project_authorization_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::ProjectAuthorization do + it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required } + it { is_expected.to belong_to(:project).class_name('Project').required } + + it { expect(described_class).to validate_jsonb_schema(['config']) } +end diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index 278e200b05c..9d305e31bad 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -268,6 +268,16 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching do it { is_expected.to contain_exactly(cluster) } end + describe '.with_name' do + subject { described_class.with_name(name) } + + let(:name) { 'this-cluster' } + let!(:cluster) { create(:cluster, :project, name: name) } + let!(:another_cluster) { create(:cluster, :project) } + + it { is_expected.to contain_exactly(cluster) } + end + describe 'validations' do subject { cluster.valid? } @@ -902,8 +912,8 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching do subject { cluster.kubernetes_namespace_for(environment, deployable: build) } let(:environment_name) { 'the-environment-name' } - let(:environment) { create(:environment, name: environment_name, project: cluster.project, last_deployable: build) } - let(:build) { create(:ci_build, environment: environment_name, project: cluster.project) } + let(:environment) { create(:environment, name: environment_name, project: cluster.project) } + let(:build) { create(:ci_build, environment: environment, project: cluster.project) } let(:cluster) { create(:cluster, :project, managed: managed_cluster) } let(:managed_cluster) { true } let(:default_namespace) { Gitlab::Kubernetes::DefaultNamespace.new(cluster, project: cluster.project).from_environment_slug(environment.slug) } diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index a951af4cc4f..7134a387e65 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -88,6 +88,15 @@ RSpec.describe CommitStatus do end end + describe '.created_at_before' do + it 'finds the relevant records' do + status = create(:commit_status, created_at: 1.day.ago, project: project) + create(:commit_status, created_at: 1.day.since, project: project) + + expect(described_class.created_at_before(Time.current)).to eq([status]) + end + end + describe '.updated_before' do let!(:lookback) { 5.days.ago } let!(:timeout) { 1.day.ago } diff --git a/spec/models/concerns/approvable_base_spec.rb b/spec/models/concerns/approvable_base_spec.rb index c7ea2631a24..79053e98db7 100644 --- a/spec/models/concerns/approvable_base_spec.rb +++ b/spec/models/concerns/approvable_base_spec.rb @@ -60,6 +60,34 @@ RSpec.describe ApprovableBase do end end + describe '#can_be_unapproved_by?' do + subject { merge_request.can_be_unapproved_by?(user) } + + before do + merge_request.project.add_developer(user) + end + + it 'returns false' do + is_expected.to be_falsy + end + + context 'when a user has approved' do + let!(:approval) { create(:approval, merge_request: merge_request, user: user) } + + it 'returns true' do + is_expected.to be_truthy + end + end + + context 'when a user is nil' do + let(:user) { nil } + + it 'returns false' do + is_expected.to be_falsy + end + end + end + describe '.not_approved_by_users_with_usernames' do subject { MergeRequest.not_approved_by_users_with_usernames([user.username, user2.username]) } diff --git a/spec/models/concerns/calloutable_spec.rb b/spec/models/concerns/calloutable_spec.rb new file mode 100644 index 00000000000..d847413de88 --- /dev/null +++ b/spec/models/concerns/calloutable_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Calloutable do + subject { build(:user_callout) } + + describe "Associations" do + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:user) } + end + + describe '#dismissed_after?' do + let(:some_feature_name) { UserCallout.feature_names.keys.second } + let(:callout_dismissed_month_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.month.ago )} + let(:callout_dismissed_day_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.day.ago )} + + it 'returns whether a callout dismissed after specified date' do + expect(callout_dismissed_month_ago.dismissed_after?(15.days.ago)).to eq(false) + expect(callout_dismissed_day_ago.dismissed_after?(15.days.ago)).to eq(true) + end + end +end diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb index 295f3523dd5..453b6f7f29a 100644 --- a/spec/models/concerns/featurable_spec.rb +++ b/spec/models/concerns/featurable_spec.rb @@ -30,8 +30,11 @@ RSpec.describe Featurable do describe '.set_available_features' do let!(:klass) do - Class.new do + Class.new(ApplicationRecord) do include Featurable + + self.table_name = 'project_features' + set_available_features %i(feature1 feature2) def feature1_access_level diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 071e0dcba44..2a3f639a8ac 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -368,6 +368,23 @@ RSpec.describe Issuable do expect(sorted_issue_ids).to eq(sorted_issue_ids.uniq) end end + + context 'by title' do + let!(:issue1) { create(:issue, project: project, title: 'foo') } + let!(:issue2) { create(:issue, project: project, title: 'bar') } + let!(:issue3) { create(:issue, project: project, title: 'baz') } + let!(:issue4) { create(:issue, project: project, title: 'Baz 2') } + + it 'sorts asc' do + issues = project.issues.sort_by_attribute('title_asc') + expect(issues).to eq([issue2, issue3, issue4, issue1]) + end + + it 'sorts desc' do + issues = project.issues.sort_by_attribute('title_desc') + expect(issues).to eq([issue1, issue4, issue3, issue2]) + end + end end describe '#subscribed?' do diff --git a/spec/models/concerns/loose_foreign_key_spec.rb b/spec/models/concerns/loose_foreign_key_spec.rb new file mode 100644 index 00000000000..ce5e33261a9 --- /dev/null +++ b/spec/models/concerns/loose_foreign_key_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe LooseForeignKey do + let(:project_klass) do + Class.new(ApplicationRecord) do + include LooseForeignKey + + self.table_name = 'projects' + + loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main + loose_foreign_key 'merge_requests', 'project_id', 'on_delete' => 'async_nullify', 'gitlab_schema' => :gitlab_main + end + end + + it 'exposes the loose foreign key definitions' do + definitions = project_klass.loose_foreign_key_definitions + + tables = definitions.map(&:to_table) + expect(tables).to eq(%w[issues merge_requests]) + end + + it 'casts strings to symbol' do + definition = project_klass.loose_foreign_key_definitions.last + + expect(definition.from_table).to eq('projects') + expect(definition.to_table).to eq('merge_requests') + expect(definition.column).to eq('project_id') + expect(definition.on_delete).to eq(:async_nullify) + expect(definition.options[:gitlab_schema]).to eq(:gitlab_main) + end + + context 'validation' do + context 'on_delete validation' do + let(:invalid_class) do + Class.new(ApplicationRecord) do + include LooseForeignKey + + self.table_name = 'projects' + + loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main + loose_foreign_key :merge_requests, :project_id, on_delete: :async_nullify, gitlab_schema: :gitlab_main + loose_foreign_key :merge_requests, :project_id, on_delete: :destroy, gitlab_schema: :gitlab_main + end + end + + it 'raises error when invalid `on_delete` option was given' do + expect { invalid_class }.to raise_error /Invalid on_delete option given: destroy/ + end + end + + context 'gitlab_schema validation' do + let(:invalid_class) do + Class.new(ApplicationRecord) do + include LooseForeignKey + + self.table_name = 'projects' + + loose_foreign_key :merge_requests, :project_id, on_delete: :async_nullify, gitlab_schema: :unknown + end + end + + it 'raises error when invalid `gitlab_schema` option was given' do + expect { invalid_class }.to raise_error /Invalid gitlab_schema option given: unknown/ + end + end + + context 'inheritance validation' do + let(:inherited_project_class) do + Class.new(Project) do + include LooseForeignKey + + loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main + end + end + + it 'raises error when loose_foreign_key is defined in a child ActiveRecord model' do + expect { inherited_project_class }.to raise_error /Please define the loose_foreign_key on the Project class/ + end + end + end +end diff --git a/spec/models/concerns/partitioned_table_spec.rb b/spec/models/concerns/partitioned_table_spec.rb index c37fb81a1cf..714db4e21bd 100644 --- a/spec/models/concerns/partitioned_table_spec.rb +++ b/spec/models/concerns/partitioned_table_spec.rb @@ -35,11 +35,5 @@ RSpec.describe PartitionedTable do expect(my_class.partitioning_strategy.partitioning_key).to eq(key) end - - it 'registers itself with the PartitionCreator' do - expect(Gitlab::Database::Partitioning::PartitionManager).to receive(:register).with(my_class) - - subject - end end end diff --git a/spec/models/concerns/sanitizable_spec.rb b/spec/models/concerns/sanitizable_spec.rb new file mode 100644 index 00000000000..4a1d463d666 --- /dev/null +++ b/spec/models/concerns/sanitizable_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sanitizable do + let_it_be(:klass) do + Class.new do + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations + include ActiveModel::Validations::Callbacks + include Sanitizable + + attribute :id, :integer + attribute :name, :string + attribute :description, :string + attribute :html_body, :string + + sanitizes! :name, :description + + def self.model_name + ActiveModel::Name.new(self, nil, 'SomeModel') + end + end + end + + shared_examples 'noop' do + it 'has no effect' do + expect(subject).to eq(input) + end + end + + shared_examples 'a sanitizable field' do |field| + let(:record) { klass.new(id: 1, name: input, description: input, html_body: input) } + + before do + record.valid? + end + + subject { record.public_send(field) } + + describe field do + context 'when input is nil' do + let_it_be(:input) { nil } + + it_behaves_like 'noop' + end + + context 'when input does not contain any html' do + let_it_be(:input) { 'hello, world!' } + + it_behaves_like 'noop' + end + + context 'when input contains html' do + let_it_be(:input) { 'hello<script>alert(1)</script>' } + + it 'sanitizes the input' do + expect(subject).to eq('hello') + end + + context 'when input includes html entities' do + let(:input) { '<div>hello&world</div>' } + + it 'does not escape them' do + expect(subject).to eq(' hello&world ') + end + end + end + + context 'when input contains pre-escaped html entities' do + let_it_be(:input) { '<script>alert(1)</script>' } + + it_behaves_like 'noop' + + it 'is not valid', :aggregate_failures do + expect(record).not_to be_valid + expect(record.errors.full_messages).to include('Name cannot contain escaped HTML entities') + end + end + end + end + + shared_examples 'a non-sanitizable field' do |field, input| + describe field do + subject { klass.new(field => input).valid? } + + it 'has no effect' do + expect(Sanitize).not_to receive(:fragment) + + subject + end + end + end + + it_behaves_like 'a non-sanitizable field', :id, 1 + it_behaves_like 'a non-sanitizable field', :html_body, 'hello<script>alert(1)</script>' + + it_behaves_like 'a sanitizable field', :name + it_behaves_like 'a sanitizable field', :description +end diff --git a/spec/models/concerns/taggable_queries_spec.rb b/spec/models/concerns/taggable_queries_spec.rb new file mode 100644 index 00000000000..0d248c4636e --- /dev/null +++ b/spec/models/concerns/taggable_queries_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TaggableQueries do + it 'keeps MAX_TAGS_IDS in sync with TAGS_LIMIT' do + expect(described_class::MAX_TAGS_IDS).to eq(Gitlab::Ci::Config::Entry::Tags::TAGS_LIMIT) + end +end diff --git a/spec/models/customer_relations/contact_spec.rb b/spec/models/customer_relations/contact_spec.rb new file mode 100644 index 00000000000..b19554dd67e --- /dev/null +++ b/spec/models/customer_relations/contact_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +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 } + end + + describe 'validations' do + subject { build(:contact) } + + it { is_expected.to validate_presence_of(:group) } + it { is_expected.to validate_presence_of(:first_name) } + it { is_expected.to validate_presence_of(:last_name) } + + it { is_expected.to validate_length_of(:phone).is_at_most(32) } + it { is_expected.to validate_length_of(:first_name).is_at_most(255) } + it { is_expected.to validate_length_of(:last_name).is_at_most(255) } + it { is_expected.to validate_length_of(:email).is_at_most(255) } + it { is_expected.to validate_length_of(:description).is_at_most(1024) } + + it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email + end + + describe '#before_validation' do + it 'strips leading and trailing whitespace' do + contact = described_class.new(first_name: ' First ', last_name: ' Last ', phone: ' 123456 ') + contact.valid? + + expect(contact.first_name).to eq('First') + expect(contact.last_name).to eq('Last') + expect(contact.phone).to eq('123456') + end + end +end diff --git a/spec/models/customer_relations/organization_spec.rb b/spec/models/customer_relations/organization_spec.rb index b79b5748156..71b455ae8c8 100644 --- a/spec/models/customer_relations/organization_spec.rb +++ b/spec/models/customer_relations/organization_spec.rb @@ -8,7 +8,7 @@ RSpec.describe CustomerRelations::Organization, type: :model do end describe 'validations' do - subject { create(:organization) } + subject { build(:organization) } it { is_expected.to validate_presence_of(:group) } it { is_expected.to validate_presence_of(:name) } diff --git a/spec/models/dependency_proxy/blob_spec.rb b/spec/models/dependency_proxy/blob_spec.rb index 7c8a1eb95e8..3797f6184fe 100644 --- a/spec/models/dependency_proxy/blob_spec.rb +++ b/spec/models/dependency_proxy/blob_spec.rb @@ -6,10 +6,13 @@ RSpec.describe DependencyProxy::Blob, type: :model 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 new file mode 100644 index 00000000000..2906ea7b774 --- /dev/null +++ b/spec/models/dependency_proxy/image_ttl_group_policy_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe DependencyProxy::ImageTtlGroupPolicy, type: :model do + describe 'relationships' do + it { is_expected.to belong_to(:group) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:group) } + + describe '#enabled' do + it { is_expected.to allow_value(true).for(:enabled) } + it { is_expected.to allow_value(false).for(:enabled) } + it { is_expected.not_to allow_value(nil).for(:enabled) } + end + + describe '#ttl' do + it { is_expected.to validate_numericality_of(:ttl).allow_nil.is_greater_than(0) } + end + end +end diff --git a/spec/models/dependency_proxy/manifest_spec.rb b/spec/models/dependency_proxy/manifest_spec.rb index 4203644c003..2a085b3613b 100644 --- a/spec/models/dependency_proxy/manifest_spec.rb +++ b/spec/models/dependency_proxy/manifest_spec.rb @@ -6,11 +6,14 @@ RSpec.describe DependencyProxy::Manifest, type: :model 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/design_management/action_spec.rb b/spec/models/design_management/action_spec.rb index 59c58191718..0a8bbc8d26e 100644 --- a/spec/models/design_management/action_spec.rb +++ b/spec/models/design_management/action_spec.rb @@ -8,37 +8,55 @@ RSpec.describe DesignManagement::Action do end describe 'scopes' do - describe '.most_recent' do - let_it_be(:design_a) { create(:design) } - let_it_be(:design_b) { create(:design) } - let_it_be(:design_c) { create(:design) } + let_it_be(:issue) { create(:issue) } + let_it_be(:design_a) { create(:design, issue: issue) } + let_it_be(:design_b) { create(:design, issue: issue) } - let(:designs) { [design_a, design_b, design_c] } + context 'with 3 designs' do + let_it_be(:design_c) { create(:design, issue: issue) } - before_all do - create(:design_version, designs: [design_a, design_b, design_c]) - create(:design_version, designs: [design_a, design_b]) - create(:design_version, designs: [design_a]) - end + let_it_be(:action_a_1) { create(:design_action, design: design_a) } + let_it_be(:action_a_2) { create(:design_action, design: design_a, event: :deletion) } + let_it_be(:action_b) { create(:design_action, design: design_b) } + let_it_be(:action_c) { create(:design_action, design: design_c, event: :deletion) } + + describe '.most_recent' do + let(:designs) { [design_a, design_b, design_c] } + + before_all do + create(:design_version, designs: [design_a, design_b, design_c]) + create(:design_version, designs: [design_a, design_b]) + create(:design_version, designs: [design_a]) + end + + it 'finds the correct version for each design' do + dvs = described_class.where(design: designs) + + expected = designs + .map(&:id) + .zip(dvs.order("version_id DESC").pluck(:version_id).uniq) - it 'finds the correct version for each design' do - dvs = described_class.where(design: designs) + actual = dvs.most_recent.map { |dv| [dv.design_id, dv.version_id] } - expected = designs - .map(&:id) - .zip(dvs.order("version_id DESC").pluck(:version_id).uniq) + expect(actual).to eq(expected) + end + end - actual = dvs.most_recent.map { |dv| [dv.design_id, dv.version_id] } + describe '.by_design' do + it 'returns the actions by design_id' do + expect(described_class.by_design([design_a.id, design_b.id])) + .to match_array([action_a_1, action_a_2, action_b]) + end + end - expect(actual).to eq(expected) + describe '.by_event' do + it 'returns the actions by event type' do + expect(described_class.by_event(:deletion)).to match_array([action_a_2, action_c]) + end end end describe '.up_to_version' do - let_it_be(:issue) { create(:issue) } - let_it_be(:design_a) { create(:design, issue: issue) } - let_it_be(:design_b) { create(:design, issue: issue) } - # let bindings are not available in before(:all) contexts, # so we need to redefine the array on each construction. let_it_be(:oldest) { create(:design_version, designs: [design_a, design_b]) } diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index 11652d9841b..f377b34679c 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -558,4 +558,31 @@ RSpec.describe DiffNote do it { is_expected.to eq('note') } end + + describe '#shas' do + it 'returns list of SHAs based on original_position' do + expect(subject.shas).to match_array([ + position.base_sha, + position.start_sha, + position.head_sha + ]) + end + + context 'when position changes' do + before do + subject.position = new_position + end + + it 'includes the new position SHAs' do + expect(subject.shas).to match_array([ + position.base_sha, + position.start_sha, + position.head_sha, + new_position.base_sha, + new_position.start_sha, + new_position.head_sha + ]) + end + end + end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 53561586d61..e3e9d1f7a71 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -135,6 +135,20 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do environment.stop end + + context 'when environment has auto stop period' do + let!(:environment) { create(:environment, :available, :auto_stoppable, project: project) } + + it 'clears auto stop period when the environment has stopped' do + environment.stop! + + expect(environment.auto_stop_at).to be_nil + end + + it 'does not clear auto stop period when the environment has not stopped' do + expect(environment.auto_stop_at).to be_present + end + end end describe '.for_name_like' do @@ -233,55 +247,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end end - describe '.stop_actions' do - subject { environments.stop_actions } - - let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { create(:user) } - - let(:environments) { Environment.all } - - before_all do - project.add_developer(user) - project.repository.add_branch(user, 'review/feature-1', 'master') - project.repository.add_branch(user, 'review/feature-2', 'master') - end - - shared_examples_for 'correct filtering' do - it 'returns stop actions for available environments only' do - expect(subject.count).to eq(1) - expect(subject.first.name).to eq('stop_review_app') - expect(subject.first.ref).to eq('review/feature-1') - end - end - - before do - create_review_app(user, project, 'review/feature-1') - create_review_app(user, project, 'review/feature-2') - end - - it 'returns stop actions for environments' do - expect(subject.count).to eq(2) - expect(subject).to match_array(Ci::Build.where(name: 'stop_review_app')) - end - - context 'when one of the stop actions has already been executed' do - before do - Ci::Build.where(ref: 'review/feature-2').find_by_name('stop_review_app').enqueue! - end - - it_behaves_like 'correct filtering' - end - - context 'when one of the deployments does not have stop action' do - before do - Deployment.where(ref: 'review/feature-2').update_all(on_stop: nil) - end - - it_behaves_like 'correct filtering' - end - end - describe '.pluck_names' do subject { described_class.pluck_names } @@ -726,6 +691,28 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end end + describe '#last_deployable' do + subject { environment.last_deployable } + + context 'does not join across databases' do + let(:pipeline_a) { create(:ci_pipeline, project: project) } + let(:pipeline_b) { create(:ci_pipeline, project: project) } + let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline_a) } + let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline_b) } + + before do + create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a) + create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b) + end + + it 'when called' do + with_cross_joins_prevented do + expect(subject.id).to eq(ci_build_a.id) + end + end + end + end + describe '#last_visible_deployment' do subject { environment.last_visible_deployment } @@ -768,6 +755,86 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end end + describe '#last_visible_deployable' do + subject { environment.last_visible_deployable } + + context 'does not join across databases' do + let(:pipeline_a) { create(:ci_pipeline, project: project) } + let(:pipeline_b) { create(:ci_pipeline, project: project) } + let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline_a) } + let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline_b) } + + before do + create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a) + create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b) + end + + it 'for direct call' do + with_cross_joins_prevented do + expect(subject.id).to eq(ci_build_b.id) + end + end + + it 'for preload' do + environment.reload + + with_cross_joins_prevented do + ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_deployable: []]) + expect(subject.id).to eq(ci_build_b.id) + end + end + end + + context 'call after preload' do + it 'fetches from association cache' do + pipeline = create(:ci_pipeline, project: project) + ci_build = create(:ci_build, project: project, pipeline: pipeline) + create(:deployment, :failed, project: project, environment: environment, deployable: ci_build) + + environment.reload + ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_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 '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 let(:user) { create(:user) } let_it_be(:project) { create(:project, :repository) } @@ -812,6 +879,35 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do expect(last_pipeline).to eq(failed_pipeline) end + context 'does not join across databases' do + let(:pipeline_a) { create(:ci_pipeline, project: project) } + let(:pipeline_b) { create(:ci_pipeline, project: project) } + let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline_a) } + let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline_b) } + + before do + create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a) + create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b) + end + + subject { environment.last_visible_pipeline } + + it 'for direct call' do + with_cross_joins_prevented do + expect(subject.id).to eq(pipeline_b.id) + end + end + + it 'for preload' do + environment.reload + + with_cross_joins_prevented do + ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_pipeline: []]) + expect(subject.id).to eq(pipeline_b.id) + end + end + end + context 'for the environment' do it 'returns the last pipeline' do pipeline = create(:ci_pipeline, project: project, user: user, sha: commit.sha) @@ -850,6 +946,57 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end end end + + context 'call after preload' do + it 'fetches from association cache' do + pipeline = create(:ci_pipeline, project: project) + ci_build = create(:ci_build, project: project, pipeline: pipeline) + create(:deployment, :failed, project: project, environment: environment, deployable: ci_build) + + environment.reload + ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_pipeline: []]) + + query_count = ActiveRecord::QueryRecorder.new do + expect(environment.last_visible_pipeline.id).to eq(pipeline.id) + end.count + + 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 57899985daf..5543392b624 100644 --- a/spec/models/error_tracking/error_spec.rb +++ b/spec/models/error_tracking/error_spec.rb @@ -16,6 +16,62 @@ RSpec.describe ErrorTracking::Error, type: :model do it { is_expected.to validate_presence_of(:actor) } end + describe '.report_error' do + it 'updates existing record with a new timestamp' do + timestamp = Time.zone.now + + reported_error = described_class.report_error( + name: error.name, + description: 'Lorem ipsum', + actor: error.actor, + platform: error.platform, + timestamp: timestamp + ) + + expect(reported_error.id).to eq(error.id) + expect(reported_error.last_seen_at).to eq(timestamp) + expect(reported_error.description).to eq('Lorem ipsum') + end + end + + describe '.sort_by_attribute' do + let!(:error2) { create(:error_tracking_error, first_seen_at: Time.zone.now - 2.weeks, last_seen_at: Time.zone.now - 1.week) } + let!(:error3) { create(:error_tracking_error, first_seen_at: Time.zone.now - 3.weeks, last_seen_at: Time.zone.now.yesterday) } + let!(:errors) { [error, error2, error3] } + + subject { described_class.where(id: errors).sort_by_attribute(sort) } + + context 'id desc by default' do + let(:sort) { nil } + + it { is_expected.to eq([error3, error2, error]) } + end + + context 'first_seen' do + let(:sort) { 'first_seen' } + + it { is_expected.to eq([error, error2, error3]) } + end + + context 'last_seen' do + let(:sort) { 'last_seen' } + + it { is_expected.to eq([error, error3, error2]) } + end + + context 'frequency' do + let(:sort) { 'frequency' } + + before do + create(:error_tracking_error_event, error: error2) + create(:error_tracking_error_event, error: error2) + create(:error_tracking_error_event, error: error3) + end + + it { is_expected.to eq([error2, error3, error]) } + end + end + describe '#title' do it { expect(error.title).to eq('ActionView::MissingTemplate Missing template posts/edit') } 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 7be61f4950e..29255e53fcf 100644 --- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb +++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb @@ -478,18 +478,17 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do describe '#sentry_enabled' do using RSpec::Parameterized::TableSyntax - where(:enabled, :integrated, :feature_flag, :sentry_enabled) do - true | false | false | true - true | true | false | true - true | true | true | false - false | false | false | false + where(:enabled, :integrated, :sentry_enabled) do + true | false | true + true | true | false + true | true | false + false | false | false end with_them do before do subject.enabled = enabled subject.integrated = integrated - stub_feature_flags(integrated_error_tracking: feature_flag) end it { expect(subject.sentry_enabled).to eq(sentry_enabled) } diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index ddf12c8e4c4..d536a0783bc 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -30,10 +30,12 @@ RSpec.describe Group do it { is_expected.to have_many(:group_deploy_keys) } it { is_expected.to have_many(:integrations) } it { is_expected.to have_one(:dependency_proxy_setting) } + it { is_expected.to have_one(:dependency_proxy_image_ttl_policy) } it { is_expected.to have_many(:dependency_proxy_blobs) } it { is_expected.to have_many(:dependency_proxy_manifests) } 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) } describe '#members & #requesters' do let(:requester) { create(:user) } @@ -80,7 +82,7 @@ RSpec.describe Group do group = build(:group, parent: build(:namespace)) expect(group).not_to be_valid - expect(group.errors[:parent_id].first).to eq('a group cannot have a user namespace as its parent') + expect(group.errors[:parent_id].first).to eq('user namespace cannot be the parent of another namespace') end it 'allows a group to have another group as its parent' do @@ -2273,19 +2275,27 @@ RSpec.describe Group do end describe '.groups_including_descendants_by' do - it 'returns the expected groups for a group and its descendants' do - parent_group1 = create(:group) - child_group1 = create(:group, parent: parent_group1) - child_group2 = create(:group, parent: parent_group1) + let_it_be(:parent_group1) { create(:group) } + let_it_be(:parent_group2) { create(:group) } + let_it_be(:extra_group) { create(:group) } + let_it_be(:child_group1) { create(:group, parent: parent_group1) } + let_it_be(:child_group2) { create(:group, parent: parent_group1) } + let_it_be(:child_group3) { create(:group, parent: parent_group2) } - parent_group2 = create(:group) - child_group3 = create(:group, parent: parent_group2) + subject { described_class.groups_including_descendants_by([parent_group2.id, parent_group1.id]) } - create(:group) + shared_examples 'returns the expected groups for a group and its descendants' do + specify { is_expected.to contain_exactly(parent_group1, parent_group2, child_group1, child_group2, child_group3) } + end + + it_behaves_like 'returns the expected groups for a group and its descendants' - groups = described_class.groups_including_descendants_by([parent_group2.id, parent_group1.id]) + context 'when :linear_group_including_descendants_by feature flag is disabled' do + before do + stub_feature_flags(linear_group_including_descendants_by: false) + end - expect(groups).to contain_exactly(parent_group1, parent_group2, child_group1, child_group2, child_group3) + it_behaves_like 'returns the expected groups for a group and its descendants' end end @@ -2477,6 +2487,12 @@ RSpec.describe Group do end end + describe '#membership_locked?' do + it 'returns false' do + expect(build(:group)).not_to be_membership_locked + end + end + describe '#default_owner' do let(:group) { build(:group) } @@ -2619,6 +2635,26 @@ RSpec.describe Group do end end + describe '.organizations' do + it 'returns organizations belonging to the group' do + organization1 = create(:organization, group: group) + create(:organization) + organization3 = create(:organization, group: group) + + expect(group.organizations).to contain_exactly(organization1, organization3) + end + end + + describe '.contacts' do + it 'returns contacts belonging to the group' do + contact1 = create(:contact, group: group) + create(:contact) + contact3 = create(:contact, group: group) + + expect(group.contacts).to contain_exactly(contact1, contact3) + end + end + describe '#to_ability_name' do it 'returns group' do group = build(:group) @@ -2696,4 +2732,40 @@ RSpec.describe Group do group.open_merge_requests_count end end + + describe '#dependency_proxy_image_prefix' do + let_it_be(:group) { build_stubbed(:group, path: 'GroupWithUPPERcaseLetters') } + + it 'converts uppercase letters to lowercase' do + expect(group.dependency_proxy_image_prefix).to end_with("/groupwithuppercaseletters#{DependencyProxy::URL_SUFFIX}") + end + + it 'removes the protocol' do + expect(group.dependency_proxy_image_prefix).not_to include('http') + end + end + + describe '#dependency_proxy_image_ttl_policy' do + subject(:ttl_policy) { group.dependency_proxy_image_ttl_policy } + + it 'builds a new policy if one does not exist', :aggregate_failures do + expect(ttl_policy.ttl).to eq(90) + expect(ttl_policy.enabled).to eq(false) + expect(ttl_policy.created_at).to be_nil + expect(ttl_policy.updated_at).to be_nil + end + + context 'with existing policy' do + before do + group.dependency_proxy_image_ttl_policy.update!(ttl: 30, enabled: true) + end + + it 'returns the policy if it already exists', :aggregate_failures do + expect(ttl_policy.ttl).to eq(30) + expect(ttl_policy.enabled).to eq(true) + expect(ttl_policy.created_at).not_to be_nil + expect(ttl_policy.updated_at).not_to be_nil + end + end + end end diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb index c68ad3bf0c4..59f4533a6c1 100644 --- a/spec/models/hooks/web_hook_spec.rb +++ b/spec/models/hooks/web_hook_spec.rb @@ -10,7 +10,11 @@ RSpec.describe WebHook do let(:hook) { build(:project_hook, project: project) } around do |example| - freeze_time { example.run } + if example.metadata[:skip_freeze_time] + example.run + else + freeze_time { example.run } + end end describe 'associations' do @@ -326,10 +330,28 @@ RSpec.describe WebHook do expect { hook.backoff! }.to change(hook, :backoff_count).by(1) end - it 'does not let the backoff count exceed the maximum failure count' do - hook.backoff_count = described_class::MAX_FAILURES + context 'when we have backed off MAX_FAILURES times' do + before do + stub_const("#{described_class}::MAX_FAILURES", 5) + 5.times { hook.backoff! } + end + + it 'does not let the backoff count exceed the maximum failure count' do + expect { hook.backoff! }.not_to change(hook, :backoff_count) + end + + it 'does not change disabled_until', :skip_freeze_time do + travel_to(hook.disabled_until - 1.minute) do + expect { hook.backoff! }.not_to change(hook, :disabled_until) + end + end - expect { hook.backoff! }.not_to change(hook, :backoff_count) + it 'changes disabled_until when it has elapsed', :skip_freeze_time do + travel_to(hook.disabled_until + 1.minute) do + expect { hook.backoff! }.to change { hook.disabled_until } + expect(hook.backoff_count).to eq(described_class::MAX_FAILURES) + end + end end include_examples 'is tolerant of invalid records' do diff --git a/spec/models/instance_configuration_spec.rb b/spec/models/instance_configuration_spec.rb index 9544f0fe6ec..551e6e7572c 100644 --- a/spec/models/instance_configuration_spec.rb +++ b/spec/models/instance_configuration_spec.rb @@ -76,24 +76,46 @@ RSpec.describe InstanceConfiguration do end end - describe '#gitlab_ci' do - let(:gitlab_ci) { subject.settings[:gitlab_ci] } + describe '#size_limits' do + before do + Gitlab::CurrentSettings.current_application_settings.update!( + max_attachment_size: 10, + receive_max_input_size: 20, + max_import_size: 30, + diff_max_patch_bytes: 409600, + max_artifacts_size: 50, + max_pages_size: 60, + snippet_size_limit: 70 + ) + end - it 'returns Settings.gitalb_ci' do - gitlab_ci.delete(:artifacts_max_size) + it 'returns size limits from application settings' do + size_limits = subject.settings[:size_limits] - expect(gitlab_ci).to eq(Settings.gitlab_ci.symbolize_keys) + expect(size_limits[:max_attachment_size]).to eq(10.megabytes) + expect(size_limits[:receive_max_input_size]).to eq(20.megabytes) + expect(size_limits[:max_import_size]).to eq(30.megabytes) + expect(size_limits[:diff_max_patch_bytes]).to eq(400.kilobytes) + expect(size_limits[:max_artifacts_size]).to eq(50.megabytes) + expect(size_limits[:max_pages_size]).to eq(60.megabytes) + expect(size_limits[:snippet_size_limit]).to eq(70.bytes) end - it 'returns the key artifacts_max_size' do - expect(gitlab_ci.keys).to include(:artifacts_max_size) + it 'returns nil if receive_max_input_size not set' do + Gitlab::CurrentSettings.current_application_settings.update!(receive_max_input_size: nil) + + size_limits = subject.settings[:size_limits] + + expect(size_limits[:receive_max_input_size]).to be_nil end - it 'returns the key artifacts_max_size with values' do - stub_application_setting(max_artifacts_size: 200) + it 'returns nil if set to 0 (unlimited)' do + Gitlab::CurrentSettings.current_application_settings.update!(max_import_size: 0, max_pages_size: 0) + + size_limits = subject.settings[:size_limits] - expect(gitlab_ci[:artifacts_max_size][:default]).to eq(100.megabytes) - expect(gitlab_ci[:artifacts_max_size][:value]).to eq(200.megabytes) + expect(size_limits[:max_import_size]).to be_nil + expect(size_limits[:max_pages_size]).to be_nil end end diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb index f5f6a425fdd..8a06f7fac99 100644 --- a/spec/models/integration_spec.rb +++ b/spec/models/integration_spec.rb @@ -825,4 +825,20 @@ RSpec.describe Integration do .to include(*described_class::PROJECT_SPECIFIC_INTEGRATION_NAMES) end end + + describe '#password_fields' do + it 'returns all fields with type `password`' do + allow(subject).to receive(:fields).and_return([ + { name: 'password', type: 'password' }, + { name: 'secret', type: 'password' }, + { name: 'public', type: 'text' } + ]) + + expect(subject.password_fields).to match_array(%w[password secret]) + end + + it 'returns an empty array if no password fields exist' do + expect(subject.password_fields).to eq([]) + end + end end diff --git a/spec/models/integrations/datadog_spec.rb b/spec/models/integrations/datadog_spec.rb index 7049e64c2ce..9c3ff7aa35b 100644 --- a/spec/models/integrations/datadog_spec.rb +++ b/spec/models/integrations/datadog_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Integrations::Datadog do let(:active) { true } let(:dd_site) { 'datadoghq.com' } - let(:default_url) { 'https://webhooks-http-intake.logs.datadoghq.com/api/v2/webhook' } + let(:default_url) { 'https://webhook-intake.datadoghq.com/api/v2/webhook' } let(:api_url) { '' } let(:api_key) { SecureRandom.hex(32) } let(:dd_env) { 'ci' } @@ -66,7 +66,7 @@ RSpec.describe Integrations::Datadog do context 'with custom api_url' do let(:dd_site) { '' } - let(:api_url) { 'https://webhooks-http-intake.logs.datad0g.com/api/v2/webhook' } + let(:api_url) { 'https://webhook-intake.datad0g.com/api/v2/webhook' } it { is_expected.not_to validate_presence_of(:datadog_site) } it { is_expected.to validate_presence_of(:api_url) } @@ -108,7 +108,7 @@ RSpec.describe Integrations::Datadog do end context 'with custom URL' do - let(:api_url) { 'https://webhooks-http-intake.logs.datad0g.com/api/v2/webhook' } + let(:api_url) { 'https://webhook-intake.datad0g.com/api/v2/webhook' } it { is_expected.to eq(api_url + "?dd-api-key=#{api_key}&env=#{dd_env}&service=#{dd_service}") } diff --git a/spec/models/integrations/pipelines_email_spec.rb b/spec/models/integrations/pipelines_email_spec.rb index 761049f25fe..afd9d71ebc4 100644 --- a/spec/models/integrations/pipelines_email_spec.rb +++ b/spec/models/integrations/pipelines_email_spec.rb @@ -48,7 +48,7 @@ RSpec.describe Integrations::PipelinesEmail, :mailer do end it 'sends email' do - emails = receivers.map { |r| double(notification_email: r) } + emails = receivers.map { |r| double(notification_email_or_default: r) } should_only_email(*emails, kind: :bcc) end diff --git a/spec/models/integrations/prometheus_spec.rb b/spec/models/integrations/prometheus_spec.rb index f6f242bf58e..76e20f20a00 100644 --- a/spec/models/integrations/prometheus_spec.rb +++ b/spec/models/integrations/prometheus_spec.rb @@ -516,7 +516,7 @@ RSpec.describe Integrations::Prometheus, :use_clean_rails_memory_store_caching, name: 'google_iap_audience_client_id', title: 'Google IAP Audience Client ID', placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'), - help: s_('PrometheusService|PrometheusService|The ID of the IAP-secured resource.'), + help: s_('PrometheusService|The ID of the IAP-secured resource.'), autocomplete: 'off', required: false }, diff --git a/spec/models/integrations/zentao_spec.rb b/spec/models/integrations/zentao_spec.rb new file mode 100644 index 00000000000..a1503ecc092 --- /dev/null +++ b/spec/models/integrations/zentao_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Zentao do + let(:url) { 'https://jihudemo.zentao.net' } + let(:api_url) { 'https://jihudemo.zentao.net' } + let(:api_token) { 'ZENTAO_TOKEN' } + let(:zentao_product_xid) { '3' } + let(:zentao_integration) { create(:zentao_integration) } + + describe '#create' do + let(:project) { create(:project, :repository) } + let(:params) do + { + project: project, + url: url, + api_url: api_url, + api_token: api_token, + zentao_product_xid: zentao_product_xid + } + end + + it 'stores data in data_fields correctly' do + tracker_data = described_class.create!(params).zentao_tracker_data + + expect(tracker_data.url).to eq(url) + expect(tracker_data.api_url).to eq(api_url) + expect(tracker_data.api_token).to eq(api_token) + expect(tracker_data.zentao_product_xid).to eq(zentao_product_xid) + end + end + + describe '#fields' do + it 'returns custom fields' do + expect(zentao_integration.fields.pluck(:name)).to eq(%w[url api_url api_token zentao_product_xid]) + end + end + + describe '#test' do + let(:test_response) { { success: true } } + + before do + allow_next_instance_of(Gitlab::Zentao::Client) do |client| + allow(client).to receive(:ping).and_return(test_response) + end + end + + it 'gets response from Gitlab::Zentao::Client#ping' do + expect(zentao_integration.test).to eq(test_response) + end + end +end diff --git a/spec/models/integrations/zentao_tracker_data_spec.rb b/spec/models/integrations/zentao_tracker_data_spec.rb new file mode 100644 index 00000000000..b078c57830b --- /dev/null +++ b/spec/models/integrations/zentao_tracker_data_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::ZentaoTrackerData do + describe 'factory available' do + let(:zentao_tracker_data) { create(:zentao_tracker_data) } + + it { expect(zentao_tracker_data.valid?).to eq true } + end + + describe 'associations' do + it { is_expected.to belong_to(:integration) } + end + + describe 'encrypted attributes' do + subject { described_class.encrypted_attributes.keys } + + it { is_expected.to contain_exactly(:url, :api_url, :zentao_product_xid, :api_token) } + end +end diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb index 6aba91d9471..51b27151ba2 100644 --- a/spec/models/internal_id_spec.rb +++ b/spec/models/internal_id_spec.rb @@ -39,266 +39,199 @@ RSpec.describe InternalId do end end - shared_examples_for 'a monotonically increasing id generator' do - describe '.generate_next' do - subject { described_class.generate_next(id_subject, scope, usage, init) } + describe '.generate_next' do + subject { described_class.generate_next(id_subject, scope, usage, init) } - context 'in the absence of a record' do - it 'creates a record if not yet present' do - expect { subject }.to change { described_class.count }.from(0).to(1) - end - - it 'stores record attributes' do - subject - - described_class.first.tap do |record| - expect(record.project).to eq(project) - expect(record.usage).to eq(usage.to_s) - end - end - - context 'with existing issues' do - before do - create_list(:issue, 2, project: project) - described_class.delete_all - end - - it 'calculates last_value values automatically' do - expect(subject).to eq(project.issues.size + 1) - end - end - end - - it 'generates a strictly monotone, gapless sequence' do - seq = Array.new(10).map do - described_class.generate_next(issue, scope, usage, init) - end - normalized = seq.map { |i| i - seq.min } - - expect(normalized).to eq((0..seq.size - 1).to_a) + context 'in the absence of a record' do + it 'creates a record if not yet present' do + expect { subject }.to change { described_class.count }.from(0).to(1) end - context 'there are no instances to pass in' do - let(:id_subject) { Issue } + it 'stores record attributes' do + subject - it 'accepts classes instead' do - expect(subject).to eq(1) + described_class.first.tap do |record| + expect(record.project).to eq(project) + expect(record.usage).to eq(usage.to_s) end end - context 'when executed outside of transaction' do - it 'increments counter with in_transaction: "false"' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases - - expect(InternalId.internal_id_transactions_total).to receive(:increment) - .with(operation: :generate, usage: 'issues', in_transaction: 'false').and_call_original - - subject + context 'with existing issues' do + before do + create_list(:issue, 2, project: project) + described_class.delete_all end - end - context 'when executed within transaction' do - it 'increments counter with in_transaction: "true"' do - expect(InternalId.internal_id_transactions_total).to receive(:increment) - .with(operation: :generate, usage: 'issues', in_transaction: 'true').and_call_original - - InternalId.transaction { subject } + it 'calculates last_value values automatically' do + expect(subject).to eq(project.issues.size + 1) end end end - describe '.reset' do - subject { described_class.reset(issue, scope, usage, value) } - - context 'in the absence of a record' do - let(:value) { 2 } - - it 'does not revert back the value' do - expect { subject }.not_to change { described_class.count } - expect(subject).to be_falsey - end + it 'generates a strictly monotone, gapless sequence' do + seq = Array.new(10).map do + described_class.generate_next(issue, scope, usage, init) end + normalized = seq.map { |i| i - seq.min } - context 'when valid iid is used to reset' do - let!(:value) { generate_next } - - context 'and iid is a latest one' do - it 'does rewind and next generated value is the same' do - expect(subject).to be_truthy - expect(generate_next).to eq(value) - end - end + expect(normalized).to eq((0..seq.size - 1).to_a) + end - context 'and iid is not a latest one' do - it 'does not rewind' do - generate_next + context 'there are no instances to pass in' do + let(:id_subject) { Issue } - expect(subject).to be_falsey - expect(generate_next).to be > value - end - end - - def generate_next - described_class.generate_next(issue, scope, usage, init) - end + it 'accepts classes instead' do + expect(subject).to eq(1) end + end - context 'when executed outside of transaction' do - let(:value) { 2 } - - it 'increments counter with in_transaction: "false"' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases + context 'when executed outside of transaction' do + it 'increments counter with in_transaction: "false"' do + allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases - expect(InternalId.internal_id_transactions_total).to receive(:increment) - .with(operation: :reset, usage: 'issues', in_transaction: 'false').and_call_original + expect(InternalId.internal_id_transactions_total).to receive(:increment) + .with(operation: :generate, usage: 'issues', in_transaction: 'false').and_call_original - subject - end + subject end + end - context 'when executed within transaction' do - let(:value) { 2 } - - it 'increments counter with in_transaction: "true"' do - expect(InternalId.internal_id_transactions_total).to receive(:increment) - .with(operation: :reset, usage: 'issues', in_transaction: 'true').and_call_original + context 'when executed within transaction' do + it 'increments counter with in_transaction: "true"' do + expect(InternalId.internal_id_transactions_total).to receive(:increment) + .with(operation: :generate, usage: 'issues', in_transaction: 'true').and_call_original - InternalId.transaction { subject } - end + InternalId.transaction { subject } end end + end - describe '.track_greatest' do - let(:value) { 9001 } - - subject { described_class.track_greatest(id_subject, scope, usage, value, init) } - - context 'in the absence of a record' do - it 'creates a record if not yet present' do - expect { subject }.to change { described_class.count }.from(0).to(1) - end - end + describe '.reset' do + subject { described_class.reset(issue, scope, usage, value) } - it 'stores record attributes' do - subject + context 'in the absence of a record' do + let(:value) { 2 } - described_class.first.tap do |record| - expect(record.project).to eq(project) - expect(record.usage).to eq(usage.to_s) - expect(record.last_value).to eq(value) - end + it 'does not revert back the value' do + expect { subject }.not_to change { described_class.count } + expect(subject).to be_falsey end + end - context 'with existing issues' do - before do - create(:issue, project: project) - described_class.delete_all - end + context 'when valid iid is used to reset' do + let!(:value) { generate_next } - it 'still returns the last value to that of the given value' do - expect(subject).to eq(value) + context 'and iid is a latest one' do + it 'does rewind and next generated value is the same' do + expect(subject).to be_truthy + expect(generate_next).to eq(value) end end - context 'when value is less than the current last_value' do - it 'returns the current last_value' do - described_class.create!(**scope, usage: usage, last_value: 10_001) + context 'and iid is not a latest one' do + it 'does not rewind' do + generate_next - expect(subject).to eq 10_001 + expect(subject).to be_falsey + expect(generate_next).to be > value end end - context 'there are no instances to pass in' do - let(:id_subject) { Issue } - - it 'accepts classes instead' do - expect(subject).to eq(value) - end + def generate_next + described_class.generate_next(issue, scope, usage, init) end + end - context 'when executed outside of transaction' do - it 'increments counter with in_transaction: "false"' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases - - expect(InternalId.internal_id_transactions_total).to receive(:increment) - .with(operation: :track_greatest, usage: 'issues', in_transaction: 'false').and_call_original + context 'when executed outside of transaction' do + let(:value) { 2 } - subject - end - end + it 'increments counter with in_transaction: "false"' do + allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases - context 'when executed within transaction' do - it 'increments counter with in_transaction: "true"' do - expect(InternalId.internal_id_transactions_total).to receive(:increment) - .with(operation: :track_greatest, usage: 'issues', in_transaction: 'true').and_call_original + expect(InternalId.internal_id_transactions_total).to receive(:increment) + .with(operation: :reset, usage: 'issues', in_transaction: 'false').and_call_original - InternalId.transaction { subject } - end + subject end end - end - context 'when the feature flag is disabled' do - stub_feature_flags(generate_iids_without_explicit_locking: false) + context 'when executed within transaction' do + let(:value) { 2 } - it_behaves_like 'a monotonically increasing id generator' - end - - context 'when the feature flag is enabled' do - stub_feature_flags(generate_iids_without_explicit_locking: true) + it 'increments counter with in_transaction: "true"' do + expect(InternalId.internal_id_transactions_total).to receive(:increment) + .with(operation: :reset, usage: 'issues', in_transaction: 'true').and_call_original - it_behaves_like 'a monotonically increasing id generator' + InternalId.transaction { subject } + end + end end - describe '#increment_and_save!' do - let(:id) { create(:internal_id) } - - subject { id.increment_and_save! } + describe '.track_greatest' do + let(:value) { 9001 } - it 'returns incremented iid' do - value = id.last_value + subject { described_class.track_greatest(id_subject, scope, usage, value, init) } - expect(subject).to eq(value + 1) + context 'in the absence of a record' do + it 'creates a record if not yet present' do + expect { subject }.to change { described_class.count }.from(0).to(1) + end end - it 'saves the record' do + it 'stores record attributes' do subject - expect(id.changed?).to be_falsey + described_class.first.tap do |record| + expect(record.project).to eq(project) + expect(record.usage).to eq(usage.to_s) + expect(record.last_value).to eq(value) + end end - context 'with last_value=nil' do - let(:id) { build(:internal_id, last_value: nil) } + context 'with existing issues' do + before do + create(:issue, project: project) + described_class.delete_all + end - it 'returns 1' do - expect(subject).to eq(1) + it 'still returns the last value to that of the given value' do + expect(subject).to eq(value) end end - end - - describe '#track_greatest_and_save!' do - let(:id) { create(:internal_id) } - let(:new_last_value) { 9001 } - subject { id.track_greatest_and_save!(new_last_value) } + context 'when value is less than the current last_value' do + it 'returns the current last_value' do + described_class.create!(**scope, usage: usage, last_value: 10_001) - it 'returns new last value' do - expect(subject).to eq new_last_value + expect(subject).to eq 10_001 + end end - it 'saves the record' do - subject + context 'there are no instances to pass in' do + let(:id_subject) { Issue } - expect(id.changed?).to be_falsey + it 'accepts classes instead' do + expect(subject).to eq(value) + end end - context 'when new last value is lower than the max' do - it 'does not update the last value' do - id.update!(last_value: 10_001) + context 'when executed outside of transaction' do + it 'increments counter with in_transaction: "false"' do + allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases + + expect(InternalId.internal_id_transactions_total).to receive(:increment) + .with(operation: :track_greatest, usage: 'issues', in_transaction: 'false').and_call_original subject + end + end + + context 'when executed within transaction' do + it 'increments counter with in_transaction: "true"' do + expect(InternalId.internal_id_transactions_total).to receive(:increment) + .with(operation: :track_greatest, usage: 'issues', in_transaction: 'true').and_call_original - expect(id.reload.last_value).to eq 10_001 + InternalId.transaction { subject } end end end diff --git a/spec/models/issue/metrics_spec.rb b/spec/models/issue/metrics_spec.rb index 49c891c20da..2fdf1f09f80 100644 --- a/spec/models/issue/metrics_spec.rb +++ b/spec/models/issue/metrics_spec.rb @@ -34,7 +34,7 @@ RSpec.describe Issue::Metrics do end end - describe "when recording the default set of issue metrics on issue save" do + context "when recording the default set of issue metrics on issue save" do context "milestones" do it "records the first time an issue is associated with a milestone" do time = Time.current @@ -80,20 +80,5 @@ RSpec.describe Issue::Metrics do expect(metrics.first_added_to_board_at).to be_like_time(time) end end - - describe "#record!" do - it "does not cause an N+1 query" do - label = create(:label) - subject.update!(label_ids: [label.id]) - - control_count = ActiveRecord::QueryRecorder.new { Issue::Metrics.find_by(issue: subject).record! }.count - - additional_labels = create_list(:label, 4) - - subject.update!(label_ids: additional_labels.map(&:id)) - - expect { Issue::Metrics.find_by(issue: subject).record! }.not_to exceed_query_limit(control_count) - end - end end end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 116bda7a18b..1747972e8ae 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -102,7 +102,7 @@ RSpec.describe Issue do end it 'records current metrics' do - expect_any_instance_of(Issue::Metrics).to receive(:record!) + expect(Issue::Metrics).to receive(:record!) create(:issue, project: reusable_project) end @@ -111,7 +111,6 @@ RSpec.describe Issue do before do subject.metrics.delete subject.reload - subject.metrics # make sure metrics association is cached (currently nil) end it 'creates the metrics record' do @@ -166,8 +165,8 @@ RSpec.describe Issue do expect(described_class.simple_sorts.keys).to include( *%w(created_asc created_at_asc created_date created_desc created_at_desc closest_future_date closest_future_date_asc due_date due_date_asc due_date_desc - id_asc id_desc relative_position relative_position_asc - updated_desc updated_asc updated_at_asc updated_at_desc)) + id_asc id_desc relative_position relative_position_asc updated_desc updated_asc + updated_at_asc updated_at_desc title_asc title_desc)) end end @@ -204,6 +203,25 @@ RSpec.describe Issue do end end + describe '.order_title' do + let_it_be(:issue1) { create(:issue, title: 'foo') } + let_it_be(:issue2) { create(:issue, title: 'bar') } + let_it_be(:issue3) { create(:issue, title: 'baz') } + let_it_be(:issue4) { create(:issue, title: 'Baz 2') } + + context 'sorting ascending' do + subject { described_class.order_title_asc } + + it { is_expected.to eq([issue2, issue3, issue4, issue1]) } + end + + context 'sorting descending' do + subject { described_class.order_title_desc } + + it { is_expected.to eq([issue1, issue4, issue3, issue2]) } + end + end + describe '#order_by_position_and_priority' do let(:project) { reusable_project } let(:p1) { create(:label, title: 'P1', project: project, priority: 1) } @@ -1177,18 +1195,33 @@ RSpec.describe Issue do it 'refreshes the number of open issues of the project' do project = subject.project - expect { subject.destroy! } - .to change { project.open_issues_count }.from(1).to(0) + expect do + subject.destroy! + + BatchLoader::Executor.clear_current + end.to change { project.open_issues_count }.from(1).to(0) end end describe '.public_only' do - it 'only returns public issues' do - public_issue = create(:issue, project: reusable_project) - create(:issue, project: reusable_project, confidential: true) + let_it_be(:banned_user) { create(:user, :banned) } + let_it_be(:public_issue) { create(:issue, project: reusable_project) } + let_it_be(:confidential_issue) { create(:issue, project: reusable_project, confidential: true) } + let_it_be(:hidden_issue) { create(:issue, project: reusable_project, author: banned_user) } + it 'only returns public issues' do expect(described_class.public_only).to eq([public_issue]) end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it 'returns public and hidden issues' do + expect(described_class.public_only).to eq([public_issue, hidden_issue]) + end + end end describe '.confidential_only' do @@ -1402,19 +1435,19 @@ RSpec.describe Issue do describe 'scheduling rebalancing' do before do allow_next_instance_of(RelativePositioning::Mover) do |mover| - allow(mover).to receive(:move) { raise ActiveRecord::QueryCanceled } + allow(mover).to receive(:move) { raise RelativePositioning::NoSpaceLeft } end end shared_examples 'schedules issues rebalancing' do let(:issue) { build_stubbed(:issue, relative_position: 100, project: project) } - it 'schedules rebalancing if we time-out when moving' do + it 'schedules rebalancing if there is no space left' do lhs = build_stubbed(:issue, relative_position: 99, project: project) to_move = build(:issue, project: project) expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, project_id, namespace_id) - expect { to_move.move_between(lhs, issue) }.to raise_error(ActiveRecord::QueryCanceled) + expect { to_move.move_between(lhs, issue) }.to raise_error(RelativePositioning::NoSpaceLeft) end end diff --git a/spec/models/loose_foreign_keys/deleted_record_spec.rb b/spec/models/loose_foreign_keys/deleted_record_spec.rb new file mode 100644 index 00000000000..db2f8b4d2d3 --- /dev/null +++ b/spec/models/loose_foreign_keys/deleted_record_spec.rb @@ -0,0 +1,56 @@ +# 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 067b3c25645..3f7f69ff34e 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -645,6 +645,16 @@ RSpec.describe Member do expect(user.authorized_projects.reload).to include(project) end + + it 'does not accept the invite if saving a new user fails' do + invalid_user = User.new(first_name: '', last_name: '') + + member.accept_invite! invalid_user + + expect(member.invite_accepted_at).to be_nil + expect(member.invite_token).not_to be_nil + expect_any_instance_of(Member).not_to receive(:after_accept_invite) + end end describe "#decline_invite!" do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 4a8a2909891..06ca88644b7 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -151,43 +151,6 @@ RSpec.describe MergeRequest, factory_default: :keep do end end - describe '#squash_in_progress?' do - let(:repo_path) do - Gitlab::GitalyClient::StorageSettings.allow_disk_access do - subject.source_project.repository.path - end - end - - let(:squash_path) { File.join(repo_path, "gitlab-worktree", "squash-#{subject.id}") } - - before do - system(*%W(#{Gitlab.config.git.bin_path} -C #{repo_path} worktree add --detach #{squash_path} master)) - end - - it 'returns true when there is a current squash directory' do - expect(subject.squash_in_progress?).to be_truthy - end - - it 'returns false when there is no squash directory' do - FileUtils.rm_rf(squash_path) - - expect(subject.squash_in_progress?).to be_falsey - end - - it 'returns false when the squash directory has expired' do - time = 20.minutes.ago.to_time - File.utime(time, time, squash_path) - - expect(subject.squash_in_progress?).to be_falsey - end - - it 'returns false when the source project has been removed' do - allow(subject).to receive(:source_project).and_return(nil) - - expect(subject.squash_in_progress?).to be_falsey - end - end - describe '#squash?' do let(:merge_request) { build(:merge_request, squash: squash) } diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index f14b9c57eb1..bc592acc80f 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -538,15 +538,6 @@ RSpec.describe Milestone do it { is_expected.to match('gitlab-org/gitlab-ce%123') } it { is_expected.to match('gitlab-org/gitlab-ce%"my-milestone"') } - - context 'when milestone_reference_pattern feature flag is false' do - before do - stub_feature_flags(milestone_reference_pattern: false) - end - - it { is_expected.to match('gitlab-org/gitlab-ce%123') } - it { is_expected.to match('gitlab-org/gitlab-ce%"my-milestone"') } - end end describe '.link_reference_pattern' do diff --git a/spec/models/namespace_setting_spec.rb b/spec/models/namespace_setting_spec.rb index e8ed6f1a460..c1cc8fc3e88 100644 --- a/spec/models/namespace_setting_spec.rb +++ b/spec/models/namespace_setting_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe NamespaceSetting, type: :model do + it_behaves_like 'sanitizable', :namespace_settings, %i[default_branch_name] + # Relationships # describe "Associations" do @@ -41,14 +43,6 @@ RSpec.describe NamespaceSetting, type: :model do it_behaves_like "doesn't return an error" end - - context "when it contains javascript tags" do - it "gets sanitized properly" do - namespace_settings.update!(default_branch_name: "hello<script>alert(1)</script>") - - expect(namespace_settings.default_branch_name).to eq('hello') - end - end end describe '#allow_mfa_for_group' do diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index e2700378f5f..51a26d82daa 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -36,27 +36,34 @@ RSpec.describe Namespace do it { is_expected.to validate_numericality_of(:max_artifacts_size).only_integer.is_greater_than(0) } context 'validating the parent of a namespace' do - context 'when the namespace has no parent' do - it 'allows a namespace to have no parent associated with it' do - namespace = build(:namespace) - - expect(namespace).to be_valid - end + using RSpec::Parameterized::TableSyntax + + 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 - context 'when the namespace has a parent' do - it 'does not allow a namespace to have a group as its parent' do - namespace = build(:namespace, parent: build(:group)) - - expect(namespace).not_to be_valid - expect(namespace.errors[:parent_id].first).to eq('a user namespace cannot have a parent') - end - - it 'does not allow a namespace to have another namespace as its parent' do - namespace = build(:namespace, parent: build(:namespace)) - - expect(namespace).not_to be_valid - expect(namespace.errors[:parent_id].first).to eq('a user namespace cannot have a parent') + with_them do + it 'validates namespace parent' do + parent = build(:namespace, type: parent_type) if parent_type + namespace = build(:namespace, type: child_type, parent: parent) + + if error + expect(namespace).not_to be_valid + expect(namespace.errors[:parent_id].first).to eq(error) + else + expect(namespace).to be_valid + end end end @@ -157,6 +164,65 @@ RSpec.describe Namespace do end end + describe 'handling STI', :aggregate_failures do + let(:namespace_type) { nil } + let(:parent) { nil } + let(:namespace) { Namespace.find(create(:namespace, type: namespace_type, parent: parent).id) } + + context 'creating a Group' do + let(:namespace_type) { 'Group' } + + it 'is valid' do + expect(namespace).to be_a(Group) + expect(namespace.kind).to eq('group') + expect(namespace.group?).to be_truthy + end + end + + context 'creating a ProjectNamespace' do + let(:namespace_type) { 'Project' } + let(:parent) { create(:group) } + + it 'is valid' do + expect(Namespace.find(namespace.id)).to be_a(Namespaces::ProjectNamespace) + expect(namespace.kind).to eq('project') + expect(namespace.project?).to be_truthy + end + end + + context 'creating a UserNamespace' do + let(:namespace_type) { 'User' } + + 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) + expect(namespace.kind).to eq('user') + expect(namespace.user?).to be_truthy + end + end + + context 'creating a default Namespace' do + let(:namespace_type) { nil } + + it 'is valid' do + expect(Namespace.find(namespace.id)).to be_a(Namespace) + expect(namespace.kind).to eq('user') + expect(namespace.user?).to be_truthy + end + end + + context 'creating an unknown Namespace type' do + let(:namespace_type) { 'One' } + + it 'defaults to a Namespace' do + expect(Namespace.find(namespace.id)).to be_a(Namespace) + expect(namespace.kind).to eq('user') + expect(namespace.user?).to be_truthy + end + end + end + describe 'scopes', :aggregate_failures do let_it_be(:namespace1) { create(:group, name: 'Namespace 1', path: 'namespace-1') } let_it_be(:namespace2) { create(:group, name: 'Namespace 2', path: 'namespace-2') } @@ -287,6 +353,12 @@ RSpec.describe Namespace do end end + describe '#owner_required?' do + specify { expect(build(:project_namespace).owner_required?).to be_falsey } + specify { expect(build(:group).owner_required?).to be_falsey } + specify { expect(build(:namespace).owner_required?).to be_truthy } + end + describe '#visibility_level_field' do it { expect(namespace.visibility_level_field).to eq(:visibility_level) } end @@ -1377,6 +1449,13 @@ RSpec.describe Namespace do expect { root_group.root_ancestor }.not_to exceed_query_limit(0) end + it 'returns root_ancestor for nested group with a single query' do + nested_group = create(:group, parent: root_group) + nested_group.reload + + expect { nested_group.root_ancestor }.not_to exceed_query_limit(1) + end + it 'returns the top most ancestor' do nested_group = create(:group, parent: root_group) deep_nested_group = create(:group, parent: nested_group) diff --git a/spec/models/namespaces/project_namespace_spec.rb b/spec/models/namespaces/project_namespace_spec.rb new file mode 100644 index 00000000000..f38e8aa85d0 --- /dev/null +++ b/spec/models/namespaces/project_namespace_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Namespaces::ProjectNamespace, type: :model do + describe 'relationships' do + it { is_expected.to have_one(:project).with_foreign_key(:project_namespace_id).inverse_of(:project_namespace) } + end + + describe 'validations' do + it { is_expected.not_to validate_presence_of :owner } + end + + context 'when deleting project namespace' do + # using delete rather than destroy due to `delete` skipping AR hooks/callbacks + # so it's ensured to work at the DB level. Uses ON DELETE CASCADE on foreign key + let_it_be(:project) { create(:project) } + let_it_be(:project_namespace) { create(:project_namespace, project: project) } + + it 'also deletes the associated project' do + project_namespace.delete + + expect { project_namespace.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { project.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 0afdae2fc93..5e3773513f1 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -500,15 +500,15 @@ RSpec.describe Note do let_it_be(:ext_issue) { create(:issue, project: ext_proj) } shared_examples "checks references" do - it "returns true" do + it "returns false" do expect(note.system_note_with_references_visible_for?(ext_issue.author)).to be_falsy end - it "returns false" do + it "returns true" do expect(note.system_note_with_references_visible_for?(private_user)).to be_truthy end - it "returns false if user visible reference count set" do + it "returns true if user visible reference count set" do note.user_visible_reference_count = 1 note.total_reference_count = 1 @@ -516,7 +516,15 @@ RSpec.describe Note do expect(note.system_note_with_references_visible_for?(ext_issue.author)).to be_truthy end - it "returns true if ref count is 0" do + it "returns false if user visible reference count set but does not match total reference count" do + note.user_visible_reference_count = 1 + note.total_reference_count = 2 + + expect(note).not_to receive(:reference_mentionables) + expect(note.system_note_with_references_visible_for?(ext_issue.author)).to be_falsy + end + + it "returns false if ref count is 0" do note.user_visible_reference_count = 0 expect(note).not_to receive(:reference_mentionables) @@ -562,13 +570,35 @@ RSpec.describe Note do end it_behaves_like "checks references" + end - it "returns true if user visible reference count set and there is a private reference" do - note.user_visible_reference_count = 1 - note.total_reference_count = 2 + context "when there is a private issue and user reference" do + let_it_be(:ext_issue2) { create(:issue, project: ext_proj) } - expect(note).not_to receive(:reference_mentionables) - expect(note.system_note_with_references_visible_for?(ext_issue.author)).to be_falsy + let(:note) do + create :note, + noteable: ext_issue2, project: ext_proj, + note: "mentioned in #{private_issue.to_reference(ext_proj)} and pinged user #{private_user.to_reference}", + system: true + end + + it_behaves_like "checks references" + end + + context "when there is a publicly visible user reference" do + let(:note) do + create :note, + noteable: ext_issue, project: ext_proj, + note: "mentioned in #{ext_proj.owner.to_reference}", + system: true + end + + it "returns true for other users" do + expect(note.system_note_with_references_visible_for?(ext_issue.author)).to be_truthy + end + + it "returns true for anonymous users" do + expect(note.system_note_with_references_visible_for?(nil)).to be_truthy end end end @@ -1543,7 +1573,15 @@ RSpec.describe Note do let(:note) { build(:note) } it 'returns cache key and author cache key by default' do - expect(note.post_processed_cache_key).to eq("#{note.cache_key}:#{note.author.cache_key}") + expect(note.post_processed_cache_key).to eq("#{note.cache_key}:#{note.author.cache_key}:#{note.project.team.human_max_access(note.author_id)}") + end + + context 'when note has no author' do + let(:note) { build(:note, author: nil) } + + it 'returns cache key only' do + expect(note.post_processed_cache_key).to eq("#{note.cache_key}:") + end end context 'when note has redacted_note_html' do @@ -1554,7 +1592,7 @@ RSpec.describe Note do end it 'returns cache key with redacted_note_html sha' do - expect(note.post_processed_cache_key).to eq("#{note.cache_key}:#{note.author.cache_key}:#{Digest::SHA1.hexdigest(redacted_note_html)}") + expect(note.post_processed_cache_key).to eq("#{note.cache_key}:#{note.author.cache_key}:#{note.project.team.human_max_access(note.author_id)}:#{Digest::SHA1.hexdigest(redacted_note_html)}") end end end diff --git a/spec/models/operations/feature_flag_scope_spec.rb b/spec/models/operations/feature_flag_scope_spec.rb deleted file mode 100644 index dc83789fade..00000000000 --- a/spec/models/operations/feature_flag_scope_spec.rb +++ /dev/null @@ -1,391 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Operations::FeatureFlagScope do - describe 'associations' do - it { is_expected.to belong_to(:feature_flag) } - end - - describe 'validations' do - context 'when duplicate environment scope is going to be created' do - let!(:existing_feature_flag_scope) do - create(:operations_feature_flag_scope) - end - - let(:new_feature_flag_scope) do - build(:operations_feature_flag_scope, - feature_flag: existing_feature_flag_scope.feature_flag, - environment_scope: existing_feature_flag_scope.environment_scope) - end - - it 'validates uniqueness of environment scope' do - new_feature_flag_scope.save - - expect(new_feature_flag_scope.errors[:environment_scope]) - .to include("(#{existing_feature_flag_scope.environment_scope})" \ - " has already been taken") - end - end - - context 'when environment scope of a default scope is updated' do - let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag) } - let!(:scope_default) { feature_flag.default_scope } - - it 'keeps default scope intact' do - scope_default.update(environment_scope: 'review/*') - - expect(scope_default.errors[:environment_scope]) - .to include("cannot be changed from default scope") - end - end - - context 'when a default scope is destroyed' do - let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag) } - let!(:scope_default) { feature_flag.default_scope } - - it 'prevents from destroying the default scope' do - expect { scope_default.destroy! }.to raise_error(ActiveRecord::ReadOnlyRecord) - end - end - - describe 'strategy validations' do - it 'handles null strategies which can occur while adding the column during migration' do - scope = create(:operations_feature_flag_scope, active: true) - allow(scope).to receive(:strategies).and_return(nil) - - scope.active = false - scope.save - - expect(scope.errors[:strategies]).to be_empty - end - - it 'validates multiple strategies' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: "default", parameters: {} }, - { name: "invalid", parameters: {} }]) - - expect(scope.errors[:strategies]).not_to be_empty - end - - where(:invalid_value) do - [{}, 600, "bad", [{ name: 'default', parameters: {} }, 300]] - end - with_them do - it 'must be an array of strategy hashes' do - scope = create(:operations_feature_flag_scope) - - scope.strategies = invalid_value - scope.save - - expect(scope.errors[:strategies]).to eq(['must be an array of strategy hashes']) - end - end - - describe 'name' do - using RSpec::Parameterized::TableSyntax - - where(:name, :params, :expected) do - 'default' | {} | [] - 'gradualRolloutUserId' | { groupId: 'mygroup', percentage: '50' } | [] - 'userWithId' | { userIds: 'sam' } | [] - 5 | nil | ['strategy name is invalid'] - nil | nil | ['strategy name is invalid'] - "nothing" | nil | ['strategy name is invalid'] - "" | nil | ['strategy name is invalid'] - 40.0 | nil | ['strategy name is invalid'] - {} | nil | ['strategy name is invalid'] - [] | nil | ['strategy name is invalid'] - end - with_them do - it 'must be one of "default", "gradualRolloutUserId", or "userWithId"' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: name, parameters: params }]) - - expect(scope.errors[:strategies]).to eq(expected) - end - end - end - - describe 'parameters' do - context 'when the strategy name is gradualRolloutUserId' do - it 'must have parameters' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'gradualRolloutUserId' }]) - - expect(scope.errors[:strategies]).to eq(['parameters are invalid']) - end - - where(:invalid_parameters) do - [nil, {}, { percentage: '40', groupId: 'mygroup', userIds: '4' }, { percentage: '40' }, - { percentage: '40', groupId: 'mygroup', extra: nil }, { groupId: 'mygroup' }] - end - with_them do - it 'must have valid parameters for the strategy' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'gradualRolloutUserId', - parameters: invalid_parameters }]) - - expect(scope.errors[:strategies]).to eq(['parameters are invalid']) - end - end - - it 'allows the parameters in any order' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'gradualRolloutUserId', - parameters: { percentage: '10', groupId: 'mygroup' } }]) - - expect(scope.errors[:strategies]).to be_empty - end - - describe 'percentage' do - where(:invalid_value) do - [50, 40.0, { key: "value" }, "garbage", "00", "01", "101", "-1", "-10", "0100", - "1000", "10.0", "5%", "25%", "100hi", "e100", "30m", " ", "\r\n", "\n", "\t", - "\n10", "20\n", "\n100", "100\n", "\n ", nil] - end - with_them do - it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'gradualRolloutUserId', - parameters: { groupId: 'mygroup', percentage: invalid_value } }]) - - expect(scope.errors[:strategies]).to eq(['percentage must be a string between 0 and 100 inclusive']) - end - end - - where(:valid_value) do - %w[0 1 10 38 100 93] - end - with_them do - it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'gradualRolloutUserId', - parameters: { groupId: 'mygroup', percentage: valid_value } }]) - - expect(scope.errors[:strategies]).to eq([]) - end - end - end - - describe 'groupId' do - where(:invalid_value) do - [nil, 4, 50.0, {}, 'spaces bad', 'bad$', '%bad', '<bad', 'bad>', '!bad', - '.bad', 'Bad', 'bad1', "", " ", "b" * 33, "ba_d", "ba\nd"] - end - with_them do - it 'must be a string value of up to 32 lowercase characters' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'gradualRolloutUserId', - parameters: { groupId: invalid_value, percentage: '40' } }]) - - expect(scope.errors[:strategies]).to eq(['groupId parameter is invalid']) - end - end - - where(:valid_value) do - ["somegroup", "anothergroup", "okay", "g", "a" * 32] - end - with_them do - it 'must be a string value of up to 32 lowercase characters' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'gradualRolloutUserId', - parameters: { groupId: valid_value, percentage: '40' } }]) - - expect(scope.errors[:strategies]).to eq([]) - end - end - end - end - - context 'when the strategy name is userWithId' do - it 'must have parameters' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'userWithId' }]) - - expect(scope.errors[:strategies]).to eq(['parameters are invalid']) - end - - where(:invalid_parameters) do - [nil, { userIds: 'sam', percentage: '40' }, { userIds: 'sam', some: 'param' }, { percentage: '40' }, {}] - end - with_them do - it 'must have valid parameters for the strategy' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'userWithId', parameters: invalid_parameters }]) - - expect(scope.errors[:strategies]).to eq(['parameters are invalid']) - end - end - - describe 'userIds' do - where(:valid_value) do - ["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike", - "gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0", - "$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed", - "a" * 256, "a,#{'b' * 256},ccc", "many spaces"] - end - with_them do - it 'is valid with a string of comma separated values' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'userWithId', parameters: { userIds: valid_value } }]) - - expect(scope.errors[:strategies]).to be_empty - end - end - - where(:invalid_value) do - [1, 2.5, {}, [], nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r", - "joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ", - " ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"] - end - with_them do - it 'is invalid' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'userWithId', parameters: { userIds: invalid_value } }]) - - expect(scope.errors[:strategies]).to include( - 'userIds must be a string of unique comma separated values each 256 characters or less' - ) - end - end - end - end - - context 'when the strategy name is default' do - it 'must have parameters' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'default' }]) - - expect(scope.errors[:strategies]).to eq(['parameters are invalid']) - end - - where(:invalid_value) do - [{ groupId: "hi", percentage: "7" }, "", "nothing", 7, nil, [], 2.5] - end - with_them do - it 'must be empty' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'default', - parameters: invalid_value }]) - - expect(scope.errors[:strategies]).to eq(['parameters are invalid']) - end - end - - it 'must be empty' do - feature_flag = create(:operations_feature_flag) - scope = described_class.create(feature_flag: feature_flag, - environment_scope: 'production', active: true, - strategies: [{ name: 'default', - parameters: {} }]) - - expect(scope.errors[:strategies]).to be_empty - end - end - end - end - end - - describe '.enabled' do - subject { described_class.enabled } - - let!(:feature_flag_scope) do - create(:operations_feature_flag_scope, active: active) - end - - context 'when scope is active' do - let(:active) { true } - - it 'returns the scope' do - is_expected.to include(feature_flag_scope) - end - end - - context 'when scope is inactive' do - let(:active) { false } - - it 'returns an empty array' do - is_expected.not_to include(feature_flag_scope) - end - end - end - - describe '.disabled' do - subject { described_class.disabled } - - let!(:feature_flag_scope) do - create(:operations_feature_flag_scope, active: active) - end - - context 'when scope is active' do - let(:active) { true } - - it 'returns an empty array' do - is_expected.not_to include(feature_flag_scope) - end - end - - context 'when scope is inactive' do - let(:active) { false } - - it 'returns the scope' do - is_expected.to include(feature_flag_scope) - end - end - end - - describe '.for_unleash_client' do - it 'returns scopes for the specified project' do - project1 = create(:project) - project2 = create(:project) - expected_feature_flag = create(:operations_feature_flag, project: project1) - create(:operations_feature_flag, project: project2) - - scopes = described_class.for_unleash_client(project1, 'sandbox').to_a - - expect(scopes).to contain_exactly(*expected_feature_flag.scopes) - end - - it 'returns a scope that matches exactly over a match with a wild card' do - project = create(:project) - feature_flag = create(:operations_feature_flag, project: project) - create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production*') - expected_scope = create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production') - - scopes = described_class.for_unleash_client(project, 'production').to_a - - expect(scopes).to contain_exactly(expected_scope) - end - end -end diff --git a/spec/models/operations/feature_flag_spec.rb b/spec/models/operations/feature_flag_spec.rb index cb9da2aea34..d689632e2b4 100644 --- a/spec/models/operations/feature_flag_spec.rb +++ b/spec/models/operations/feature_flag_spec.rb @@ -49,28 +49,7 @@ RSpec.describe Operations::FeatureFlag do it { is_expected.to validate_presence_of(:project) } it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) } - it { is_expected.to define_enum_for(:version).with_values(legacy_flag: 1, new_version_flag: 2) } - - context 'a version 1 feature flag' do - it 'is valid if associated with Operations::FeatureFlagScope models' do - project = create(:project) - feature_flag = described_class.create!({ name: 'test', project: project, version: 1, - scopes_attributes: [{ environment_scope: '*', active: false }] }) - - expect(feature_flag).to be_valid - end - - it 'is invalid if associated with Operations::FeatureFlags::Strategy models' do - project = create(:project) - feature_flag = described_class.new({ name: 'test', project: project, version: 1, - strategies_attributes: [{ name: 'default', parameters: {} }] }) - - expect(feature_flag.valid?).to eq(false) - expect(feature_flag.errors.messages).to eq({ - version_associations: ["version 1 feature flags may not have strategies"] - }) - end - end + 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 @@ -102,64 +81,9 @@ RSpec.describe Operations::FeatureFlag do end end - describe 'feature flag version' do - it 'defaults to 1 if unspecified' do - project = create(:project) - - feature_flag = described_class.create!(name: 'my_flag', project: project, active: true) - - expect(feature_flag).to be_valid - expect(feature_flag.version_before_type_cast).to eq(1) - end - end - - describe 'Scope creation' do - subject { described_class.new(**params) } - - let(:project) { create(:project) } - - let(:params) do - { name: 'test', project: project, scopes_attributes: scopes_attributes } - end - - let(:scopes_attributes) do - [{ environment_scope: '*', active: false }, - { environment_scope: 'review/*', active: true }] - end - - it { is_expected.to be_valid } - - context 'when the first scope is not wildcard' do - let(:scopes_attributes) do - [{ environment_scope: 'review/*', active: true }, - { environment_scope: '*', active: false }] - end - - it { is_expected.not_to be_valid } - end - end - describe 'the default scope' do let_it_be(:project) { create(:project) } - context 'with a version 1 feature flag' do - it 'creates a default scope' do - feature_flag = described_class.create!({ name: 'test', project: project, scopes_attributes: [], version: 1 }) - - expect(feature_flag.scopes.count).to eq(1) - expect(feature_flag.scopes.first.environment_scope).to eq('*') - end - - it 'allows specifying the default scope in the parameters' do - feature_flag = described_class.create!({ name: 'test', project: project, - scopes_attributes: [{ environment_scope: '*', active: false }, - { environment_scope: 'review/*', active: true }], version: 1 }) - - expect(feature_flag.scopes.count).to eq(2) - expect(feature_flag.scopes.first.environment_scope).to eq('*') - end - end - 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 }) @@ -180,16 +104,6 @@ RSpec.describe Operations::FeatureFlag do end end - context 'when the feature flag is active and all scopes are inactive' do - let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, active: true) } - - it 'returns the flag' do - feature_flag.default_scope.update!(active: false) - - is_expected.to eq([feature_flag]) - end - end - context 'when the feature flag is inactive' do let!(:feature_flag) { create(:operations_feature_flag, active: false) } @@ -197,16 +111,6 @@ RSpec.describe Operations::FeatureFlag do is_expected.to be_empty end end - - context 'when the feature flag is inactive and all scopes are active' do - let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, active: false) } - - it 'does not return the flag' do - feature_flag.default_scope.update!(active: true) - - is_expected.to be_empty - end - end end describe '.disabled' do @@ -220,16 +124,6 @@ RSpec.describe Operations::FeatureFlag do end end - context 'when the feature flag is active and all scopes are inactive' do - let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, active: true) } - - it 'does not return the flag' do - feature_flag.default_scope.update!(active: false) - - is_expected.to be_empty - end - end - context 'when the feature flag is inactive' do let!(:feature_flag) { create(:operations_feature_flag, active: false) } @@ -237,16 +131,6 @@ RSpec.describe Operations::FeatureFlag do is_expected.to eq([feature_flag]) end end - - context 'when the feature flag is inactive and all scopes are active' do - let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, active: false) } - - it 'returns the flag' do - feature_flag.default_scope.update!(active: true) - - is_expected.to eq([feature_flag]) - end - end end describe '.for_unleash_client' do diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb index 90910fcb7ce..450656e3e9c 100644 --- a/spec/models/packages/package_file_spec.rb +++ b/spec/models/packages/package_file_spec.rb @@ -2,6 +2,8 @@ require 'spec_helper' RSpec.describe Packages::PackageFile, type: :model do + using RSpec::Parameterized::TableSyntax + let_it_be(:project) { create(:project) } let_it_be(:package_file1) { create(:package_file, :xml, file_name: 'FooBar') } let_it_be(:package_file2) { create(:package_file, :xml, file_name: 'ThisIsATest') } @@ -139,6 +141,71 @@ RSpec.describe Packages::PackageFile, type: :model do end end + describe '.most_recent!' do + it { expect(described_class.most_recent!).to eq(debian_package.package_files.last) } + end + + describe '.most_recent_for' do + let_it_be(:package1) { create(:npm_package) } + let_it_be(:package2) { create(:npm_package) } + let_it_be(:package3) { create(:npm_package) } + let_it_be(:package4) { create(:npm_package) } + + let_it_be(:package_file2_2) { create(:package_file, :npm, package: package2) } + + let_it_be(:package_file3_2) { create(:package_file, :npm, package: package3) } + let_it_be(:package_file3_3) { create(:package_file, :npm, package: package3) } + + let_it_be(:package_file4_2) { create(:package_file, :npm, package: package2) } + let_it_be(:package_file4_3) { create(:package_file, :npm, package: package2) } + let_it_be(:package_file4_4) { create(:package_file, :npm, package: package2) } + + let(:most_recent_package_file1) { package1.package_files.recent.first } + let(:most_recent_package_file2) { package2.package_files.recent.first } + let(:most_recent_package_file3) { package3.package_files.recent.first } + let(:most_recent_package_file4) { package4.package_files.recent.first } + + subject { described_class.most_recent_for(packages) } + + where( + package_input1: [1, nil], + package_input2: [2, nil], + package_input3: [3, nil], + package_input4: [4, nil] + ) + + with_them do + let(:compact_inputs) { [package_input1, package_input2, package_input3, package_input4].compact } + let(:packages) do + ::Packages::Package.id_in( + compact_inputs.map { |pkg_number| public_send("package#{pkg_number}") } + .map(&:id) + ) + end + + let(:expected_package_files) { compact_inputs.map { |pkg_number| public_send("most_recent_package_file#{pkg_number}") } } + + it { is_expected.to contain_exactly(*expected_package_files) } + end + + context 'extra join and extra where' do + let_it_be(:helm_package) { create(:helm_package, without_package_files: true) } + let_it_be(:helm_package_file1) { create(:helm_package_file, channel: 'alpha') } + let_it_be(:helm_package_file2) { create(:helm_package_file, channel: 'alpha', package: helm_package) } + let_it_be(:helm_package_file3) { create(:helm_package_file, channel: 'beta', package: helm_package) } + let_it_be(:helm_package_file4) { create(:helm_package_file, channel: 'beta', package: helm_package) } + + let(:extra_join) { :helm_file_metadatum } + let(:extra_where) { { packages_helm_file_metadata: { channel: 'alpha' } } } + + subject { described_class.most_recent_for(Packages::Package.id_in(helm_package.id), extra_join: extra_join, extra_where: extra_where) } + + it 'returns the most recent package for the selected channel' do + expect(subject).to contain_exactly(helm_package_file2) + end + end + end + describe '#update_file_store callback' do let_it_be(:package_file) { build(:package_file, :nuget, size: nil) } diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb index 4d4d4ad4fa9..99e5769fc1f 100644 --- a/spec/models/packages/package_spec.rb +++ b/spec/models/packages/package_spec.rb @@ -1165,4 +1165,47 @@ RSpec.describe Packages::Package, type: :model do it_behaves_like 'not enqueuing a sync worker job' end end + + describe '#create_build_infos!' do + let_it_be(:package) { create(:package) } + let_it_be(:pipeline) { create(:ci_pipeline) } + + let(:build) { double(pipeline: pipeline) } + + subject { package.create_build_infos!(build) } + + context 'with a valid build' do + it 'creates a build info' do + expect { subject }.to change { ::Packages::BuildInfo.count }.by(1) + + last_build = ::Packages::BuildInfo.last + expect(last_build.package).to eq(package) + expect(last_build.pipeline).to eq(pipeline) + end + + context 'with an already existing build info' do + let_it_be(:build_info) { create(:packages_build_info, package: package, pipeline: pipeline) } + + it 'does not create a build info' do + expect { subject }.not_to change { ::Packages::BuildInfo.count } + end + end + end + + context 'with a nil build' do + let(:build) { nil } + + it 'does not create a build info' do + expect { subject }.not_to change { ::Packages::BuildInfo.count } + end + end + + context 'with a build without a pipeline' do + let(:build) { double(pipeline: nil) } + + it 'does not create a build info' do + expect { subject }.not_to change { ::Packages::BuildInfo.count } + end + end + end end diff --git a/spec/models/preloaders/commit_status_preloader_spec.rb b/spec/models/preloaders/commit_status_preloader_spec.rb new file mode 100644 index 00000000000..85ea784335c --- /dev/null +++ b/spec/models/preloaders/commit_status_preloader_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Preloaders::CommitStatusPreloader do + let_it_be(:pipeline) { create(:ci_pipeline) } + + let_it_be(:build1) { create(:ci_build, :tags, pipeline: pipeline) } + let_it_be(:build2) { create(:ci_build, :tags, pipeline: pipeline) } + let_it_be(:bridge1) { create(:ci_bridge, pipeline: pipeline) } + let_it_be(:bridge2) { create(:ci_bridge, pipeline: pipeline) } + let_it_be(:generic_commit_status1) { create(:generic_commit_status, pipeline: pipeline) } + let_it_be(:generic_commit_status2) { create(:generic_commit_status, pipeline: pipeline) } + + describe '#execute' do + let(:relations) { %i[pipeline metadata tags job_artifacts_archive downstream_pipeline] } + let(:statuses) { CommitStatus.where(commit_id: pipeline.id).all } + + subject(:execute) { described_class.new(statuses).execute(relations) } + + it 'prevents N+1 for specified relations', :use_sql_query_cache do + execute + + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + call_each_relation(statuses.sample(3)) + end + + expect do + call_each_relation(statuses) + end.to issue_same_number_of_queries_as(control_count) + end + + private + + def call_each_relation(statuses) + statuses.each do |status| + relations.each { |relation| status.public_send(relation) if status.respond_to?(relation) } + end + end + end +end diff --git a/spec/models/preloaders/merge_requests_preloader_spec.rb b/spec/models/preloaders/merge_requests_preloader_spec.rb new file mode 100644 index 00000000000..7108de2e491 --- /dev/null +++ b/spec/models/preloaders/merge_requests_preloader_spec.rb @@ -0,0 +1,42 @@ +# 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/preloaders/user_max_access_level_in_groups_preloader_spec.rb b/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb new file mode 100644 index 00000000000..8144e1ad233 --- /dev/null +++ b/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Preloaders::UserMaxAccessLevelInGroupsPreloader do + let_it_be(:user) { create(:user) } + let_it_be(:group1) { create(:group, :private).tap { |g| g.add_developer(user) } } + let_it_be(:group2) { create(:group, :private).tap { |g| g.add_developer(user) } } + let_it_be(:group3) { create(:group, :private) } + + let(:max_query_regex) { /SELECT MAX\("members"\."access_level"\).+/ } + let(:groups) { [group1, group2, group3] } + + shared_examples 'executes N max member permission queries to the DB' do + it 'executes the specified max membership queries' do + queries = ActiveRecord::QueryRecorder.new do + groups.each { |group| user.can?(:read_group, group) } + end + + max_queries = queries.log.grep(max_query_regex) + + expect(max_queries.count).to eq(expected_query_count) + end + end + + context 'when the preloader is used', :request_store do + before do + described_class.new(groups, user).execute + end + + it_behaves_like 'executes N max member permission queries to the DB' do + # Will query all groups where the user is not already a member + let(:expected_query_count) { 1 } + end + + context 'when user has access but is not a direct member of the group' do + let(:groups) { [group1, group2, group3, create(:group, :private, parent: group1)] } + + it_behaves_like 'executes N max member permission queries to the DB' do + # One query for group with no access and another one where the user is not a direct member + let(:expected_query_count) { 2 } + end + end + end + + context 'when the preloader is not used', :request_store do + it_behaves_like 'executes N max member permission queries to the DB' do + let(:expected_query_count) { groups.count } + end + end +end diff --git a/spec/models/project_ci_cd_setting_spec.rb b/spec/models/project_ci_cd_setting_spec.rb index caab182cda8..406485d8cc8 100644 --- a/spec/models/project_ci_cd_setting_spec.rb +++ b/spec/models/project_ci_cd_setting_spec.rb @@ -21,12 +21,6 @@ RSpec.describe ProjectCiCdSetting do end end - describe '#job_token_scope_enabled' do - it 'is false by default' do - expect(described_class.new.job_token_scope_enabled).to be_falsey - end - end - describe '#default_git_depth' do let(:default_value) { described_class::DEFAULT_GIT_DEPTH } diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index 5f720f8c4f8..75e43ed9a67 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -41,18 +41,15 @@ RSpec.describe ProjectFeature do end end - context 'public features' do - features = ProjectFeature::FEATURES - %i(pages) + it_behaves_like 'access level validation', ProjectFeature::FEATURES - %i(pages) do + let(:container_features) { project.project_feature } + end - features.each do |feature| - it "does not allow public access level for #{feature}" do - project_feature = project.project_feature - field = "#{feature}_access_level".to_sym - project_feature.update_attribute(field, ProjectFeature::PUBLIC) + it 'allows public access level for :pages feature' do + project_feature = project.project_feature + project_feature.pages_access_level = ProjectFeature::PUBLIC - expect(project_feature.valid?).to be_falsy, "#{field} failed" - end - end + expect(project_feature.valid?).to be_truthy end describe 'default pages access level' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index d8f3a63d221..3989ddc31e8 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -16,6 +16,7 @@ RSpec.describe Project, factory_default: :keep do describe 'associations' do it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:namespace) } + it { is_expected.to belong_to(:project_namespace).class_name('Namespaces::ProjectNamespace').with_foreign_key('project_namespace_id').inverse_of(:project) } it { is_expected.to belong_to(:creator).class_name('User') } it { is_expected.to belong_to(:pool_repository) } it { is_expected.to have_many(:users) } @@ -137,6 +138,8 @@ RSpec.describe Project, factory_default: :keep do it { is_expected.to have_many(:timelogs) } it { is_expected.to have_many(:error_tracking_errors).class_name('ErrorTracking::Error') } 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') } # GitLab Pages it { is_expected.to have_many(:pages_domains) } @@ -183,6 +186,20 @@ RSpec.describe Project, factory_default: :keep do end end + context 'when deleting project' do + # using delete rather than destroy due to `delete` skipping AR hooks/callbacks + # so it's ensured to work at the DB level. Uses AFTER DELETE trigger. + let_it_be(:project) { create(:project) } + let_it_be(:project_namespace) { create(:project_namespace, project: project) } + + it 'also deletes the associated ProjectNamespace' do + project.delete + + expect { project.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { project_namespace.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + context 'when creating a new project' do let_it_be(:project) { create(:project) } @@ -602,6 +619,12 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#membership_locked?' do + it 'returns false' do + expect(build(:project)).not_to be_membership_locked + end + end + describe '#autoclose_referenced_issues' do context 'when DB entry is nil' do let(:project) { build(:project, autoclose_referenced_issues: nil) } @@ -1051,12 +1074,12 @@ RSpec.describe Project, factory_default: :keep do project.open_issues_count(user) end - it 'invokes the count service with no current_user' do - count_service = instance_double(Projects::OpenIssuesCountService) - expect(Projects::OpenIssuesCountService).to receive(:new).with(project, nil).and_return(count_service) - expect(count_service).to receive(:count) + it 'invokes the batch count service with no current_user' do + count_service = instance_double(Projects::BatchOpenIssuesCountService) + expect(Projects::BatchOpenIssuesCountService).to receive(:new).with([project]).and_return(count_service) + expect(count_service).to receive(:refresh_cache_and_retrieve_data).and_return({}) - project.open_issues_count + project.open_issues_count.to_s end end @@ -1257,19 +1280,19 @@ RSpec.describe Project, factory_default: :keep do end it 'returns an active external wiki' do - create(:service, project: project, type: 'ExternalWikiService', active: true) + create(:external_wiki_integration, project: project, active: true) is_expected.to be_kind_of(Integrations::ExternalWiki) end it 'does not return an inactive external wiki' do - create(:service, project: project, type: 'ExternalWikiService', active: false) + create(:external_wiki_integration, project: project, active: false) is_expected.to eq(nil) end it 'sets Project#has_external_wiki when it is nil' do - create(:service, project: project, type: 'ExternalWikiService', active: true) + create(:external_wiki_integration, project: project, active: true) project.update_column(:has_external_wiki, nil) expect { subject }.to change { project.has_external_wiki }.from(nil).to(true) @@ -1279,36 +1302,40 @@ RSpec.describe Project, factory_default: :keep do describe '#has_external_wiki' do let_it_be(:project) { create(:project) } - def subject + def has_external_wiki project.reload.has_external_wiki end - specify { is_expected.to eq(false) } + specify { expect(has_external_wiki).to eq(false) } - context 'when there is an active external wiki service' do - let!(:service) do - create(:service, project: project, type: 'ExternalWikiService', active: true) + context 'when there is an active external wiki integration' do + let(:active) { true } + + let!(:integration) do + create(:external_wiki_integration, project: project, active: active) end - specify { is_expected.to eq(true) } + specify { expect(has_external_wiki).to eq(true) } it 'becomes false if the external wiki service is destroyed' do expect do - Integration.find(service.id).delete - end.to change { subject }.to(false) + Integration.find(integration.id).delete + end.to change { has_external_wiki }.to(false) end it 'becomes false if the external wiki service becomes inactive' do expect do - service.update_column(:active, false) - end.to change { subject }.to(false) + integration.update_column(:active, false) + end.to change { has_external_wiki }.to(false) end - end - it 'is false when external wiki service is not active' do - create(:service, project: project, type: 'ExternalWikiService', active: false) + context 'when created as inactive' do + let(:active) { false } - is_expected.to eq(false) + it 'is false' do + expect(has_external_wiki).to eq(false) + end + end end end @@ -2536,7 +2563,7 @@ RSpec.describe Project, factory_default: :keep do end describe '#uses_default_ci_config?' do - let(:project) { build(:project)} + let(:project) { build(:project) } it 'has a custom ci config path' do project.ci_config_path = 'something_custom' @@ -2557,6 +2584,44 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#uses_external_project_ci_config?' do + subject(:uses_external_project_ci_config) { project.uses_external_project_ci_config? } + + let(:project) { build(:project) } + + context 'when ci_config_path is configured with external project' do + before do + project.ci_config_path = '.gitlab-ci.yml@hello/world' + end + + it { is_expected.to eq(true) } + end + + context 'when ci_config_path is nil' do + before do + project.ci_config_path = nil + end + + it { is_expected.to eq(false) } + end + + context 'when ci_config_path is configured with a file in the project' do + before do + project.ci_config_path = 'hello/world/gitlab-ci.yml' + end + + it { is_expected.to eq(false) } + end + + context 'when ci_config_path is configured with remote file' do + before do + project.ci_config_path = 'https://example.org/file.yml' + end + + it { is_expected.to eq(false) } + end + end + describe '#latest_successful_build_for_ref' do let_it_be(:project) { create(:project, :repository) } let_it_be(:pipeline) { create_pipeline(project) } @@ -3260,6 +3325,16 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#after_change_head_branch_does_not_exist' do + let_it_be(:project) { create(:project) } + + it 'adds an error to container if branch does not exist' do + expect do + project.after_change_head_branch_does_not_exist('unexisted-branch') + end.to change { project.errors.size }.from(0).to(1) + end + end + describe '#lfs_objects_for_repository_types' do let(:project) { create(:project) } @@ -4496,44 +4571,6 @@ RSpec.describe Project, factory_default: :keep do end end - describe '#legacy_remove_pages' do - let(:project) { create(:project).tap { |project| project.mark_pages_as_deployed } } - let(:pages_metadatum) { project.pages_metadatum } - let(:namespace) { project.namespace } - let(:pages_path) { project.pages_path } - - around do |example| - FileUtils.mkdir_p(pages_path) - begin - example.run - ensure - FileUtils.rm_rf(pages_path) - end - end - - it 'removes the pages directory and marks the project as not having pages deployed' do - expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return(true) - expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, namespace.full_path, anything) - - expect { project.legacy_remove_pages }.to change { pages_metadatum.reload.deployed }.from(true).to(false) - end - - it 'does nothing if updates on legacy storage are disabled' do - allow(Settings.pages.local_store).to receive(:enabled).and_return(false) - - expect(Gitlab::PagesTransfer).not_to receive(:new) - expect(PagesWorker).not_to receive(:perform_in) - - project.legacy_remove_pages - end - - it 'is run when the project is destroyed' do - expect(project).to receive(:legacy_remove_pages).and_call_original - - expect { project.destroy! }.not_to raise_error - end - end - describe '#remove_export' do let(:project) { create(:project, :with_export) } @@ -7037,6 +7074,15 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#ci_config_external_project' do + subject(:ci_config_external_project) { project.ci_config_external_project } + + let(:other_project) { create(:project) } + let(:project) { build(:project, ci_config_path: ".gitlab-ci.yml@#{other_project.full_path}") } + + it { is_expected.to eq(other_project) } + end + describe '#enabled_group_deploy_keys' do let_it_be(:project) { create(:project) } @@ -7131,15 +7177,96 @@ RSpec.describe Project, factory_default: :keep do end describe 'topics' do - let_it_be(:project) { create(:project, topic_list: 'topic1, topic2, topic3') } + let_it_be(:project) { create(:project, name: 'topic-project', topic_list: 'topic1, topic2, topic3') } it 'topic_list returns correct string array' do - expect(project.topic_list).to match_array(%w[topic1 topic2 topic3]) + expect(project.topic_list).to eq(%w[topic1 topic2 topic3]) + end + + it 'topics returns correct topic records' do + expect(project.topics.first.class.name).to eq('Projects::Topic') + expect(project.topics.map(&:name)).to eq(%w[topic1 topic2 topic3]) + end + + context 'topic_list=' do + using RSpec::Parameterized::TableSyntax + + where(:topic_list, :expected_result) do + ['topicA', 'topicB'] | %w[topicA topicB] # rubocop:disable Style/WordArray, Lint/BinaryOperatorWithIdenticalOperands + ['topicB', 'topicA'] | %w[topicB topicA] # rubocop:disable Style/WordArray, Lint/BinaryOperatorWithIdenticalOperands + [' topicC ', ' topicD '] | %w[topicC topicD] + ['topicE', 'topicF', 'topicE'] | %w[topicE topicF] # rubocop:disable Style/WordArray + ['topicE ', 'topicF', ' topicE'] | %w[topicE topicF] + 'topicA, topicB' | %w[topicA topicB] + 'topicB, topicA' | %w[topicB topicA] + ' topicC , topicD ' | %w[topicC topicD] + 'topicE, topicF, topicE' | %w[topicE topicF] + 'topicE , topicF, topicE' | %w[topicE topicF] + end + + with_them do + it 'set topics' do + project.topic_list = topic_list + project.save! + + expect(project.topics.map(&:name)).to eq(expected_result) + end + end + + it 'set topics if only the order is changed' do + project.topic_list = 'topicA, topicB' + project.save! + + expect(project.reload.topics.map(&:name)).to eq(%w[topicA topicB]) + + project.topic_list = 'topicB, topicA' + project.save! + + expect(project.reload.topics.map(&:name)).to eq(%w[topicB topicA]) + end + + it 'does not persist topics before project is saved' do + project.topic_list = 'topicA, topicB' + + expect(project.reload.topics.map(&:name)).to eq(%w[topic1 topic2 topic3]) + end + + it 'does not update topics if project is not valid' do + project.name = nil + project.topic_list = 'topicA, topicB' + + expect(project.save).to be_falsy + expect(project.reload.topics.map(&:name)).to eq(%w[topic1 topic2 topic3]) + end end - it 'topics returns correct tag records' do - expect(project.topics.first.class.name).to eq('ActsAsTaggableOn::Tag') - expect(project.topics.map(&:name)).to match_array(%w[topic1 topic2 topic3]) + 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 diff --git a/spec/models/projects/project_topic_spec.rb b/spec/models/projects/project_topic_spec.rb new file mode 100644 index 00000000000..c7a989040c7 --- /dev/null +++ b/spec/models/projects/project_topic_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::ProjectTopic do + let_it_be(:project_topic, reload: true) { create(:project_topic) } + + subject { project_topic } + + it { expect(subject).to be_valid } + + describe 'associations' do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:topic) } + end +end diff --git a/spec/models/projects/topic_spec.rb b/spec/models/projects/topic_spec.rb new file mode 100644 index 00000000000..409dc932709 --- /dev/null +++ b/spec/models/projects/topic_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::Topic do + let_it_be(:topic, reload: true) { create(:topic) } + + subject { topic } + + it { expect(subject).to be_valid } + + describe 'associations' do + it { is_expected.to have_many(:project_topics) } + it { is_expected.to have_many(:projects) } + end + + describe 'validations' 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) } + end +end diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb index a173ab48f17..019c01af672 100644 --- a/spec/models/protected_branch_spec.rb +++ b/spec/models/protected_branch_spec.rb @@ -162,6 +162,30 @@ RSpec.describe ProtectedBranch do expect(described_class.protected?(project, 'staging/some-branch')).to eq(false) end + + context 'with caching', :use_clean_rails_memory_store_caching do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:protected_branch) { create(:protected_branch, project: project, name: "jawn") } + + before do + allow(described_class).to receive(:matching).once.and_call_original + # the original call works and warms the cache + described_class.protected?(project, 'jawn') + end + + it 'correctly invalidates a cache' do + expect(described_class).to receive(:matching).once.and_call_original + + create(:protected_branch, project: project, name: "bar") + # the cache is invalidated because the project has been "updated" + expect(described_class.protected?(project, 'jawn')).to eq(true) + end + + it 'correctly uses the cached version' do + expect(described_class).not_to receive(:matching) + expect(described_class.protected?(project, 'jawn')).to eq(true) + end + end end context 'new project' do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 211e448b6cf..dc55214c1dd 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -68,51 +68,69 @@ RSpec.describe Repository do describe 'tags_sorted_by' do let(:tags_to_compare) { %w[v1.0.0 v1.1.0] } + let(:feature_flag) { true } + + before do + stub_feature_flags(gitaly_tags_finder: feature_flag) + end context 'name_desc' do subject { repository.tags_sorted_by('name_desc').map(&:name) & tags_to_compare } it { is_expected.to eq(['v1.1.0', 'v1.0.0']) } + + context 'when feature flag is disabled' do + let(:feature_flag) { false } + + it { is_expected.to eq(['v1.1.0', 'v1.0.0']) } + end end context 'name_asc' do subject { repository.tags_sorted_by('name_asc').map(&:name) & tags_to_compare } it { is_expected.to eq(['v1.0.0', 'v1.1.0']) } + + context 'when feature flag is disabled' do + let(:feature_flag) { false } + + it { is_expected.to eq(['v1.0.0', 'v1.1.0']) } + end end context 'updated' do - let(:tag_a) { repository.find_tag('v1.0.0') } - let(:tag_b) { repository.find_tag('v1.1.0') } + let(:latest_tag) { 'v0.0.0' } + + before do + rugged_repo(repository).tags.create(latest_tag, repository.commit.id) + end + + after do + rugged_repo(repository).tags.delete(latest_tag) + end context 'desc' do - subject { repository.tags_sorted_by('updated_desc').map(&:name) } + subject { repository.tags_sorted_by('updated_desc').map(&:name) & (tags_to_compare + [latest_tag]) } - before do - double_first = double(committed_date: Time.current) - double_last = double(committed_date: Time.current - 1.second) + it { is_expected.to eq([latest_tag, 'v1.1.0', 'v1.0.0']) } - allow(tag_a).to receive(:dereferenced_target).and_return(double_first) - allow(tag_b).to receive(:dereferenced_target).and_return(double_last) - allow(repository).to receive(:tags).and_return([tag_a, tag_b]) - end + context 'when feature flag is disabled' do + let(:feature_flag) { false } - it { is_expected.to eq(['v1.0.0', 'v1.1.0']) } + it { is_expected.to eq([latest_tag, 'v1.1.0', 'v1.0.0']) } + end end context 'asc' do - subject { repository.tags_sorted_by('updated_asc').map(&:name) } + subject { repository.tags_sorted_by('updated_asc').map(&:name) & (tags_to_compare + [latest_tag]) } - before do - double_first = double(committed_date: Time.current - 1.second) - double_last = double(committed_date: Time.current) + it { is_expected.to eq(['v1.0.0', 'v1.1.0', latest_tag]) } - allow(tag_a).to receive(:dereferenced_target).and_return(double_last) - allow(tag_b).to receive(:dereferenced_target).and_return(double_first) - allow(repository).to receive(:tags).and_return([tag_a, tag_b]) - end + context 'when feature flag is disabled' do + let(:feature_flag) { false } - it { is_expected.to eq(['v1.1.0', 'v1.0.0']) } + it { is_expected.to eq(['v1.0.0', 'v1.1.0', latest_tag]) } + end end context 'annotated tag pointing to a blob' do @@ -125,29 +143,32 @@ RSpec.describe Repository do tagger: { name: 'John Smith', email: 'john@gmail.com' } } rugged_repo(repository).tags.create(annotated_tag_name, 'a48e4fc218069f68ef2e769dd8dfea3991362175', **options) + end - double_first = double(committed_date: Time.current - 1.second) - double_last = double(committed_date: Time.current) + it { is_expected.to eq(['v1.0.0', 'v1.1.0', annotated_tag_name]) } - allow(tag_a).to receive(:dereferenced_target).and_return(double_last) - allow(tag_b).to receive(:dereferenced_target).and_return(double_first) - end + context 'when feature flag is disabled' do + let(:feature_flag) { false } - it { is_expected.to eq(['v1.1.0', 'v1.0.0', annotated_tag_name]) } + it { is_expected.to eq(['v1.0.0', 'v1.1.0', annotated_tag_name]) } + end after do rugged_repo(repository).tags.delete(annotated_tag_name) end end end - end - describe '#ref_name_for_sha' do - it 'returns the ref' do - allow(repository.raw_repository).to receive(:ref_name_for_sha) - .and_return('refs/environments/production/77') + context 'unknown option' do + subject { repository.tags_sorted_by('unknown_desc').map(&:name) & tags_to_compare } + + it { is_expected.to eq(['v1.0.0', 'v1.1.0']) } - expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq 'refs/environments/production/77' + context 'when feature flag is disabled' do + let(:feature_flag) { false } + + it { is_expected.to eq(['v1.0.0', 'v1.1.0']) } + end end end @@ -479,6 +500,29 @@ RSpec.describe Repository do end end + describe '#commits_between' do + let(:commit) { project.commit } + + it 'delegates to Gitlab::Git::Commit#between, returning decorated commits' do + expect(Gitlab::Git::Commit) + .to receive(:between) + .with(repository.raw_repository, commit.parent_id, commit.id, limit: 5) + .and_call_original + + result = repository.commits_between(commit.parent_id, commit.id, limit: 5) + + expect(result).to contain_exactly(instance_of(Commit), instance_of(Commit)) + end + + it 'defaults to no limit' do + expect(Gitlab::Git::Commit) + .to receive(:between) + .with(repository.raw_repository, commit.parent_id, commit.id, limit: nil) + + repository.commits_between(commit.parent_id, commit.id) + end + end + describe '#find_commits_by_message' do it 'returns commits with messages containing a given string' do commit_ids = repository.find_commits_by_message('submodule').map(&:id) @@ -1294,6 +1338,15 @@ RSpec.describe Repository do expect(repository.license).to be_nil end + it 'returns nil when license_key is not recognized' do + expect(repository).to receive(:license_key).twice.and_return('not-recognized') + expect(Gitlab::ErrorTracking).to receive(:track_exception) do |ex| + expect(ex).to be_a(Licensee::InvalidLicense) + end + + expect(repository.license).to be_nil + end + it 'returns other when the content is not recognizable' do license = Licensee::License.new('other') repository.create_file(user, 'LICENSE', 'Gitlab B.V.', @@ -1773,7 +1826,7 @@ RSpec.describe Repository do expect(merge_commit.message).to eq('Custom message') expect(merge_commit.author_name).to eq(user.name) - expect(merge_commit.author_email).to eq(user.commit_email) + expect(merge_commit.author_email).to eq(user.commit_email_or_default) expect(repository.blob_at(merge_commit.id, 'files/ruby/feature.rb')).to be_present end end @@ -2313,6 +2366,42 @@ RSpec.describe Repository do end end + describe '#find_tag' do + before do + allow(Gitlab::GitalyClient).to receive(:call).and_call_original + end + + it 'finds a tag with specified name by performing FindTag request' do + expect(Gitlab::GitalyClient) + .to receive(:call).with(anything, :ref_service, :find_tag, anything, anything).and_call_original + + expect(repository.find_tag('v1.1.0').name).to eq('v1.1.0') + end + + it 'does not perform Gitaly call when tags are preloaded' do + repository.tags + + expect(Gitlab::GitalyClient).not_to receive(:call) + + expect(repository.find_tag('v1.1.0').name).to eq('v1.1.0') + end + + it 'returns nil when tag does not exists' do + expect(repository.find_tag('does-not-exist')).to be_nil + end + + context 'when find_tag_via_gitaly is disabled' do + it 'fetches all tags' do + stub_feature_flags(find_tag_via_gitaly: false) + + expect(Gitlab::GitalyClient) + .to receive(:call).with(anything, :ref_service, :find_all_tags, anything, anything).and_call_original + + expect(repository.find_tag('v1.1.0').name).to eq('v1.1.0') + end + end + end + describe '#avatar' do it 'returns nil if repo does not exist' do allow(repository).to receive(:root_ref).and_raise(Gitlab::Git::Repository::NoRepository) @@ -3230,26 +3319,54 @@ RSpec.describe Repository do describe '#change_head' do let(:branch) { repository.container.default_branch } - it 'adds an error to container if branch does not exist' do - expect(repository.change_head('unexisted-branch')).to be false - expect(repository.container.errors.size).to eq(1) - end + context 'when the branch exists' do + it 'returns truthy' do + expect(repository.change_head(branch)).to be_truthy + end - it 'calls the before_change_head and after_change_head methods' do - expect(repository).to receive(:before_change_head) - expect(repository).to receive(:after_change_head) + it 'does not call container.after_change_head_branch_does_not_exist' do + expect(repository.container).not_to receive(:after_change_head_branch_does_not_exist) - repository.change_head(branch) - end + repository.change_head(branch) + end + + it 'calls repository hooks' do + expect(repository).to receive(:before_change_head) + expect(repository).to receive(:after_change_head) - it 'copies the gitattributes' do - expect(repository).to receive(:copy_gitattributes).with(branch) - repository.change_head(branch) + repository.change_head(branch) + end + + it 'copies the gitattributes' do + expect(repository).to receive(:copy_gitattributes).with(branch) + repository.change_head(branch) + end + + it 'reloads the default branch' do + expect(repository.container).to receive(:reload_default_branch) + repository.change_head(branch) + end end - it 'reloads the default branch' do - expect(repository.container).to receive(:reload_default_branch) - repository.change_head(branch) + context 'when the branch does not exist' do + let(:branch) { 'non-existent-branch' } + + it 'returns falsey' do + expect(repository.change_head(branch)).to be_falsey + end + + it 'calls container.after_change_head_branch_does_not_exist' do + expect(repository.container).to receive(:after_change_head_branch_does_not_exist).with(branch) + + repository.change_head(branch) + end + + it 'does not call repository hooks' do + expect(repository).not_to receive(:before_change_head) + expect(repository).not_to receive(:after_change_head) + + repository.change_head(branch) + end end end end diff --git a/spec/models/shard_spec.rb b/spec/models/shard_spec.rb index a9d11f4290c..38729fa1758 100644 --- a/spec/models/shard_spec.rb +++ b/spec/models/shard_spec.rb @@ -33,19 +33,21 @@ RSpec.describe Shard do expect(result.name).to eq('foo') end - it 'retries if creation races' do + it 'returns existing record if creation races' do + shard_created_by_others = double(described_class) + expect(described_class) - .to receive(:find_or_create_by) - .with(name: 'default') - .and_raise(ActiveRecord::RecordNotUnique, 'fail') - .once + .to receive(:find_by) + .with(name: 'new_shard') + .and_return(nil, shard_created_by_others) expect(described_class) - .to receive(:find_or_create_by) - .with(name: 'default') - .and_call_original + .to receive(:create) + .with(name: 'new_shard') + .and_raise(ActiveRecord::RecordNotUnique, 'fail') + .once - expect(described_class.by_name('default')).to eq(default_shard) + expect(described_class.by_name('new_shard')).to eq(shard_created_by_others) end end end diff --git a/spec/models/user_callout_spec.rb b/spec/models/user_callout_spec.rb index eb66f074293..5b36c8450ea 100644 --- a/spec/models/user_callout_spec.rb +++ b/spec/models/user_callout_spec.rb @@ -3,29 +3,12 @@ require 'spec_helper' RSpec.describe UserCallout do - let!(:callout) { create(:user_callout) } + let_it_be(:callout) { create(:user_callout) } it_behaves_like 'having unique enum values' - describe 'relationships' do - it { is_expected.to belong_to(:user) } - end - describe 'validations' do - it { is_expected.to validate_presence_of(:user) } - it { is_expected.to validate_presence_of(:feature_name) } it { is_expected.to validate_uniqueness_of(:feature_name).scoped_to(:user_id).ignoring_case_sensitivity } end - - describe '#dismissed_after?' do - let(:some_feature_name) { described_class.feature_names.keys.second } - let(:callout_dismissed_month_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.month.ago )} - let(:callout_dismissed_day_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.day.ago )} - - it 'returns whether a callout dismissed after specified date' do - expect(callout_dismissed_month_ago.dismissed_after?(15.days.ago)).to eq(false) - expect(callout_dismissed_day_ago.dismissed_after?(15.days.ago)).to eq(true) - end - end end diff --git a/spec/models/user_detail_spec.rb b/spec/models/user_detail_spec.rb index 3c87dcdcbd9..ba7ea3f7ce2 100644 --- a/spec/models/user_detail_spec.rb +++ b/spec/models/user_detail_spec.rb @@ -25,29 +25,4 @@ RSpec.describe UserDetail do it { is_expected.to validate_length_of(:bio).is_at_most(255) } end end - - describe '#bio_html' do - let(:user) { create(:user, bio: 'some **bio**') } - - subject { user.user_detail.bio_html } - - it 'falls back to #bio when the html representation is missing' do - user.user_detail.update!(bio_html: nil) - - expect(subject).to eq(user.user_detail.bio) - end - - it 'stores rendered html' do - expect(subject).to include('some <strong>bio</strong>') - end - - it 'does not try to set the value when the column is not there' do - without_bio_html_column = UserDetail.column_names - ['bio_html'] - - expect(described_class).to receive(:column_names).at_least(:once).and_return(without_bio_html_column) - expect(user.user_detail).not_to receive(:bio_html=) - - subject - end - end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index d73bc95a2f2..334f9b4ae30 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -65,9 +65,6 @@ RSpec.describe User do it { is_expected.to delegate_method(:render_whitespace_in_code).to(:user_preference) } it { is_expected.to delegate_method(:render_whitespace_in_code=).to(:user_preference).with_arguments(:args) } - it { is_expected.to delegate_method(:experience_level).to(:user_preference) } - it { is_expected.to delegate_method(:experience_level=).to(:user_preference).with_arguments(:args) } - it { is_expected.to delegate_method(:markdown_surround_selection).to(:user_preference) } it { is_expected.to delegate_method(:markdown_surround_selection=).to(:user_preference).with_arguments(:args) } @@ -82,7 +79,6 @@ 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(:bio_html).to(:user_detail).allow_nil } end describe 'associations' do @@ -110,7 +106,6 @@ RSpec.describe User do it { is_expected.to have_many(:spam_logs).dependent(:destroy) } it { is_expected.to have_many(:todos) } it { is_expected.to have_many(:award_emoji).dependent(:destroy) } - it { is_expected.to have_many(:triggers).dependent(:destroy) } it { is_expected.to have_many(:builds).dependent(:nullify) } it { is_expected.to have_many(:pipelines).dependent(:nullify) } it { is_expected.to have_many(:chat_names).dependent(:destroy) } @@ -125,6 +120,8 @@ RSpec.describe User do it { is_expected.to have_many(:created_custom_emoji).inverse_of(:creator) } it { is_expected.to have_many(:in_product_marketing_emails) } it { is_expected.to have_many(:timelogs) } + 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 it 'does not persist `user_detail` by default' do @@ -404,7 +401,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 MIME type format is not allowed.') + expect(user.errors.full_messages).to include('Username ending with a file extension is not allowed.') expect(build(:user, username: "test#{type}")).to be_valid end end @@ -438,12 +435,12 @@ RSpec.describe User do subject { create(:user).tap { |user| user.emails << build(:email, email: email_value, confirmed_at: Time.current) } } end - describe '#commit_email' do + describe '#commit_email_or_default' do subject(:user) { create(:user) } it 'defaults to the primary email' do expect(user.email).to be_present - expect(user.commit_email).to eq(user.email) + expect(user.commit_email_or_default).to eq(user.email) end it 'defaults to the primary email when the column in the database is null' do @@ -451,38 +448,37 @@ RSpec.describe User do found_user = described_class.find_by(id: user.id) - expect(found_user.commit_email).to eq(user.email) + expect(found_user.commit_email_or_default).to eq(user.email) end it 'returns the private commit email when commit_email has _private' do user.update_column(:commit_email, Gitlab::PrivateCommitEmail::TOKEN) - expect(user.commit_email).to eq(user.private_commit_email) + expect(user.commit_email_or_default).to eq(user.private_commit_email) end + end + + describe '#commit_email=' do + subject(:user) { create(:user) } it 'can be set to a confirmed email' do confirmed = create(:email, :confirmed, user: user) user.commit_email = confirmed.email expect(user).to be_valid - expect(user.commit_email).to eq(confirmed.email) end it 'can not be set to an unconfirmed email' do unconfirmed = create(:email, user: user) user.commit_email = unconfirmed.email - # This should set the commit_email attribute to the primary email - expect(user).to be_valid - expect(user.commit_email).to eq(user.email) + expect(user).not_to be_valid end it 'can not be set to a non-existent email' do user.commit_email = 'non-existent-email@nonexistent.nonexistent' - # This should set the commit_email attribute to the primary email - expect(user).to be_valid - expect(user.commit_email).to eq(user.email) + expect(user).not_to be_valid end it 'can not be set to an invalid email, even if confirmed' do @@ -692,70 +688,26 @@ RSpec.describe User do end end - context 'owns_notification_email' do - it 'accepts temp_oauth_email emails' do - user = build(:user, email: "temp-email-for-oauth@example.com") - expect(user).to be_valid - end - - it 'does not accept not verified emails' do - email = create(:email) - user = email.user - user.notification_email = email.email + context 'when secondary email is same as primary' do + let(:user) { create(:user, email: 'user@example.com') } - expect(user).to be_invalid - expect(user.errors[:notification_email]).to include(_('must be an email you have verified')) - end - end + it 'lets user change primary email without failing validations' do + user.commit_email = user.email + user.notification_email = user.email + user.public_email = user.email + user.save! - context 'owns_public_email' do - it 'accepts verified emails' do - email = create(:email, :confirmed, email: 'test@test.com') - user = email.user - user.notification_email = email.email + user.email = 'newemail@example.com' + user.confirm expect(user).to be_valid end - - it 'does not accept not verified emails' do - email = create(:email) - user = email.user - user.public_email = email.email - - expect(user).to be_invalid - expect(user.errors[:public_email]).to include(_('must be an email you have verified')) - end end - context 'set_commit_email' do - it 'keeps commit email when private commit email is being used' do - user = create(:user, commit_email: Gitlab::PrivateCommitEmail::TOKEN) - - expect(user.read_attribute(:commit_email)).to eq(Gitlab::PrivateCommitEmail::TOKEN) - end - - it 'keeps the commit email when nil' do - user = create(:user, commit_email: nil) - - expect(user.read_attribute(:commit_email)).to be_nil - end - - it 'reverts to nil when email is not verified' do - user = create(:user, commit_email: "foo@bar.com") - - expect(user.read_attribute(:commit_email)).to be_nil - end - end - - context 'owns_commit_email' do - it 'accepts private commit email' do - user = build(:user, commit_email: Gitlab::PrivateCommitEmail::TOKEN) - - expect(user).to be_valid - end - - it 'accepts nil commit email' do - user = build(:user, commit_email: nil) + context 'when commit_email is changed to _private' do + it 'passes user validations' do + user = create(:user) + user.commit_email = '_private' expect(user).to be_valid end @@ -931,12 +883,8 @@ RSpec.describe User do end context 'maximum value' do - before do - allow(Devise.password_length).to receive(:max).and_return(201) - end - it 'is determined by the current value of `Devise.password_length.max`' do - expect(password_length.max).to eq(201) + expect(password_length.max).to eq(Devise.password_length.max) end end end @@ -1311,53 +1259,57 @@ RSpec.describe User do end end - describe '#update_notification_email' do - # Regression: https://gitlab.com/gitlab-org/gitlab-foss/issues/22846 - context 'when changing :email' do - let(:user) { create(:user) } - let(:new_email) { 'new-email@example.com' } + describe 'when changing email' do + let(:user) { create(:user) } + let(:new_email) { 'new-email@example.com' } + context 'if notification_email was nil' do it 'sets :unconfirmed_email' do expect do user.tap { |u| u.update!(email: new_email) }.reload end.to change(user, :unconfirmed_email).to(new_email) end - it 'does not change :notification_email' do + + it 'does not change notification_email or notification_email_or_default before email is confirmed' do expect do user.tap { |u| u.update!(email: new_email) }.reload - end.not_to change(user, :notification_email) + end.not_to change(user, :notification_email_or_default) + + expect(user.notification_email).to be_nil end - it 'updates :notification_email to the new email once confirmed' do + it 'updates notification_email_or_default to the new email once confirmed' do user.update!(email: new_email) expect do user.tap(&:confirm).reload - end.to change(user, :notification_email).to eq(new_email) + end.to change(user, :notification_email_or_default).to eq(new_email) + + expect(user.notification_email).to be_nil end + end - context 'and :notification_email is set to a secondary email' do - let!(:email_attrs) { attributes_for(:email, :confirmed, user: user) } - let(:secondary) { create(:email, :confirmed, email: 'secondary@example.com', user: user) } + context 'when notification_email is set to a secondary email' do + let!(:email_attrs) { attributes_for(:email, :confirmed, user: user) } + let(:secondary) { create(:email, :confirmed, email: 'secondary@example.com', user: user) } - before do - user.emails.create!(email_attrs) - user.tap { |u| u.update!(notification_email: email_attrs[:email]) }.reload - end + before do + user.emails.create!(email_attrs) + user.tap { |u| u.update!(notification_email: email_attrs[:email]) }.reload + end - it 'does not change :notification_email to :email' do - expect do - user.tap { |u| u.update!(email: new_email) }.reload - end.not_to change(user, :notification_email) - end + it 'does not change notification_email to email before email is confirmed' do + expect do + user.tap { |u| u.update!(email: new_email) }.reload + end.not_to change(user, :notification_email) + end - it 'does not change :notification_email to :email once confirmed' do - user.update!(email: new_email) + it 'does not change notification_email to email once confirmed' do + user.update!(email: new_email) - expect do - user.tap(&:confirm).reload - end.not_to change(user, :notification_email) - end + expect do + user.tap(&:confirm).reload + end.not_to change(user, :notification_email) end end end @@ -1833,14 +1785,26 @@ RSpec.describe User do end describe '#manageable_groups' do - it 'includes all the namespaces the user can manage' do - expect(user.manageable_groups).to contain_exactly(group, subgroup) + shared_examples 'manageable groups examples' do + it 'includes all the namespaces the user can manage' do + expect(user.manageable_groups).to contain_exactly(group, subgroup) + end + + it 'does not include duplicates if a membership was added for the subgroup' do + subgroup.add_owner(user) + + expect(user.manageable_groups).to contain_exactly(group, subgroup) + end end - it 'does not include duplicates if a membership was added for the subgroup' do - subgroup.add_owner(user) + it_behaves_like 'manageable groups examples' + + context 'when feature flag :linear_user_manageable_groups is disabled' do + before do + stub_feature_flags(linear_user_manageable_groups: false) + end - expect(user.manageable_groups).to contain_exactly(group, subgroup) + it_behaves_like 'manageable groups examples' end end @@ -1925,12 +1889,25 @@ RSpec.describe User do expect(user.deactivated?).to be_truthy end - 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) + context "when user deactivation emails are disabled" do + before do + stub_application_setting(user_deactivation_emails_enabled: false) end + it 'does not send deactivated user an email' do + expect(NotificationService).not_to receive(:new) - user.deactivate + user.deactivate + end + end + + 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) + end + + user.deactivate + end end end @@ -1997,15 +1974,15 @@ RSpec.describe User do user.ban! end - it 'activates the user' do - user.activate + it 'unbans the user' do + user.unban expect(user.banned?).to eq(false) expect(user.active?).to eq(true) end it 'deletes the BannedUser record' do - expect { user.activate }.to change { Users::BannedUser.count }.by(-1) + expect { user.unban }.to change { Users::BannedUser.count }.by(-1) expect(Users::BannedUser.where(user_id: user.id)).not_to exist end end @@ -3125,15 +3102,15 @@ RSpec.describe User do end end - describe '#notification_email' do + describe '#notification_email_or_default' do let(:email) { 'gonzo@muppets.com' } context 'when the column in the database is null' do subject { create(:user, email: email, notification_email: nil) } it 'defaults to the primary email' do - expect(subject.read_attribute(:notification_email)).to be nil - expect(subject.notification_email).to eq(email) + expect(subject.notification_email).to be nil + expect(subject.notification_email_or_default).to eq(email) end end end @@ -3467,17 +3444,32 @@ RSpec.describe User do end describe '#membership_groups' do - let!(:user) { create(:user) } - let!(:parent_group) { create(:group) } - let!(:child_group) { create(:group, parent: parent_group) } + let_it_be(:user) { create(:user) } - before do - parent_group.add_user(user, Gitlab::Access::MAINTAINER) + let_it_be(:parent_group) do + create(:group).tap do |g| + g.add_user(user, Gitlab::Access::MAINTAINER) + end end + let_it_be(:child_group) { create(:group, parent: parent_group) } + let_it_be(:other_group) { create(:group) } + subject { user.membership_groups } - it { is_expected.to contain_exactly parent_group, child_group } + shared_examples 'returns groups where the user is a member' do + specify { is_expected.to contain_exactly(parent_group, child_group) } + end + + it_behaves_like 'returns groups where the user is a member' + + context 'when feature flag :linear_user_membership_groups is disabled' do + before do + stub_feature_flags(linear_user_membership_groups: false) + end + + it_behaves_like 'returns groups where the user is a member' + end end describe '#authorizations_for_projects' do @@ -3686,6 +3678,11 @@ RSpec.describe User do it 'loads all the runners in the tree of groups' do expect(user.ci_owned_runners).to contain_exactly(runner, group_runner) end + + it 'returns true for owns_runner?' do + expect(user.owns_runner?(runner)).to eq(true) + expect(user.owns_runner?(group_runner)).to eq(true) + end end end @@ -3698,6 +3695,10 @@ RSpec.describe User do it 'loads the runners in the group' do expect(user.ci_owned_runners).to contain_exactly(group_runner) end + + it 'returns true for owns_runner?' do + expect(user.owns_runner?(group_runner)).to eq(true) + end end end @@ -3706,6 +3707,10 @@ RSpec.describe User do it 'loads the runner belonging to the project' do expect(user.ci_owned_runners).to contain_exactly(runner) end + + it 'returns true for owns_runner?' do + expect(user.owns_runner?(runner)).to eq(true) + end end end @@ -3718,6 +3723,10 @@ RSpec.describe User do it 'loads the runners of the project' do expect(user.ci_owned_runners).to contain_exactly(project_runner) end + + it 'returns true for owns_runner?' do + expect(user.owns_runner?(project_runner)).to eq(true) + end end context 'when the user is a developer' do @@ -3728,6 +3737,10 @@ RSpec.describe User do it 'does not load any runner' do expect(user.ci_owned_runners).to be_empty end + + it 'returns false for owns_runner?' do + expect(user.owns_runner?(project_runner)).to eq(false) + end end context 'when the user is a reporter' do @@ -3738,6 +3751,10 @@ RSpec.describe User do it 'does not load any runner' do expect(user.ci_owned_runners).to be_empty end + + it 'returns false for owns_runner?' do + expect(user.owns_runner?(project_runner)).to eq(false) + end end context 'when the user is a guest' do @@ -3748,6 +3765,10 @@ RSpec.describe User do it 'does not load any runner' do expect(user.ci_owned_runners).to be_empty end + + it 'returns false for owns_runner?' do + expect(user.owns_runner?(project_runner)).to eq(false) + end end end @@ -3760,6 +3781,10 @@ RSpec.describe User do it 'does not load the runners of the group' do expect(user.ci_owned_runners).to be_empty end + + it 'returns false for owns_runner?' do + expect(user.owns_runner?(runner)).to eq(false) + end end context 'when the user is a developer' do @@ -3770,6 +3795,10 @@ RSpec.describe User do it 'does not load any runner' do expect(user.ci_owned_runners).to be_empty end + + it 'returns false for owns_runner?' do + expect(user.owns_runner?(runner)).to eq(false) + end end context 'when the user is a reporter' do @@ -3780,6 +3809,10 @@ RSpec.describe User do it 'does not load any runner' do expect(user.ci_owned_runners).to be_empty end + + it 'returns false for owns_runner?' do + expect(user.owns_runner?(runner)).to eq(false) + end end context 'when the user is a guest' do @@ -3790,6 +3823,10 @@ RSpec.describe User do it 'does not load any runner' do expect(user.ci_owned_runners).to be_empty end + + it 'returns false for owns_runner?' do + expect(user.owns_runner?(runner)).to eq(false) + end end end @@ -3797,6 +3834,10 @@ RSpec.describe User do it 'does not load any runner' do expect(user.ci_owned_runners).to be_empty end + + it 'returns false for owns_runner?' do + expect(user.owns_runner?(create(:ci_runner))).to eq(false) + end end context 'with runner in a personal project' do @@ -5312,7 +5353,7 @@ RSpec.describe User do let(:group) { nil } it 'returns global notification email' do - is_expected.to eq(user.notification_email) + is_expected.to eq(user.notification_email_or_default) end end @@ -5320,7 +5361,7 @@ RSpec.describe User do it 'returns global notification email' do create(:notification_setting, user: user, source: group, notification_email: '') - is_expected.to eq(user.notification_email) + is_expected.to eq(user.notification_email_or_default) end end @@ -5521,22 +5562,17 @@ RSpec.describe User do end describe '#dismissed_callout?' do - subject(:user) { create(:user) } - - let(:feature_name) { UserCallout.feature_names.each_key.first } + let_it_be(:user, refind: true) { create(:user) } + let_it_be(:feature_name) { UserCallout.feature_names.each_key.first } context 'when no callout dismissal record exists' do it 'returns false when no ignore_dismissal_earlier_than provided' do expect(user.dismissed_callout?(feature_name: feature_name)).to eq false end - - it 'returns false when ignore_dismissal_earlier_than provided' do - expect(user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: 3.months.ago)).to eq false - end end context 'when dismissed callout exists' do - before do + before_all do create(:user_callout, user: user, feature_name: feature_name, dismissed_at: 4.months.ago) end @@ -5554,6 +5590,123 @@ RSpec.describe User do end end + describe '#find_or_initialize_callout' do + let_it_be(:user, refind: true) { create(:user) } + let_it_be(:feature_name) { UserCallout.feature_names.each_key.first } + + subject(:find_or_initialize_callout) { user.find_or_initialize_callout(feature_name) } + + context 'when callout exists' do + let!(:callout) { create(:user_callout, user: user, feature_name: feature_name) } + + it 'returns existing callout' do + expect(find_or_initialize_callout).to eq(callout) + end + end + + context 'when callout does not exist' do + context 'when feature name is valid' do + it 'initializes a new callout' do + expect(find_or_initialize_callout).to be_a_new(UserCallout) + end + + it 'is valid' do + expect(find_or_initialize_callout).to be_valid + end + end + + context 'when feature name is not valid' do + let(:feature_name) { 'notvalid' } + + it 'initializes a new callout' do + expect(find_or_initialize_callout).to be_a_new(UserCallout) + end + + it 'is not valid' do + expect(find_or_initialize_callout).not_to be_valid + end + end + end + end + + describe '#dismissed_callout_for_group?' do + let_it_be(:user, refind: true) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:feature_name) { Users::GroupCallout.feature_names.each_key.first } + + context 'when no callout dismissal record exists' do + it 'returns false when no ignore_dismissal_earlier_than provided' do + expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group)).to eq false + end + end + + context 'when dismissed callout exists' do + before_all do + create(:group_callout, + user: user, + group_id: group.id, + feature_name: feature_name, + dismissed_at: 4.months.ago) + end + + it 'returns true when no ignore_dismissal_earlier_than provided' do + expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group)).to eq true + end + + it 'returns true when ignore_dismissal_earlier_than is earlier than dismissed_at' do + expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group, ignore_dismissal_earlier_than: 6.months.ago)).to eq true + end + + it 'returns false when ignore_dismissal_earlier_than is later than dismissed_at' do + expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group, ignore_dismissal_earlier_than: 3.months.ago)).to eq false + end + end + end + + describe '#find_or_initialize_group_callout' do + let_it_be(:user, refind: true) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:feature_name) { Users::GroupCallout.feature_names.each_key.first } + + subject(:callout_with_source) do + user.find_or_initialize_group_callout(feature_name, group.id) + end + + context 'when callout exists' do + let!(:callout) do + create(:group_callout, user: user, feature_name: feature_name, group_id: group.id) + end + + it 'returns existing callout' do + expect(callout_with_source).to eq(callout) + end + end + + context 'when callout does not exist' do + context 'when feature name is valid' do + it 'initializes a new callout' do + expect(callout_with_source).to be_a_new(Users::GroupCallout) + end + + it 'is valid' do + expect(callout_with_source).to be_valid + end + end + + context 'when feature name is not valid' do + let(:feature_name) { 'notvalid' } + + it 'initializes a new callout' do + expect(callout_with_source).to be_a_new(Users::GroupCallout) + end + + it 'is not valid' do + expect(callout_with_source).not_to be_valid + end + end + end + end + describe '#hook_attrs' do it 'includes id, name, username, avatar_url, and email' do user = create(:user) @@ -5916,45 +6069,6 @@ RSpec.describe User do end end - describe '#find_or_initialize_callout' do - subject(:find_or_initialize_callout) { user.find_or_initialize_callout(feature_name) } - - let(:user) { create(:user) } - let(:feature_name) { UserCallout.feature_names.each_key.first } - - context 'when callout exists' do - let!(:callout) { create(:user_callout, user: user, feature_name: feature_name) } - - it 'returns existing callout' do - expect(find_or_initialize_callout).to eq(callout) - end - end - - context 'when callout does not exist' do - context 'when feature name is valid' do - it 'initializes a new callout' do - expect(find_or_initialize_callout).to be_a_new(UserCallout) - end - - it 'is valid' do - expect(find_or_initialize_callout).to be_valid - end - end - - context 'when feature name is not valid' do - let(:feature_name) { 'notvalid' } - - it 'initializes a new callout' do - expect(find_or_initialize_callout).to be_a_new(UserCallout) - end - - it 'is not valid' do - expect(find_or_initialize_callout).not_to be_valid - end - end - end - end - describe '#default_dashboard?' do it 'is the default dashboard' do user = build(:user) @@ -6024,4 +6138,75 @@ RSpec.describe User do expect(described_class.by_provider_and_extern_uid(:github, 'my_github_id')).to match_array([expected_user]) end end + + describe '#unset_secondary_emails_matching_deleted_email!' do + let(:deleted_email) { 'kermit@muppets.com' } + + subject { build(:user, commit_email: commit_email) } + + context 'when no secondary email matches the deleted email' do + let(:commit_email) { 'fozzie@muppets.com' } + + it 'does nothing' do + expect(subject).not_to receive(:save) + subject.unset_secondary_emails_matching_deleted_email!(deleted_email) + expect(subject.commit_email).to eq commit_email + end + end + + context 'when a secondary email matches the deleted_email' do + let(:commit_email) { deleted_email } + + it 'un-sets the secondary email' do + expect(subject).to receive(:save) + subject.unset_secondary_emails_matching_deleted_email!(deleted_email) + expect(subject.commit_email).to be nil + end + end + end + + describe '#groups_with_developer_maintainer_project_access' do + let_it_be(:user) { create(:user) } + let_it_be(:group1) { create(:group) } + + let_it_be(:developer_group1) do + create(:group).tap do |g| + g.add_developer(user) + end + end + + let_it_be(:developer_group2) do + create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS).tap do |g| + g.add_developer(user) + end + end + + let_it_be(:guest_group1) do + create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS).tap do |g| + g.add_guest(user) + end + end + + let_it_be(:developer_group1) do + create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS).tap do |g| + g.add_maintainer(user) + end + end + + subject { user.send(:groups_with_developer_maintainer_project_access) } + + shared_examples 'groups_with_developer_maintainer_project_access examples' do + specify { is_expected.to contain_exactly(developer_group2) } + end + + it_behaves_like 'groups_with_developer_maintainer_project_access examples' + + context 'when feature flag :linear_user_groups_with_developer_maintainer_project_access is disabled' do + before do + stub_feature_flags(linear_user_groups_with_developer_maintainer_project_access: false) + end + + it_behaves_like 'groups_with_developer_maintainer_project_access examples' + end + end end diff --git a/spec/models/users/group_callout_spec.rb b/spec/models/users/group_callout_spec.rb new file mode 100644 index 00000000000..461b5fd7715 --- /dev/null +++ b/spec/models/users/group_callout_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::GroupCallout do + let_it_be(:user) { create_default(:user) } + let_it_be(:group) { create_default(:group) } + let_it_be(:callout) { create(:group_callout) } + + it_behaves_like 'having unique enum values' + + describe 'relationships' do + it { is_expected.to belong_to(:group) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:group) } + it { is_expected.to validate_presence_of(:feature_name) } + it { is_expected.to validate_uniqueness_of(:feature_name).scoped_to(:user_id, :group_id).ignoring_case_sensitivity } + end + + describe '#source_feature_name' do + it 'provides string based off source and feature' do + expect(callout.source_feature_name).to eq "#{callout.feature_name}_#{callout.group_id}" + end + end +end diff --git a/spec/models/work_item/type_spec.rb b/spec/models/work_item/type_spec.rb index 90f551b7d63..dd5324d63a0 100644 --- a/spec/models/work_item/type_spec.rb +++ b/spec/models/work_item/type_spec.rb @@ -19,8 +19,10 @@ RSpec.describe WorkItem::Type do it 'deletes type but not unrelated issues' do type = create(:work_item_type) + expect(WorkItem::Type.count).to eq(5) + expect { type.destroy! }.not_to change(Issue, :count) - expect(WorkItem::Type.count).to eq 0 + expect(WorkItem::Type.count).to eq(4) end end @@ -28,7 +30,7 @@ RSpec.describe WorkItem::Type do type = create(:work_item_type, work_items: [work_item]) expect { type.destroy! }.to raise_error(ActiveRecord::InvalidForeignKey) - expect(Issue.count).to eq 1 + expect(Issue.count).to eq(1) end end diff --git a/spec/policies/custom_emoji_policy_spec.rb b/spec/policies/custom_emoji_policy_spec.rb new file mode 100644 index 00000000000..9538ef9bb4a --- /dev/null +++ b/spec/policies/custom_emoji_policy_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CustomEmojiPolicy do + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:custom_emoji) { create(:custom_emoji, group: group) } + + let(:custom_emoji_permissions) do + [ + :create_custom_emoji, + :delete_custom_emoji + ] + end + + context 'custom emoji permissions' do + subject { described_class.new(user, custom_emoji) } + + context 'when user is' do + context 'a developer' do + before do + group.add_developer(user) + end + + it do + expect_allowed(:create_custom_emoji) + end + end + + context 'is maintainer' do + before do + group.add_maintainer(user) + end + + it do + expect_allowed(*custom_emoji_permissions) + end + end + + context 'is owner' do + before do + group.add_owner(user) + end + + it do + expect_allowed(*custom_emoji_permissions) + end + end + + context 'is developer and emoji creator' do + before do + group.add_developer(user) + custom_emoji.update_attribute(:creator, user) + end + + it do + expect_allowed(*custom_emoji_permissions) + end + end + + context 'is emoji creator but not a member of the group' do + before do + custom_emoji.update_attribute(:creator, user) + end + + it do + expect_disallowed(*custom_emoji_permissions) + end + end + end + end +end diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 9fac5521aa6..482e12c029d 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -11,6 +11,9 @@ RSpec.describe GroupPolicy do it do expect_allowed(:read_group) + expect_allowed(:read_organization) + expect_allowed(:read_contact) + expect_allowed(:read_counts) expect_allowed(*read_group_permissions) expect_disallowed(:upload_file) expect_disallowed(*reporter_permissions) @@ -30,6 +33,9 @@ RSpec.describe GroupPolicy do end it { expect_disallowed(:read_group) } + it { expect_disallowed(:read_organization) } + it { expect_disallowed(:read_contact) } + it { expect_disallowed(:read_counts) } it { expect_disallowed(*read_group_permissions) } end @@ -42,6 +48,9 @@ RSpec.describe GroupPolicy do end it { expect_disallowed(:read_group) } + it { expect_disallowed(:read_organization) } + it { expect_disallowed(:read_contact) } + it { expect_disallowed(:read_counts) } it { expect_disallowed(*read_group_permissions) } end @@ -245,6 +254,7 @@ RSpec.describe GroupPolicy do let(:current_user) { nil } it do + expect_disallowed(:read_counts) expect_disallowed(*read_group_permissions) expect_disallowed(*guest_permissions) expect_disallowed(*reporter_permissions) @@ -258,6 +268,7 @@ RSpec.describe GroupPolicy do let(:current_user) { guest } it do + expect_allowed(:read_counts) expect_allowed(*read_group_permissions) expect_allowed(*guest_permissions) expect_disallowed(*reporter_permissions) @@ -271,6 +282,7 @@ RSpec.describe GroupPolicy do let(:current_user) { reporter } it do + expect_allowed(:read_counts) expect_allowed(*read_group_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) @@ -284,6 +296,7 @@ RSpec.describe GroupPolicy do let(:current_user) { developer } it do + expect_allowed(:read_counts) expect_allowed(*read_group_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) @@ -297,6 +310,7 @@ RSpec.describe GroupPolicy do let(:current_user) { maintainer } it do + expect_allowed(:read_counts) expect_allowed(*read_group_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) @@ -310,6 +324,7 @@ RSpec.describe GroupPolicy do let(:current_user) { owner } it do + expect_allowed(:read_counts) expect_allowed(*read_group_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) @@ -878,6 +893,34 @@ RSpec.describe GroupPolicy do end end + describe 'dependency proxy' do + context 'feature disabled' do + let(:current_user) { owner } + + it { is_expected.to be_disallowed(:read_dependency_proxy) } + it { is_expected.to be_disallowed(:admin_dependency_proxy) } + end + + context 'feature enabled' do + before do + stub_config(dependency_proxy: { enabled: true }) + group.create_dependency_proxy_setting!(enabled: true) + end + + context 'reporter' do + let(:current_user) { reporter } + + it { is_expected.to be_disallowed(:admin_dependency_proxy) } + end + + context 'developer' do + let(:current_user) { developer } + + it { is_expected.to be_allowed(:admin_dependency_proxy) } + end + end + end + context 'deploy token access' do let!(:group_deploy_token) do create(:group_deploy_token, group: group, deploy_token: deploy_token) @@ -890,6 +933,8 @@ RSpec.describe GroupPolicy do it { is_expected.to be_allowed(:read_package) } it { is_expected.to be_allowed(:read_group) } + it { is_expected.to be_allowed(:read_organization) } + it { is_expected.to be_allowed(:read_contact) } it { is_expected.to be_disallowed(:create_package) } end @@ -899,8 +944,22 @@ RSpec.describe GroupPolicy do it { is_expected.to be_allowed(:create_package) } it { is_expected.to be_allowed(:read_package) } it { is_expected.to be_allowed(:read_group) } + it { is_expected.to be_allowed(:read_organization) } + it { is_expected.to be_allowed(:read_contact) } it { is_expected.to be_disallowed(:destroy_package) } end + + context 'a deploy token with dependency proxy scopes' do + let_it_be(:deploy_token) { create(:deploy_token, :group, :dependency_proxy_scopes) } + + before do + stub_config(dependency_proxy: { enabled: true }) + group.create_dependency_proxy_setting!(enabled: true) + end + + it { is_expected.to be_allowed(:read_dependency_proxy) } + it { is_expected.to be_disallowed(:admin_dependency_proxy) } + end end it_behaves_like 'Self-managed Core resource access tokens' diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb index d62271eedf6..3805976b3e7 100644 --- a/spec/policies/issue_policy_spec.rb +++ b/spec/policies/issue_policy_spec.rb @@ -27,17 +27,17 @@ RSpec.describe IssuePolicy do end it 'allows support_bot to read issues, create and set metadata on new issues' do - expect(permissions(support_bot, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(support_bot, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(support_bot, new_issue)).to be_allowed(:create_issue, :set_issue_metadata) + expect(permissions(support_bot, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(support_bot, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(support_bot, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality) end end shared_examples 'support bot with service desk disabled' do - it 'allows support_bot to read issues, create and set metadata on new issues' do - expect(permissions(support_bot, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(support_bot, issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(support_bot, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata) + it 'does not allow support_bot to read issues, create and set metadata on new issues' do + expect(permissions(support_bot, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(support_bot, issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(support_bot, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata, :set_confidentiality) end end @@ -60,50 +60,50 @@ RSpec.describe IssuePolicy do it 'allows guests to read issues' do expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid) - expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid) - expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) - expect(permissions(guest, new_issue)).to be_allowed(:create_issue, :set_issue_metadata) + expect(permissions(guest, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it 'allows reporters to read, update, and admin issues' do - expect(permissions(reporter, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata) + expect(permissions(reporter, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it 'allows reporters from group links to read, update, and admin issues' do - expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(reporter_from_group_link, new_issue)).to be_allowed(:create_issue, :set_issue_metadata) + expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(reporter_from_group_link, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it 'allows issue authors to read and update their issues' do expect(permissions(author, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue) - expect(permissions(author, issue)).to be_disallowed(:admin_issue, :set_issue_metadata) + expect(permissions(author, issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(author, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid) - expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) - expect(permissions(author, new_issue)).to be_allowed(:create_issue, :set_issue_metadata) + expect(permissions(author, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it 'allows issue assignees to read and update their issues' do expect(permissions(assignee, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue) - expect(permissions(assignee, issue)).to be_disallowed(:admin_issue, :set_issue_metadata) + expect(permissions(assignee, issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(assignee, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid) - expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) - expect(permissions(assignee, new_issue)).to be_allowed(:create_issue, :set_issue_metadata) + expect(permissions(assignee, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it 'does not allow non-members to read, update or create issues' do - expect(permissions(non_member, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(non_member, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata) + expect(permissions(non_member, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(non_member, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it_behaves_like 'support bot with service desk disabled' @@ -115,49 +115,49 @@ RSpec.describe IssuePolicy do it 'does not allow non-members to read confidential issues' do expect(permissions(non_member, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue) - expect(permissions(non_member, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(non_member, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) end it 'does not allow guests to read confidential issues' do expect(permissions(guest, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue) - expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) end it 'allows reporters to read, update, and admin confidential issues' do - expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) end it 'allows reporters from group links to read, update, and admin confidential issues' do - expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) end it 'allows issue authors to read and update their confidential issues' do expect(permissions(author, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue) - expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata) + expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue) - expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:admin_issue, :set_issue_metadata) + expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality) end it 'does not allow issue author to read or update confidential issue moved to an private project' do confidential_issue.project = create(:project, :private) - expect(permissions(author, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :set_issue_metadata) + expect(permissions(author, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :set_issue_metadata, :set_confidentiality) end it 'allows issue assignees to read and update their confidential issues' do expect(permissions(assignee, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue) - expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata) + expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality) - expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) end it 'does not allow issue assignees to read or update confidential issue moved to an private project' do confidential_issue.project = create(:project, :private) - expect(permissions(assignee, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :set_issue_metadata) + expect(permissions(assignee, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :set_issue_metadata, :set_confidentiality) end end end @@ -180,48 +180,48 @@ RSpec.describe IssuePolicy do it 'does not allow anonymous user to create todos' do expect(permissions(nil, issue)).to be_allowed(:read_issue) - expect(permissions(nil, issue)).to be_disallowed(:create_todo, :update_subscription, :set_issue_metadata) - expect(permissions(nil, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata) + expect(permissions(nil, issue)).to be_disallowed(:create_todo, :update_subscription, :set_issue_metadata, :set_confidentiality) + expect(permissions(nil, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it 'allows guests to read issues' do expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid, :create_todo, :update_subscription) - expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata) + expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid) - expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata) + expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(guest, issue_locked)).to be_allowed(:read_issue, :read_issue_iid) - expect(permissions(guest, issue_locked)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata) + expect(permissions(guest, issue_locked)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality) - expect(permissions(guest, new_issue)).to be_allowed(:create_issue, :set_issue_metadata) + expect(permissions(guest, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it 'allows reporters to read, update, reopen, and admin issues' do - expect(permissions(reporter, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata) - expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata) - expect(permissions(reporter, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(reporter, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(reporter, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(reporter, issue_locked)).to be_disallowed(:reopen_issue) - expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata) + expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it 'allows reporters from group links to read, update, reopen and admin issues' do - expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata) - expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata) - expect(permissions(reporter_from_group_link, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(reporter_from_group_link, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(reporter_from_group_link, issue_locked)).to be_disallowed(:reopen_issue) - expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata) + expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it 'allows issue authors to read, reopen and update their issues' do expect(permissions(author, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :reopen_issue) - expect(permissions(author, issue)).to be_disallowed(:admin_issue, :set_issue_metadata) + expect(permissions(author, issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(author, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid) - expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata) + expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(author, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue) - expect(permissions(author, issue_locked)).to be_disallowed(:admin_issue, :reopen_issue, :set_issue_metadata) + expect(permissions(author, issue_locked)).to be_disallowed(:admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(author, new_issue)).to be_allowed(:create_issue) expect(permissions(author, new_issue)).to be_disallowed(:set_issue_metadata) @@ -229,13 +229,13 @@ RSpec.describe IssuePolicy do it 'allows issue assignees to read, reopen and update their issues' do expect(permissions(assignee, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :reopen_issue) - expect(permissions(assignee, issue)).to be_disallowed(:admin_issue, :set_issue_metadata) + expect(permissions(assignee, issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(assignee, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid) - expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata) + expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(assignee, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue) - expect(permissions(assignee, issue_locked)).to be_disallowed(:admin_issue, :reopen_issue, :set_issue_metadata) + expect(permissions(assignee, issue_locked)).to be_disallowed(:admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality) end it 'allows non-members to read and create issues' do @@ -249,22 +249,25 @@ RSpec.describe IssuePolicy do expect(permissions(non_member, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid) end - it 'does not allow non-members to update, admin or set metadata' do - expect(permissions(non_member, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata) + it 'does not allow non-members to update, admin or set metadata except for set confidential flag' do + expect(permissions(non_member, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(non_member, new_issue)).to be_disallowed(:set_issue_metadata) + # this is allowed for non-members in a public project, as we want to let users report security issues + # see https://gitlab.com/gitlab-org/gitlab/-/issues/337665 + expect(permissions(non_member, new_issue)).to be_allowed(:set_confidentiality) end it 'allows support_bot to read issues' do # support_bot is still allowed read access in public projects through :public_access permission, # see project_policy public_access rules policy (rule { can?(:public_access) }.policy {...}) expect(permissions(support_bot, issue)).to be_allowed(:read_issue, :read_issue_iid) - expect(permissions(support_bot, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(support_bot, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) expect(permissions(support_bot, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid) - expect(permissions(support_bot, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(support_bot, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) - expect(permissions(support_bot, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata) + expect(permissions(support_bot, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it_behaves_like 'support bot with service desk enabled' @@ -318,9 +321,9 @@ RSpec.describe IssuePolicy do end it 'does not allow non-members to update or create issues' do - expect(permissions(non_member, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata) - expect(permissions(non_member, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata) + expect(permissions(non_member, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) + expect(permissions(non_member, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata, :set_confidentiality) end it_behaves_like 'support bot with service desk disabled' @@ -333,31 +336,31 @@ RSpec.describe IssuePolicy do it 'does not allow guests to read confidential issues' do expect(permissions(guest, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue) - expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) end it 'allows reporters to read, update, and admin confidential issues' do expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue) - expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) end it 'allows reporter from group links to read, update, and admin confidential issues' do expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue) - expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) end it 'allows issue authors to read and update their confidential issues' do expect(permissions(author, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue) - expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata) + expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality) - expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) end it 'allows issue assignees to read and update their confidential issues' do expect(permissions(assignee, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue) - expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata) + expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality) - expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata) + expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality) end end diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index 78212f06526..b800e7dbc43 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -3,8 +3,12 @@ require 'spec_helper' RSpec.describe UserPolicy do - let(:current_user) { create(:user) } - let(:user) { create(:user) } + let_it_be(:admin) { create(:user, :admin) } + let_it_be(:regular_user) { create(:user) } + let_it_be(:subject_user) { create(:user) } + + let(:current_user) { regular_user } + let(:user) { subject_user } subject { described_class.new(current_user, user) } @@ -16,7 +20,7 @@ RSpec.describe UserPolicy do let(:token) { create(:personal_access_token, user: user) } context 'when user is admin' do - let(:current_user) { create(:user, :admin) } + let(:current_user) { admin } context 'when admin mode is enabled', :enable_admin_mode do it { is_expected.to be_allowed(:read_user_personal_access_tokens) } @@ -42,7 +46,7 @@ RSpec.describe UserPolicy do describe "creating a different user's Personal Access Tokens" do context 'when current_user is admin' do - let(:current_user) { create(:user, :admin) } + let(:current_user) { admin } context 'when admin mode is enabled and current_user is not blocked', :enable_admin_mode do it { is_expected.to be_allowed(:create_user_personal_access_token) } @@ -92,7 +96,7 @@ RSpec.describe UserPolicy do end context "when an admin user tries to destroy a regular user" do - let(:current_user) { create(:user, :admin) } + let(:current_user) { admin } context 'when admin mode is enabled', :enable_admin_mode do it { is_expected.to be_allowed(ability) } @@ -104,7 +108,7 @@ RSpec.describe UserPolicy do end context "when an admin user tries to destroy a ghost user" do - let(:current_user) { create(:user, :admin) } + let(:current_user) { admin } let(:user) { create(:user, :ghost) } it { is_expected.not_to be_allowed(ability) } @@ -132,7 +136,7 @@ RSpec.describe UserPolicy do context 'disabling the two-factor authentication of another user' do context 'when the executor is an admin', :enable_admin_mode do - let(:current_user) { create(:user, :admin) } + let(:current_user) { admin } it { is_expected.to be_allowed(:disable_two_factor) } end @@ -145,7 +149,7 @@ RSpec.describe UserPolicy do describe "reading a user's group count" do context "when current_user is an admin", :enable_admin_mode do - let(:current_user) { create(:user, :admin) } + let(:current_user) { admin } it { is_expected.to be_allowed(:read_group_count) } end @@ -172,4 +176,30 @@ RSpec.describe UserPolicy do it { is_expected.to be_allowed(:read_user_profile) } end end + + describe ':read_user_groups' do + context 'when user is admin' do + let(:current_user) { admin } + + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed(:read_user_groups) } + end + + context 'when admin mode is disabled' do + it { is_expected.not_to be_allowed(:read_user_groups) } + end + end + + context 'when user is not an admin' do + context 'requesting their own manageable groups' do + subject { described_class.new(current_user, current_user) } + + it { is_expected.to be_allowed(:read_user_groups) } + end + + context "requesting a different user's manageable groups" do + it { is_expected.not_to be_allowed(:read_user_groups) } + end + end + end end diff --git a/spec/presenters/packages/helm/index_presenter_spec.rb b/spec/presenters/packages/helm/index_presenter_spec.rb new file mode 100644 index 00000000000..38e1dc17f49 --- /dev/null +++ b/spec/presenters/packages/helm/index_presenter_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Helm::IndexPresenter do + include_context 'with expected presenters dependency groups' + + let_it_be(:project) { create(:project) } + let_it_be(:packages) { create_list(:helm_package, 5, project: project) } + let_it_be(:package_files3_1) { create(:helm_package_file, package: packages[2], file_sha256: '3_1', file_name: 'file3_1') } + let_it_be(:package_files3_2) { create(:helm_package_file, package: packages[2], file_sha256: '3_2', file_name: 'file3_2') } + let_it_be(:package_files4_1) { create(:helm_package_file, package: packages[3], file_sha256: '4_1', file_name: 'file4_1') } + let_it_be(:package_files4_2) { create(:helm_package_file, package: packages[3], file_sha256: '4_2', file_name: 'file4_2') } + let_it_be(:package_files4_3) { create(:helm_package_file, package: packages[3], file_sha256: '4_3', file_name: 'file4_3') } + + let(:project_id_param) { project.id } + let(:channel) { 'stable' } + let(:presenter) { described_class.new(project_id_param, channel, ::Packages::Package.id_in(packages.map(&:id))) } + + describe('#entries') do + subject { presenter.entries } + + it 'returns the correct hash' do + expect(subject.size).to eq(5) + expect(subject.keys).to eq(packages.map(&:name)) + subject.values.zip(packages) do |raws, pkg| + expect(raws.size).to eq(1) + + file = pkg.package_files.recent.first + raw = raws.first + expect(raw['name']).to eq(pkg.name) + expect(raw['version']).to eq(pkg.version) + expect(raw['apiVersion']).to eq("v2") + expect(raw['created']).to eq(file.created_at.utc.strftime('%Y-%m-%dT%H:%M:%S.%NZ')) + expect(raw['digest']).to eq(file.file_sha256) + expect(raw['urls']).to eq(["charts/#{file.file_name}"]) + end + end + + context 'with an unknown channel' do + let(:channel) { 'unknown' } + + it { is_expected.to be_empty } + end + + context 'with a nil channel' do + let(:channel) { nil } + + it { is_expected.to be_empty } + end + end + + describe('#api_version') do + subject { presenter.api_version } + + it { is_expected.to eq(described_class::API_VERSION) } + end + + describe('#generated') do + subject { presenter.generated } + + it 'returns the expected format' do + freeze_time do + expect(subject).to eq(Time.zone.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%NZ')) + end + end + end + + describe('#server_info') do + subject { presenter.server_info } + + it { is_expected.to eq({ 'contextPath' => "/api/v4/projects/#{project.id}/packages/helm" }) } + + context 'with url encoded project id param' do + let_it_be(:project_id_param) { 'foo/bar' } + + it { is_expected.to eq({ 'contextPath' => '/api/v4/projects/foo%2Fbar/packages/helm' }) } + end + end +end diff --git a/spec/presenters/packages/npm/package_presenter_spec.rb b/spec/presenters/packages/npm/package_presenter_spec.rb index e524edaadc6..65f69d4056b 100644 --- a/spec/presenters/packages/npm/package_presenter_spec.rb +++ b/spec/presenters/packages/npm/package_presenter_spec.rb @@ -5,10 +5,10 @@ require 'spec_helper' RSpec.describe ::Packages::Npm::PackagePresenter do let_it_be(:project) { create(:project) } let_it_be(:package_name) { "@#{project.root_namespace.path}/test" } + let_it_be(:package1) { create(:npm_package, version: '2.0.4', project: project, name: package_name) } + let_it_be(:package2) { create(:npm_package, version: '2.0.6', project: project, name: package_name) } + let_it_be(:latest_package) { create(:npm_package, version: '2.0.11', project: project, name: package_name) } - let!(:package1) { create(:npm_package, version: '1.0.4', project: project, name: package_name) } - let!(:package2) { create(:npm_package, version: '1.0.6', project: project, name: package_name) } - let!(:latest_package) { create(:npm_package, version: '1.0.11', project: project, name: package_name) } let(:packages) { project.packages.npm.with_name(package_name).last_of_each_version } let(:presenter) { described_class.new(package_name, packages) } @@ -20,23 +20,39 @@ RSpec.describe ::Packages::Npm::PackagePresenter do it { expect(subject[package1.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') } it { expect(subject[package2.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') } - described_class::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type| + ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| it { expect(subject.dig(package1.version, dependency_type)).to be nil } it { expect(subject.dig(package2.version, dependency_type)).to be nil } end + + it 'avoids N+1 database queries' do + check_n_plus_one(:versions) do + create_list(:npm_package, 5, project: project, name: package_name) + end + end end context 'for packages with dependencies' do - described_class::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type| - let!("package_dependency_link_for_#{dependency_type}") { create(:packages_dependency_link, package: package1, dependency_type: dependency_type) } + ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| + let_it_be("package_dependency_link_for_#{dependency_type}") { create(:packages_dependency_link, package: package1, dependency_type: dependency_type) } end it { is_expected.to be_a(Hash) } it { expect(subject[package1.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') } it { expect(subject[package2.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') } - described_class::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type| + ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| it { expect(subject.dig(package1.version, dependency_type.to_s)).to be_any } end + + it 'avoids N+1 database queries' do + check_n_plus_one(:versions) do + create_list(:npm_package, 5, project: project, name: package_name).each do |npm_package| + ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| + create(:packages_dependency_link, package: npm_package, dependency_type: dependency_type) + end + end + end + end end end @@ -46,14 +62,20 @@ RSpec.describe ::Packages::Npm::PackagePresenter do context 'for packages without tags' do it { is_expected.to be_a(Hash) } it { expect(subject["latest"]).to eq(latest_package.version) } + + it 'avoids N+1 database queries' do + check_n_plus_one(:dist_tags) do + create_list(:npm_package, 5, project: project, name: package_name) + end + end end context 'for packages with tags' do - let!(:package_tag1) { create(:packages_tag, package: package1, name: 'release_a') } - let!(:package_tag2) { create(:packages_tag, package: package1, name: 'test_release') } - let!(:package_tag3) { create(:packages_tag, package: package2, name: 'release_b') } - let!(:package_tag4) { create(:packages_tag, package: latest_package, name: 'release_c') } - let!(:package_tag5) { create(:packages_tag, package: latest_package, name: 'latest') } + let_it_be(:package_tag1) { create(:packages_tag, package: package1, name: 'release_a') } + let_it_be(:package_tag2) { create(:packages_tag, package: package1, name: 'test_release') } + let_it_be(:package_tag3) { create(:packages_tag, package: package2, name: 'release_b') } + let_it_be(:package_tag4) { create(:packages_tag, package: latest_package, name: 'release_c') } + let_it_be(:package_tag5) { create(:packages_tag, package: latest_package, name: 'latest') } it { is_expected.to be_a(Hash) } it { expect(subject[package_tag1.name]).to eq(package1.version) } @@ -61,6 +83,25 @@ RSpec.describe ::Packages::Npm::PackagePresenter do it { expect(subject[package_tag3.name]).to eq(package2.version) } it { expect(subject[package_tag4.name]).to eq(latest_package.version) } it { expect(subject[package_tag5.name]).to eq(latest_package.version) } + + it 'avoids N+1 database queries' do + check_n_plus_one(:dist_tags) do + create_list(:npm_package, 5, project: project, name: package_name).each_with_index do |npm_package, index| + create(:packages_tag, package: npm_package, name: "tag_#{index}") + end + end + end end end + + def check_n_plus_one(field) + pkgs = project.packages.npm.with_name(package_name).last_of_each_version.preload_files + control = ActiveRecord::QueryRecorder.new { described_class.new(package_name, pkgs).public_send(field) } + + yield + + pkgs = project.packages.npm.with_name(package_name).last_of_each_version.preload_files + + expect { described_class.new(package_name, pkgs).public_send(field) }.not_to exceed_query_limit(control) + end end diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb index fd75c8411d5..5f789f59908 100644 --- a/spec/presenters/project_presenter_spec.rb +++ b/spec/presenters/project_presenter_spec.rb @@ -649,36 +649,18 @@ RSpec.describe ProjectPresenter do end end - describe 'experiment(:repo_integrations_link)' do - context 'when enabled' do - before do - stub_experiments(repo_integrations_link: :candidate) - end - - it 'includes a button to configure integrations for maintainers' do - project.add_maintainer(user) - - expect(empty_repo_statistics_buttons.map(&:label)).to include( - a_string_including('Configure Integration') - ) - end - - it 'does not include a button if not a maintainer' do - expect(empty_repo_statistics_buttons.map(&:label)).not_to include( - a_string_including('Configure Integration') - ) - end - end + it 'includes a button to configure integrations for maintainers' do + project.add_maintainer(user) - context 'when disabled' do - it 'does not include a button' do - project.add_maintainer(user) + expect(empty_repo_statistics_buttons.map(&:label)).to include( + a_string_including('Configure Integration') + ) + end - expect(empty_repo_statistics_buttons.map(&:label)).not_to include( - a_string_including('Configure Integration') - ) - end - end + it 'does not include a button if not a maintainer' do + expect(empty_repo_statistics_buttons.map(&:label)).not_to include( + a_string_including('Configure Integration') + ) end context 'for a developer' do diff --git a/spec/presenters/snippet_blob_presenter_spec.rb b/spec/presenters/snippet_blob_presenter_spec.rb index 1a5130dcdf6..d7f56c30b5e 100644 --- a/spec/presenters/snippet_blob_presenter_spec.rb +++ b/spec/presenters/snippet_blob_presenter_spec.rb @@ -10,6 +10,7 @@ RSpec.describe SnippetBlobPresenter do describe '#rich_data' do let(:data_endpoint_url) { "/-/snippets/#{snippet.id}/raw/#{branch}/#{file}" } + let(:data_raw_dir) { "/-/snippets/#{snippet.id}/raw/#{branch}/" } before do allow_next_instance_of(described_class) do |instance| @@ -45,7 +46,7 @@ RSpec.describe SnippetBlobPresenter do let(:file) { 'test.ipynb' } it 'returns rich notebook content' do - expect(subject.strip).to eq %Q(<div class="file-content" data-endpoint="#{data_endpoint_url}" id="js-notebook-viewer"></div>) + expect(subject.strip).to eq %Q(<div class="file-content" data-endpoint="#{data_endpoint_url}" data-relative-raw-path="#{data_raw_dir}" id="js-notebook-viewer"></div>) end end diff --git a/spec/rake_helper.rb b/spec/rake_helper.rb index 7df1cf7444f..ca5b4d8337c 100644 --- a/spec/rake_helper.rb +++ b/spec/rake_helper.rb @@ -12,6 +12,6 @@ RSpec.configure do |config| end config.after(:all) do - delete_from_all_tables! + delete_from_all_tables!(except: deletion_except_tables) end end diff --git a/spec/requests/admin/background_migrations_controller_spec.rb b/spec/requests/admin/background_migrations_controller_spec.rb new file mode 100644 index 00000000000..c7d5d5cae08 --- /dev/null +++ b/spec/requests/admin/background_migrations_controller_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Admin::BackgroundMigrationsController, :enable_admin_mode do + let(:admin) { create(:admin) } + + before do + sign_in(admin) + end + + describe 'POST #retry' do + let(:migration) { create(:batched_background_migration, status: 'failed') } + + before do + create(:batched_background_migration_job, batched_migration: migration, batch_size: 10, min_value: 6, max_value: 15, status: :failed, attempts: 3) + + allow_next_instance_of(Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchingStrategy) do |batch_class| + allow(batch_class).to receive(:next_batch).with(anything, anything, batch_min_value: 6, batch_size: 5).and_return([6, 10]) + end + end + + subject(:retry_migration) { post retry_admin_background_migration_path(migration) } + + it 'redirects the user to the admin migrations page' do + retry_migration + + expect(response).to redirect_to(admin_background_migrations_path) + end + + it 'retries the migration' do + retry_migration + + expect(migration.reload.status).to eql 'active' + end + + context 'when the migration is not failed' do + let(:migration) { create(:batched_background_migration, status: 'paused') } + + it 'keeps the same migration status' do + expect { retry_migration }.not_to change { migration.reload.status } + end + end + end +end diff --git a/spec/requests/api/admin/sidekiq_spec.rb b/spec/requests/api/admin/sidekiq_spec.rb index 3c488816bed..1e626c90e7e 100644 --- a/spec/requests/api/admin/sidekiq_spec.rb +++ b/spec/requests/api/admin/sidekiq_spec.rb @@ -36,7 +36,7 @@ RSpec.describe API::Admin::Sidekiq, :clean_gitlab_redis_queues do add_job(admin, [2]) add_job(create(:user), [3]) - delete api("/admin/sidekiq/queues/authorized_projects?user=#{admin.username}", admin) + delete api("/admin/sidekiq/queues/authorized_projects?user=#{admin.username}&worker_class=AuthorizedProjectsWorker", admin) expect(response).to have_gitlab_http_status(:ok) expect(json_response).to eq('completed' => true, diff --git a/spec/requests/api/ci/pipelines_spec.rb b/spec/requests/api/ci/pipelines_spec.rb index 640e1ee6422..7ae350885f4 100644 --- a/spec/requests/api/ci/pipelines_spec.rb +++ b/spec/requests/api/ci/pipelines_spec.rb @@ -37,24 +37,10 @@ RSpec.describe API::Ci::Pipelines do end describe 'keys in the response' do - context 'when `pipeline_source_filter` feature flag is disabled' do - before do - stub_feature_flags(pipeline_source_filter: false) - end + it 'includes pipeline source' do + get api("/projects/#{project.id}/pipelines", user) - it 'does not includes pipeline source' do - get api("/projects/#{project.id}/pipelines", user) - - expect(json_response.first.keys).to contain_exactly(*%w[id project_id sha ref status web_url created_at updated_at]) - end - end - - context 'when `pipeline_source_filter` feature flag is disabled' do - it 'includes pipeline source' do - get api("/projects/#{project.id}/pipelines", user) - - expect(json_response.first.keys).to contain_exactly(*%w[id project_id sha ref status web_url created_at updated_at source]) - end + expect(json_response.first.keys).to contain_exactly(*%w[id project_id sha ref status web_url created_at updated_at source]) end end @@ -182,30 +168,6 @@ RSpec.describe API::Ci::Pipelines do end end - context 'when name is specified' do - let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) } - - context 'when name exists' do - it 'returns matched pipelines' do - get api("/projects/#{project.id}/pipelines", user), params: { name: user.name } - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to include_pagination_headers - expect(json_response.first['id']).to eq(pipeline.id) - end - end - - context 'when name does not exist' do - it 'returns empty' do - get api("/projects/#{project.id}/pipelines", user), params: { name: 'invalid-name' } - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to include_pagination_headers - expect(json_response).to be_empty - end - end - end - context 'when username is specified' do let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) } @@ -323,37 +285,20 @@ RSpec.describe API::Ci::Pipelines do create(:ci_pipeline, project: project, source: :api) end - context 'when `pipeline_source_filter` feature flag is disabled' do - before do - stub_feature_flags(pipeline_source_filter: false) - end - - it 'returns all pipelines' do - get api("/projects/#{project.id}/pipelines", user), params: { source: 'web' } + it 'returns matched pipelines' do + get api("/projects/#{project.id}/pipelines", user), params: { source: 'web' } - expect(response).to have_gitlab_http_status(:ok) - expect(response).to include_pagination_headers - expect(json_response).not_to be_empty - expect(json_response.length).to be >= 3 - end + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).not_to be_empty + json_response.each { |r| expect(r['source']).to eq('web') } end - context 'when `pipeline_source_filter` feature flag is enabled' do - it 'returns matched pipelines' do - get api("/projects/#{project.id}/pipelines", user), params: { source: 'web' } - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to include_pagination_headers - expect(json_response).not_to be_empty - json_response.each { |r| expect(r['source']).to eq('web') } - end - - context 'when source is invalid' do - it 'returns bad_request' do - get api("/projects/#{project.id}/pipelines", user), params: { source: 'invalid-source' } + context 'when source is invalid' do + it 'returns bad_request' do + get api("/projects/#{project.id}/pipelines", user), params: { source: 'invalid-source' } - expect(response).to have_gitlab_http_status(:bad_request) - end + expect(response).to have_gitlab_http_status(:bad_request) end 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 new file mode 100644 index 00000000000..7623d3f1b17 --- /dev/null +++ b/spec/requests/api/ci/runners_reset_registration_token_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Ci::Runners do + subject { post api("#{prefix}/runners/reset_registration_token", user) } + + shared_examples 'bad request' do |result| + it 'returns 400 error' do + expect { subject }.not_to change { get_token } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq(result) + end + end + + shared_examples 'unauthenticated' do + it 'returns 401 error' do + expect { subject }.not_to change { get_token } + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + shared_examples 'unauthorized' do + it 'returns 403 error' do + expect { subject }.not_to change { get_token } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + shared_examples 'not found' do |scope| + it 'returns 404 error' do + expect { subject }.not_to change { get_token } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response).to eq({ 'message' => "404 #{scope.capitalize} Not Found" }) + end + end + + shared_context 'when unauthorized' do |scope| + context 'when unauthorized' do + let_it_be(:user) { create(:user) } + + context "when not a #{scope} member" do + it_behaves_like 'not found', scope + end + + context "with a non-admin #{scope} member" do + before do + target.add_developer(user) + end + + it_behaves_like 'unauthorized' + end + end + end + + shared_context 'when authorized' do |scope| + it 'resets runner registration token' do + expect { subject }.to change { get_token } + + expect(response).to have_gitlab_http_status(:success) + expect(json_response).to eq({ 'token' => get_token }) + end + + if scope != 'instance' + context 'when malformed id is provided' do + let(:prefix) { "/#{scope.pluralize}/some%20string" } + + it_behaves_like 'not found', scope + end + end + end + + describe '/api/v4/runners/reset_registration_token' do + describe 'POST /api/v4/runners/reset_registration_token' do + before do + ApplicationSetting.create_from_defaults + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + end + + let(:prefix) { '' } + + context 'when unauthenticated' do + let(:user) { nil } + + it_behaves_like 'unauthenticated' + end + + context 'when unauthorized' do + let(:user) { create(:user) } + + context "with a non-admin instance member" do + it_behaves_like 'unauthorized' + end + end + + include_context 'when authorized', 'instance' do + let_it_be(:user) { create(:user, :admin) } + + def get_token + ApplicationSetting.current_without_cache.runners_registration_token + end + end + end + end + + describe '/api/v4/groups/:id/runners/reset_registration_token' do + describe 'POST /api/v4/groups/:id/runners/reset_registration_token' do + let_it_be(:group) { create_default(:group, :private) } + + let(:prefix) { "/groups/#{group.id}" } + + include_context 'when unauthorized', 'group' do + let(:target) { group } + end + + include_context 'when authorized', 'group' do + let_it_be(:user) { create_default(:group_member, :maintainer, user: create(:user), group: group ).user } + + def get_token + group.reload.runners_token + end + end + end + end + + describe '/api/v4/projects/:id/runners/reset_registration_token' do + describe 'POST /api/v4/projects/:id/runners/reset_registration_token' do + let_it_be(:project) { create_default(:project) } + + let(:prefix) { "/projects/#{project.id}" } + + include_context 'when unauthorized', 'project' do + let(:target) { project } + end + + include_context 'when authorized', 'project' do + let_it_be(:user) { project.owner } + + def get_token + project.reload.runners_token + end + end + end + end +end diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb index ccc9f8c50c4..47bc3eb74a6 100644 --- a/spec/requests/api/commit_statuses_spec.rb +++ b/spec/requests/api/commit_statuses_spec.rb @@ -345,38 +345,12 @@ RSpec.describe API::CommitStatuses do expect(json_response['status']).to eq('success') end - context 'feature flags' do - using RSpec::Parameterized::TableSyntax - - where(:ci_fix_commit_status_retried, :ci_remove_update_retried_from_process_pipeline, :previous_statuses_retried) do - true | true | true - true | false | true - false | true | false - false | false | true - end - - with_them do - before do - stub_feature_flags( - ci_fix_commit_status_retried: ci_fix_commit_status_retried, - ci_remove_update_retried_from_process_pipeline: ci_remove_update_retried_from_process_pipeline - ) - end - - it 'retries a commit status', :sidekiq_might_not_need_inline do - post_request - - expect(CommitStatus.count).to eq 2 + it 'retries the commit status', :sidekiq_might_not_need_inline do + post_request - if previous_statuses_retried - expect(CommitStatus.first).to be_retried - expect(CommitStatus.last.pipeline).to be_success - else - expect(CommitStatus.first).not_to be_retried - expect(CommitStatus.last.pipeline).to be_failed - end - end - end + expect(CommitStatus.count).to eq 2 + expect(CommitStatus.first).to be_retried + expect(CommitStatus.last.pipeline).to be_success end end diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 1162ae76d15..1d76c281dee 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -1879,6 +1879,26 @@ RSpec.describe API::Commits do expect(json_response['line_type']).to eq('new') end + it 'correctly adds a note for the "old" line type' do + commit = project.repository.commit("markdown") + commit_id = commit.id + route = "/projects/#{project_id}/repository/commits/#{commit_id}/comments" + + post api(route, current_user), params: { + note: 'My comment', + path: commit.raw_diffs.first.old_path, + line: 4, + line_type: 'old' + } + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/commit_note') + expect(json_response['note']).to eq('My comment') + expect(json_response['path']).to eq(commit.raw_diffs.first.old_path) + expect(json_response['line']).to eq(4) + expect(json_response['line_type']).to eq('old') + end + context 'when ref does not exist' do let(:commit_id) { 'unknown' } diff --git a/spec/requests/api/dependency_proxy_spec.rb b/spec/requests/api/dependency_proxy_spec.rb index d59f2bf06e3..2837d1c02c4 100644 --- a/spec/requests/api/dependency_proxy_spec.rb +++ b/spec/requests/api/dependency_proxy_spec.rb @@ -13,60 +13,74 @@ RSpec.describe API::DependencyProxy, api: true do group.add_owner(user) stub_config(dependency_proxy: { enabled: true }) stub_last_activity_update - group.create_dependency_proxy_setting!(enabled: true) end describe 'DELETE /groups/:id/dependency_proxy/cache' do - subject { delete api("/groups/#{group.id}/dependency_proxy/cache", user) } + subject { delete api("/groups/#{group_id}/dependency_proxy/cache", user) } - context 'with feature available and enabled' do - let_it_be(:lease_key) { "dependency_proxy:delete_group_blobs:#{group.id}" } + shared_examples 'responding to purge requests' do + context 'with feature available and enabled' do + let_it_be(:lease_key) { "dependency_proxy:delete_group_blobs:#{group.id}" } - context 'an admin user' do - it 'deletes the blobs and returns no content' do - stub_exclusive_lease(lease_key, timeout: 1.hour) - expect(PurgeDependencyProxyCacheWorker).to receive(:perform_async) + context 'an admin user' do + it 'deletes the blobs and returns no content' do + stub_exclusive_lease(lease_key, timeout: 1.hour) + expect(PurgeDependencyProxyCacheWorker).to receive(:perform_async) - subject + subject - expect(response).to have_gitlab_http_status(:no_content) - end + expect(response).to have_gitlab_http_status(:accepted) + expect(response.body).to eq('202') + end - context 'called multiple times in one hour', :clean_gitlab_redis_shared_state do - it 'returns 409 with an error message' do - stub_exclusive_lease_taken(lease_key, timeout: 1.hour) + context 'called multiple times in one hour', :clean_gitlab_redis_shared_state do + it 'returns 409 with an error message' do + stub_exclusive_lease_taken(lease_key, timeout: 1.hour) - subject + subject - expect(response).to have_gitlab_http_status(:conflict) - expect(response.body).to include('This request has already been made.') + expect(response).to have_gitlab_http_status(:conflict) + expect(response.body).to include('This request has already been made.') + end + + it 'executes service only for the first time' do + expect(PurgeDependencyProxyCacheWorker).to receive(:perform_async).once + + 2.times { subject } + end end + end - it 'executes service only for the first time' do - expect(PurgeDependencyProxyCacheWorker).to receive(:perform_async).once + context 'a non-admin' do + let(:user) { create(:user) } - 2.times { subject } + before do + group.add_maintainer(user) end + + it_behaves_like 'returning response status', :forbidden end end - context 'a non-admin' do - let(:user) { create(:user) } - + context 'depencency proxy is not enabled in the config' do before do - group.add_maintainer(user) + stub_config(dependency_proxy: { enabled: false }) end - it_behaves_like 'returning response status', :forbidden + it_behaves_like 'returning response status', :not_found end end - context 'depencency proxy is not enabled' do - before do - stub_config(dependency_proxy: { enabled: false }) - end + context 'with a group id' do + let(:group_id) { group.id } + + it_behaves_like 'responding to purge requests' + end + + context 'with an url encoded group id' do + let(:group_id) { ERB::Util.url_encode(group.full_path) } - it_behaves_like 'returning response status', :not_found + it_behaves_like 'responding to purge requests' end end end diff --git a/spec/requests/api/error_tracking_client_keys_spec.rb b/spec/requests/api/error_tracking_client_keys_spec.rb new file mode 100644 index 00000000000..886ec5ade3d --- /dev/null +++ b/spec/requests/api/error_tracking_client_keys_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::ErrorTrackingClientKeys do + let_it_be(:guest) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:setting) { create(:project_error_tracking_setting) } + let_it_be(:project) { setting.project } + + let!(:client_key) { create(:error_tracking_client_key, project: project) } + + before do + project.add_guest(guest) + project.add_maintainer(maintainer) + end + + shared_examples 'endpoint with authorization' do + context 'when unauthenticated' do + let(:user) { nil } + + it { expect(response).to have_gitlab_http_status(:unauthorized) } + end + + context 'when authenticated as non-maintainer' do + let(:user) { guest } + + it { expect(response).to have_gitlab_http_status(:forbidden) } + end + end + + describe "GET /projects/:id/error_tracking/client_keys" do + before do + get api("/projects/#{project.id}/error_tracking/client_keys", user) + end + + it_behaves_like 'endpoint with authorization' + + context 'when authenticated as maintainer' do + let(:user) { maintainer } + + it 'returns client keys' do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response.size).to eq(1) + expect(json_response.first['id']).to eq(client_key.id) + end + end + end + + describe "POST /projects/:id/error_tracking/client_keys" do + before do + post api("/projects/#{project.id}/error_tracking/client_keys", user) + end + + it_behaves_like 'endpoint with authorization' + + context 'when authenticated as maintainer' do + let(:user) { maintainer } + + it 'returns a newly created client key' do + new_key = project.error_tracking_client_keys.last + + expect(json_response['id']).to eq(new_key.id) + expect(json_response['public_key']).to eq(new_key.public_key) + expect(json_response['sentry_dsn']).to eq(new_key.sentry_dsn) + end + end + end + + describe "DELETE /projects/:id/error_tracking/client_keys/:key_id" do + before do + delete api("/projects/#{project.id}/error_tracking/client_keys/#{client_key.id}", user) + end + + it_behaves_like 'endpoint with authorization' + + context 'when authenticated as maintainer' do + let(:user) { maintainer } + + it 'returns a correct status' do + expect(response).to have_gitlab_http_status(:ok) + end + end + end +end diff --git a/spec/requests/api/error_tracking_collector_spec.rb b/spec/requests/api/error_tracking_collector_spec.rb index 4b186657c4a..35d3ea01f87 100644 --- a/spec/requests/api/error_tracking_collector_spec.rb +++ b/spec/requests/api/error_tracking_collector_spec.rb @@ -7,6 +7,30 @@ RSpec.describe API::ErrorTrackingCollector do let_it_be(:setting) { create(:project_error_tracking_setting, :integrated, project: project) } let_it_be(:client_key) { create(:error_tracking_client_key, project: project) } + RSpec.shared_examples 'not found' do + it 'reponds with 404' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + RSpec.shared_examples 'bad request' do + it 'responds with 400' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + RSpec.shared_examples 'successful request' do + it 'writes to the database and returns no content' do + expect { subject }.to change { ErrorTracking::ErrorEvent.count }.by(1) + + expect(response).to have_gitlab_http_status(:no_content) + end + end + describe "POST /error_tracking/collector/api/:id/envelope" do let_it_be(:raw_event) { fixture_file('error_tracking/event.txt') } let_it_be(:url) { "/error_tracking/collector/api/#{project.id}/envelope" } @@ -16,22 +40,6 @@ RSpec.describe API::ErrorTrackingCollector do subject { post api(url), params: params, headers: headers } - RSpec.shared_examples 'not found' do - it 'reponds with 404' do - subject - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - RSpec.shared_examples 'bad request' do - it 'responds with 400' do - subject - - expect(response).to have_gitlab_http_status(:bad_request) - end - end - context 'error tracking feature is disabled' do before do setting.update!(enabled: false) @@ -48,14 +56,6 @@ RSpec.describe API::ErrorTrackingCollector do it_behaves_like 'not found' end - context 'feature flag is disabled' do - before do - stub_feature_flags(integrated_error_tracking: false) - end - - it_behaves_like 'not found' - end - context 'auth headers are missing' do let(:headers) { {} } @@ -96,10 +96,53 @@ RSpec.describe API::ErrorTrackingCollector do end end - it 'writes to the database and returns no content' do - expect { subject }.to change { ErrorTracking::ErrorEvent.count }.by(1) + it_behaves_like 'successful request' + end - expect(response).to have_gitlab_http_status(:no_content) + describe "POST /error_tracking/collector/api/:id/store" do + let_it_be(:raw_event) { fixture_file('error_tracking/parsed_event.json') } + let_it_be(:url) { "/error_tracking/collector/api/#{project.id}/store" } + + let(:params) { raw_event } + let(:headers) { { 'X-Sentry-Auth' => "Sentry sentry_key=#{client_key.public_key}" } } + + subject { post api(url), params: params, headers: headers } + + it_behaves_like 'successful request' + + context 'empty headers' do + let(:headers) { {} } + + it_behaves_like 'bad request' + end + + context 'empty body' do + let(:params) { '' } + + it_behaves_like 'bad request' + end + + context 'sentry_key as param and empty headers' do + let(:url) { "/error_tracking/collector/api/#{project.id}/store?sentry_key=#{sentry_key}" } + let(:headers) { {} } + + context 'key is wrong' do + let(:sentry_key) { 'glet_1fedb514e17f4b958435093deb02048c' } + + it_behaves_like 'not found' + end + + context 'key is empty' do + let(:sentry_key) { '' } + + it_behaves_like 'bad request' + end + + context 'key is correct' do + let(:sentry_key) { client_key.public_key } + + it_behaves_like 'successful request' + end end end end diff --git a/spec/requests/api/feature_flags_spec.rb b/spec/requests/api/feature_flags_spec.rb index 8c8c6803a38..a1aedc1d6b2 100644 --- a/spec/requests/api/feature_flags_spec.rb +++ b/spec/requests/api/feature_flags_spec.rb @@ -116,21 +116,6 @@ RSpec.describe API::FeatureFlags do }]) end end - - context 'with version 1 and 2 feature flags' do - it 'returns both versions of flags ordered by name' do - create(:operations_feature_flag, project: project, name: 'legacy_flag') - feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'new_version_flag') - strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) - create(:operations_scope, strategy: strategy, environment_scope: 'production') - - subject - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to match_response_schema('public_api/v4/feature_flags') - expect(json_response.map { |f| f['name'] }).to eq(%w[legacy_flag new_version_flag]) - end - end end describe 'GET /projects/:id/feature_flags/:name' do @@ -185,22 +170,13 @@ RSpec.describe API::FeatureFlags do end describe 'POST /projects/:id/feature_flags' do - def scope_default - { - environment_scope: '*', - active: false, - strategies: [{ name: 'default', parameters: {} }].to_json - } - end - subject do post api("/projects/#{project.id}/feature_flags", user), params: params end let(:params) do { - name: 'awesome-feature', - scopes: [scope_default] + name: 'awesome-feature' } end @@ -215,14 +191,14 @@ RSpec.describe API::FeatureFlags do expect(feature_flag.description).to eq(params[:description]) end - it 'defaults to a version 1 (legacy) feature flag' do + it 'defaults to a version 2 (new) feature flag' do subject expect(response).to have_gitlab_http_status(:created) expect(response).to match_response_schema('public_api/v4/feature_flag') feature_flag = project.operations_feature_flags.last - expect(feature_flag.version).to eq('legacy_flag') + expect(feature_flag.version).to eq('new_version_flag') end it_behaves_like 'check user permission' @@ -232,38 +208,7 @@ RSpec.describe API::FeatureFlags do expect(response).to have_gitlab_http_status(:created) expect(response).to match_response_schema('public_api/v4/feature_flag') - expect(json_response['version']).to eq('legacy_flag') - end - - context 'with active set to false in the params for a legacy flag' do - let(:params) do - { - name: 'awesome-feature', - version: 'legacy_flag', - active: 'false', - scopes: [scope_default] - } - end - - it 'creates an inactive feature flag' do - subject - - expect(response).to have_gitlab_http_status(:created) - expect(response).to match_response_schema('public_api/v4/feature_flag') - expect(json_response['active']).to eq(false) - end - end - - context 'when no scopes passed in parameters' do - let(:params) { { name: 'awesome-feature' } } - - it 'creates a new feature flag with active default scope' do - subject - - expect(response).to have_gitlab_http_status(:created) - feature_flag = project.operations_feature_flags.last - expect(feature_flag.default_scope).to be_active - end + expect(json_response['version']).to eq('new_version_flag') end context 'when there is a feature flag with the same name already' do @@ -278,43 +223,6 @@ RSpec.describe API::FeatureFlags do end end - context 'when create a feature flag with two scopes' do - let(:params) do - { - name: 'awesome-feature', - description: 'this is awesome', - scopes: [ - scope_default, - scope_with_user_with_id - ] - } - end - - let(:scope_with_user_with_id) do - { - environment_scope: 'production', - active: true, - strategies: [{ - name: 'userWithId', - parameters: { userIds: 'user:1' } - }].to_json - } - end - - it 'creates a new feature flag with two scopes' do - subject - - expect(response).to have_gitlab_http_status(:created) - - feature_flag = project.operations_feature_flags.last - feature_flag.scopes.ordered.each_with_index do |scope, index| - expect(scope.environment_scope).to eq(params[:scopes][index][:environment_scope]) - expect(scope.active).to eq(params[:scopes][index][:active]) - expect(scope.strategies).to eq(Gitlab::Json.parse(params[:scopes][index][:strategies])) - end - end - end - context 'when creating a version 2 feature flag' do it 'creates a new feature flag' do params = { @@ -455,23 +363,6 @@ RSpec.describe API::FeatureFlags do end describe 'PUT /projects/:id/feature_flags/:name' do - context 'with a legacy feature flag' do - let!(:feature_flag) do - create(:operations_feature_flag, :legacy_flag, project: project, - name: 'feature1', description: 'old description') - end - - it 'returns a 404' do - params = { description: 'new description' } - - put api("/projects/#{project.id}/feature_flags/feature1", user), params: params - - expect(response).to have_gitlab_http_status(:not_found) - expect(json_response).to eq({ 'message' => '404 Not Found' }) - expect(feature_flag.reload.description).to eq('old description') - end - end - context 'with a version 2 feature flag' do let!(:feature_flag) do create(:operations_feature_flag, :new_version_flag, project: project, active: true, @@ -781,7 +672,7 @@ RSpec.describe API::FeatureFlags do params: params end - let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, project: project) } + let!(:feature_flag) { create(:operations_feature_flag, project: project) } let(:params) { {} } it 'destroys the feature flag' do @@ -794,7 +685,7 @@ RSpec.describe API::FeatureFlags do subject expect(response).to have_gitlab_http_status(:ok) - expect(json_response['version']).to eq('legacy_flag') + expect(json_response['version']).to eq('new_version_flag') end context 'with a version 2 feature flag' do diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index 869df06b60c..0b898496dd6 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -95,6 +95,19 @@ RSpec.describe API::Files do expect(response.headers['X-Gitlab-Content-Sha256']).to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887') end + it 'caches sha256 of the content', :use_clean_rails_redis_caching do + head api(route(file_path), current_user, **options), params: params + + expect(Rails.cache.fetch("blob_content_sha256:#{project.full_path}:#{response.headers['X-Gitlab-Blob-Id']}")) + .to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887') + + expect_next_instance_of(Gitlab::Git::Blob) do |instance| + expect(instance).not_to receive(:load_all_data!) + end + + head api(route(file_path), current_user, **options), params: params + end + it 'returns file by commit sha' do # This file is deleted on HEAD file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee" diff --git a/spec/requests/api/generic_packages_spec.rb b/spec/requests/api/generic_packages_spec.rb index 4091253fb54..7e439a22e4b 100644 --- a/spec/requests/api/generic_packages_spec.rb +++ b/spec/requests/api/generic_packages_spec.rb @@ -18,7 +18,7 @@ RSpec.describe API::GenericPackages do let_it_be(:project_deploy_token_wo) { create(:project_deploy_token, deploy_token: deploy_token_wo, project: project) } let(:user) { personal_access_token.user } - let(:ci_build) { create(:ci_build, :running, user: user) } + let(:ci_build) { create(:ci_build, :running, user: user, project: project) } let(:snowplow_standard_context_params) { { user: user, project: project, namespace: project.namespace } } def auth_header @@ -388,9 +388,11 @@ RSpec.describe API::GenericPackages do end context 'event tracking' do + let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, user: user } } + subject { upload_file(params, workhorse_headers.merge(personal_access_token_header)) } - it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + it_behaves_like 'a package tracking event', described_class.name, 'push_package' end it 'rejects request without a file from workhorse' do @@ -542,13 +544,15 @@ RSpec.describe API::GenericPackages do end context 'event tracking' do + let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, user: user } } + before do project.add_developer(user) end subject { download_file(personal_access_token_header) } - it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package' + it_behaves_like 'a package tracking event', described_class.name, 'pull_package' end it 'rejects a malicious file name request' do diff --git a/spec/requests/api/go_proxy_spec.rb b/spec/requests/api/go_proxy_spec.rb index e678b6cf1c8..0143340de11 100644 --- a/spec/requests/api/go_proxy_spec.rb +++ b/spec/requests/api/go_proxy_spec.rb @@ -11,7 +11,7 @@ RSpec.describe API::GoProxy do let_it_be(:base) { "#{Settings.build_gitlab_go_url}/#{project.full_path}" } let_it_be(:oauth) { create :oauth_access_token, scopes: 'api', resource_owner: user } - let_it_be(:job) { create :ci_build, user: user, status: :running } + let_it_be(:job) { create :ci_build, user: user, status: :running, project: project } let_it_be(:pa_token) { create :personal_access_token, user: user } let_it_be(:modules) do 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 3628171fcc1..008241b8055 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 @@ -48,13 +48,18 @@ RSpec.describe 'get board lists' do issues_data.map { |i| i['title'] } end + def issue_relative_positions + issues_data.map { |i| i['relativePosition'] } + end + shared_examples 'group and project board list issues query' do let!(:board) { create(:board, resource_parent: board_parent) } let!(:label_list) { create(:list, board: board, label: label, position: 10) } let!(:issue1) { create(:issue, project: issue_project, labels: [label, label2], relative_position: 9) } let!(:issue2) { create(:issue, project: issue_project, labels: [label, label2], relative_position: 2) } - let!(:issue3) { create(:issue, project: issue_project, labels: [label], relative_position: 9) } - let!(:issue4) { create(:issue, project: issue_project, labels: [label2], relative_position: 432) } + 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) } context 'when the user does not have access to the board' do it 'returns nil' do @@ -69,10 +74,11 @@ RSpec.describe 'get board lists' do board_parent.add_reporter(user) end - it 'can access the issues' do + it 'can access the issues', :aggregate_failures do post_graphql(query("id: \"#{global_id_of(label_list)}\""), current_user: user) - expect(issue_titles).to eq([issue2.title, issue1.title]) + expect(issue_titles).to eq([issue2.title, issue1.title, issue3.title]) + expect(issue_relative_positions).not_to include(nil) end end end diff --git a/spec/requests/api/graphql/ci/stages_spec.rb b/spec/requests/api/graphql/ci/stages_spec.rb index cd48a24b9c8..50d2cf75097 100644 --- a/spec/requests/api/graphql/ci/stages_spec.rb +++ b/spec/requests/api/graphql/ci/stages_spec.rb @@ -4,11 +4,13 @@ require 'spec_helper' RSpec.describe 'Query.project.pipeline.stages' do include GraphqlHelpers - let(:project) { create(:project, :repository, :public) } - let(:user) { create(:user) } - let(:pipeline) { create(:ci_pipeline, project: project, user: user) } - let(:stage_graphql_data) { graphql_data['project']['pipeline']['stages'] } + subject(:post_query) { post_graphql(query, current_user: user) } + let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:user) { create(:user) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) } + + let(:stage_nodes) { graphql_data_at(:project, :pipeline, :stages, :nodes) } let(:params) { {} } let(:fields) do @@ -33,14 +35,42 @@ RSpec.describe 'Query.project.pipeline.stages' do ) end - before do + before_all do create(:ci_stage_entity, pipeline: pipeline, name: 'deploy') - post_graphql(query, current_user: user) + create_list(:ci_build, 2, pipeline: pipeline, stage: 'deploy') end - it_behaves_like 'a working graphql query' + it_behaves_like 'a working graphql query' do + before do + post_query + end + end it 'returns the stage of a pipeline' do - expect(stage_graphql_data['nodes'].first['name']).to eq('deploy') + post_query + + expect(stage_nodes.first['name']).to eq('deploy') + end + + describe 'job pagination' do + let(:job_nodes) { graphql_dig_at(stage_nodes, :jobs, :nodes) } + + it 'returns up to default limit jobs per stage' do + post_query + + expect(job_nodes.count).to eq(2) + end + + context 'when the limit is manually set' do + before do + stub_application_setting(jobs_per_stage_page_size: 1) + end + + it 'returns up to custom limit jobs per stage' do + post_query + + expect(job_nodes.count).to eq(1) + end + end end end diff --git a/spec/requests/api/graphql/current_user/groups_query_spec.rb b/spec/requests/api/graphql/current_user/groups_query_spec.rb new file mode 100644 index 00000000000..39f323b21a3 --- /dev/null +++ b/spec/requests/api/graphql/current_user/groups_query_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Query current user groups' do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') } + let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer') } + let_it_be(:public_developer_group) { create(:group, :private, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') } + let_it_be(:public_maintainer_group) { create(:group, :private, name: 'a public maintainer', path: 'a-public-maintainer') } + + let(:group_arguments) { {} } + let(:current_user) { user } + + let(:fields) do + <<~GRAPHQL + nodes { id path fullPath name } + GRAPHQL + end + + let(:query) do + graphql_query_for('currentUser', {}, query_graphql_field('groups', group_arguments, fields)) + end + + before_all do + guest_group.add_guest(user) + private_maintainer_group.add_maintainer(user) + public_developer_group.add_developer(user) + public_maintainer_group.add_maintainer(user) + end + + subject { graphql_data.dig('currentUser', 'groups', 'nodes') } + + before do + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'avoids N+1 queries', :request_store do + control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) } + + new_group = create(:group, :private) + new_group.add_maintainer(current_user) + + expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control) + end + + it 'returns all groups where the user is a direct member' do + is_expected.to match( + expected_group_hash( + public_maintainer_group, + private_maintainer_group, + public_developer_group, + guest_group + ) + ) + end + + context 'when permission_scope is CREATE_PROJECTS' do + let(:group_arguments) { { permission_scope: :CREATE_PROJECTS } } + + specify do + is_expected.to match( + expected_group_hash( + public_maintainer_group, + private_maintainer_group, + public_developer_group + ) + ) + end + + context 'when search is provided' do + let(:group_arguments) { { permission_scope: :CREATE_PROJECTS, search: 'maintainer' } } + + specify do + is_expected.to match( + expected_group_hash( + public_maintainer_group, + private_maintainer_group + ) + ) + end + end + end + + context 'when search is provided' do + let(:group_arguments) { { search: 'maintainer' } } + + specify do + is_expected.to match( + expected_group_hash( + public_maintainer_group, + private_maintainer_group + ) + ) + end + end + + def expected_group_hash(*groups) + groups.map do |group| + { + 'id' => group.to_global_id.to_s, + 'name' => group.name, + 'path' => group.path, + 'fullPath' => group.full_path + } + end + end +end diff --git a/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb new file mode 100644 index 00000000000..cdb21512894 --- /dev/null +++ b/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe 'getting dependency proxy blobs in a group' do + using RSpec::Parameterized::TableSyntax + include GraphqlHelpers + + let_it_be(:owner) { create(:user) } + let_it_be_with_reload(:group) { create(:group) } + let_it_be(:blob) { create(:dependency_proxy_blob, group: group) } + let_it_be(:blob2) { create(:dependency_proxy_blob, file_name: 'blob2.json', group: group) } + let_it_be(:blobs) { [blob, blob2].flatten } + + let(:dependency_proxy_blob_fields) do + <<~GQL + edges { + node { + #{all_graphql_fields_for('dependency_proxy_blobs'.classify, max_depth: 1)} + } + } + GQL + end + + let(:fields) do + <<~GQL + #{query_graphql_field('dependency_proxy_blobs', {}, dependency_proxy_blob_fields)} + dependencyProxyBlobCount + dependencyProxyTotalSize + GQL + end + + let(:query) do + graphql_query_for( + 'group', + { 'fullPath' => group.full_path }, + fields + ) + end + + let(:user) { owner } + let(:variables) { {} } + let(:dependency_proxy_blobs_response) { graphql_data.dig('group', 'dependencyProxyBlobs', 'edges') } + let(:dependency_proxy_blob_count_response) { graphql_data.dig('group', 'dependencyProxyBlobCount') } + let(:dependency_proxy_total_size_response) { graphql_data.dig('group', 'dependencyProxyTotalSize') } + + before do + stub_config(dependency_proxy: { enabled: true }) + group.add_owner(owner) + 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 + let_it_be(:user) { create(:user) } + + 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 + + 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_blobs_response.size).to eq(blobs.size) + else + expect(dependency_proxy_blobs_response).to be_blank + end + end + end + end + + context 'limiting the number of blobs' do + let(:limit) { 1 } + let(:variables) do + { path: group.full_path, n: limit } + end + + let(:query) do + <<~GQL + query($path: ID!, $n: Int) { + group(fullPath: $path) { + dependencyProxyBlobs(first: $n) { #{dependency_proxy_blob_fields} } + } + } + GQL + end + + it 'only returns N blobs' do + subject + + expect(dependency_proxy_blobs_response.size).to eq(limit) + end + end + + it 'returns the total count of blobs' do + subject + + expect(dependency_proxy_blob_count_response).to eq(blobs.size) + end + + it 'returns the total size' do + subject + expected_size = blobs.inject(0) { |sum, blob| sum + blob.size } + expect(dependency_proxy_total_size_response).to eq(ActiveSupport::NumberHelper.number_to_human_size(expected_size)) + end +end 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 new file mode 100644 index 00000000000..c5c6d85d1e6 --- /dev/null +++ b/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe 'getting dependency proxy settings for a group' do + using RSpec::Parameterized::TableSyntax + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:group) { create(:group) } + + let(:dependency_proxy_group_setting_fields) do + <<~GQL + #{all_graphql_fields_for('dependency_proxy_setting'.classify, max_depth: 1)} + GQL + end + + let(:fields) do + <<~GQL + #{query_graphql_field('dependency_proxy_setting', {}, dependency_proxy_group_setting_fields)} + GQL + end + + let(:query) do + graphql_query_for( + 'group', + { 'fullPath' => group.full_path }, + fields + ) + end + + let(:variables) { {} } + let(:dependency_proxy_group_setting_response) { graphql_data.dig('group', 'dependencyProxySetting') } + + 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 + 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 + 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 + end + end + end + end +end diff --git a/spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb new file mode 100644 index 00000000000..c8797d84906 --- /dev/null +++ b/spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe 'getting dependency proxy image ttl policy for a group' do + using RSpec::Parameterized::TableSyntax + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:group) { create(:group) } + + let(:dependency_proxy_image_ttl_policy_fields) do + <<~GQL + #{all_graphql_fields_for('dependency_proxy_image_ttl_group_policy'.classify, max_depth: 1)} + GQL + end + + let(:fields) do + <<~GQL + #{query_graphql_field('dependency_proxy_image_ttl_policy', {}, dependency_proxy_image_ttl_policy_fields)} + GQL + end + + let(:query) do + graphql_query_for( + 'group', + { 'fullPath' => group.full_path }, + fields + ) + end + + let(:variables) { {} } + let(:dependency_proxy_image_ttl_policy_response) { graphql_data.dig('group', 'dependencyProxyImageTtlPolicy') } + + before do + stub_config(dependency_proxy: { 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 + 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 + end + + it 'return the proper response' do + subject + + if access_granted + expect(dependency_proxy_image_ttl_policy_response).to eq("createdAt" => nil, "enabled" => false, "ttl" => 90, "updatedAt" => nil) + else + expect(dependency_proxy_image_ttl_policy_response).to be_blank + end + end + end + end +end diff --git a/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb new file mode 100644 index 00000000000..30e704adb92 --- /dev/null +++ b/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe 'getting dependency proxy manifests in a group' do + using RSpec::Parameterized::TableSyntax + include GraphqlHelpers + + let_it_be(:owner) { create(:user) } + let_it_be_with_reload(:group) { create(:group) } + let_it_be(:manifest) { create(:dependency_proxy_manifest, group: group) } + let_it_be(:manifest2) { create(:dependency_proxy_manifest, file_name: 'image2.json', group: group) } + let_it_be(:manifests) { [manifest, manifest2].flatten } + + let(:dependency_proxy_manifest_fields) do + <<~GQL + edges { + node { + #{all_graphql_fields_for('dependency_proxy_manifests'.classify, max_depth: 1)} + } + } + GQL + end + + let(:fields) do + <<~GQL + #{query_graphql_field('dependency_proxy_manifests', {}, dependency_proxy_manifest_fields)} + dependencyProxyImageCount + GQL + end + + let(:query) do + graphql_query_for( + 'group', + { 'fullPath' => group.full_path }, + fields + ) + end + + let(:user) { owner } + let(:variables) { {} } + let(:dependency_proxy_manifests_response) { graphql_data.dig('group', 'dependencyProxyManifests', 'edges') } + let(:dependency_proxy_image_count_response) { graphql_data.dig('group', 'dependencyProxyImageCount') } + + before do + stub_config(dependency_proxy: { enabled: true }) + group.add_owner(owner) + 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 + let_it_be(:user) { create(:user) } + + 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 + + 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_manifests_response.size).to eq(manifests.size) + else + expect(dependency_proxy_manifests_response).to be_blank + end + end + end + end + + context 'limiting the number of manifests' do + let(:limit) { 1 } + let(:variables) do + { path: group.full_path, n: limit } + end + + let(:query) do + <<~GQL + query($path: ID!, $n: Int) { + group(fullPath: $path) { + dependencyProxyManifests(first: $n) { #{dependency_proxy_manifest_fields} } + } + } + GQL + end + + it 'only returns N manifests' do + subject + + expect(dependency_proxy_manifests_response.size).to eq(limit) + end + end + + it 'returns the total count of manifests' do + subject + + expect(dependency_proxy_image_count_response).to eq(manifests.size) + end +end diff --git a/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb b/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb index 1692cfbcf84..f992e46879f 100644 --- a/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb +++ b/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'Deleting Sidekiq jobs', :clean_gitlab_redis_queues do let(:queue) { 'authorized_projects' } - let(:variables) { { user: admin.username, queue_name: queue } } + let(:variables) { { user: admin.username, worker_class: 'AuthorizedProjectsWorker', queue_name: queue } } let(:mutation) { graphql_mutation(:admin_sidekiq_queues_delete_jobs, variables) } def mutation_response diff --git a/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb new file mode 100644 index 00000000000..07fd57a2cee --- /dev/null +++ b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Deletion of custom emoji' do + include GraphqlHelpers + + let_it_be(:group) { create(:group) } + let_it_be(:current_user) { create(:user) } + let_it_be(:user2) { create(:user) } + let_it_be_with_reload(:custom_emoji) { create(:custom_emoji, group: group, creator: user2) } + + let(:mutation) do + variables = { + id: GitlabSchema.id_from_object(custom_emoji).to_s + } + + graphql_mutation(:destroy_custom_emoji, variables) + end + + shared_examples 'does not delete custom emoji' do + it 'does not change count' do + expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(CustomEmoji, :count) + end + end + + shared_examples 'deletes custom emoji' do + it 'changes count' do + expect { post_graphql_mutation(mutation, current_user: current_user) }.to change(CustomEmoji, :count).by(-1) + end + end + + context 'when the user' do + context 'has no permissions' do + it_behaves_like 'does not delete custom emoji' + end + + context 'when the user is developer and not creator of custom emoji' do + before do + group.add_developer(current_user) + end + + it_behaves_like 'does not delete custom emoji' + end + end + + context 'when user' do + context 'is maintainer' do + before do + group.add_maintainer(current_user) + end + + it_behaves_like 'deletes custom emoji' + end + + context 'is owner' do + before do + group.add_owner(current_user) + end + + it_behaves_like 'deletes custom emoji' + end + + context 'is developer and creator of the emoji' do + before do + group.add_developer(current_user) + custom_emoji.update_attribute(:creator, current_user) + end + + it_behaves_like 'deletes custom emoji' + end + end +end diff --git a/spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb b/spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb new file mode 100644 index 00000000000..c9e9a22ee0b --- /dev/null +++ b/spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Updating the dependency proxy image ttl policy' do + include GraphqlHelpers + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + + let(:params) do + { + group_path: group.full_path, + enabled: false, + ttl: 2 + } + end + + let(:mutation) do + graphql_mutation(:update_dependency_proxy_image_ttl_group_policy, params) do + <<~QL + dependencyProxyImageTtlPolicy { + enabled + ttl + } + errors + QL + end + end + + let(:mutation_response) { graphql_mutation_response(:update_dependency_proxy_image_ttl_group_policy) } + let(:ttl_policy_response) { mutation_response['dependencyProxyImageTtlPolicy'] } + + 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(:ttl_policy, reload: true) { create(:image_ttl_group_policy) } + let_it_be(:group, reload: true) { ttl_policy.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 image ttl policy', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['errors']).to be_empty + expect(ttl_policy_response).to include( + 'enabled' => params[:enabled], + 'ttl' => params[:ttl] + ) + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/issues/create_spec.rb b/spec/requests/api/graphql/mutations/issues/create_spec.rb index 66450f8c604..886f3140086 100644 --- a/spec/requests/api/graphql/mutations/issues/create_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/create_spec.rb @@ -39,11 +39,14 @@ RSpec.describe 'Create an issue' do end it 'creates the issue' do - post_graphql_mutation(mutation, current_user: current_user) + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to change(Issue, :count).by(1) expect(response).to have_gitlab_http_status(:success) expect(mutation_response['issue']).to include(input) expect(mutation_response['issue']).to include('discussionLocked' => true) + expect(Issue.last.work_item_type.base_type).to eq('issue') end end end diff --git a/spec/requests/api/graphql/mutations/issues/update_spec.rb b/spec/requests/api/graphql/mutations/issues/update_spec.rb index c3aaf090703..0f2eeb90894 100644 --- a/spec/requests/api/graphql/mutations/issues/update_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/update_spec.rb @@ -44,6 +44,19 @@ RSpec.describe 'Update of an existing issue' do expect(mutation_response['issue']).to include('discussionLocked' => true) end + context 'when issue_type is updated' do + let(:input) { { 'iid' => issue.iid.to_s, 'type' => 'INCIDENT' } } + + it 'updates issue_type and work_item_type' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + issue.reload + end.to change { issue.work_item_type.base_type }.from('issue').to('incident').and( + change(issue, :issue_type).from('issue').to('incident') + ) + end + end + context 'setting labels' do let(:mutation) do graphql_mutation(:update_issue, input_params) do diff --git a/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb b/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb index 80376f56ee8..a540386a9de 100644 --- a/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb +++ b/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' RSpec.describe 'sentry errors requests' do include GraphqlHelpers + let_it_be(:project) { create(:project, :repository) } let_it_be(:project_setting) { create(:project_error_tracking_setting, project: project) } let_it_be(:current_user) { project.owner } @@ -30,7 +31,7 @@ RSpec.describe 'sentry errors requests' do let(:error_data) { graphql_data.dig('project', 'sentryErrors', 'detailedError') } - it 'returns a successful response', :aggregate_failures, :quarantine do + it 'returns a successful response', :aggregate_failures do post_graphql(query, current_user: current_user) expect(response).to have_gitlab_http_status(:success) @@ -48,11 +49,9 @@ RSpec.describe 'sentry errors requests' do end end - context 'reactive cache returns data' do + context 'when reactive cache returns data' do before do - allow_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting) - .to receive(:issue_details) - .and_return(issue: sentry_detailed_error) + stub_setting_for(:issue_details, issue: sentry_detailed_error) post_graphql(query, current_user: current_user) end @@ -72,7 +71,7 @@ RSpec.describe 'sentry errors requests' do end end - context 'user does not have permission' do + context 'when user does not have permission' do let(:current_user) { create(:user) } it 'is expected to return an empty error' do @@ -81,11 +80,9 @@ RSpec.describe 'sentry errors requests' do end end - context 'sentry api returns an error' do + context 'when sentry api returns an error' do before do - expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting) - .to receive(:issue_details) - .and_return(error: 'error message') + stub_setting_for(:issue_details, error: 'error message') post_graphql(query, current_user: current_user) end @@ -140,11 +137,11 @@ RSpec.describe 'sentry errors requests' do end end - context 'reactive cache returns data' do + context 'when reactive cache returns data' do before do - expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting) - .to receive(:list_sentry_issues) - .and_return(issues: [sentry_error], pagination: pagination) + stub_setting_for(:list_sentry_issues, + issues: [sentry_error], + pagination: pagination) post_graphql(query, current_user: current_user) end @@ -177,11 +174,9 @@ RSpec.describe 'sentry errors requests' do end end - context 'sentry api itself errors out' do + context 'when sentry api itself errors out' do before do - expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting) - .to receive(:list_sentry_issues) - .and_return(error: 'error message') + stub_setting_for(:list_sentry_issues, error: 'error message') post_graphql(query, current_user: current_user) end @@ -223,18 +218,16 @@ RSpec.describe 'sentry errors requests' do end end - context 'reactive cache returns data' do + context 'when reactive cache returns data' do before do - allow_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting) - .to receive(:issue_latest_event) - .and_return(latest_event: sentry_stack_trace) + stub_setting_for(:issue_latest_event, latest_event: sentry_stack_trace) post_graphql(query, current_user: current_user) end it_behaves_like 'setting stack trace error' - context 'user does not have permission' do + context 'when user does not have permission' do let(:current_user) { create(:user) } it 'is expected to return an empty error' do @@ -243,11 +236,9 @@ RSpec.describe 'sentry errors requests' do end end - context 'sentry api returns an error' do + context 'when sentry api returns an error' do before do - expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting) - .to receive(:issue_latest_event) - .and_return(error: 'error message') + stub_setting_for(:issue_latest_event, error: 'error message') post_graphql(query, current_user: current_user) end @@ -257,4 +248,12 @@ RSpec.describe 'sentry errors requests' do end end end + + private + + def stub_setting_for(method, **return_value) + allow_next_found_instance_of(ErrorTracking::ProjectErrorTrackingSetting) do |setting| + allow(setting).to receive(method).and_return(**return_value) + end + end end diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb index ff0d7ecceb5..c6b4d82bf15 100644 --- a/spec/requests/api/graphql/project/issues_spec.rb +++ b/spec/requests/api/graphql/project/issues_spec.rb @@ -61,6 +61,34 @@ RSpec.describe 'getting an issue list for a project' do end end + context 'filtering by my_reaction_emoji' do + using RSpec::Parameterized::TableSyntax + + 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] } + 'any' | lazy { [issue_a_gid] } + 'AnY' | lazy { [issue_a_gid] } + 'NONE' | lazy { [issue_b_gid] } + 'thumbsdown' | lazy { [] } + end + + with_them do + let(:issue_filter_params) { { my_reaction_emoji: value } } + + it 'returns correctly filtered issues' do + post_graphql(query, current_user: current_user) + + expect(graphql_dig_at(issues_data, :node, :id)).to eq(gids) + end + end + end + context 'when limiting the number of results' do let(:query) do <<~GQL diff --git a/spec/requests/api/graphql/project/pipeline_spec.rb b/spec/requests/api/graphql/project/pipeline_spec.rb index cb6755640a9..d46ef313563 100644 --- a/spec/requests/api/graphql/project/pipeline_spec.rb +++ b/spec/requests/api/graphql/project/pipeline_spec.rb @@ -311,6 +311,10 @@ RSpec.describe 'getting pipeline information nested in a project' do end it 'does not generate N+1 queries', :request_store, :use_sql_query_cache do + # create extra statuses + create(:generic_commit_status, :pending, name: 'generic-build-a', pipeline: pipeline, stage_idx: 0, stage: 'build') + create(:ci_bridge, :failed, name: 'deploy-a', pipeline: pipeline, stage_idx: 2, stage: 'deploy') + # warm up post_graphql(query, current_user: current_user) @@ -318,9 +322,11 @@ RSpec.describe 'getting pipeline information nested in a project' do post_graphql(query, current_user: current_user) end - create(:ci_build, name: 'test-a', pipeline: pipeline, stage_idx: 1, stage: 'test') - create(:ci_build, name: 'test-b', pipeline: pipeline, stage_idx: 1, stage: 'test') - create(:ci_build, name: 'deploy-a', pipeline: pipeline, stage_idx: 2, stage: 'deploy') + create(:generic_commit_status, :pending, name: 'generic-build-b', pipeline: pipeline, stage_idx: 0, stage: 'build') + create(:ci_build, :failed, name: 'test-a', pipeline: pipeline, stage_idx: 1, stage: 'test') + create(:ci_build, :running, name: 'test-b', pipeline: pipeline, stage_idx: 1, stage: 'test') + create(:ci_build, :pending, name: 'deploy-b', pipeline: pipeline, stage_idx: 2, stage: 'deploy') + create(:ci_bridge, :failed, name: 'deploy-c', pipeline: pipeline, stage_idx: 2, stage: 'deploy') expect do post_graphql(query, current_user: current_user) diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 30df47ccc41..38abedde7da 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -158,6 +158,127 @@ RSpec.describe API::Groups do end end + context 'pagination strategies' do + let_it_be(:group_1) { create(:group, name: '1_group') } + let_it_be(:group_2) { create(:group, name: '2_group') } + + context 'when the user is anonymous' do + context 'offset pagination' do + context 'on making requests beyond the allowed offset pagination threshold' do + it 'returns error and suggests to use keyset pagination' do + get api('/groups'), params: { page: 3000, per_page: 25 } + + expect(response).to have_gitlab_http_status(:method_not_allowed) + expect(json_response['error']).to eq( + 'Offset pagination has a maximum allowed offset of 50000 for requests that return objects of type Group. '\ + 'Remaining records can be retrieved using keyset pagination.' + ) + end + + context 'when the feature flag `keyset_pagination_for_groups_api` is disabled' do + before do + stub_feature_flags(keyset_pagination_for_groups_api: false) + end + + it 'returns successful response' do + get api('/groups'), params: { page: 3000, per_page: 25 } + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + context 'on making requests below the allowed offset pagination threshold' do + it 'paginates the records' do + get api('/groups'), params: { page: 1, per_page: 1 } + + expect(response).to have_gitlab_http_status(:ok) + records = json_response + expect(records.size).to eq(1) + expect(records.first['id']).to eq(group_1.id) + + # next page + + get api('/groups'), params: { page: 2, per_page: 1 } + + expect(response).to have_gitlab_http_status(:ok) + records = Gitlab::Json.parse(response.body) + expect(records.size).to eq(1) + expect(records.first['id']).to eq(group_2.id) + end + end + end + + context 'keyset pagination' do + def pagination_links(response) + link = response.headers['LINK'] + return unless link + + link.split(',').map do |link| + match = link.match(/<(?<url>.*)>; rel="(?<rel>\w+)"/) + break nil unless match + + { url: match[:url], rel: match[:rel] } + end.compact + end + + def params_for_next_page(response) + next_url = pagination_links(response).find { |link| link[:rel] == 'next' }[:url] + Rack::Utils.parse_query(URI.parse(next_url).query) + end + + context 'on making requests with supported ordering structure' do + it 'paginates the records correctly' do + # first page + get api('/groups'), params: { pagination: 'keyset', per_page: 1 } + + expect(response).to have_gitlab_http_status(:ok) + records = json_response + expect(records.size).to eq(1) + expect(records.first['id']).to eq(group_1.id) + + params_for_next_page = params_for_next_page(response) + expect(params_for_next_page).to include('cursor') + + get api('/groups'), params: params_for_next_page + + expect(response).to have_gitlab_http_status(:ok) + records = Gitlab::Json.parse(response.body) + expect(records.size).to eq(1) + expect(records.first['id']).to eq(group_2.id) + end + + context 'when the feature flag `keyset_pagination_for_groups_api` is disabled' do + before do + stub_feature_flags(keyset_pagination_for_groups_api: false) + end + + it 'ignores the keyset pagination params and performs offset pagination' do + get api('/groups'), params: { pagination: 'keyset', per_page: 1 } + + expect(response).to have_gitlab_http_status(:ok) + records = json_response + expect(records.size).to eq(1) + expect(records.first['id']).to eq(group_1.id) + + params_for_next_page = params_for_next_page(response) + expect(params_for_next_page).not_to include('cursor') + end + end + end + + context 'on making requests with unsupported ordering structure' do + it 'returns error' do + get api('/groups'), params: { pagination: 'keyset', per_page: 1, order_by: 'path', sort: 'desc' } + + expect(response).to have_gitlab_http_status(:method_not_allowed) + expect(json_response['error']).to eq('Keyset pagination is not yet available for this type of request') + end + end + end + end + end + context "when authenticated as admin" do it "admin: returns an array of all groups" do get api("/groups", admin) diff --git a/spec/requests/api/helm_packages_spec.rb b/spec/requests/api/helm_packages_spec.rb index 08b4489a6e3..3236857c5fc 100644 --- a/spec/requests/api/helm_packages_spec.rb +++ b/spec/requests/api/helm_packages_spec.rb @@ -9,16 +9,32 @@ RSpec.describe API::HelmPackages do let_it_be_with_reload(:project) { create(:project, :public) } let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } - let_it_be(:package) { create(:helm_package, project: project) } + let_it_be(:package) { create(:helm_package, project: project, without_package_files: true) } + let_it_be(:package_file1) { create(:helm_package_file, package: package) } + let_it_be(:package_file2) { create(:helm_package_file, package: package) } + let_it_be(:package2) { create(:helm_package, project: project, without_package_files: true) } + let_it_be(:package_file2_1) { create(:helm_package_file, package: package2, file_sha256: 'file2', file_name: 'filename2.tgz', description: 'hello from stable channel') } + let_it_be(:package_file2_2) { create(:helm_package_file, package: package2, file_sha256: 'file2', file_name: 'filename2.tgz', channel: 'test', description: 'hello from test channel') } + let_it_be(:other_package) { create(:npm_package, project: project) } describe 'GET /api/v4/projects/:id/packages/helm/:channel/index.yaml' do - it_behaves_like 'handling helm chart index requests' do - let(:url) { "/projects/#{project.id}/packages/helm/#{package.package_files.first.helm_channel}/index.yaml" } + let(:url) { "/projects/#{project_id}/packages/helm/stable/index.yaml" } + + context 'with a project id' do + let(:project_id) { project.id } + + it_behaves_like 'handling helm chart index requests' + end + + context 'with an url encoded project id' do + let(:project_id) { ERB::Util.url_encode(project.full_path) } + + it_behaves_like 'handling helm chart index requests' end end describe 'GET /api/v4/projects/:id/packages/helm/:channel/charts/:file_name.tgz' do - let(:url) { "/projects/#{project.id}/packages/helm/#{package.package_files.first.helm_channel}/charts/#{package.name}-#{package.version}.tgz" } + let(:url) { "/projects/#{project.id}/packages/helm/stable/charts/#{package.name}-#{package.version}.tgz" } subject { get api(url), headers: headers } diff --git a/spec/requests/api/internal/kubernetes_spec.rb b/spec/requests/api/internal/kubernetes_spec.rb index 2acf6951d50..24422f7b0dd 100644 --- a/spec/requests/api/internal/kubernetes_spec.rb +++ b/spec/requests/api/internal/kubernetes_spec.rb @@ -93,6 +93,48 @@ RSpec.describe API::Internal::Kubernetes do end end + describe 'POST /internal/kubernetes/agent_configuration' do + def send_request(headers: {}, params: {}) + post api('/internal/kubernetes/agent_configuration'), params: params, headers: headers.reverse_merge(jwt_auth_headers) + end + + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, namespace: group) } + let_it_be(:agent) { create(:cluster_agent, project: project) } + let_it_be(:config) do + { + ci_access: { + groups: [ + { id: group.full_path, default_namespace: 'production' } + ], + projects: [ + { id: project.full_path, default_namespace: 'staging' } + ] + } + } + end + + include_examples 'authorization' + + context 'agent exists' do + it 'configures the agent and returns a 204' do + send_request(params: { agent_id: agent.id, agent_config: config }) + + expect(response).to have_gitlab_http_status(:no_content) + expect(agent.authorized_groups).to contain_exactly(group) + expect(agent.authorized_projects).to contain_exactly(project) + end + end + + context 'agent does not exist' do + it 'returns a 404' do + send_request(params: { agent_id: -1, agent_config: config }) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + describe 'GET /internal/kubernetes/agent_info' do def send_request(headers: {}, params: {}) get api('/internal/kubernetes/agent_info'), params: params, headers: headers.reverse_merge(jwt_auth_headers) diff --git a/spec/requests/api/issues/get_group_issues_spec.rb b/spec/requests/api/issues/get_group_issues_spec.rb index cebde747210..3663a82891c 100644 --- a/spec/requests/api/issues/get_group_issues_spec.rb +++ b/spec/requests/api/issues/get_group_issues_spec.rb @@ -402,14 +402,7 @@ RSpec.describe API::Issues do expect_paginated_array_response([group_closed_issue.id, group_issue.id]) end - shared_examples 'labels parameter' do - it 'returns an array of labeled group issues' do - get api(base_url, user), params: { labels: group_label.title } - - expect_paginated_array_response(group_issue.id) - expect(json_response.first['labels']).to eq([group_label.title]) - end - + context 'labels parameter' do it 'returns an array of labeled group issues' do get api(base_url, user), params: { labels: group_label.title } @@ -458,22 +451,6 @@ RSpec.describe API::Issues do end end - context 'when `optimized_issuable_label_filter` feature flag is off' do - before do - stub_feature_flags(optimized_issuable_label_filter: false) - end - - it_behaves_like 'labels parameter' - end - - context 'when `optimized_issuable_label_filter` feature flag is on' do - before do - stub_feature_flags(optimized_issuable_label_filter: true) - end - - it_behaves_like 'labels parameter' - end - it 'returns issues matching given search string for title' do get api(base_url, user), params: { search: group_issue.title } diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb index 125db58ed69..8a33e63b80b 100644 --- a/spec/requests/api/issues/issues_spec.rb +++ b/spec/requests/api/issues/issues_spec.rb @@ -3,21 +3,25 @@ require 'spec_helper' RSpec.describe API::Issues do + using RSpec::Parameterized::TableSyntax + let_it_be(:user) { create(:user) } let_it_be(:project, reload: true) { create(:project, :public, :repository, creator_id: user.id, namespace: user.namespace) } let_it_be(:private_mrs_project) do create(:project, :public, :repository, creator_id: user.id, namespace: user.namespace, merge_requests_access_level: ProjectFeature::PRIVATE) end - let(:user2) { create(:user) } - let(:non_member) { create(:user) } + 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(:assignee) { create(:assignee) } - let(:admin) { create(:user, :admin) } - let(:issue_title) { 'foo' } - let(:issue_description) { 'closed' } - let!(:closed_issue) do + let_it_be(:admin) { create(:user, :admin) } + + let_it_be(:milestone) { create(:milestone, title: '1.0.0', project: project) } + let_it_be(:empty_milestone) { create(:milestone, title: '2.0.0', project: project) } + + let_it_be(:closed_issue) do create :closed_issue, author: user, assignees: [user], @@ -29,7 +33,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, @@ -39,7 +43,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], @@ -47,21 +51,16 @@ 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: 'bar' end 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_it_be(:label_link) { create(:label_link, label: label, target: issue) } + let_it_be(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } let(:no_milestone_title) { 'None' } let(:any_milestone_title) { 'Any' } @@ -683,6 +682,71 @@ RSpec.describe API::Issues do end end + context 'filtering by milestone_id' do + let_it_be(:upcoming_milestone) { create(:milestone, project: project, title: "upcoming milestone", start_date: 1.day.ago, due_date: 1.day.from_now) } + let_it_be(:started_milestone) { create(:milestone, project: project, title: "started milestone", start_date: 2.days.ago, due_date: 1.day.ago) } + let_it_be(:future_milestone) { create(:milestone, project: project, title: "future milestone", start_date: 7.days.from_now, due_date: 14.days.from_now) } + let_it_be(:issue_upcoming) { create(:issue, project: project, state: :opened, milestone: upcoming_milestone) } + let_it_be(:issue_started) { create(:issue, project: project, state: :opened, milestone: started_milestone) } + let_it_be(:issue_future) { create(:issue, project: project, state: :opened, milestone: future_milestone) } + let_it_be(:issue_none) { create(:issue, project: project, state: :opened) } + + let(:wildcard_started) { 'Started' } + let(:wildcard_upcoming) { 'Upcoming' } + let(:wildcard_any) { 'Any' } + let(:wildcard_none) { 'None' } + + where(:milestone_id, :not_milestone, :expected_issues) do + ref(:wildcard_none) | nil | lazy { [issue_none.id] } + ref(:wildcard_any) | nil | lazy { [issue_future.id, issue_started.id, issue_upcoming.id, issue.id, closed_issue.id] } + ref(:wildcard_started) | nil | lazy { [issue_started.id, issue_upcoming.id] } + ref(:wildcard_upcoming) | nil | lazy { [issue_upcoming.id] } + ref(:wildcard_any) | "upcoming milestone" | lazy { [issue_future.id, issue_started.id, issue.id, closed_issue.id] } + ref(:wildcard_upcoming) | "upcoming milestone" | [] + end + + with_them do + it "returns correct issues when filtering with 'milestone_id' and optionally negated 'milestone'" do + get api('/issues', user), params: { milestone_id: milestone_id, not: not_milestone ? { milestone: not_milestone } : {} } + + expect_paginated_array_response(expected_issues) + end + end + + context 'negated filtering' do + where(:not_milestone_id, :expected_issues) do + ref(:wildcard_started) | lazy { [issue_future.id] } + ref(:wildcard_upcoming) | lazy { [issue_started.id] } + end + + with_them do + it "returns correct issues when filtering with negated 'milestone_id'" do + get api('/issues', user), params: { not: { milestone_id: not_milestone_id } } + + expect_paginated_array_response(expected_issues) + end + end + end + + context 'when mutually exclusive params are passed' do + where(:params) do + [ + [lazy { { milestone: "foo", milestone_id: wildcard_any } }], + [lazy { { not: { milestone: "foo", milestone_id: wildcard_any } } }] + ] + end + + with_them do + it "raises an error", :aggregate_failures do + get api('/issues', user), params: params + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response["error"]).to include("mutually exclusive") + end + end + end + end + it 'returns an array of issues found by iids' do get api('/issues', user), params: { iids: [closed_issue.iid] } @@ -711,8 +775,8 @@ RSpec.describe API::Issues do milestone: milestone, created_at: closed_issue.created_at, updated_at: 1.hour.ago, - title: issue_title, - description: issue_description + title: 'foo', + description: 'bar' end it 'page breaks first page correctly' do @@ -751,6 +815,18 @@ RSpec.describe API::Issues do expect_paginated_array_response([closed_issue.id, issue.id]) end + it 'sorts by title asc when requested' do + get api('/issues', user), params: { order_by: :title, sort: :asc } + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'sorts by title desc when requested' do + get api('/issues', user), params: { order_by: :title, sort: :desc } + + expect_paginated_array_response([closed_issue.id, issue.id]) + end + context 'with issues list sort options' do it 'accepts only predefined order by params' do API::Helpers::IssuesHelpers.sort_options.each do |sort_opt| @@ -760,7 +836,7 @@ RSpec.describe API::Issues do end it 'fails to sort with non predefined options' do - %w(milestone title abracadabra).each do |sort_opt| + %w(milestone abracadabra).each do |sort_opt| get api('/issues', user), params: { order_by: sort_opt, sort: 'asc' } expect(response).to have_gitlab_http_status(:bad_request) end @@ -1001,13 +1077,15 @@ RSpec.describe API::Issues do end describe 'DELETE /projects/:id/issues/:issue_iid' do + let(:issue_for_deletion) { create(:issue, author: user, assignees: [user], project: project) } + it 'rejects a non member from deleting an issue' do - delete api("/projects/#{project.id}/issues/#{issue.iid}", non_member) + delete api("/projects/#{project.id}/issues/#{issue_for_deletion.iid}", non_member) expect(response).to have_gitlab_http_status(:forbidden) end it 'rejects a developer from deleting an issue' do - delete api("/projects/#{project.id}/issues/#{issue.iid}", author) + delete api("/projects/#{project.id}/issues/#{issue_for_deletion.iid}", author) expect(response).to have_gitlab_http_status(:forbidden) end @@ -1016,13 +1094,13 @@ RSpec.describe API::Issues do let(:project) { create(:project, namespace: owner.namespace) } it 'deletes the issue if an admin requests it' do - delete api("/projects/#{project.id}/issues/#{issue.iid}", owner) + delete api("/projects/#{project.id}/issues/#{issue_for_deletion.iid}", owner) expect(response).to have_gitlab_http_status(:no_content) end it_behaves_like '412 response' do - let(:request) { api("/projects/#{project.id}/issues/#{issue.iid}", owner) } + let(:request) { api("/projects/#{project.id}/issues/#{issue_for_deletion.iid}", owner) } end end @@ -1035,7 +1113,7 @@ RSpec.describe API::Issues do end it 'returns 404 when using the issue ID instead of IID' do - delete api("/projects/#{project.id}/issues/#{issue.id}", user) + delete api("/projects/#{project.id}/issues/#{issue_for_deletion.id}", user) expect(response).to have_gitlab_http_status(:not_found) end diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb index 7fe516d3daa..d7f22b9d619 100644 --- a/spec/requests/api/lint_spec.rb +++ b/spec/requests/api/lint_spec.rb @@ -113,7 +113,6 @@ RSpec.describe API::Lint do expect(response).to have_gitlab_http_status(:ok) expect(json_response['status']).to eq('valid') expect(json_response['warnings']).not_to be_empty - expect(json_response['status']).to eq('valid') expect(json_response['errors']).to eq([]) end end diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb index c3fd02dad51..07111dd1d62 100644 --- a/spec/requests/api/maven_packages_spec.rb +++ b/spec/requests/api/maven_packages_spec.rb @@ -15,7 +15,7 @@ RSpec.describe API::MavenPackages do let_it_be(:package_file) { package.package_files.with_file_name_like('%.xml').first } let_it_be(:jar_file) { package.package_files.with_file_name_like('%.jar').first } let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } - let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running) } + let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running, project: project) } let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } let_it_be(:deploy_token_for_group) { create(:deploy_token, :group, read_package_registry: true, write_package_registry: true) } diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb index 48ded93d85f..a1daf86de31 100644 --- a/spec/requests/api/members_spec.rb +++ b/spec/requests/api/members_spec.rb @@ -198,7 +198,7 @@ RSpec.describe API::Members do # Member attributes expect(json_response['access_level']).to eq(Member::DEVELOPER) - expect(json_response['created_at'].to_time).to be_like_time(developer.created_at) + expect(json_response['created_at'].to_time).to be_present end end end @@ -311,36 +311,6 @@ RSpec.describe API::Members do expect(json_response['status']).to eq('error') expect(json_response['message']).to eq(error_message) end - - context 'with invite_source considerations', :snowplow do - let(:params) { { user_id: user_ids, access_level: Member::DEVELOPER } } - - it 'tracks the invite source as api' do - post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), - params: params - - expect_snowplow_event( - category: 'Members::CreateService', - action: 'create_member', - label: 'members-api', - property: 'existing_user', - user: maintainer - ) - end - - it 'tracks the invite source from params' do - post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), - params: params.merge(invite_source: '_invite_source_') - - expect_snowplow_event( - category: 'Members::CreateService', - action: 'create_member', - label: '_invite_source_', - property: 'existing_user', - user: maintainer - ) - end - end end end @@ -410,48 +380,28 @@ RSpec.describe API::Members do end context 'with areas_of_focus considerations', :snowplow do - context 'when there is 1 user to add' do - let(:user_id) { stranger.id } + let(:user_id) { stranger.id } - context 'when areas_of_focus is present in params' do - it 'tracks the areas_of_focus' do - post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), - params: { user_id: user_id, access_level: Member::DEVELOPER, areas_of_focus: 'Other' } - - expect_snowplow_event( - category: 'Members::CreateService', - action: 'area_of_focus', - label: 'Other', - property: source.members.last.id.to_s - ) - end - end - - context 'when areas_of_focus is not present in params' do - it 'does not track the areas_of_focus' do - post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), - params: { user_id: user_id, access_level: Member::DEVELOPER } + context 'when areas_of_focus is present in params' do + it 'tracks the areas_of_focus' do + post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), + params: { user_id: user_id, access_level: Member::DEVELOPER, areas_of_focus: 'Other' } - expect_no_snowplow_event(category: 'Members::CreateService', action: 'area_of_focus') - end + expect_snowplow_event( + category: 'Members::CreateService', + action: 'area_of_focus', + label: 'Other', + property: source.members.last.id.to_s + ) end end - context 'when there are multiple users to add' do - let(:user_id) { [developer.id, stranger.id].join(',') } + context 'when areas_of_focus is not present in params' do + it 'does not track the areas_of_focus' do + post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), + params: { user_id: user_id, access_level: Member::DEVELOPER } - context 'when areas_of_focus is present in params' do - it 'tracks the areas_of_focus' do - post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), - params: { user_id: user_id, access_level: Member::DEVELOPER, areas_of_focus: 'Other' } - - expect_snowplow_event( - category: 'Members::CreateService', - action: 'area_of_focus', - label: 'Other', - property: source.members.last.id.to_s - ) - end + expect_no_snowplow_event(category: 'Members::CreateService', action: 'area_of_focus') end end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 4b5fc57571b..7a587e82683 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1072,7 +1072,7 @@ RSpec.describe API::MergeRequests do end describe "GET /groups/:id/merge_requests" do - let_it_be(:group) { create(:group, :public) } + let_it_be(:group, reload: true) { create(:group, :public) } let_it_be(:project) { create(:project, :public, :repository, creator: user, namespace: group, only_allow_merge_if_pipeline_succeeds: false) } include_context 'with merge requests' diff --git a/spec/requests/api/notification_settings_spec.rb b/spec/requests/api/notification_settings_spec.rb index 7b4a58e63da..b5551c21738 100644 --- a/spec/requests/api/notification_settings_spec.rb +++ b/spec/requests/api/notification_settings_spec.rb @@ -13,7 +13,7 @@ RSpec.describe API::NotificationSettings do expect(response).to have_gitlab_http_status(:ok) expect(json_response).to be_a Hash - expect(json_response['notification_email']).to eq(user.notification_email) + expect(json_response['notification_email']).to eq(user.notification_email_or_default) expect(json_response['level']).to eq(user.global_notification_setting.level) end end diff --git a/spec/requests/api/npm_project_packages_spec.rb b/spec/requests/api/npm_project_packages_spec.rb index 8c35a1642e2..0d04c2cad5b 100644 --- a/spec/requests/api/npm_project_packages_spec.rb +++ b/spec/requests/api/npm_project_packages_spec.rb @@ -120,9 +120,11 @@ RSpec.describe API::NpmProjectPackages do project.add_developer(user) end + subject(:upload_package_with_token) { upload_with_token(package_name, params) } + shared_examples 'handling invalid record with 400 error' do it 'handles an ActiveRecord::RecordInvalid exception with 400 error' do - expect { upload_package_with_token(package_name, params) } + expect { upload_package_with_token } .not_to change { project.packages.count } expect(response).to have_gitlab_http_status(:bad_request) @@ -136,6 +138,7 @@ RSpec.describe API::NpmProjectPackages do let(:params) { upload_params(package_name: package_name) } it_behaves_like 'handling invalid record with 400 error' + it_behaves_like 'not a package tracking event' end context 'invalid package version' do @@ -157,6 +160,7 @@ RSpec.describe API::NpmProjectPackages do let(:params) { upload_params(package_name: package_name, package_version: version) } it_behaves_like 'handling invalid record with 400 error' + it_behaves_like 'not a package tracking event' end end end @@ -169,8 +173,6 @@ RSpec.describe API::NpmProjectPackages do shared_examples 'handling upload with different authentications' do context 'with access token' do - subject { upload_package_with_token(package_name, params) } - it_behaves_like 'a package tracking event', 'API::NpmPackages', 'push_package' it 'creates npm package with file' do @@ -184,7 +186,7 @@ RSpec.describe API::NpmProjectPackages do end it 'creates npm package with file with job token' do - expect { upload_package_with_job_token(package_name, params) } + expect { upload_with_job_token(package_name, params) } .to change { project.packages.count }.by(1) .and change { Packages::PackageFile.count }.by(1) @@ -205,7 +207,7 @@ RSpec.describe API::NpmProjectPackages do end it 'creates the package metadata' do - upload_package_with_token(package_name, params) + upload_package_with_token expect(response).to have_gitlab_http_status(:ok) expect(project.reload.packages.find(json_response['id']).original_build_info.pipeline).to eq job.pipeline @@ -215,7 +217,7 @@ RSpec.describe API::NpmProjectPackages do shared_examples 'uploading the package' do it 'uploads the package' do - expect { upload_package_with_token(package_name, params) } + expect { upload_package_with_token } .to change { project.packages.count }.by(1) expect(response).to have_gitlab_http_status(:ok) @@ -249,6 +251,7 @@ RSpec.describe API::NpmProjectPackages do let(:package_name) { "@#{group.path}/test" } it_behaves_like 'handling invalid record with 400 error' + it_behaves_like 'not a package tracking event' context 'with a new version' do let_it_be(:version) { '4.5.6' } @@ -271,9 +274,14 @@ RSpec.describe API::NpmProjectPackages do let(:package_name) { "@#{group.path}/my_package_name" } let(:params) { upload_params(package_name: package_name) } - it 'returns an error if the package already exists' do + before do create(:npm_package, project: project, version: '1.0.1', name: "@#{group.path}/my_package_name") - expect { upload_package_with_token(package_name, params) } + end + + it_behaves_like 'not a package tracking event' + + it 'returns an error if the package already exists' do + expect { upload_package_with_token } .not_to change { project.packages.count } expect(response).to have_gitlab_http_status(:forbidden) @@ -285,7 +293,7 @@ RSpec.describe API::NpmProjectPackages do let(:params) { upload_params(package_name: package_name, file: 'npm/payload_with_duplicated_packages.json') } it 'creates npm package with file and dependencies' do - expect { upload_package_with_token(package_name, params) } + expect { upload_package_with_token } .to change { project.packages.count }.by(1) .and change { Packages::PackageFile.count }.by(1) .and change { Packages::Dependency.count}.by(4) @@ -297,11 +305,11 @@ RSpec.describe API::NpmProjectPackages do context 'with existing dependencies' do before do name = "@#{group.path}/existing_package" - upload_package_with_token(name, upload_params(package_name: name, file: 'npm/payload_with_duplicated_packages.json')) + upload_with_token(name, upload_params(package_name: name, file: 'npm/payload_with_duplicated_packages.json')) end it 'reuses them' do - expect { upload_package_with_token(package_name, params) } + expect { upload_package_with_token } .to change { project.packages.count }.by(1) .and change { Packages::PackageFile.count }.by(1) .and not_change { Packages::Dependency.count} @@ -317,11 +325,11 @@ RSpec.describe API::NpmProjectPackages do put api("/projects/#{project.id}/packages/npm/#{package_name.sub('/', '%2f')}"), params: params, headers: headers end - def upload_package_with_token(package_name, params = {}) + def upload_with_token(package_name, params = {}) upload_package(package_name, params.merge(access_token: token.token)) end - def upload_package_with_job_token(package_name, params = {}) + def upload_with_job_token(package_name, params = {}) upload_package(package_name, params.merge(job_token: job.token)) end diff --git a/spec/requests/api/pages/pages_spec.rb b/spec/requests/api/pages/pages_spec.rb index f4c6de00e40..0eb2ae64f43 100644 --- a/spec/requests/api/pages/pages_spec.rb +++ b/spec/requests/api/pages/pages_spec.rb @@ -36,12 +36,7 @@ RSpec.describe API::Pages do end it 'removes the pages' do - expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true - expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything) - - Sidekiq::Testing.inline! do - delete api("/projects/#{project.id}/pages", admin ) - end + delete api("/projects/#{project.id}/pages", admin ) expect(project.reload.pages_metadatum.deployed?).to be(false) end diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml index c5bcedd491a..9174356f123 100644 --- a/spec/requests/api/project_attributes.yml +++ b/spec/requests/api/project_attributes.yml @@ -32,6 +32,7 @@ itself: # project - pages_https_only - pending_delete - pool_repository_id + - project_namespace_id - pull_mirror_available_overridden - pull_mirror_branch_prefix - remote_mirror_available_overridden @@ -55,6 +56,7 @@ itself: # project - can_create_merge_request_in - compliance_frameworks - container_expiration_policy + - container_registry_enabled - container_registry_image_prefix - default_branch - empty_repo @@ -149,6 +151,7 @@ build_service_desk_setting: # service_desk_setting unexposed_attributes: - project_id - issue_template_key + - file_template_project_id - outgoing_name remapped_attributes: project_key: service_desk_address diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 3622eedfed5..80bccdfee0c 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -2616,6 +2616,23 @@ RSpec.describe API::Projects do expect(json_response).to have_key 'service_desk_enabled' expect(json_response).to have_key 'service_desk_address' end + + context 'when project is shared to multiple groups' do + it 'avoids N+1 queries' do + create(:project_group_link, project: project) + get api("/projects/#{project.id}", user) + + control = ActiveRecord::QueryRecorder.new do + get api("/projects/#{project.id}", user) + end + + create(:project_group_link, project: project) + + expect do + get api("/projects/#{project.id}", user) + end.not_to exceed_query_limit(control) + end + end end describe 'GET /projects/:id/users' do diff --git a/spec/requests/api/pypi_packages_spec.rb b/spec/requests/api/pypi_packages_spec.rb index 8df2460a2b6..c17d0600aca 100644 --- a/spec/requests/api/pypi_packages_spec.rb +++ b/spec/requests/api/pypi_packages_spec.rb @@ -13,7 +13,7 @@ RSpec.describe API::PypiPackages do let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } - let_it_be(:job) { create(:ci_build, :running, user: user) } + let_it_be(:job) { create(:ci_build, :running, user: user, project: project) } let(:headers) { {} } diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb index 87b08587904..90b03a480a8 100644 --- a/spec/requests/api/releases_spec.rb +++ b/spec/requests/api/releases_spec.rb @@ -839,7 +839,7 @@ RSpec.describe API::Releases do context 'when a valid token is provided' do it 'creates the release for a running job' do - job.update!(status: :running) + job.update!(status: :running, project: project) post api("/projects/#{project.id}/releases"), params: params.merge(job_token: job.token) expect(response).to have_gitlab_http_status(:created) diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index d3262b8056b..a576e1ab1ee 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -22,7 +22,7 @@ RSpec.describe API::Repositories do expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers - expect(json_response).to be_an Array + expect(json_response).to be_an(Array) first_commit = json_response.first expect(first_commit['name']).to eq('bar') @@ -73,6 +73,25 @@ RSpec.describe API::Repositories do end end end + + context 'keyset pagination mode' do + let(:first_response) do + get api(route, current_user), params: { pagination: "keyset" } + + Gitlab::Json.parse(response.body) + end + + it 'paginates using keysets' do + page_token = first_response.last["id"] + + get api(route, current_user), params: { pagination: "keyset", page_token: page_token } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an(Array) + expect(json_response).not_to eq(first_response) + expect(json_response.map { |t| t["id"] }).not_to include(page_token) + end + end end context 'when unauthenticated', 'and project is public' do @@ -354,6 +373,7 @@ RSpec.describe API::Repositories do expect(response).to have_gitlab_http_status(:ok) expect(json_response['commits']).to be_present expect(json_response['diffs']).to be_present + expect(json_response['web_url']).to be_present end it "compares branches with explicit merge-base mode" do @@ -365,6 +385,7 @@ RSpec.describe API::Repositories do expect(response).to have_gitlab_http_status(:ok) expect(json_response['commits']).to be_present expect(json_response['diffs']).to be_present + expect(json_response['web_url']).to be_present end it "compares branches with explicit straight mode" do @@ -376,6 +397,7 @@ RSpec.describe API::Repositories do expect(response).to have_gitlab_http_status(:ok) expect(json_response['commits']).to be_present expect(json_response['diffs']).to be_present + expect(json_response['web_url']).to be_present end it "compares tags" do @@ -384,6 +406,7 @@ RSpec.describe API::Repositories do expect(response).to have_gitlab_http_status(:ok) expect(json_response['commits']).to be_present expect(json_response['diffs']).to be_present + expect(json_response['web_url']).to be_present end it "compares commits" do @@ -393,6 +416,7 @@ RSpec.describe API::Repositories do expect(json_response['commits']).to be_empty expect(json_response['diffs']).to be_empty expect(json_response['compare_same_ref']).to be_falsey + expect(json_response['web_url']).to be_present end it "compares commits in reverse order" do @@ -401,6 +425,7 @@ RSpec.describe API::Repositories do expect(response).to have_gitlab_http_status(:ok) expect(json_response['commits']).to be_present expect(json_response['diffs']).to be_present + expect(json_response['web_url']).to be_present end it "compare commits between different projects with non-forked relation" do diff --git a/spec/requests/api/rubygem_packages_spec.rb b/spec/requests/api/rubygem_packages_spec.rb index afa7adad80c..9b104520b52 100644 --- a/spec/requests/api/rubygem_packages_spec.rb +++ b/spec/requests/api/rubygem_packages_spec.rb @@ -10,7 +10,7 @@ RSpec.describe API::RubygemPackages do let_it_be_with_reload(:project) { create(:project) } let_it_be(:personal_access_token) { create(:personal_access_token) } let_it_be(:user) { personal_access_token.user } - let_it_be(:job) { create(:ci_build, :running, user: user) } + let_it_be(:job) { create(:ci_build, :running, user: user, project: project) } let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } let_it_be(:headers) { {} } diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 4008b57a1cf..f5d261ba4c6 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -47,6 +47,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do expect(json_response['personal_access_token_prefix']).to be_nil 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) end end @@ -133,6 +134,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do import_sources: 'github,bitbucket', wiki_page_max_content_bytes: 12345, personal_access_token_prefix: "GL-", + user_deactivation_emails_enabled: false, admin_mode: true } @@ -184,6 +186,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do expect(json_response['wiki_page_max_content_bytes']).to eq(12345) 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) end end @@ -222,6 +225,45 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do expect(json_response['asset_proxy_allowlist']).to eq(['example.com', '*.example.com', 'localhost']) end + it 'supports the deprecated `throttle_unauthenticated_*` attributes' do + put api('/application/settings', admin), params: { + throttle_unauthenticated_enabled: true, + throttle_unauthenticated_period_in_seconds: 123, + throttle_unauthenticated_requests_per_period: 456 + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include( + 'throttle_unauthenticated_enabled' => true, + 'throttle_unauthenticated_period_in_seconds' => 123, + 'throttle_unauthenticated_requests_per_period' => 456, + 'throttle_unauthenticated_web_enabled' => true, + 'throttle_unauthenticated_web_period_in_seconds' => 123, + 'throttle_unauthenticated_web_requests_per_period' => 456 + ) + end + + it 'prefers the new `throttle_unauthenticated_web_*` attributes' do + put api('/application/settings', admin), params: { + throttle_unauthenticated_enabled: false, + throttle_unauthenticated_period_in_seconds: 0, + throttle_unauthenticated_requests_per_period: 0, + throttle_unauthenticated_web_enabled: true, + throttle_unauthenticated_web_period_in_seconds: 123, + throttle_unauthenticated_web_requests_per_period: 456 + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include( + 'throttle_unauthenticated_enabled' => true, + 'throttle_unauthenticated_period_in_seconds' => 123, + 'throttle_unauthenticated_requests_per_period' => 456, + 'throttle_unauthenticated_web_enabled' => true, + 'throttle_unauthenticated_web_period_in_seconds' => 123, + 'throttle_unauthenticated_web_requests_per_period' => 456 + ) + end + it 'disables ability to switch to legacy storage' do put api("/application/settings", admin), params: { hashed_storage_enabled: false } @@ -552,5 +594,20 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do expect(json_response['error']).to eq('whats_new_variant does not have a valid value') end end + + context 'sidekiq job limit settings' do + it 'updates the settings' do + settings = { + sidekiq_job_limiter_mode: 'track', + sidekiq_job_limiter_compression_threshold_bytes: 1, + sidekiq_job_limiter_limit_bytes: 2 + }.stringify_keys + + put api("/application/settings", admin), params: settings + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.slice(*settings.keys)).to eq(settings) + end + end end end diff --git a/spec/requests/api/terraform/modules/v1/packages_spec.rb b/spec/requests/api/terraform/modules/v1/packages_spec.rb index 6803c09b8c2..b04f5ad9a94 100644 --- a/spec/requests/api/terraform/modules/v1/packages_spec.rb +++ b/spec/requests/api/terraform/modules/v1/packages_spec.rb @@ -12,7 +12,7 @@ RSpec.describe API::Terraform::Modules::V1::Packages do let_it_be(:package) { create(:terraform_module_package, project: project) } let_it_be(:personal_access_token) { create(:personal_access_token) } let_it_be(:user) { personal_access_token.user } - let_it_be(:job) { create(:ci_build, :running, user: user) } + let_it_be(:job) { create(:ci_build, :running, user: user, project: project) } let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } diff --git a/spec/requests/api/unleash_spec.rb b/spec/requests/api/unleash_spec.rb index 0718710f15c..6cb801538c6 100644 --- a/spec/requests/api/unleash_spec.rb +++ b/spec/requests/api/unleash_spec.rb @@ -176,25 +176,6 @@ RSpec.describe API::Unleash do it_behaves_like 'authenticated request' - context 'with version 1 (legacy) feature flags' do - let(:feature_flag) { create(:operations_feature_flag, :legacy_flag, project: project, name: 'feature1', active: true, version: 1) } - - it 'does not return a legacy feature flag' do - create(:operations_feature_flag_scope, - feature_flag: feature_flag, - environment_scope: 'sandbox', - active: true, - strategies: [{ name: "gradualRolloutUserId", - parameters: { groupId: "default", percentage: "50" } }]) - headers = { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "sandbox" } - - get api(features_url), headers: headers - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['features']).to be_empty - end - end - context 'with version 2 feature flags' do it 'does not return a flag without any strategies' do create(:operations_feature_flag, project: project, diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 383940ce34a..527e548ad19 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -9,9 +9,13 @@ RSpec.describe API::Users do let_it_be(:gpg_key) { create(:gpg_key, user: user) } let_it_be(:email) { create(:email, user: user) } + let(:blocked_user) { create(:user, :blocked) } let(:omniauth_user) { create(:omniauth_user) } let(:ldap_blocked_user) { create(:omniauth_user, provider: 'ldapmain', state: 'ldap_blocked') } let(:private_user) { create(:user, private_profile: true) } + let(:deactivated_user) { create(:user, state: 'deactivated') } + let(:banned_user) { create(:user, :banned) } + let(:internal_user) { create(:user, :bot) } context 'admin notes' do let_it_be(:admin) { create(:admin, note: '2019-10-06 | 2FA added | user requested | www.gitlab.com') } @@ -1199,7 +1203,7 @@ RSpec.describe API::Users do it 'updates user with a new email' do old_email = user.email - old_notification_email = user.notification_email + old_notification_email = user.notification_email_or_default put api("/users/#{user.id}", admin), params: { email: 'new@email.com' } user.reload @@ -1207,7 +1211,7 @@ RSpec.describe API::Users do expect(response).to have_gitlab_http_status(:ok) expect(user).to be_confirmed expect(user.email).to eq(old_email) - expect(user.notification_email).to eq(old_notification_email) + expect(user.notification_email_or_default).to eq(old_notification_email) expect(user.unconfirmed_email).to eq('new@email.com') end @@ -2599,15 +2603,13 @@ RSpec.describe API::Users do let(:api_user) { admin } context 'for a deactivated user' do - before do - user.deactivate - end + let(:user_id) { deactivated_user.id } it 'activates a deactivated user' do activate expect(response).to have_gitlab_http_status(:created) - expect(user.reload.state).to eq('active') + expect(deactivated_user.reload.state).to eq('active') end end @@ -2625,16 +2627,14 @@ RSpec.describe API::Users do end context 'for a blocked user' do - before do - user.block - end + let(:user_id) { blocked_user.id } it 'returns 403' do activate expect(response).to have_gitlab_http_status(:forbidden) expect(json_response['message']).to eq('403 Forbidden - A blocked user must be unblocked to be activated') - expect(user.reload.state).to eq('blocked') + expect(blocked_user.reload.state).to eq('blocked') end end @@ -2711,29 +2711,25 @@ RSpec.describe API::Users do end context 'for a deactivated user' do - before do - user.deactivate - end + let(:user_id) { deactivated_user.id } it 'returns 201' do deactivate expect(response).to have_gitlab_http_status(:created) - expect(user.reload.state).to eq('deactivated') + expect(deactivated_user.reload.state).to eq('deactivated') end end context 'for a blocked user' do - before do - user.block - end + let(:user_id) { blocked_user.id } it 'returns 403' do deactivate expect(response).to have_gitlab_http_status(:forbidden) expect(json_response['message']).to eq('403 Forbidden - A blocked user cannot be deactivated by the API') - expect(user.reload.state).to eq('blocked') + expect(blocked_user.reload.state).to eq('blocked') end end @@ -2775,7 +2771,9 @@ RSpec.describe API::Users do end end - context 'approve pending user' do + context 'approve and reject pending user' do + let(:pending_user) { create(:user, :blocked_pending_approval) } + shared_examples '404' do it 'returns 404' do expect(response).to have_gitlab_http_status(:not_found) @@ -2786,10 +2784,6 @@ RSpec.describe API::Users do describe 'POST /users/:id/approve' do subject(:approve) { post api("/users/#{user_id}/approve", api_user) } - let_it_be(:pending_user) { create(:user, :blocked_pending_approval) } - let_it_be(:deactivated_user) { create(:user, :deactivated) } - let_it_be(:blocked_user) { create(:user, :blocked) } - context 'performed by a non-admin user' do let(:api_user) { user } let(:user_id) { pending_user.id } @@ -2865,102 +2859,403 @@ RSpec.describe API::Users do end end end - end - describe 'POST /users/:id/block' do - let(:blocked_user) { create(:user, state: 'blocked') } + describe 'POST /users/:id/reject', :aggregate_failures do + subject(:reject) { post api("/users/#{user_id}/reject", api_user) } - it 'blocks existing user' do - post api("/users/#{user.id}/block", admin) + shared_examples 'returns 409' do + it 'returns 409' do + reject - aggregate_failures do - expect(response).to have_gitlab_http_status(:created) - expect(response.body).to eq('true') - expect(user.reload.state).to eq('blocked') + expect(response).to have_gitlab_http_status(:conflict) + expect(json_response['message']).to eq('User does not have a pending request') + end + end + + context 'performed by a non-admin user' do + let(:api_user) { user } + let(:user_id) { pending_user.id } + + it 'returns 403' do + expect { reject }.not_to change { pending_user.reload.state } + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('You are not allowed to reject a user') + end + end + + context 'performed by an admin user' do + let(:api_user) { admin } + + context 'for an pending approval user' do + let(:user_id) { pending_user.id } + + it 'returns 200' do + reject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['message']).to eq('Success') + end + end + + context 'for a deactivated user' do + let(:user_id) { deactivated_user.id } + + it 'does not reject a deactivated user' do + expect { reject }.not_to change { deactivated_user.reload.state } + end + + it_behaves_like 'returns 409' + end + + context 'for an active user' do + let(:user_id) { user.id } + + it 'does not reject an active user' do + expect { reject }.not_to change { user.reload.state } + end + + it_behaves_like 'returns 409' + end + + context 'for a blocked user' do + let(:user_id) { blocked_user.id } + + it 'does not reject a blocked user' do + expect { reject }.not_to change { blocked_user.reload.state } + end + + it_behaves_like 'returns 409' + end + + context 'for a ldap blocked user' do + let(:user_id) { ldap_blocked_user.id } + + it 'does not reject a ldap blocked user' do + expect { reject }.not_to change { ldap_blocked_user.reload.state } + end + + it_behaves_like 'returns 409' + end + + context 'for a user that does not exist' do + let(:user_id) { non_existing_record_id } + + before do + reject + end + + it_behaves_like '404' + end end end + end - it 'does not re-block ldap blocked users' do - post api("/users/#{ldap_blocked_user.id}/block", admin) - expect(response).to have_gitlab_http_status(:forbidden) - expect(ldap_blocked_user.reload.state).to eq('ldap_blocked') + describe 'POST /users/:id/block', :aggregate_failures do + context 'when admin' do + subject(:block_user) { post api("/users/#{user_id}/block", admin) } + + context 'with an existing user' do + let(:user_id) { user.id } + + it 'blocks existing user' do + block_user + + expect(response).to have_gitlab_http_status(:created) + expect(response.body).to eq('true') + expect(user.reload.state).to eq('blocked') + end + end + + context 'with an ldap blocked user' do + let(:user_id) { ldap_blocked_user.id } + + it 'does not re-block ldap blocked users' do + block_user + + expect(response).to have_gitlab_http_status(:forbidden) + expect(ldap_blocked_user.reload.state).to eq('ldap_blocked') + end + end + + context 'with a non existent user' do + let(:user_id) { non_existing_record_id } + + it 'does not block non existent user, returns 404' do + block_user + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 User Not Found') + end + end + + context 'with an internal user' do + let(:user_id) { internal_user.id } + + it 'does not block internal user, returns 403' do + block_user + + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('An internal user cannot be blocked') + end + end + + context 'with a blocked user' do + let(:user_id) { blocked_user.id } + + it 'returns a 201 if user is already blocked' do + block_user + + expect(response).to have_gitlab_http_status(:created) + expect(response.body).to eq('null') + end + end end - it 'does not be available for non admin users' do + it 'is not available for non admin users' do post api("/users/#{user.id}/block", user) + expect(response).to have_gitlab_http_status(:forbidden) expect(user.reload.state).to eq('active') end + end - it 'returns a 404 error if user id not found' do - post api('/users/0/block', admin) - expect(response).to have_gitlab_http_status(:not_found) - expect(json_response['message']).to eq('404 User Not Found') - end + describe 'POST /users/:id/unblock', :aggregate_failures do + context 'when admin' do + subject(:unblock_user) { post api("/users/#{user_id}/unblock", admin) } - it 'returns a 403 error if user is internal' do - internal_user = create(:user, :bot) + context 'with an existing user' do + let(:user_id) { user.id } - post api("/users/#{internal_user.id}/block", admin) + it 'unblocks existing user' do + unblock_user - expect(response).to have_gitlab_http_status(:forbidden) - expect(json_response['message']).to eq('An internal user cannot be blocked') - end + expect(response).to have_gitlab_http_status(:created) + expect(user.reload.state).to eq('active') + end + end - it 'returns a 201 if user is already blocked' do - post api("/users/#{blocked_user.id}/block", admin) + context 'with a blocked user' do + let(:user_id) { blocked_user.id } - aggregate_failures do - expect(response).to have_gitlab_http_status(:created) - expect(response.body).to eq('null') + it 'unblocks a blocked user' do + unblock_user + + expect(response).to have_gitlab_http_status(:created) + expect(blocked_user.reload.state).to eq('active') + end end - end - end - describe 'POST /users/:id/unblock' do - let(:blocked_user) { create(:user, state: 'blocked') } - let(:deactivated_user) { create(:user, state: 'deactivated') } + context 'with a ldap blocked user' do + let(:user_id) { ldap_blocked_user.id } - it 'unblocks existing user' do - post api("/users/#{user.id}/unblock", admin) - expect(response).to have_gitlab_http_status(:created) - expect(user.reload.state).to eq('active') - end + it 'does not unblock ldap blocked users' do + unblock_user - it 'unblocks a blocked user' do - post api("/users/#{blocked_user.id}/unblock", admin) - expect(response).to have_gitlab_http_status(:created) - expect(blocked_user.reload.state).to eq('active') + expect(response).to have_gitlab_http_status(:forbidden) + expect(ldap_blocked_user.reload.state).to eq('ldap_blocked') + end + end + + context 'with a deactivated user' do + let(:user_id) { deactivated_user.id } + + it 'does not unblock deactivated users' do + unblock_user + + expect(response).to have_gitlab_http_status(:forbidden) + expect(deactivated_user.reload.state).to eq('deactivated') + end + end + + context 'with a non existent user' do + let(:user_id) { non_existing_record_id } + + it 'returns a 404 error if user id not found' do + unblock_user + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 User Not Found') + end + end + + context 'with an invalid user id' do + let(:user_id) { 'ASDF' } + + it 'returns a 404' do + unblock_user + + expect(response).to have_gitlab_http_status(:not_found) + end + end end - it 'does not unblock ldap blocked users' do - post api("/users/#{ldap_blocked_user.id}/unblock", admin) + it 'is not available for non admin users' do + post api("/users/#{user.id}/unblock", user) expect(response).to have_gitlab_http_status(:forbidden) - expect(ldap_blocked_user.reload.state).to eq('ldap_blocked') + expect(user.reload.state).to eq('active') end + end - it 'does not unblock deactivated users' do - post api("/users/#{deactivated_user.id}/unblock", admin) - expect(response).to have_gitlab_http_status(:forbidden) - expect(deactivated_user.reload.state).to eq('deactivated') + describe 'POST /users/:id/ban', :aggregate_failures do + context 'when admin' do + subject(:ban_user) { post api("/users/#{user_id}/ban", admin) } + + context 'with an active user' do + let(:user_id) { user.id } + + it 'bans an active user' do + ban_user + + expect(response).to have_gitlab_http_status(:created) + expect(response.body).to eq('true') + expect(user.reload.state).to eq('banned') + end + end + + context 'with an ldap blocked user' do + let(:user_id) { ldap_blocked_user.id } + + it 'does not ban ldap blocked users' do + ban_user + + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('You cannot ban ldap_blocked users.') + expect(ldap_blocked_user.reload.state).to eq('ldap_blocked') + end + end + + context 'with a deactivated user' do + let(:user_id) { deactivated_user.id } + + it 'does not ban deactivated users' do + ban_user + + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('You cannot ban deactivated users.') + expect(deactivated_user.reload.state).to eq('deactivated') + end + end + + context 'with a banned user' do + let(:user_id) { banned_user.id } + + it 'does not ban banned users' do + ban_user + + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('You cannot ban banned users.') + expect(banned_user.reload.state).to eq('banned') + end + end + + context 'with a non existent user' do + let(:user_id) { non_existing_record_id } + + it 'does not ban non existent users' do + ban_user + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 User Not Found') + end + end + + context 'with an invalid id' do + let(:user_id) { 'ASDF' } + + it 'does not ban invalid id users' do + ban_user + + expect(response).to have_gitlab_http_status(:not_found) + end + end end - it 'is not available for non admin users' do - post api("/users/#{user.id}/unblock", user) + it 'is not available for non-admin users' do + post api("/users/#{user.id}/ban", user) + expect(response).to have_gitlab_http_status(:forbidden) expect(user.reload.state).to eq('active') end + end - it 'returns a 404 error if user id not found' do - post api('/users/0/block', admin) - expect(response).to have_gitlab_http_status(:not_found) - expect(json_response['message']).to eq('404 User Not Found') + describe 'POST /users/:id/unban', :aggregate_failures do + context 'when admin' do + subject(:unban_user) { post api("/users/#{user_id}/unban", admin) } + + context 'with a banned user' do + let(:user_id) { banned_user.id } + + it 'activates a banned user' do + unban_user + + expect(response).to have_gitlab_http_status(:created) + expect(banned_user.reload.state).to eq('active') + end + end + + context 'with an ldap_blocked user' do + let(:user_id) { ldap_blocked_user.id } + + it 'does not unban ldap_blocked users' do + unban_user + + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('You cannot unban ldap_blocked users.') + expect(ldap_blocked_user.reload.state).to eq('ldap_blocked') + end + end + + context 'with a deactivated user' do + let(:user_id) { deactivated_user.id } + + it 'does not unban deactivated users' do + unban_user + + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('You cannot unban deactivated users.') + expect(deactivated_user.reload.state).to eq('deactivated') + end + end + + context 'with an active user' do + let(:user_id) { user.id } + + it 'does not unban active users' do + unban_user + + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('You cannot unban active users.') + expect(user.reload.state).to eq('active') + end + end + + context 'with a non existent user' do + let(:user_id) { non_existing_record_id } + + it 'does not unban non existent users' do + unban_user + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 User Not Found') + end + end + + context 'with an invalid id user' do + let(:user_id) { 'ASDF' } + + it 'does not unban invalid id users' do + unban_user + + expect(response).to have_gitlab_http_status(:not_found) + end + end end - it "returns a 404 for invalid ID" do - post api("/users/ASDF/block", admin) + it 'is not available for non admin users' do + post api("/users/#{banned_user.id}/unban", user) - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:forbidden) + expect(user.reload.state).to eq('active') end end diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index e4a0c034b20..a16f5abf608 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -882,6 +882,10 @@ RSpec.describe 'Git HTTP requests' do before do build.update!(user: user) project.add_reporter(user) + create(:ci_job_token_project_scope_link, + source_project: project, + target_project: other_project, + added_by: user) end shared_examples 'can download code only' do @@ -1447,6 +1451,10 @@ RSpec.describe 'Git HTTP requests' do before do build.update!(project: project) # can't associate it on factory create + create(:ci_job_token_project_scope_link, + source_project: project, + target_project: other_project, + added_by: user) end context 'when build created by system is authenticated' do diff --git a/spec/requests/jira_connect/installations_controller_spec.rb b/spec/requests/jira_connect/installations_controller_spec.rb new file mode 100644 index 00000000000..6315c66a41a --- /dev/null +++ b/spec/requests/jira_connect/installations_controller_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe JiraConnect::InstallationsController do + let_it_be(:installation) { create(:jira_connect_installation) } + + describe 'GET /-/jira_connect/installations' do + before do + get '/-/jira_connect/installations', params: { jwt: jwt } + end + + context 'without JWT' do + let(:jwt) { nil } + + it 'returns 403' do + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'with valid JWT' do + let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/installations', 'GET', 'https://gitlab.test') } + let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) } + + it 'returns status ok' do + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns the installation as json' do + expect(json_response).to eq({ + 'gitlab_com' => true, + 'instance_url' => nil + }) + end + + context 'with instance_url' do + let_it_be(:installation) { create(:jira_connect_installation, instance_url: 'https://example.com') } + + it 'returns the installation as json' do + expect(json_response).to eq({ + 'gitlab_com' => false, + 'instance_url' => 'https://example.com' + }) + end + end + end + end + + describe 'PUT /-/jira_connect/installations' do + before do + put '/-/jira_connect/installations', params: { jwt: jwt, installation: { instance_url: update_instance_url } } + end + + let(:update_instance_url) { 'https://example.com' } + + context 'without JWT' do + let(:jwt) { nil } + + it 'returns 403' do + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'with valid JWT' do + let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test') } + let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) } + + it 'returns 200' do + expect(response).to have_gitlab_http_status(:ok) + end + + it 'updates the instance_url' do + expect(json_response).to eq({ + 'gitlab_com' => false, + 'instance_url' => 'https://example.com' + }) + end + + context 'invalid URL' do + let(:update_instance_url) { 'invalid url' } + + it 'returns 422 and errors', :aggregate_failures do + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response).to eq({ + 'errors' => { + 'instance_url' => [ + 'is blocked: Only allowed schemes are http, https' + ] + } + }) + end + end + end + end +end diff --git a/spec/requests/members/mailgun/permanent_failure_spec.rb b/spec/requests/members/mailgun/permanent_failure_spec.rb new file mode 100644 index 00000000000..e47aedf8e94 --- /dev/null +++ b/spec/requests/members/mailgun/permanent_failure_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'receive a permanent failure' do + describe 'POST /members/mailgun/permanent_failures', :aggregate_failures do + let_it_be(:member) { create(:project_member, :invited) } + + let(:raw_invite_token) { member.raw_invite_token } + let(:mailgun_events) { true } + let(:mailgun_signing_key) { 'abc123' } + + subject(:post_request) { post members_mailgun_permanent_failures_path(standard_params) } + + before do + stub_application_setting(mailgun_events_enabled: mailgun_events, mailgun_signing_key: mailgun_signing_key) + end + + it 'marks the member invite email success as false' do + expect { post_request }.to change { member.reload.invite_email_success }.from(true).to(false) + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'when the change to a member is not made' do + context 'with incorrect signing key' do + context 'with incorrect signing key' do + let(:mailgun_signing_key) { '_foobar_' } + + it 'does not change member status and responds as not_found' do + expect { post_request }.not_to change { member.reload.invite_email_success } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'with nil signing key' do + let(:mailgun_signing_key) { nil } + + it 'does not change member status and responds as not_found' do + expect { post_request }.not_to change { member.reload.invite_email_success } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + context 'when the feature is not enabled' do + let(:mailgun_events) { false } + + it 'does not change member status and responds as expected' do + expect { post_request }.not_to change { member.reload.invite_email_success } + + expect(response).to have_gitlab_http_status(:not_acceptable) + end + end + + context 'when it is not an invite email' do + before do + stub_const('::Members::Mailgun::INVITE_EMAIL_TAG', '_foobar_') + end + + it 'does not change member status and responds as expected' do + expect { post_request }.not_to change { member.reload.invite_email_success } + + expect(response).to have_gitlab_http_status(:not_acceptable) + end + end + end + + def standard_params + { + "signature": { + "timestamp": "1625056677", + "token": "eb944d0ace7227667a1b97d2d07276ae51d2b849ed2cfa68f3", + "signature": "9790cc6686eb70f0b1f869180d906870cdfd496d27fee81da0aa86b9e539e790" + }, + "event-data": { + "severity": "permanent", + "tags": ["invite_email"], + "timestamp": 1521233195.375624, + "storage": { + "url": "_anything_", + "key": "_anything_" + }, + "log-level": "error", + "id": "_anything_", + "campaigns": [], + "reason": "suppress-bounce", + "user-variables": { + "invite_token": raw_invite_token + }, + "flags": { + "is-routed": false, + "is-authenticated": true, + "is-system-test": false, + "is-test-mode": false + }, + "recipient-domain": "example.com", + "envelope": { + "sender": "bob@mg.gitlab.com", + "transport": "smtp", + "targets": "alice@example.com" + }, + "message": { + "headers": { + "to": "Alice <alice@example.com>", + "message-id": "20130503192659.13651.20287@mg.gitlab.com", + "from": "Bob <bob@mg.gitlab.com>", + "subject": "Test permanent_fail webhook" + }, + "attachments": [], + "size": 111 + }, + "recipient": "alice@example.com", + "event": "failed", + "delivery-status": { + "attempt-no": 1, + "message": "", + "code": 605, + "description": "Not delivering to previously bounced address", + "session-seconds": 0 + } + } + } + end + end +end diff --git a/spec/requests/oauth_tokens_spec.rb b/spec/requests/oauth_tokens_spec.rb index 6d944bbc783..fdcc76f42cc 100644 --- a/spec/requests/oauth_tokens_spec.rb +++ b/spec/requests/oauth_tokens_spec.rb @@ -55,5 +55,29 @@ RSpec.describe 'OAuth Tokens requests' do expect(json_response['access_token']).not_to be_nil end + + context 'when the application is configured to use expiring tokens' do + before do + application.update!(expire_access_tokens: true) + end + + it 'generates an access token with an expiration' do + request_access_token(user) + + expect(json_response['expires_in']).not_to be_nil + end + end + + context 'when the application is configured not to use expiring tokens' do + before do + application.update!(expire_access_tokens: false) + end + + it 'generates an access token without an expiration' do + request_access_token(user) + + expect(json_response.key?('expires_in')).to eq(false) + end + end end end diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb index 5bf786f2290..5ec23382698 100644 --- a/spec/requests/openid_connect_spec.rb +++ b/spec/requests/openid_connect_spec.rb @@ -149,7 +149,15 @@ RSpec.describe 'OpenID Connect requests' do end context 'ID token payload' do + let!(:group1) { create :group } + let!(:group2) { create :group } + let!(:group3) { create :group, parent: group2 } + let!(:group4) { create :group, parent: group3 } + before do + group1.add_user(user, Gitlab::Access::OWNER) + group3.add_user(user, Gitlab::Access::DEVELOPER) + request_access_token! @payload = JSON::JWT.decode(json_response['id_token'], :skip_verification) end @@ -175,7 +183,12 @@ RSpec.describe 'OpenID Connect requests' do end it 'does not include any unknown properties' do - expect(@payload.keys).to eq %w[iss sub aud exp iat auth_time sub_legacy email email_verified] + expect(@payload.keys).to eq %w[iss sub aud exp iat auth_time sub_legacy email email_verified groups_direct] + end + + it 'does include groups' do + expected_groups = [group1.full_path, group3.full_path] + expect(@payload['groups_direct']).to match_array(expected_groups) end end @@ -331,7 +344,15 @@ RSpec.describe 'OpenID Connect requests' do end context 'ID token payload' do + let!(:group1) { create :group } + let!(:group2) { create :group } + let!(:group3) { create :group, parent: group2 } + let!(:group4) { create :group, parent: group3 } + before do + group1.add_user(user, Gitlab::Access::OWNER) + group3.add_user(user, Gitlab::Access::DEVELOPER) + request_access_token! @payload = JSON::JWT.decode(json_response['id_token'], :skip_verification) end @@ -343,6 +364,11 @@ RSpec.describe 'OpenID Connect requests' do it 'has true in email_verified claim' do expect(@payload['email_verified']).to eq(true) end + + it 'does include groups' do + expected_groups = [group1.full_path, group3.full_path] + expect(@payload['groups_direct']).to match_array(expected_groups) + 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 c68745b9271..8057a091bba 100644 --- a/spec/requests/projects/merge_requests_discussions_spec.rb +++ b/spec/requests/projects/merge_requests_discussions_spec.rb @@ -59,6 +59,7 @@ RSpec.describe 'merge requests discussions' do let!(:first_note) { create(:diff_note_on_merge_request, author: author, noteable: merge_request, project: project, note: "reference: #{reference.to_reference}") } let!(:second_note) { create(:diff_note_on_merge_request, in_reply_to: first_note, noteable: merge_request, project: project) } let!(:award_emoji) { create(:award_emoji, awardable: first_note) } + let!(:author_membership) { project.add_maintainer(author) } before do # Make a request to cache the discussions @@ -229,6 +230,16 @@ RSpec.describe 'merge requests discussions' do end end + context 'when author role changes' do + before do + Members::UpdateService.new(user, access_level: Gitlab::Access::GUEST).execute(author_membership) + end + + it_behaves_like 'cache miss' do + let(:changed_notes) { [first_note, second_note] } + end + end + context 'when merge_request_discussion_cache is disabled' do before do stub_feature_flags(merge_request_discussion_cache: false) diff --git a/spec/requests/projects/usage_quotas_spec.rb b/spec/requests/projects/usage_quotas_spec.rb new file mode 100644 index 00000000000..04e01da61ef --- /dev/null +++ b/spec/requests/projects/usage_quotas_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Project Usage Quotas' do + let_it_be(:project) { create(:project) } + let_it_be(:role) { :maintainer } + let_it_be(:user) { create(:user) } + + before do + project.add_role(user, role) + login_as(user) + end + + shared_examples 'response with 404 status' do + it 'renders :not_found' do + get project_usage_quotas_path(project) + + expect(response).to have_gitlab_http_status(:not_found) + expect(response.body).not_to include(project_usage_quotas_path(project)) + end + end + + describe 'GET /:namespace/:project/usage_quotas' do + context 'with project_storage_ui feature flag enabled' do + before do + stub_feature_flags(project_storage_ui: true) + end + + it 'renders usage quotas path' do + mock_storage_app_data = { + project_path: project.full_path, + usage_quotas_help_page_path: help_page_path('user/usage_quotas'), + build_artifacts_help_page_path: help_page_path('ci/pipelines/job_artifacts', anchor: 'when-job-artifacts-are-deleted'), + packages_help_page_path: help_page_path('user/packages/package_registry/index.md', anchor: 'delete-a-package'), + repository_help_page_path: help_page_path('user/project/repository/reducing_the_repo_size_using_git'), + snippets_help_page_path: help_page_path('user/snippets', anchor: 'reduce-snippets-repository-size'), + wiki_help_page_path: help_page_path('administration/wikis/index.md', anchor: 'reduce-wiki-repository-size') + } + get project_usage_quotas_path(project) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to include(project_usage_quotas_path(project)) + expect(assigns[:storage_app_data]).to eq(mock_storage_app_data) + expect(response.body).to include("Usage of project resources across the <strong>#{project.name}</strong> project") + end + + context 'renders :not_found for user without permission' do + let(:role) { :developer } + + it_behaves_like 'response with 404 status' + end + end + + context 'with project_storage_ui feature flag disabled' do + before do + stub_feature_flags(project_storage_ui: false) + end + + it_behaves_like 'response with 404 status' + end + end +end diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb index a0f9d4c11ed..87ef6fa1a18 100644 --- a/spec/requests/rack_attack_global_spec.rb +++ b/spec/requests/rack_attack_global_spec.rb @@ -11,6 +11,8 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac # the right settings are being exercised let(:settings_to_set) do { + throttle_unauthenticated_api_requests_per_period: 100, + throttle_unauthenticated_api_period_in_seconds: 1, throttle_unauthenticated_requests_per_period: 100, throttle_unauthenticated_period_in_seconds: 1, throttle_authenticated_api_requests_per_period: 100, @@ -22,7 +24,13 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac throttle_unauthenticated_packages_api_requests_per_period: 100, throttle_unauthenticated_packages_api_period_in_seconds: 1, throttle_authenticated_packages_api_requests_per_period: 100, - throttle_authenticated_packages_api_period_in_seconds: 1 + throttle_authenticated_packages_api_period_in_seconds: 1, + throttle_authenticated_git_lfs_requests_per_period: 100, + throttle_authenticated_git_lfs_period_in_seconds: 1, + 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 } end @@ -33,186 +41,21 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac include_context 'rack attack cache store' - describe 'unauthenticated requests' do - let(:url_that_does_not_require_authentication) { '/users/sign_in' } - let(:url_api_internal) { '/api/v4/internal/check' } - - before do - # Disabling protected paths throttle, otherwise requests to - # '/users/sign_in' are caught by this throttle. - settings_to_set[:throttle_protected_paths_enabled] = false - - # Set low limits - settings_to_set[:throttle_unauthenticated_requests_per_period] = requests_per_period - settings_to_set[:throttle_unauthenticated_period_in_seconds] = period_in_seconds - end - - context 'when the throttle is enabled' do - before do - settings_to_set[:throttle_unauthenticated_enabled] = true - stub_application_setting(settings_to_set) - end - - it 'rejects requests over the rate limit' do - # At first, allow requests under the rate limit. - requests_per_period.times do - get url_that_does_not_require_authentication - expect(response).to have_gitlab_http_status(:ok) - end - - # the last straw - expect_rejection { get url_that_does_not_require_authentication } - end - - context 'with custom response text' do - before do - stub_application_setting(rate_limiting_response_text: 'Custom response') - end - - it 'rejects requests over the rate limit' do - # At first, allow requests under the rate limit. - requests_per_period.times do - get url_that_does_not_require_authentication - expect(response).to have_gitlab_http_status(:ok) - end - - # the last straw - expect_rejection { get url_that_does_not_require_authentication } - expect(response.body).to eq("Custom response\n") - end - end - - it 'allows requests after throttling and then waiting for the next period' do - requests_per_period.times do - get url_that_does_not_require_authentication - expect(response).to have_gitlab_http_status(:ok) - end - - expect_rejection { get url_that_does_not_require_authentication } - - travel_to(period.from_now) do - requests_per_period.times do - get url_that_does_not_require_authentication - expect(response).to have_gitlab_http_status(:ok) - end - - expect_rejection { get url_that_does_not_require_authentication } - end - end - - it 'counts requests from different IPs separately' do - requests_per_period.times do - get url_that_does_not_require_authentication - expect(response).to have_gitlab_http_status(:ok) - end - - expect_next_instance_of(Rack::Attack::Request) do |instance| - expect(instance).to receive(:ip).at_least(:once).and_return('1.2.3.4') - end - - # would be over limit for the same IP - get url_that_does_not_require_authentication - expect(response).to have_gitlab_http_status(:ok) - end - - context 'when the request is to the api internal endpoints' do - it 'allows requests over the rate limit' do - (1 + requests_per_period).times do - get url_api_internal, params: { secret_token: Gitlab::Shell.secret_token } - expect(response).to have_gitlab_http_status(:ok) - end - end - end - - context 'when the request is authenticated by a runner token' do - let(:request_jobs_url) { '/api/v4/jobs/request' } - let(:runner) { create(:ci_runner) } - - it 'does not count as unauthenticated' do - (1 + requests_per_period).times do - post request_jobs_url, params: { token: runner.token } - expect(response).to have_gitlab_http_status(:no_content) - end - end - end - - context 'when the request is to a health endpoint' do - let(:health_endpoint) { '/-/metrics' } - - it 'does not throttle the requests' do - (1 + requests_per_period).times do - get health_endpoint - expect(response).to have_gitlab_http_status(:ok) - end - end - end - - context 'when the request is to a container registry notification endpoint' do - let(:secret_token) { 'secret_token' } - let(:events) { [{ action: 'push' }] } - let(:registry_endpoint) { '/api/v4/container_registry_event/events' } - let(:registry_headers) { { 'Content-Type' => ::API::ContainerRegistryEvent::DOCKER_DISTRIBUTION_EVENTS_V1_JSON } } - - before do - allow(Gitlab.config.registry).to receive(:notification_secret) { secret_token } - - event = spy(:event) - allow(::ContainerRegistry::Event).to receive(:new).and_return(event) - allow(event).to receive(:supported?).and_return(true) - end - - it 'does not throttle the requests' do - (1 + requests_per_period).times do - post registry_endpoint, - params: { events: events }.to_json, - headers: registry_headers.merge('Authorization' => secret_token) - - expect(response).to have_gitlab_http_status(:ok) - end - end - end - - it 'logs RackAttack info into structured logs' do - requests_per_period.times do - get url_that_does_not_require_authentication - expect(response).to have_gitlab_http_status(:ok) - end - - arguments = a_hash_including({ - message: 'Rack_Attack', - env: :throttle, - remote_ip: '127.0.0.1', - request_method: 'GET', - path: '/users/sign_in', - matched: 'throttle_unauthenticated' - }) - - expect(Gitlab::AuthLogger).to receive(:error).with(arguments) - - get url_that_does_not_require_authentication - end - - it_behaves_like 'tracking when dry-run mode is set' do - let(:throttle_name) { 'throttle_unauthenticated' } - - def do_request - get url_that_does_not_require_authentication - end - end + describe 'unauthenticated API requests' do + it_behaves_like 'rate-limited unauthenticated requests' do + let(:throttle_name) { 'throttle_unauthenticated_api' } + let(:throttle_setting_prefix) { 'throttle_unauthenticated_api' } + let(:url_that_does_not_require_authentication) { '/api/v4/projects' } + let(:url_that_is_not_matched) { '/users/sign_in' } end + end - context 'when the throttle is disabled' do - before do - settings_to_set[:throttle_unauthenticated_enabled] = false - stub_application_setting(settings_to_set) - end - - it 'allows requests over the rate limit' do - (1 + requests_per_period).times do - get url_that_does_not_require_authentication - expect(response).to have_gitlab_http_status(:ok) - end - end + describe 'unauthenticated web requests' do + it_behaves_like 'rate-limited unauthenticated requests' do + let(:throttle_name) { 'throttle_unauthenticated_web' } + let(:throttle_setting_prefix) { 'throttle_unauthenticated' } + let(:url_that_does_not_require_authentication) { '/users/sign_in' } + let(:url_that_is_not_matched) { '/api/v4/projects' } end end @@ -473,9 +316,9 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac context 'when unauthenticated api throttle is enabled' do before do - settings_to_set[:throttle_unauthenticated_requests_per_period] = requests_per_period - settings_to_set[:throttle_unauthenticated_period_in_seconds] = period_in_seconds - settings_to_set[:throttle_unauthenticated_enabled] = true + 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 @@ -488,6 +331,22 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac 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 packages api throttle is enabled' do @@ -509,9 +368,9 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac context 'when unauthenticated api throttle is lower' do before do - settings_to_set[:throttle_unauthenticated_requests_per_period] = 0 - settings_to_set[:throttle_unauthenticated_period_in_seconds] = period_in_seconds - settings_to_set[:throttle_unauthenticated_enabled] = true + 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 @@ -620,6 +479,317 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac end end + describe 'authenticated git lfs requests', :api do + let_it_be(:project) { create(:project, :internal) } + let_it_be(:user) { create(: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(:request_method) { 'GET' } + let(:throttle_setting_prefix) { 'throttle_authenticated_git_lfs' } + let(:git_lfs_url) { "/#{project.full_path}.git/info/lfs/locks" } + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + stub_application_setting(settings_to_set) + end + + context 'with regular login' do + let(:url_that_requires_authentication) { git_lfs_url } + + it_behaves_like 'rate-limited web authenticated requests' + end + + context 'with the token in the headers' do + let(:request_args) { [git_lfs_url, { headers: basic_auth_headers(user, token) }] } + let(:other_user_request_args) { [git_lfs_url, { headers: basic_auth_headers(other_user, other_user_token) }] } + + it_behaves_like 'rate-limited token-authenticated requests' + end + + context 'precedence over authenticated web throttle' do + before do + settings_to_set[:throttle_authenticated_git_lfs_requests_per_period] = requests_per_period + settings_to_set[:throttle_authenticated_git_lfs_period_in_seconds] = period_in_seconds + end + + def do_request + get git_lfs_url, headers: basic_auth_headers(user, token) + end + + context 'when authenticated git lfs throttle is enabled' do + before do + settings_to_set[:throttle_authenticated_git_lfs_enabled] = true + end + + context 'when authenticated web throttle is lower' do + before do + settings_to_set[:throttle_authenticated_web_requests_per_period] = 0 + settings_to_set[:throttle_authenticated_web_period_in_seconds] = period_in_seconds + settings_to_set[:throttle_authenticated_web_enabled] = true + stub_application_setting(settings_to_set) + end + + it 'ignores authenticated web 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 git lfs throttle is disabled' do + before do + settings_to_set[:throttle_authenticated_git_lfs_enabled] = false + end + + context 'when authenticated web throttle is enabled' do + before do + settings_to_set[:throttle_authenticated_web_requests_per_period] = requests_per_period + settings_to_set[:throttle_authenticated_web_period_in_seconds] = period_in_seconds + settings_to_set[:throttle_authenticated_web_enabled] = true + stub_application_setting(settings_to_set) + end + + it 'rejects requests over the authenticated web 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 + + describe 'Files API' do + let(:request_method) { 'GET' } + + context 'unauthenticated' do + let_it_be(:project) { create(:project, :public, :custom_repo, files: { 'README' => 'foo' }) } + + let(:throttle_setting_prefix) { 'throttle_unauthenticated_files_api' } + let(:files_path_that_does_not_require_authentication) { "/api/v4/projects/#{project.id}/repository/files/README?ref=master" } + + def do_request + get files_path_that_does_not_require_authentication + end + + before do + settings_to_set[:throttle_unauthenticated_files_api_requests_per_period] = requests_per_period + settings_to_set[:throttle_unauthenticated_files_api_period_in_seconds] = period_in_seconds + end + + context 'when unauthenticated files api throttle is disabled' do + before do + settings_to_set[:throttle_unauthenticated_files_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 files api throttle is enabled' do + before do + settings_to_set[:throttle_unauthenticated_files_api_requests_per_period] = requests_per_period # 1 + settings_to_set[:throttle_unauthenticated_files_api_period_in_seconds] = period_in_seconds # 10_000 + settings_to_set[:throttle_unauthenticated_files_api_enabled] = true + stub_application_setting(settings_to_set) + 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 feature flag is off' do + before do + stub_feature_flags(files_api_throttling: false) + 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 + 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_files_api' } + end + end + end + + context 'authenticated', :api do + let_it_be(:project) { create(:project, :internal, :custom_repo, files: { 'README' => 'foo' }) } + let_it_be(:user) { create(: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_files_api' } + let(:api_partial_url) { "/projects/#{project.id}/repository/files/README?ref=master" } + + before do + stub_application_setting(settings_to_set) + end + + context 'with the token in the query string' do + let(:request_args) { [api(api_partial_url, personal_access_token: token), {}] } + let(:other_user_request_args) { [api(api_partial_url, 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(api_partial_url, personal_access_token_headers(token)) } + let(:other_user_request_args) { api_get_args_with_token_headers(api_partial_url, 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_files_api_requests_per_period] = requests_per_period + settings_to_set[:throttle_authenticated_files_api_period_in_seconds] = period_in_seconds + end + + def do_request + get api(api_partial_url, personal_access_token: token) + end + + context 'when authenticated files api throttle is enabled' do + before do + settings_to_set[:throttle_authenticated_files_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 + + context 'when feature flag is off' do + before do + stub_feature_flags(files_api_throttling: false) + 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 + end + end + + context 'when authenticated files api throttle is disabled' do + before do + settings_to_set[:throttle_authenticated_files_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/requests/users/group_callouts_spec.rb b/spec/requests/users/group_callouts_spec.rb new file mode 100644 index 00000000000..a8680c3add4 --- /dev/null +++ b/spec/requests/users/group_callouts_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Group callouts' do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + + before do + sign_in(user) + end + + describe 'POST /-/users/group_callouts' do + let(:params) { { feature_name: feature_name, group_id: group.id } } + + subject { post group_callouts_path, params: params, headers: { 'ACCEPT' => 'application/json' } } + + context 'with valid feature name and group' do + let(:feature_name) { Users::GroupCallout.feature_names.each_key.first } + + context 'when callout entry does not exist' do + it 'creates a callout entry with dismissed state' do + expect { subject }.to change { Users::GroupCallout.count }.by(1) + end + + it 'returns success' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when callout entry already exists' do + let!(:callout) do + create(:group_callout, + feature_name: Users::GroupCallout.feature_names.each_key.first, + user: user, + group: group) + end + + it 'returns success', :aggregate_failures do + expect { subject }.not_to change { Users::GroupCallout.count } + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + context 'with invalid feature name' do + let(:feature_name) { 'bogus_feature_name' } + + it 'returns bad request' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end +end diff --git a/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb b/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb index 35b21477d80..6b5b07fb357 100644 --- a/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb +++ b/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb @@ -16,6 +16,7 @@ RSpec.describe RuboCop::Cop::Gitlab::MarkUsedFeatureFlags do stub_const("#{described_class}::DYNAMIC_FEATURE_FLAGS", []) allow(cop).to receive(:defined_feature_flags).and_return(defined_feature_flags) allow(cop).to receive(:usage_data_counters_known_event_feature_flags).and_return([]) + described_class.feature_flags_already_tracked = false end def feature_flag_path(feature_flag_name) diff --git a/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb index 899872859a9..f6bed0d74fb 100644 --- a/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb +++ b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb @@ -11,6 +11,7 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do before do allow(cop).to receive(:in_migration?).and_return(true) + allow(cop).to receive(:version).and_return(described_class::TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE + 5) end context 'when text columns are defined without a limit' do @@ -26,7 +27,7 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do ^^^^ #{msg} end - create_table_with_constraints :test_text_limits_create do |t| + create_table :test_text_limits_create do |t| t.integer :test_id, null: false t.text :title t.text :description @@ -61,13 +62,10 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do t.text :name end - create_table_with_constraints :test_text_limits_create do |t| + create_table :test_text_limits_create do |t| t.integer :test_id, null: false - t.text :title - t.text :description - - t.text_limit :title, 100 - t.text_limit :description, 255 + t.text :title, limit: 100 + t.text :description, limit: 255 end add_column :test_text_limits, :email, :text @@ -82,6 +80,30 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do end RUBY end + + context 'for migrations before 2021_09_10_00_00_00' do + it 'when limit: attribute is used (which is not supported yet for this version): registers an offense' do + allow(cop).to receive(:version).and_return(described_class::TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE - 5) + + expect_offense(<<~RUBY) + class TestTextLimits < ActiveRecord::Migration[6.0] + def up + create_table :test_text_limit_attribute do |t| + t.integer :test_id, null: false + t.text :name, limit: 100 + ^^^^ Text columns should always have a limit set (255 is suggested). Using limit: is not supported in this version. You can add a limit to a `text` column by using `add_text_limit` or `.text_limit` inside `create_table` + end + + create_table_with_constraints :test_text_limit_attribute do |t| + t.integer :test_id, null: false + t.text :name, limit: 100 + ^^^^ Text columns should always have a limit set (255 is suggested). Using limit: is not supported in this version. You can add a limit to a `text` column by using `add_text_limit` or `.text_limit` inside `create_table` + end + end + end + RUBY + end + end end context 'when text array columns are defined without a limit' do diff --git a/spec/rubocop/cop/migration/prevent_index_creation_spec.rb b/spec/rubocop/cop/migration/prevent_index_creation_spec.rb index a3965f54bbd..ed7c8974d8d 100644 --- a/spec/rubocop/cop/migration/prevent_index_creation_spec.rb +++ b/spec/rubocop/cop/migration/prevent_index_creation_spec.rb @@ -6,28 +6,76 @@ require_relative '../../../../rubocop/cop/migration/prevent_index_creation' RSpec.describe RuboCop::Cop::Migration::PreventIndexCreation do subject(:cop) { described_class.new } + let(:forbidden_tables) { %w(ci_builds) } + let(:forbidden_tables_list) { forbidden_tables.join(', ') } + context 'when in migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end context 'when adding an index to a forbidden table' do - it 'registers an offense when add_index is used' do - expect_offense(<<~RUBY) - def change - add_index :ci_builds, :protected - ^^^^^^^^^ Adding new index to ci_builds is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886 + context 'when table_name is a symbol' do + it "registers an offense when add_index is used", :aggregate_failures do + forbidden_tables.each do |table_name| + expect_offense(<<~RUBY) + def change + add_index :#{table_name}, :protected + ^^^^^^^^^ Adding new index to #{forbidden_tables_list} is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886 + end + RUBY end - RUBY + end + + it "registers an offense when add_concurrent_index is used", :aggregate_failures do + forbidden_tables.each do |table_name| + expect_offense(<<~RUBY) + def change + add_concurrent_index :#{table_name}, :protected + ^^^^^^^^^^^^^^^^^^^^ Adding new index to #{forbidden_tables_list} is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886 + end + RUBY + end + end end - it 'registers an offense when add_concurrent_index is used' do - expect_offense(<<~RUBY) - def change - add_concurrent_index :ci_builds, :protected - ^^^^^^^^^^^^^^^^^^^^ Adding new index to ci_builds is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886 + context 'when table_name is a string' do + it "registers an offense when add_index is used", :aggregate_failures do + forbidden_tables.each do |table_name| + expect_offense(<<~RUBY) + def change + add_index "#{table_name}", :protected + ^^^^^^^^^ Adding new index to #{forbidden_tables_list} is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886 + end + RUBY end - RUBY + end + + it "registers an offense when add_concurrent_index is used", :aggregate_failures do + forbidden_tables.each do |table_name| + expect_offense(<<~RUBY) + def change + add_concurrent_index "#{table_name}", :protected + ^^^^^^^^^^^^^^^^^^^^ Adding new index to #{forbidden_tables_list} is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886 + end + RUBY + end + end + end + + context 'when table_name is a constant' do + it "registers an offense when add_concurrent_index is used", :aggregate_failures do + expect_offense(<<~RUBY) + INDEX_NAME = "index_name" + TABLE_NAME = :ci_builds + disable_ddl_transaction! + + def change + add_concurrent_index TABLE_NAME, :protected + ^^^^^^^^^^^^^^^^^^^^ Adding new index to #{forbidden_tables_list} is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886 + end + RUBY + end end end @@ -39,6 +87,20 @@ RSpec.describe RuboCop::Cop::Migration::PreventIndexCreation do end RUBY end + + context 'when using a constant' do + it 'does not register an offense' do + expect_no_offenses(<<~RUBY) + disable_ddl_transaction! + + TABLE_NAME = "not_forbidden" + + def up + add_concurrent_index TABLE_NAME, :protected + end + RUBY + end + end end end diff --git a/spec/rubocop/cop/migration/versioned_migration_class_spec.rb b/spec/rubocop/cop/migration/versioned_migration_class_spec.rb new file mode 100644 index 00000000000..d9b0cd4546c --- /dev/null +++ b/spec/rubocop/cop/migration/versioned_migration_class_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require_relative '../../../../rubocop/cop/migration/versioned_migration_class' + +RSpec.describe RuboCop::Cop::Migration::VersionedMigrationClass do + subject(:cop) { described_class.new } + + let(:migration) do + <<~SOURCE + class TestMigration < Gitlab::Database::Migration[1.0] + def up + execute 'select 1' + end + + def down + execute 'select 1' + end + end + SOURCE + end + + shared_examples 'a disabled cop' do + it 'does not register any offenses' do + expect_no_offenses(migration) + end + end + + context 'outside of a migration' do + it_behaves_like 'a disabled cop' + end + + context 'in migration' do + before do + allow(cop).to receive(:in_migration?).and_return(true) + end + + context 'in an old migration' do + before do + allow(cop).to receive(:version).and_return(described_class::ENFORCED_SINCE - 5) + end + + it_behaves_like 'a disabled cop' + end + + context 'that is recent' do + before do + allow(cop).to receive(:version).and_return(described_class::ENFORCED_SINCE + 5) + end + + it 'adds an offence if inheriting from ActiveRecord::Migration' do + expect_offense(<<~RUBY) + class MyMigration < ActiveRecord::Migration[6.1] + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't inherit from ActiveRecord::Migration but use Gitlab::Database::Migration[1.0] instead. See https://docs.gitlab.com/ee/development/migration_style_guide.html#migration-helpers-and-versioning. + end + RUBY + end + + it 'adds an offence if including Gitlab::Database::MigrationHelpers directly' do + expect_offense(<<~RUBY) + class MyMigration < Gitlab::Database::Migration[1.0] + include Gitlab::Database::MigrationHelpers + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't include migration helper modules directly. Inherit from Gitlab::Database::Migration[1.0] instead. See https://docs.gitlab.com/ee/development/migration_style_guide.html#migration-helpers-and-versioning. + end + RUBY + end + + it 'excludes ActiveRecord classes defined inside the migration' do + expect_no_offenses(<<~RUBY) + class TestMigration < Gitlab::Database::Migration[1.0] + class TestModel < ApplicationRecord + end + + class AnotherTestModel < ActiveRecord::Base + end + end + RUBY + end + end + end +end diff --git a/spec/rubocop/cop/performance/active_record_subtransaction_methods_spec.rb b/spec/rubocop/cop/performance/active_record_subtransaction_methods_spec.rb new file mode 100644 index 00000000000..df18121e2df --- /dev/null +++ b/spec/rubocop/cop/performance/active_record_subtransaction_methods_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' + +require_relative '../../../../rubocop/cop/performance/active_record_subtransaction_methods' + +RSpec.describe RuboCop::Cop::Performance::ActiveRecordSubtransactionMethods do + subject(:cop) { described_class.new } + + let(:message) { described_class::MSG } + + shared_examples 'a method that uses a subtransaction' do |method_name| + it 'registers an offense' do + expect_offense(<<~RUBY, method_name: method_name, message: message) + Project.%{method_name} + ^{method_name} %{message} + RUBY + end + end + + context 'when the method uses a subtransaction' do + where(:method) { described_class::DISALLOWED_METHODS.to_a } + + with_them do + include_examples 'a method that uses a subtransaction', params[:method] + end + end +end diff --git a/spec/rubocop/cop/performance/active_record_subtransactions_spec.rb b/spec/rubocop/cop/performance/active_record_subtransactions_spec.rb new file mode 100644 index 00000000000..0da2e30062a --- /dev/null +++ b/spec/rubocop/cop/performance/active_record_subtransactions_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require_relative '../../../../rubocop/cop/performance/active_record_subtransactions' + +RSpec.describe RuboCop::Cop::Performance::ActiveRecordSubtransactions do + subject(:cop) { described_class.new } + + let(:message) { described_class::MSG } + + context 'when calling #transaction with only requires_new: true' do + it 'registers an offense' do + expect_offense(<<~RUBY) + ApplicationRecord.transaction(requires_new: true) do + ^^^^^^^^^^^^^^^^^^ #{message} + Project.create!(name: 'MyProject') + end + RUBY + end + end + + context 'when passing multiple arguments to #transaction, including requires_new: true' do + it 'registers an offense' do + expect_offense(<<~RUBY) + ApplicationRecord.transaction(isolation: :read_committed, requires_new: true) do + ^^^^^^^^^^^^^^^^^^ #{message} + Project.create!(name: 'MyProject') + end + RUBY + end + end + + context 'when calling #transaction with requires_new: false' do + it 'does not register an offense' do + expect_no_offenses(<<~RUBY) + ApplicationRecord.transaction(requires_new: false) do + Project.create!(name: 'MyProject') + end + RUBY + end + end + + context 'when calling #transaction with other options' do + it 'does not register an offense' do + expect_no_offenses(<<~RUBY) + ApplicationRecord.transaction(isolation: :read_committed) do + Project.create!(name: 'MyProject') + end + RUBY + end + end + + context 'when calling #transaction with no arguments' do + it 'does not register an offense' do + expect_no_offenses(<<~RUBY) + ApplicationRecord.transaction do + Project.create!(name: 'MyProject') + end + RUBY + end + end +end diff --git a/spec/rubocop/cop/worker_data_consistency_spec.rb b/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_spec.rb index 5fa42bf2b87..cf8d0d1b66f 100644 --- a/spec/rubocop/cop/worker_data_consistency_spec.rb +++ b/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_spec.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true require 'fast_spec_helper' -require_relative '../../../rubocop/cop/worker_data_consistency' +require_relative '../../../../rubocop/cop/sidekiq_load_balancing/worker_data_consistency' -RSpec.describe RuboCop::Cop::WorkerDataConsistency do +RSpec.describe RuboCop::Cop::SidekiqLoadBalancing::WorkerDataConsistency do subject(:cop) { described_class.new } before do diff --git a/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_with_deduplication_spec.rb b/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_with_deduplication_spec.rb new file mode 100644 index 00000000000..6e7212b1002 --- /dev/null +++ b/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_with_deduplication_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' +require_relative '../../../../rubocop/cop/sidekiq_load_balancing/worker_data_consistency_with_deduplication' + +RSpec.describe RuboCop::Cop::SidekiqLoadBalancing::WorkerDataConsistencyWithDeduplication do + using RSpec::Parameterized::TableSyntax + + subject(:cop) { described_class.new } + + before do + allow(cop) + .to receive(:in_worker?) + .and_return(true) + end + + where(:data_consistency) { %i[delayed sticky] } + + with_them do + let(:strategy) { described_class::DEFAULT_STRATEGY } + let(:corrected) do + <<~CORRECTED + class SomeWorker + include ApplicationWorker + + data_consistency :#{data_consistency} + + deduplicate #{strategy}, including_scheduled: true + idempotent! + end + CORRECTED + end + + context 'when deduplication strategy is not explicitly set' do + it 'registers an offense and corrects using default strategy' do + expect_offense(<<~CODE) + class SomeWorker + include ApplicationWorker + + data_consistency :#{data_consistency} + + idempotent! + ^^^^^^^^^^^ Workers that declare either `:sticky` or `:delayed` data consistency [...] + end + CODE + + expect_correction(corrected) + end + + context 'when identation is different' do + let(:corrected) do + <<~CORRECTED + class SomeWorker + include ApplicationWorker + + data_consistency :#{data_consistency} + + deduplicate #{strategy}, including_scheduled: true + idempotent! + end + CORRECTED + end + + it 'registers an offense and corrects with correct identation' do + expect_offense(<<~CODE) + class SomeWorker + include ApplicationWorker + + data_consistency :#{data_consistency} + + idempotent! + ^^^^^^^^^^^ Workers that declare either `:sticky` or `:delayed` data consistency [...] + end + CODE + + expect_correction(corrected) + end + end + end + + context 'when deduplication strategy does not include including_scheduling option' do + let(:strategy) { ':until_executed' } + + it 'registers an offense and corrects' do + expect_offense(<<~CODE) + class SomeWorker + include ApplicationWorker + + data_consistency :#{data_consistency} + + deduplicate :until_executed + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Workers that declare either `:sticky` or `:delayed` data consistency [...] + idempotent! + end + CODE + + expect_correction(corrected) + end + end + + context 'when deduplication strategy has including_scheduling option disabled' do + let(:strategy) { ':until_executed' } + + it 'registers an offense and corrects' do + expect_offense(<<~CODE) + class SomeWorker + include ApplicationWorker + + data_consistency :#{data_consistency} + + deduplicate :until_executed, including_scheduled: false + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Workers that declare either `:sticky` or `:delayed` data consistency [...] + idempotent! + end + CODE + + expect_correction(corrected) + end + end + + context "when deduplication strategy is :none" do + it 'does not register an offense' do + expect_no_offenses(<<~CODE) + class SomeWorker + include ApplicationWorker + + data_consistency :always + + deduplicate :none + idempotent! + end + CODE + end + end + + context "when deduplication strategy has including_scheduling option enabled" do + it 'does not register an offense' do + expect_no_offenses(<<~CODE) + class SomeWorker + include ApplicationWorker + + data_consistency :always + + deduplicate :until_executing, including_scheduled: true + idempotent! + end + CODE + end + end + end + + context "data_consistency: :always" do + it 'does not register an offense' do + expect_no_offenses(<<~CODE) + class SomeWorker + include ApplicationWorker + + data_consistency :always + + idempotent! + end + CODE + end + end +end diff --git a/spec/serializers/group_child_entity_spec.rb b/spec/serializers/group_child_entity_spec.rb index 7f330da44a7..e4844c25067 100644 --- a/spec/serializers/group_child_entity_spec.rb +++ b/spec/serializers/group_child_entity_spec.rb @@ -87,7 +87,7 @@ RSpec.describe GroupChildEntity do expect(json[:children_count]).to eq(2) end - %w[children_count leave_path parent_id number_projects_with_delimiter number_users_with_delimiter project_count subgroup_count].each do |attribute| + %w[children_count leave_path parent_id number_users_with_delimiter project_count subgroup_count].each do |attribute| it "includes #{attribute}" do expect(json[attribute.to_sym]).to be_present end @@ -114,6 +114,40 @@ RSpec.describe GroupChildEntity do it_behaves_like 'group child json' end + describe 'for a private group' do + let(:object) do + create(:group, :private) + end + + describe 'user is member of the group' do + before do + object.add_owner(user) + end + + it 'includes the counts' do + expect(json.keys).to include(*%i(project_count subgroup_count)) + end + end + + describe 'user is not a member of the group' do + it 'does not include the counts' do + expect(json.keys).not_to include(*%i(project_count subgroup_count)) + end + end + + describe 'user is only a member of a project in the group' do + let(:project) { create(:project, namespace: object) } + + before do + project.add_guest(user) + end + + it 'does not include the counts' do + expect(json.keys).not_to include(*%i(project_count subgroup_count)) + end + end + end + describe 'for a project with external authorization enabled' do let(:object) do create(:project, :with_avatar, diff --git a/spec/serializers/issuable_sidebar_extras_entity_spec.rb b/spec/serializers/issuable_sidebar_extras_entity_spec.rb index f49b9acfd5d..80c135cdc22 100644 --- a/spec/serializers/issuable_sidebar_extras_entity_spec.rb +++ b/spec/serializers/issuable_sidebar_extras_entity_spec.rb @@ -10,11 +10,7 @@ RSpec.describe IssuableSidebarExtrasEntity do subject { described_class.new(resource, request: request).as_json } - it 'have subscribe attributes' do - expect(subject).to include(:participants, - :project_emails_disabled, - :subscribe_disabled_description, - :subscribed, - :assignees) + it 'have assignee attribute' do + expect(subject).to include(:assignees) end end diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index bcad9eb6e23..587d167520f 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -202,7 +202,7 @@ RSpec.describe PipelineSerializer do # Existing numbers are high and require performance optimization # Ongoing issue: # https://gitlab.com/gitlab-org/gitlab/-/issues/225156 - expected_queries = Gitlab.ee? ? 77 : 70 + expected_queries = Gitlab.ee? ? 74 : 70 expect(recorded.count).to be_within(2).of(expected_queries) expect(recorded.cached_count).to eq(0) diff --git a/spec/services/application_settings/update_service_spec.rb b/spec/services/application_settings/update_service_spec.rb index 56c1284927d..a1fd89bcad7 100644 --- a/spec/services/application_settings/update_service_spec.rb +++ b/spec/services/application_settings/update_service_spec.rb @@ -336,6 +336,31 @@ RSpec.describe ApplicationSettings::UpdateService do end end + context 'when general rate limits are passed' do + let(:params) do + { + throttle_authenticated_api_enabled: true, + throttle_authenticated_api_period_in_seconds: 10, + throttle_authenticated_api_requests_per_period: 20, + throttle_authenticated_web_enabled: true, + throttle_authenticated_web_period_in_seconds: 30, + throttle_authenticated_web_requests_per_period: 40, + throttle_unauthenticated_api_enabled: true, + throttle_unauthenticated_api_period_in_seconds: 50, + throttle_unauthenticated_api_requests_per_period: 60, + throttle_unauthenticated_enabled: true, + throttle_unauthenticated_period_in_seconds: 50, + throttle_unauthenticated_requests_per_period: 60 + } + end + + it 'updates general throttle settings' do + subject.execute + + expect(application_settings.reload).to have_attributes(params) + end + end + context 'when package registry rate limits are passed' do let(:params) do { @@ -362,6 +387,52 @@ RSpec.describe ApplicationSettings::UpdateService do end end + context 'when files API rate limits are passed' do + let(:params) do + { + throttle_unauthenticated_files_api_enabled: 1, + throttle_unauthenticated_files_api_period_in_seconds: 500, + throttle_unauthenticated_files_api_requests_per_period: 20, + throttle_authenticated_files_api_enabled: 1, + throttle_authenticated_files_api_period_in_seconds: 600, + throttle_authenticated_files_api_requests_per_period: 10 + } + end + + it 'updates files API throttle settings' do + subject.execute + + application_settings.reload + + expect(application_settings.throttle_unauthenticated_files_api_enabled).to be_truthy + expect(application_settings.throttle_unauthenticated_files_api_period_in_seconds).to eq(500) + expect(application_settings.throttle_unauthenticated_files_api_requests_per_period).to eq(20) + expect(application_settings.throttle_authenticated_files_api_enabled).to be_truthy + expect(application_settings.throttle_authenticated_files_api_period_in_seconds).to eq(600) + expect(application_settings.throttle_authenticated_files_api_requests_per_period).to eq(10) + end + end + + context 'when git lfs rate limits are passed' do + let(:params) do + { + throttle_authenticated_git_lfs_enabled: 1, + throttle_authenticated_git_lfs_period_in_seconds: 600, + throttle_authenticated_git_lfs_requests_per_period: 10 + } + end + + it 'updates git lfs throttle settings' do + subject.execute + + application_settings.reload + + expect(application_settings.throttle_authenticated_git_lfs_enabled).to be_truthy + expect(application_settings.throttle_authenticated_git_lfs_period_in_seconds).to eq(600) + expect(application_settings.throttle_authenticated_git_lfs_requests_per_period).to eq(10) + end + end + context 'when issues_create_limit is passed' do let(:params) do { diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb index b456f7a2745..46cc027fcb3 100644 --- a/spec/services/auth/container_registry_authentication_service_spec.rb +++ b/spec/services/auth/container_registry_authentication_service_spec.rb @@ -84,5 +84,36 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do it_behaves_like 'a modified token' end + + describe '#access_token' do + let(:token) { described_class.access_token(%w[push], [project.full_path]) } + + subject { { token: token } } + + it_behaves_like 'a modified token' + end + end + + context 'when not in migration mode' do + include_context 'container registry auth service context' + + let_it_be(:project) { create(:project) } + + before do + stub_feature_flags(container_registry_migration_phase1: false) + end + + shared_examples 'an unmodified token' do + it_behaves_like 'a valid token' + it { expect(payload['access']).not_to include(have_key('migration_eligible')) } + end + + describe '#access_token' do + let(:token) { described_class.access_token(%w[push], [project.full_path]) } + + subject { { token: token } } + + it_behaves_like 'an unmodified token' + end end end diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb index bbdc178b234..d1f854f72bc 100644 --- a/spec/services/boards/issues/list_service_spec.rb +++ b/spec/services/boards/issues/list_service_spec.rb @@ -139,4 +139,51 @@ RSpec.describe Boards::Issues::ListService do end # rubocop: enable RSpec/MultipleMemoizedHelpers end + + describe '.initialize_relative_positions' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :empty_repo) } + let_it_be(:board) { create(:board, project: project) } + let_it_be(:backlog) { create(:backlog_list, board: board) } + + let(:issue) { create(:issue, project: project, relative_position: nil) } + + context "when 'Gitlab::Database::read_write?' is true" do + before do + allow(Gitlab::Database).to receive(:read_write?).and_return(true) + end + + context 'user cannot move issues' do + it 'does not initialize the relative positions of issues' do + described_class.initialize_relative_positions(board, user, [issue]) + + expect(issue.relative_position).to eq nil + end + end + + context 'user can move issues' do + before do + project.add_developer(user) + end + + it 'initializes the relative positions of issues' do + described_class.initialize_relative_positions(board, user, [issue]) + + expect(issue.relative_position).not_to eq nil + end + end + end + + context "when 'Gitlab::Database::read_write?' is false" do + before do + allow(Gitlab::Database).to receive(:read_write?).and_return(false) + end + + it 'does not initialize the relative positions of issues' do + described_class.initialize_relative_positions(board, user, [issue]) + + expect(issue.relative_position).to eq nil + end + end + end end diff --git a/spec/services/ci/after_requeue_job_service_spec.rb b/spec/services/ci/after_requeue_job_service_spec.rb index df5ddcafb37..2a5a971fdac 100644 --- a/spec/services/ci/after_requeue_job_service_spec.rb +++ b/spec/services/ci/after_requeue_job_service_spec.rb @@ -44,16 +44,6 @@ RSpec.describe Ci::AfterRequeueJobService do it 'marks subsequent skipped jobs as processable' do expect { execute_service }.to change { test4.reload.status }.from('skipped').to('created') end - - context 'with ci_same_stage_job_needs FF disabled' do - before do - stub_feature_flags(ci_same_stage_job_needs: false) - end - - it 'does nothing with the build' do - expect { execute_service }.not_to change { test4.reload.status } - end - end end context 'when the pipeline is a downstream pipeline and the bridge is depended' do diff --git a/spec/services/ci/archive_trace_service_spec.rb b/spec/services/ci/archive_trace_service_spec.rb index 12804efc28c..071b5c3b2f9 100644 --- a/spec/services/ci/archive_trace_service_spec.rb +++ b/spec/services/ci/archive_trace_service_spec.rb @@ -12,6 +12,7 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do expect { subject }.not_to raise_error expect(job.reload.job_artifacts_trace).to be_exist + expect(job.trace_metadata.trace_artifact).to eq(job.job_artifacts_trace) end context 'when trace is already archived' do @@ -27,7 +28,7 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do context 'when live trace chunks still exist' do before do - create(:ci_build_trace_chunk, build: job) + create(:ci_build_trace_chunk, build: job, chunk_index: 0) end it 'removes the trace chunks' do @@ -39,8 +40,14 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do job.job_artifacts_trace.file.remove! end - it 'removes the trace artifact' do - expect { subject }.to change { job.reload.job_artifacts_trace }.to(nil) + it 'removes the trace artifact and builds a new one' do + existing_trace = job.job_artifacts_trace + expect(existing_trace).to receive(:destroy!).and_call_original + + subject + + expect(job.reload.job_artifacts_trace).to be_present + expect(job.reload.job_artifacts_trace.file.file).to be_present end end end @@ -59,6 +66,54 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do end end + context 'when the job is out of archival attempts' do + before do + create(:ci_build_trace_metadata, + build: job, + archival_attempts: Ci::BuildTraceMetadata::MAX_ATTEMPTS + 1, + last_archival_attempt_at: 1.week.ago) + end + + it 'skips archiving' do + expect(job.trace).not_to receive(:archive!) + + subject + end + + it 'leaves a warning message in sidekiq log' do + expect(Sidekiq.logger).to receive(:warn).with( + class: Ci::ArchiveTraceWorker.name, + message: 'The job is out of archival attempts.', + job_id: job.id) + + subject + end + end + + context 'when the archival process is backed off' do + before do + create(:ci_build_trace_metadata, + build: job, + archival_attempts: Ci::BuildTraceMetadata::MAX_ATTEMPTS - 1, + last_archival_attempt_at: 1.hour.ago) + end + + it 'skips archiving' do + expect(job.trace).not_to receive(:archive!) + + subject + end + + it 'leaves a warning message in sidekiq log' do + expect(Sidekiq.logger).to receive(:warn).with( + class: Ci::ArchiveTraceWorker.name, + message: 'The job can not be archived right now.', + job_id: job.id) + + subject + end + end + context 'when job failed to archive trace but did not raise an exception' do before do allow_next_instance_of(Gitlab::Ci::Trace) do |instance| @@ -98,6 +153,7 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do .and_call_original expect { subject }.not_to raise_error + expect(job.trace_metadata.archival_attempts).to eq(1) end end end diff --git a/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb b/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb index 6eb1315fff4..4326fa5533f 100644 --- a/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb +++ b/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb @@ -127,6 +127,32 @@ RSpec.describe Ci::CreatePipelineService, '#execute' do end end end + + context 'when resource group key includes a variable' do + let(:config) do + <<~YAML + instrumentation_test: + stage: test + resource_group: $CI_ENVIRONMENT_NAME + trigger: + include: path/to/child.yml + strategy: depend + YAML + end + + it 'ignores the resource group keyword because it fails to expand the variable', :aggregate_failures do + pipeline = create_pipeline! + Ci::InitialPipelineProcessWorker.new.perform(pipeline.id) + + test = pipeline.statuses.find_by(name: 'instrumentation_test') + expect(pipeline).to be_created_successfully + expect(pipeline.triggered_pipelines).not_to be_exist + expect(project.resource_groups.count).to eq(0) + expect(test).to be_a Ci::Bridge + expect(test).to be_pending + expect(test.resource_group).to be_nil + end + end end end diff --git a/spec/services/ci/create_pipeline_service/tags_spec.rb b/spec/services/ci/create_pipeline_service/tags_spec.rb new file mode 100644 index 00000000000..335d35010c8 --- /dev/null +++ b/spec/services/ci/create_pipeline_service/tags_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Ci::CreatePipelineService do + describe 'tags:' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { project.owner } + + let(:ref) { 'refs/heads/master' } + let(:source) { :push } + let(:service) { described_class.new(project, user, { ref: ref }) } + let(:pipeline) { service.execute(source).payload } + + before do + stub_ci_pipeline_yaml_file(config) + end + + context 'with valid config' do + let(:config) { YAML.dump({ test: { script: 'ls', tags: %w[tag1 tag2] } }) } + + it 'creates a pipeline', :aggregate_failures do + expect(pipeline).to be_created_successfully + expect(pipeline.builds.first.tag_list).to match_array(%w[tag1 tag2]) + end + end + + context 'with too many tags' do + let(:tags) { Array.new(50) {|i| "tag-#{i}" } } + let(:config) { YAML.dump({ test: { script: 'ls', tags: tags } }) } + + it 'creates a pipeline without builds', :aggregate_failures do + expect(pipeline).not_to be_created_successfully + expect(pipeline.builds).to be_empty + expect(pipeline.yaml_errors).to eq("jobs:test:tags config must be less than the limit of #{Gitlab::Ci::Config::Entry::Tags::TAGS_LIMIT} tags") + end + end + end +end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 2fdb0ed3c0d..78646665539 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Ci::CreatePipelineService do let(:ref_name) { 'refs/heads/master' } before do - stub_ci_pipeline_yaml_file(gitlab_ci_yaml) + stub_ci_pipeline_to_return_yaml_file end describe '#execute' do @@ -991,6 +991,58 @@ RSpec.describe Ci::CreatePipelineService do end end + context 'when resource group is defined for review app deployment' do + before do + config = YAML.dump( + review_app: { + stage: 'test', + script: 'deploy', + environment: { + name: 'review/$CI_COMMIT_REF_SLUG', + on_stop: 'stop_review_app' + }, + resource_group: '$CI_ENVIRONMENT_NAME' + }, + stop_review_app: { + stage: 'test', + script: 'stop', + when: 'manual', + environment: { + name: 'review/$CI_COMMIT_REF_SLUG', + action: 'stop' + }, + resource_group: '$CI_ENVIRONMENT_NAME' + } + ) + + stub_ci_pipeline_yaml_file(config) + end + + it 'persists the association correctly' do + result = execute_service.payload + deploy_job = result.builds.find_by_name!(:review_app) + stop_job = result.builds.find_by_name!(:stop_review_app) + + expect(result).to be_persisted + expect(deploy_job.resource_group.key).to eq('review/master') + expect(stop_job.resource_group.key).to eq('review/master') + expect(project.resource_groups.count).to eq(1) + end + + it 'initializes scoped variables only once for each build' do + # Bypassing `stub_build` hack because it distrubs the expectations below. + allow_next_instances_of(Gitlab::Ci::Build::Context::Build, 2) do |build_context| + allow(build_context).to receive(:variables) { Gitlab::Ci::Variables::Collection.new } + end + + expect_next_instances_of(::Ci::Build, 2) do |ci_build| + expect(ci_build).to receive(:scoped_variables).once.and_call_original + end + + expect(execute_service.payload).to be_created_successfully + end + end + context 'with timeout' do context 'when builds with custom timeouts are configured' do before do @@ -1248,16 +1300,47 @@ RSpec.describe Ci::CreatePipelineService do end context 'when pipeline variables are specified' do - let(:variables_attributes) do - [{ key: 'first', secret_value: 'world' }, - { key: 'second', secret_value: 'second_world' }] + subject(:pipeline) { execute_service(variables_attributes: variables_attributes).payload } + + context 'with valid pipeline variables' do + let(:variables_attributes) do + [{ key: 'first', secret_value: 'world' }, + { key: 'second', secret_value: 'second_world' }] + end + + it 'creates a pipeline with specified variables' do + expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) }) + .to eq variables_attributes.map(&:with_indifferent_access) + end end - subject(:pipeline) { execute_service(variables_attributes: variables_attributes).payload } + context 'with duplicate pipeline variables' do + let(:variables_attributes) do + [{ key: 'hello', secret_value: 'world' }, + { key: 'hello', secret_value: 'second_world' }] + end - it 'creates a pipeline with specified variables' do - expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) }) - .to eq variables_attributes.map(&:with_indifferent_access) + it 'fails to create the pipeline' do + expect(pipeline).to be_failed + expect(pipeline.variables).to be_empty + expect(pipeline.errors[:base]).to eq(['Duplicate variable name: hello']) + end + end + + context 'with more than one duplicate pipeline variable' do + let(:variables_attributes) do + [{ key: 'hello', secret_value: 'world' }, + { key: 'hello', secret_value: 'second_world' }, + { key: 'single', secret_value: 'variable' }, + { key: 'other', secret_value: 'value' }, + { key: 'other', secret_value: 'other value' }] + end + + it 'fails to create the pipeline' do + expect(pipeline).to be_failed + expect(pipeline.variables).to be_empty + expect(pipeline.errors[:base]).to eq(['Duplicate variable names: hello, other']) + end end end diff --git a/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb b/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb index 2b310443b37..04d75630295 100644 --- a/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb +++ b/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb @@ -12,7 +12,7 @@ RSpec.describe Ci::ExternalPullRequests::CreatePipelineService do project.add_maintainer(user) end - subject(:response) { described_class.new(project, user).execute(pull_request) } + subject(:execute) { described_class.new(project, user).execute(pull_request) } context 'when pull request is open' do before do @@ -21,26 +21,43 @@ RSpec.describe Ci::ExternalPullRequests::CreatePipelineService do context 'when source sha is the head of the source branch' do let(:source_branch) { project.repository.branches.last } - let(:create_pipeline_service) { instance_double(Ci::CreatePipelineService) } before do pull_request.update!(source_branch: source_branch.name, source_sha: source_branch.target) end - it 'creates a pipeline for external pull request', :aggregate_failures do - pipeline = response.payload - - expect(response).to be_success - expect(pipeline).to be_valid - expect(pipeline).to be_persisted - expect(pipeline).to be_external_pull_request_event - expect(pipeline).to eq(project.ci_pipelines.last) - expect(pipeline.external_pull_request).to eq(pull_request) - expect(pipeline.user).to eq(user) - expect(pipeline.status).to eq('created') - expect(pipeline.ref).to eq(pull_request.source_branch) - expect(pipeline.sha).to eq(pull_request.source_sha) - expect(pipeline.source_sha).to eq(pull_request.source_sha) + context 'when the FF ci_create_external_pr_pipeline_async is disabled' do + before do + stub_feature_flags(ci_create_external_pr_pipeline_async: false) + end + + it 'creates a pipeline for external pull request', :aggregate_failures do + pipeline = execute.payload + + expect(execute).to be_success + expect(pipeline).to be_valid + expect(pipeline).to be_persisted + expect(pipeline).to be_external_pull_request_event + expect(pipeline).to eq(project.ci_pipelines.last) + expect(pipeline.external_pull_request).to eq(pull_request) + expect(pipeline.user).to eq(user) + expect(pipeline.status).to eq('created') + expect(pipeline.ref).to eq(pull_request.source_branch) + expect(pipeline.sha).to eq(pull_request.source_sha) + expect(pipeline.source_sha).to eq(pull_request.source_sha) + end + end + + it 'enqueues Ci::ExternalPullRequests::CreatePipelineWorker' do + expect { execute } + .to change { ::Ci::ExternalPullRequests::CreatePipelineWorker.jobs.count } + .by(1) + + args = ::Ci::ExternalPullRequests::CreatePipelineWorker.jobs.last['args'] + + expect(args[0]).to eq(project.id) + expect(args[1]).to eq(user.id) + expect(args[2]).to eq(pull_request.id) end end @@ -53,11 +70,12 @@ RSpec.describe Ci::ExternalPullRequests::CreatePipelineService do end it 'does nothing', :aggregate_failures do - expect(Ci::CreatePipelineService).not_to receive(:new) + expect { execute } + .not_to change { ::Ci::ExternalPullRequests::CreatePipelineWorker.jobs.count } - expect(response).to be_error - expect(response.message).to eq('The source sha is not the head of the source branch') - expect(response.payload).to be_nil + expect(execute).to be_error + expect(execute.message).to eq('The source sha is not the head of the source branch') + expect(execute.payload).to be_nil end end end @@ -68,11 +86,12 @@ RSpec.describe Ci::ExternalPullRequests::CreatePipelineService do end it 'does nothing', :aggregate_failures do - expect(Ci::CreatePipelineService).not_to receive(:new) + expect { execute } + .not_to change { ::Ci::ExternalPullRequests::CreatePipelineWorker.jobs.count } - expect(response).to be_error - expect(response.message).to eq('The pull request is not opened') - expect(response.payload).to be_nil + expect(execute).to be_error + expect(execute.message).to eq('The pull request is not opened') + expect(execute.payload).to be_nil end end end diff --git a/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb b/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb index 04fa55068f2..7a91ad9dcc1 100644 --- a/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb +++ b/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb @@ -10,20 +10,16 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s describe '.execute' do subject { service.execute } - let_it_be(:artifact, refind: true) do - create(:ci_job_artifact, expire_at: 1.day.ago) - end - - before(:all) do - artifact.job.pipeline.unlocked! - end + let_it_be(:locked_pipeline) { create(:ci_pipeline, :artifacts_locked) } + let_it_be(:pipeline) { create(:ci_pipeline, :unlocked) } + let_it_be(:locked_job) { create(:ci_build, :success, pipeline: locked_pipeline) } + let_it_be(:job) { create(:ci_build, :success, pipeline: pipeline) } context 'when artifact is expired' do + let!(:artifact) { create(:ci_job_artifact, :expired, job: job) } + context 'with preloaded relationships' do before do - job = create(:ci_build, pipeline: artifact.job.pipeline) - create(:ci_job_artifact, :archive, :expired, job: job) - stub_const("#{described_class}::LOOP_LIMIT", 1) end @@ -39,7 +35,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s # COMMIT # SELECT next expired ci_job_artifacts - expect(log.count).to be_within(1).of(11) + expect(log.count).to be_within(1).of(10) end end @@ -48,7 +44,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s expect { subject }.to change { Ci::JobArtifact.count }.by(-1) end - context 'when the artifact does not a file attached to it' do + context 'when the artifact does not have a file attached to it' do it 'does not create deleted objects' do expect(artifact.exists?).to be_falsy # sanity check @@ -57,10 +53,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when the artifact has a file attached to it' do - before do - artifact.file = fixture_file_upload(Rails.root.join('spec/fixtures/ci_build_artifacts.zip'), 'application/zip') - artifact.save! - end + let!(:artifact) { create(:ci_job_artifact, :expired, :zip, job: job) } it 'creates a deleted object' do expect { subject }.to change { Ci::DeletedObject.count }.by(1) @@ -81,9 +74,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when artifact is locked' do - before do - artifact.job.pipeline.artifacts_locked! - end + let!(:artifact) { create(:ci_job_artifact, :expired, job: locked_job) } it 'does not destroy job artifact' do expect { subject }.not_to change { Ci::JobArtifact.count } @@ -92,9 +83,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when artifact is not expired' do - before do - artifact.update_column(:expire_at, 1.day.since) - end + let!(:artifact) { create(:ci_job_artifact, job: job) } it 'does not destroy expired job artifacts' do expect { subject }.not_to change { Ci::JobArtifact.count } @@ -102,9 +91,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when artifact is permanent' do - before do - artifact.update_column(:expire_at, nil) - end + let!(:artifact) { create(:ci_job_artifact, expire_at: nil, job: job) } it 'does not destroy expired job artifacts' do expect { subject }.not_to change { Ci::JobArtifact.count } @@ -112,6 +99,8 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when failed to destroy artifact' do + let!(:artifact) { create(:ci_job_artifact, :expired, job: job) } + before do stub_const("#{described_class}::LOOP_LIMIT", 10) end @@ -146,58 +135,67 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end context 'when exclusive lease has already been taken by the other instance' do + let!(:artifact) { create(:ci_job_artifact, :expired, job: job) } + before do stub_exclusive_lease_taken(described_class::EXCLUSIVE_LOCK_KEY, timeout: described_class::LOCK_TIMEOUT) end it 'raises an error and does not start destroying' do expect { subject }.to raise_error(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError) + .and not_change { Ci::JobArtifact.count }.from(1) end end - context 'when timeout happens' do - let!(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) } + context 'with a second artifact and batch size of 1' do + let(:second_job) { create(:ci_build, :success, pipeline: pipeline) } + let!(:second_artifact) { create(:ci_job_artifact, :archive, expire_at: 1.day.ago, job: second_job) } + let!(:artifact) { create(:ci_job_artifact, :expired, job: job) } before do - stub_const("#{described_class}::LOOP_TIMEOUT", 0.seconds) stub_const("#{described_class}::BATCH_SIZE", 1) - - second_artifact.job.pipeline.unlocked! end - it 'destroys one artifact' do - expect { subject }.to change { Ci::JobArtifact.count }.by(-1) - end - - it 'reports the number of destroyed artifacts' do - is_expected.to eq(1) - end - end + context 'when timeout happens' do + before do + stub_const("#{described_class}::LOOP_TIMEOUT", 0.seconds) + end - context 'when loop reached loop limit' do - before do - stub_const("#{described_class}::LOOP_LIMIT", 1) - stub_const("#{described_class}::BATCH_SIZE", 1) + it 'destroys one artifact' do + expect { subject }.to change { Ci::JobArtifact.count }.by(-1) + end - second_artifact.job.pipeline.unlocked! + it 'reports the number of destroyed artifacts' do + is_expected.to eq(1) + end end - let!(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) } + context 'when loop reached loop limit' do + before do + stub_const("#{described_class}::LOOP_LIMIT", 1) + end - it 'destroys one artifact' do - expect { subject }.to change { Ci::JobArtifact.count }.by(-1) + it 'destroys one artifact' do + expect { subject }.to change { Ci::JobArtifact.count }.by(-1) + end + + it 'reports the number of destroyed artifacts' do + is_expected.to eq(1) + end end - it 'reports the number of destroyed artifacts' do - is_expected.to eq(1) + context 'when the number of artifacts is greater than than batch size' do + it 'destroys all expired artifacts' do + expect { subject }.to change { Ci::JobArtifact.count }.by(-2) + end + + it 'reports the number of destroyed artifacts' do + is_expected.to eq(2) + end end end context 'when there are no artifacts' do - before do - artifact.destroy! - end - it 'does not raise error' do expect { subject }.not_to raise_error end @@ -207,42 +205,18 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s end end - context 'when there are artifacts more than batch sizes' do - before do - stub_const("#{described_class}::BATCH_SIZE", 1) - - second_artifact.job.pipeline.unlocked! - end - - let!(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) } - - it 'destroys all expired artifacts' do - expect { subject }.to change { Ci::JobArtifact.count }.by(-2) - end - - it 'reports the number of destroyed artifacts' do - is_expected.to eq(2) - end - end - context 'when some artifacts are locked' do - before do - pipeline = create(:ci_pipeline, locked: :artifacts_locked) - job = create(:ci_build, pipeline: pipeline) - create(:ci_job_artifact, expire_at: 1.day.ago, job: job) - end + let!(:artifact) { create(:ci_job_artifact, :expired, job: job) } + let!(:locked_artifact) { create(:ci_job_artifact, :expired, job: locked_job) } it 'destroys only unlocked artifacts' do expect { subject }.to change { Ci::JobArtifact.count }.by(-1) + expect(locked_artifact).to be_persisted end end context 'when all artifacts are locked' do - before do - pipeline = create(:ci_pipeline, locked: :artifacts_locked) - job = create(:ci_build, pipeline: pipeline) - artifact.update!(job: job) - end + let!(:artifact) { create(:ci_job_artifact, :expired, job: locked_job) } it 'destroys no artifacts' do expect { subject }.to change { Ci::JobArtifact.count }.by(0) diff --git a/spec/services/ci/pipeline_processing/shared_processing_service.rb b/spec/services/ci/pipeline_processing/shared_processing_service.rb index a4bc8e68b2d..8de9b308429 100644 --- a/spec/services/ci/pipeline_processing/shared_processing_service.rb +++ b/spec/services/ci/pipeline_processing/shared_processing_service.rb @@ -908,6 +908,39 @@ RSpec.shared_examples 'Pipeline Processing Service' do end end + context 'when a bridge job has invalid downstream project', :sidekiq_inline do + let(:config) do + <<-EOY + test: + stage: test + script: echo test + + deploy: + stage: deploy + trigger: + project: invalid-project + EOY + end + + let(:pipeline) do + Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push).payload + end + + before do + stub_ci_pipeline_yaml_file(config) + end + + it 'creates a pipeline, then fails the bridge job' do + expect(all_builds_names).to contain_exactly('test', 'deploy') + expect(all_builds_statuses).to contain_exactly('pending', 'created') + + succeed_pending + + expect(all_builds_names).to contain_exactly('test', 'deploy') + expect(all_builds_statuses).to contain_exactly('success', 'failed') + end + end + private def all_builds diff --git a/spec/services/ci/pipeline_trigger_service_spec.rb b/spec/services/ci/pipeline_trigger_service_spec.rb index 2f93b1ecd3c..29d12b0dd0e 100644 --- a/spec/services/ci/pipeline_trigger_service_spec.rb +++ b/spec/services/ci/pipeline_trigger_service_spec.rb @@ -103,6 +103,17 @@ RSpec.describe Ci::PipelineTriggerService do end end + context 'when params have duplicate variables' do + let(:params) { { token: trigger.token, ref: 'master', variables: variables } } + let(:variables) { { 'TRIGGER_PAYLOAD' => 'duplicate value' } } + + it 'creates a failed pipeline without variables' do + expect { result }.to change { Ci::Pipeline.count } + expect(result).to be_error + expect(result.message[:base]).to eq(['Duplicate variable name: TRIGGER_PAYLOAD']) + end + end + it_behaves_like 'detecting an unprocessable pipeline trigger' end @@ -201,6 +212,17 @@ RSpec.describe Ci::PipelineTriggerService do end end + context 'when params have duplicate variables' do + let(:params) { { token: job.token, ref: 'master', variables: variables } } + let(:variables) { { 'TRIGGER_PAYLOAD' => 'duplicate value' } } + + it 'creates a failed pipeline without variables' do + expect { result }.to change { Ci::Pipeline.count } + expect(result).to be_error + expect(result.message[:base]).to eq(['Duplicate variable name: TRIGGER_PAYLOAD']) + end + end + it_behaves_like 'detecting an unprocessable pipeline trigger' end diff --git a/spec/services/ci/pipelines/add_job_service_spec.rb b/spec/services/ci/pipelines/add_job_service_spec.rb index bdf7e577fa8..3a77d26dd9e 100644 --- a/spec/services/ci/pipelines/add_job_service_spec.rb +++ b/spec/services/ci/pipelines/add_job_service_spec.rb @@ -59,18 +59,6 @@ RSpec.describe Ci::Pipelines::AddJobService do end end - context 'when the FF ci_fix_commit_status_retried is disabled' do - before do - stub_feature_flags(ci_fix_commit_status_retried: false) - end - - it 'does not call update_older_statuses_retried!' do - expect(job).not_to receive(:update_older_statuses_retried!) - - execute - end - end - context 'exclusive lock' do let(:lock_uuid) { 'test' } let(:lock_key) { "ci:pipelines:#{pipeline.id}:add-job" } diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 2f37d0ea42d..73ff15ec393 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -40,12 +40,16 @@ module Ci context 'runner follow tag list' do it "picks build with the same tag" do pending_job.update!(tag_list: ["linux"]) + pending_job.reload + pending_job.create_queuing_entry! specific_runner.update!(tag_list: ["linux"]) expect(execute(specific_runner)).to eq(pending_job) end it "does not pick build with different tag" do pending_job.update!(tag_list: ["linux"]) + pending_job.reload + pending_job.create_queuing_entry! specific_runner.update!(tag_list: ["win32"]) expect(execute(specific_runner)).to be_falsey end @@ -56,6 +60,8 @@ module Ci it "does not pick build with tag" do pending_job.update!(tag_list: ["linux"]) + pending_job.reload + pending_job.create_queuing_entry! expect(execute(specific_runner)).to be_falsey end @@ -81,8 +87,30 @@ module Ci end context 'for specific runner' do - it 'does not pick a build' do - expect(execute(specific_runner)).to be_nil + context 'with FF disabled' do + before do + stub_feature_flags( + ci_pending_builds_project_runners_decoupling: false, + ci_queueing_builds_enabled_checks: false) + end + + it 'does not pick a build' do + expect(execute(specific_runner)).to be_nil + end + end + + context 'with FF enabled' do + before do + stub_feature_flags( + ci_pending_builds_project_runners_decoupling: true, + ci_queueing_builds_enabled_checks: true) + end + + it 'does not pick a build' do + expect(execute(specific_runner)).to be_nil + expect(pending_job.reload).to be_failed + expect(pending_job.queuing_entry).to be_nil + end end end end @@ -219,6 +247,8 @@ module Ci before do project.update!(shared_runners_enabled: true, group_runners_enabled: true) project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) + + pending_job.reload.create_queuing_entry! end context 'and uses shared runner' do @@ -236,7 +266,29 @@ module Ci context 'and uses project runner' do let(:build) { execute(specific_runner) } - it { expect(build).to be_nil } + context 'with FF disabled' do + before do + stub_feature_flags( + ci_pending_builds_project_runners_decoupling: false, + ci_queueing_builds_enabled_checks: false) + end + + it { expect(build).to be_nil } + end + + context 'with FF enabled' do + before do + stub_feature_flags( + ci_pending_builds_project_runners_decoupling: true, + ci_queueing_builds_enabled_checks: true) + end + + it 'does not pick a build' do + expect(build).to be_nil + expect(pending_job.reload).to be_failed + expect(pending_job.queuing_entry).to be_nil + end + end end end @@ -304,6 +356,8 @@ module Ci context 'disallow group runners' do before do project.update!(group_runners_enabled: false) + + pending_job.reload.create_queuing_entry! end context 'group runner' do @@ -739,6 +793,30 @@ module Ci include_examples 'handles runner assignment' end + + context 'with ci_queueing_denormalize_tags_information enabled' do + before do + stub_feature_flags(ci_queueing_denormalize_tags_information: true) + end + + include_examples 'handles runner assignment' + end + + context 'with ci_queueing_denormalize_tags_information disabled' do + before do + stub_feature_flags(ci_queueing_denormalize_tags_information: false) + end + + include_examples 'handles runner assignment' + end + + context 'with ci_queueing_denormalize_namespace_traversal_ids disabled' do + before do + stub_feature_flags(ci_queueing_denormalize_namespace_traversal_ids: false) + end + + include_examples 'handles runner assignment' + end end context 'when not using pending builds table' do diff --git a/spec/services/ci/stuck_builds/drop_service_spec.rb b/spec/services/ci/stuck_builds/drop_service_spec.rb new file mode 100644 index 00000000000..8dfd1bc1b3d --- /dev/null +++ b/spec/services/ci/stuck_builds/drop_service_spec.rb @@ -0,0 +1,284 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::StuckBuilds::DropService do + let!(:runner) { create :ci_runner } + let!(:job) { create :ci_build, runner: runner } + let(:created_at) { } + let(:updated_at) { } + + subject(:service) { described_class.new } + + before do + job_attributes = { status: status } + job_attributes[:created_at] = created_at if created_at + job_attributes[:updated_at] = updated_at if updated_at + 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' } + + context 'when job is not stuck' do + before do + allow_next_found_instance_of(Ci::Build) do |build| + allow(build).to receive(:stuck?).and_return(false) + end + end + + context 'when job was updated_at more than 1 day ago' do + let(:updated_at) { 1.5.days.ago } + + context 'when created_at is the same as updated_at' do + let(:created_at) { 1.5.days.ago } + + it_behaves_like 'job is dropped' + end + + context 'when created_at is before updated_at' do + let(:created_at) { 3.days.ago } + + it_behaves_like 'job is dropped' + end + + context 'when created_at is outside lookback window' do + let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } + + it_behaves_like 'job is unchanged' + end + end + + context 'when job was updated less than 1 day ago' do + let(:updated_at) { 6.hours.ago } + + context 'when created_at is the same as updated_at' do + let(:created_at) { 1.5.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 + + context 'when created_at is outside lookback window' do + let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } + + it_behaves_like 'job is unchanged' + end + end + + context 'when job was updated more than 1 hour ago' do + let(:updated_at) { 2.hours.ago } + + context 'when created_at is the same as updated_at' do + let(:created_at) { 2.hours.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 + + context 'when created_at is outside lookback window' do + let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } + + it_behaves_like 'job is unchanged' + end + end + end + + context 'when job is stuck' do + before do + allow_next_found_instance_of(Ci::Build) do |build| + allow(build).to receive(:stuck?).and_return(true) + end + end + + context 'when job was updated_at more than 1 hour ago' do + let(:updated_at) { 1.5.hours.ago } + + context 'when created_at is the same as updated_at' do + let(:created_at) { 1.5.hours.ago } + + it_behaves_like 'job is dropped' + end + + context 'when created_at is before updated_at' do + let(:created_at) { 3.days.ago } + + it_behaves_like 'job is dropped' + end + + context 'when created_at is outside lookback window' do + let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } + + it_behaves_like 'job is unchanged' + end + end + + context 'when job was updated in less than 1 hour ago' do + let(:updated_at) { 30.minutes.ago } + + context 'when created_at is the same as updated_at' do + let(:created_at) { 30.minutes.ago } + + it_behaves_like 'job is unchanged' + end + + context 'when created_at is before updated_at' do + let(:created_at) { 2.days.ago } + + it_behaves_like 'job is unchanged' + end + + context 'when created_at is outside lookback window' do + let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } + + it_behaves_like 'job is unchanged' + end + end + end + end + + context 'when job is running' do + let(:status) { 'running' } + + context 'when job was updated_at more than an hour ago' do + let(:updated_at) { 2.hours.ago } + + it_behaves_like 'job is dropped' + end + + context 'when job was updated in less than 1 hour ago' do + let(:updated_at) { 30.minutes.ago } + + it_behaves_like 'job is unchanged' + end + end + + %w(success skipped failed canceled).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 + + context 'when created_at is outside lookback window' do + let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } + + it_behaves_like 'job is unchanged' + end + end + end + + context 'for deleted project' do + let(:status) { 'running' } + let(:updated_at) { 2.days.ago } + + before 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 + end +end diff --git a/spec/services/ci/update_pending_build_service_spec.rb b/spec/services/ci/update_pending_build_service_spec.rb new file mode 100644 index 00000000000..d842042de40 --- /dev/null +++ b/spec/services/ci/update_pending_build_service_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +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 } } + + subject(:service) { described_class.new(model, update_params).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) + end + end + + context 'when params is invalid' do + let(:model) { group } + let(:update_params) { { minutes_exceeded: true } } + + it 'raises an error' do + expect { service }.to raise_error(described_class::InvalidParamsError) + end + end + end + + context 'when model is a group with pending builds' do + let(:model) { group } + + it 'updates all pending builds', :aggregate_failures do + service + + expect(pending_build_1.reload.instance_runners_enabled).to be_truthy + expect(pending_build_2.reload.instance_runners_enabled).to be_truthy + end + + context 'when ci_pending_builds_maintain_shared_runners_data is disabled' do + before do + stub_feature_flags(ci_pending_builds_maintain_shared_runners_data: false) + end + + it 'does not update all pending builds', :aggregate_failures do + service + + expect(pending_build_1.reload.instance_runners_enabled).to be_falsey + expect(pending_build_2.reload.instance_runners_enabled).to be_truthy + end + end + end + + context 'when model is a project with pending builds' do + let(:model) { project } + + it 'updates all pending builds', :aggregate_failures do + service + + expect(pending_build_1.reload.instance_runners_enabled).to be_truthy + expect(pending_build_2.reload.instance_runners_enabled).to be_truthy + end + + context 'when ci_pending_builds_maintain_shared_runners_data is disabled' do + before do + stub_feature_flags(ci_pending_builds_maintain_shared_runners_data: false) + end + + it 'does not update all pending builds', :aggregate_failures do + service + + expect(pending_build_1.reload.instance_runners_enabled).to be_falsey + expect(pending_build_2.reload.instance_runners_enabled).to be_truthy + end + end + end + end +end diff --git a/spec/services/clusters/agents/refresh_authorization_service_spec.rb b/spec/services/clusters/agents/refresh_authorization_service_spec.rb new file mode 100644 index 00000000000..77ba81ea9c0 --- /dev/null +++ b/spec/services/clusters/agents/refresh_authorization_service_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::RefreshAuthorizationService do + describe '#execute' do + let_it_be(:root_ancestor) { create(:group) } + + let_it_be(:removed_group) { create(:group, parent: root_ancestor) } + let_it_be(:modified_group) { create(:group, parent: root_ancestor) } + let_it_be(:added_group) { create(:group, parent: root_ancestor) } + + let_it_be(:removed_project) { create(:project, namespace: root_ancestor) } + let_it_be(:modified_project) { create(:project, namespace: root_ancestor) } + let_it_be(:added_project) { create(:project, namespace: root_ancestor) } + + let(:project) { create(:project, namespace: root_ancestor) } + let(:agent) { create(:cluster_agent, project: project) } + + let(:config) do + { + ci_access: { + groups: [ + { id: added_group.full_path, default_namespace: 'default' }, + { id: modified_group.full_path, default_namespace: 'new-namespace' } + ], + projects: [ + { id: added_project.full_path, default_namespace: 'default' }, + { id: modified_project.full_path, default_namespace: 'new-namespace' } + ] + } + }.deep_stringify_keys + end + + subject { described_class.new(agent, config: config).execute } + + before do + default_config = { default_namespace: 'default' } + + agent.group_authorizations.create!(group: removed_group, config: default_config) + agent.group_authorizations.create!(group: modified_group, config: default_config) + + agent.project_authorizations.create!(project: removed_project, config: default_config) + agent.project_authorizations.create!(project: modified_project, config: default_config) + end + + shared_examples 'removing authorization' do + context 'config contains no groups' do + let(:config) { {} } + + it 'removes all authorizations' do + expect(subject).to be_truthy + expect(authorizations).to be_empty + end + end + + context 'config contains groups outside of the configuration project hierarchy' do + let(:project) { create(:project, namespace: create(:group)) } + + it 'removes all authorizations' do + expect(subject).to be_truthy + expect(authorizations).to be_empty + end + end + + context 'configuration project does not belong to a group' do + let(:project) { create(:project) } + + it 'removes all authorizations' do + expect(subject).to be_truthy + expect(authorizations).to be_empty + end + end + end + + describe 'group authorization' do + it 'refreshes authorizations for the agent' do + expect(subject).to be_truthy + expect(agent.authorized_groups).to contain_exactly(added_group, modified_group) + + added_authorization = agent.group_authorizations.find_by(group: added_group) + expect(added_authorization.config).to eq({ 'default_namespace' => 'default' }) + + modified_authorization = agent.group_authorizations.find_by(group: modified_group) + expect(modified_authorization.config).to eq({ 'default_namespace' => 'new-namespace' }) + end + + context 'config contains too many groups' do + before do + stub_const("#{described_class}::AUTHORIZED_ENTITY_LIMIT", 1) + end + + it 'authorizes groups up to the limit' do + expect(subject).to be_truthy + expect(agent.authorized_groups).to contain_exactly(added_group) + end + end + + include_examples 'removing authorization' do + let(:authorizations) { agent.authorized_groups } + end + end + + describe 'project authorization' do + it 'refreshes authorizations for the agent' do + expect(subject).to be_truthy + expect(agent.authorized_projects).to contain_exactly(added_project, modified_project) + + added_authorization = agent.project_authorizations.find_by(project: added_project) + expect(added_authorization.config).to eq({ 'default_namespace' => 'default' }) + + modified_authorization = agent.project_authorizations.find_by(project: modified_project) + expect(modified_authorization.config).to eq({ 'default_namespace' => 'new-namespace' }) + end + + context 'config contains too many projects' do + before do + stub_const("#{described_class}::AUTHORIZED_ENTITY_LIMIT", 1) + end + + it 'authorizes projects up to the limit' do + expect(subject).to be_truthy + expect(agent.authorized_projects).to contain_exactly(added_project) + end + end + + include_examples 'removing authorization' do + let(:authorizations) { agent.authorized_projects } + 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 new file mode 100644 index 00000000000..b4764f6b97a --- /dev/null +++ b/spec/services/customer_relations/organizations/create_service_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CustomerRelations::Organizations::CreateService do + describe '#execute' do + let_it_be(:user) { create(:user) } + + let(:group) { create(:group) } + let(:params) { attributes_for(:organization, group: group) } + + subject(:response) { described_class.new(group: group, current_user: user, params: params).execute } + + it 'creates an organization' do + group.add_reporter(user) + + expect(response).to be_success + end + + it 'returns an error when user does not have permission' do + expect(response).to be_error + expect(response.message).to eq('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) + params[:name] = nil + + expect(response).to be_error + expect(response.message).to eq(["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 new file mode 100644 index 00000000000..eb253540863 --- /dev/null +++ b/spec/services/customer_relations/organizations/update_service_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CustomerRelations::Organizations::UpdateService do + let_it_be(:user) { create(:user) } + + let(:organization) { create(:organization, name: 'Test', group: group) } + + subject(:update) { described_class.new(group: group, current_user: user, params: params).execute(organization) } + + describe '#execute' do + context 'when the user has no permission' do + let_it_be(:group) { create(:group) } + + let(:params) { { name: 'GitLab' } } + + it 'returns an error' do + response = update + + expect(response).to be_error + expect(response.message).to eq('You have insufficient permissions to update an organization for this group') + end + end + + context 'when user has permission' do + let_it_be(:group) { create(:group) } + + before_all do + group.add_reporter(user) + end + + context 'when name is changed' do + let(:params) { { name: 'GitLab' } } + + it 'updates the organization' do + response = update + + expect(response).to be_success + expect(response.payload.name).to eq('GitLab') + end + end + + context 'when the organization is invalid' do + let(:params) { { name: nil } } + + it 'returns an error' do + response = update + + expect(response).to be_error + expect(response.message).to eq(["Name can't be blank"]) + end + end + end + end +end diff --git a/spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb b/spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb new file mode 100644 index 00000000000..ceac8985c8e --- /dev/null +++ b/spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::DependencyProxy::ImageTtlGroupPolicies::UpdateService do + using RSpec::Parameterized::TableSyntax + + let_it_be_with_reload(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:params) { {} } + + describe '#execute' do + subject { described_class.new(container: group, current_user: user, params: params).execute } + + shared_examples 'returning a success' do + it 'returns a success' do + result = subject + + expect(result.payload[:dependency_proxy_image_ttl_policy]).to be_present + expect(result).to be_success + end + end + + shared_examples 'returning an error' do |message, http_status| + it 'returns an error' do + result = subject + + expect(result).to have_attributes( + message: message, + status: :error, + http_status: http_status + ) + end + end + + shared_examples 'updating the dependency proxy image ttl policy' do + it_behaves_like 'updating the dependency proxy image ttl policy attributes', + from: { enabled: true, ttl: 90 }, + to: { enabled: false, ttl: 2 } + + it_behaves_like 'returning a success' + + context 'with invalid params' do + let_it_be(:params) { { enabled: nil } } + + it_behaves_like 'not creating the dependency proxy image ttl policy' + + it "doesn't update" do + expect { subject } + .not_to change { ttl_policy.reload.enabled } + end + + it_behaves_like 'returning an error', 'Enabled is not included in the list', 400 + end + end + + shared_examples 'denying access to dependency proxy image ttl policy' do + context 'with existing dependency proxy image ttl policy' do + it_behaves_like 'not creating the dependency proxy image ttl policy' + + it_behaves_like 'returning an error', 'Access Denied', 403 + end + end + + before do + stub_config(dependency_proxy: { enabled: true }) + end + + context 'with existing dependency proxy image ttl policy' do + let_it_be(:ttl_policy) { create(:image_ttl_group_policy, group: group) } + let_it_be(:params) { { enabled: false, ttl: 2 } } + + where(:user_role, :shared_examples_name) do + :maintainer | 'updating the dependency proxy image ttl policy' + :developer | 'updating the dependency proxy image ttl policy' + :reporter | 'denying access to dependency proxy image ttl policy' + :guest | 'denying access to dependency proxy image ttl policy' + :anonymous | 'denying access to dependency proxy image ttl policy' + end + + with_them do + before do + group.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + end + + context 'without existing dependency proxy image ttl policy' do + let_it_be(:ttl_policy) { group.dependency_proxy_image_ttl_policy } + + where(:user_role, :shared_examples_name) do + :maintainer | 'creating the dependency proxy image ttl policy' + :developer | 'creating the dependency proxy image ttl policy' + :reporter | 'denying access to dependency proxy image ttl policy' + :guest | 'denying access to dependency proxy image ttl policy' + :anonymous | 'denying access to dependency proxy image ttl policy' + end + + with_them do + before do + group.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + + context 'when the policy is not found' do + before do + group.add_developer(user) + expect(group).to receive(:dependency_proxy_image_ttl_policy).and_return nil + end + + it_behaves_like 'returning an error', 'Dependency proxy image TTL Policy not found', 404 + end + end + end +end diff --git a/spec/services/design_management/delete_designs_service_spec.rb b/spec/services/design_management/delete_designs_service_spec.rb index 341f71fa62c..bc7625d7c28 100644 --- a/spec/services/design_management/delete_designs_service_spec.rb +++ b/spec/services/design_management/delete_designs_service_spec.rb @@ -149,6 +149,12 @@ RSpec.describe DesignManagement::DeleteDesignsService do expect { run_service } .to change { designs.first.deleted? }.from(false).to(true) end + + it 'schedules deleting todos for that design' do + expect(TodosDestroyer::DestroyedDesignsWorker).to receive(:perform_async).with([designs.first.id]) + + run_service + end end context 'more than one design is passed' do @@ -168,6 +174,12 @@ RSpec.describe DesignManagement::DeleteDesignsService do .and change { Event.destroyed_action.for_design.count }.by(2) end + it 'schedules deleting todos for that design' do + expect(TodosDestroyer::DestroyedDesignsWorker).to receive(:perform_async).with(designs.map(&:id)) + + run_service + end + it_behaves_like "a success" context 'after executing the service' do diff --git a/spec/services/draft_notes/publish_service_spec.rb b/spec/services/draft_notes/publish_service_spec.rb index 4f761454516..51ef30c91c0 100644 --- a/spec/services/draft_notes/publish_service_spec.rb +++ b/spec/services/draft_notes/publish_service_spec.rb @@ -33,7 +33,8 @@ RSpec.describe DraftNotes::PublishService do end it 'does not skip notification', :sidekiq_might_not_need_inline do - expect(Notes::CreateService).to receive(:new).with(project, user, drafts.first.publish_params).and_call_original + note_params = drafts.first.publish_params.merge(skip_keep_around_commits: false) + expect(Notes::CreateService).to receive(:new).with(project, user, note_params).and_call_original expect_next_instance_of(NotificationService) do |notification_service| expect(notification_service).to receive(:new_note) end @@ -127,12 +128,17 @@ RSpec.describe DraftNotes::PublishService do publish end - context 'capturing diff notes positions' do + context 'capturing diff notes positions and keeping around commits' do before do # Need to execute this to ensure that we'll be able to test creation of # DiffNotePosition records as that only happens when the `MergeRequest#merge_ref_head` # is present. This service creates that for the specified merge request. MergeRequests::MergeToRefService.new(project: project, current_user: user).execute(merge_request) + + # Need to re-stub this and call original as we are stubbing + # `Gitlab::Git::KeepAround#execute` in spec_helper for performance reason. + # Enabling it here so we can test the Gitaly calls it makes. + allow(Gitlab::Git::KeepAround).to receive(:execute).and_call_original end it 'creates diff_note_positions for diff notes' do @@ -143,11 +149,26 @@ RSpec.describe DraftNotes::PublishService do expect(notes.last.diff_note_positions).to be_any end + it 'keeps around the commits of each published note' do + publish + + repository = project.repository + notes = merge_request.notes.order(id: :asc) + + notes.first.shas.each do |sha| + expect(repository.ref_exists?("refs/keep-around/#{sha}")).to be_truthy + end + + notes.last.shas.each do |sha| + expect(repository.ref_exists?("refs/keep-around/#{sha}")).to be_truthy + end + end + it 'does not requests a lot from Gitaly', :request_store do # NOTE: This should be reduced as we work on reducing Gitaly calls. # Gitaly requests shouldn't go above this threshold as much as possible # as it may add more to the Gitaly N+1 issue we are experiencing. - expect { publish }.to change { Gitlab::GitalyClient.get_request_count }.by(11) + expect { publish }.to change { Gitlab::GitalyClient.get_request_count }.by(21) end end diff --git a/spec/services/environments/auto_stop_service_spec.rb b/spec/services/environments/auto_stop_service_spec.rb index 93b1596586f..8dad59cbefd 100644 --- a/spec/services/environments/auto_stop_service_spec.rb +++ b/spec/services/environments/auto_stop_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Environments::AutoStopService, :clean_gitlab_redis_shared_state do +RSpec.describe Environments::AutoStopService, :clean_gitlab_redis_shared_state, :sidekiq_inline do include CreateEnvironmentsHelpers include ExclusiveLeaseHelpers @@ -42,6 +42,15 @@ RSpec.describe Environments::AutoStopService, :clean_gitlab_redis_shared_state d expect(Ci::Build.where(name: 'stop_review_app').map(&:status).uniq).to eq(['pending']) end + it 'schedules stop processes in bulk' do + args = [[Environment.find_by_name('review/feature-1').id], [Environment.find_by_name('review/feature-2').id]] + + expect(Environments::AutoStopWorker) + .to receive(:bulk_perform_async).with(args).once.and_call_original + + subject + end + context 'when the other sidekiq worker has already been running' do before do stub_exclusive_lease_taken(described_class::EXCLUSIVE_LOCK_KEY) diff --git a/spec/services/environments/stop_service_spec.rb b/spec/services/environments/stop_service_spec.rb index 52be512612d..acc9869002f 100644 --- a/spec/services/environments/stop_service_spec.rb +++ b/spec/services/environments/stop_service_spec.rb @@ -237,60 +237,6 @@ RSpec.describe Environments::StopService do end end - describe '.execute_in_batch' do - subject { described_class.execute_in_batch(environments) } - - let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { create(:user) } - - let(:environments) { Environment.available } - - before_all do - project.add_developer(user) - project.repository.add_branch(user, 'review/feature-1', 'master') - project.repository.add_branch(user, 'review/feature-2', 'master') - end - - before do - create_review_app(user, project, 'review/feature-1') - create_review_app(user, project, 'review/feature-2') - end - - it 'stops environments' do - expect { subject } - .to change { project.environments.all.map(&:state).uniq } - .from(['available']).to(['stopped']) - - expect(project.environments.all.map(&:auto_stop_at).uniq).to eq([nil]) - end - - it 'plays stop actions' do - expect { subject } - .to change { Ci::Build.where(name: 'stop_review_app').map(&:status).uniq } - .from(['manual']).to(['pending']) - end - - context 'when user does not have a permission to play the stop action' do - before do - project.team.truncate - end - - it 'tracks the exception' do - expect(Gitlab::ErrorTracking) - .to receive(:track_exception) - .with(Gitlab::Access::AccessDeniedError, anything) - .twice - .and_call_original - - subject - end - - after do - project.add_developer(user) - end - end - end - def expect_environment_stopped_on(branch) expect { service.execute_for_branch(branch) } .to change { Environment.last.state }.from('available').to('stopped') diff --git a/spec/services/error_tracking/collect_error_service_spec.rb b/spec/services/error_tracking/collect_error_service_spec.rb index 14cd588f40b..ee9d0813e64 100644 --- a/spec/services/error_tracking/collect_error_service_spec.rb +++ b/spec/services/error_tracking/collect_error_service_spec.rb @@ -34,11 +34,35 @@ RSpec.describe ErrorTracking::CollectErrorService do expect(error.platform).to eq 'ruby' expect(error.last_seen_at).to eq '2021-07-08T12:59:16Z' - expect(event.description).to eq 'ActionView::MissingTemplate' + expect(event.description).to start_with 'Missing template posts/error2' expect(event.occurred_at).to eq '2021-07-08T12:59:16Z' expect(event.level).to eq 'error' expect(event.environment).to eq 'development' expect(event.payload).to eq parsed_event end + + context 'unusual payload' do + let(:modified_event) { parsed_event } + + context 'missing transaction' do + it 'builds actor from stacktrace' do + modified_event.delete('transaction') + + event = described_class.new(project, nil, event: modified_event).execute + + expect(event.error.actor).to eq 'find()' + end + end + + context 'timestamp is numeric' do + it 'parses timestamp' do + modified_event['timestamp'] = '1631015580.50' + + event = described_class.new(project, nil, event: modified_event).execute + + expect(event.occurred_at).to eq '2021-09-07T11:53:00.5' + end + end + end end end diff --git a/spec/services/feature_flags/create_service_spec.rb b/spec/services/feature_flags/create_service_spec.rb index 4eb2b25fb64..5a517ce6a64 100644 --- a/spec/services/feature_flags/create_service_spec.rb +++ b/spec/services/feature_flags/create_service_spec.rb @@ -48,8 +48,9 @@ RSpec.describe FeatureFlags::CreateService do { name: 'feature_flag', description: 'description', - scopes_attributes: [{ environment_scope: '*', active: true }, - { environment_scope: 'production', active: false }] + version: 'new_version_flag', + strategies_attributes: [{ name: 'default', scopes_attributes: [{ environment_scope: '*' }], parameters: {} }, + { name: 'default', parameters: {}, scopes_attributes: [{ environment_scope: 'production' }] }] } end @@ -68,15 +69,10 @@ RSpec.describe FeatureFlags::CreateService do end it 'creates audit event' do - expected_message = 'Created feature flag feature_flag '\ - 'with description "description". '\ - 'Created rule * and set it as active '\ - 'with strategies [{"name"=>"default", "parameters"=>{}}]. '\ - 'Created rule production and set it as inactive '\ - 'with strategies [{"name"=>"default", "parameters"=>{}}].' - expect { subject }.to change { AuditEvent.count }.by(1) - expect(AuditEvent.last.details[:custom_message]).to eq(expected_message) + expect(AuditEvent.last.details[:custom_message]).to start_with('Created feature flag feature_flag with description "description".') + expect(AuditEvent.last.details[:custom_message]).to include('Created strategy "default" with scopes "*".') + expect(AuditEvent.last.details[:custom_message]).to include('Created strategy "default" with scopes "production".') end context 'when user is reporter' do diff --git a/spec/services/git/base_hooks_service_spec.rb b/spec/services/git/base_hooks_service_spec.rb index 539c294a2e7..a8d753ff124 100644 --- a/spec/services/git/base_hooks_service_spec.rb +++ b/spec/services/git/base_hooks_service_spec.rb @@ -19,9 +19,13 @@ RSpec.describe Git::BaseHooksService do :push_hooks end - def commits + def limited_commits [] end + + def commits_count + 0 + end end end diff --git a/spec/services/git/branch_hooks_service_spec.rb b/spec/services/git/branch_hooks_service_spec.rb index a93f594b360..79c2cb1fca3 100644 --- a/spec/services/git/branch_hooks_service_spec.rb +++ b/spec/services/git/branch_hooks_service_spec.rb @@ -362,6 +362,9 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do end end + let(:commits_count) { service.send(:commits_count) } + let(:threshold_limit) { described_class::PROCESS_COMMIT_LIMIT + 1 } + let(:oldrev) { project.commit(commit_ids.first).parent_id } let(:newrev) { commit_ids.last } @@ -373,17 +376,31 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do let(:oldrev) { Gitlab::Git::BLANK_SHA } it 'processes a limited number of commit messages' do + expect(project.repository) + .to receive(:commits) + .with(newrev, limit: threshold_limit) + .and_call_original + expect(ProcessCommitWorker).to receive(:perform_async).twice service.execute + + expect(commits_count).to eq(project.repository.commit_count_for_ref(newrev)) end end context 'updating the default branch' do it 'processes a limited number of commit messages' do + expect(project.repository) + .to receive(:commits_between) + .with(oldrev, newrev, limit: threshold_limit) + .and_call_original + expect(ProcessCommitWorker).to receive(:perform_async).twice service.execute + + expect(commits_count).to eq(project.repository.count_commits_between(oldrev, newrev)) end end @@ -391,9 +408,13 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do let(:newrev) { Gitlab::Git::BLANK_SHA } it 'does not process commit messages' do + expect(project.repository).not_to receive(:commits) + expect(project.repository).not_to receive(:commits_between) expect(ProcessCommitWorker).not_to receive(:perform_async) service.execute + + expect(commits_count).to eq(0) end end @@ -402,9 +423,16 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do let(:oldrev) { Gitlab::Git::BLANK_SHA } it 'processes a limited number of commit messages' do + expect(project.repository) + .to receive(:commits_between) + .with(project.default_branch, newrev, limit: threshold_limit) + .and_call_original + expect(ProcessCommitWorker).to receive(:perform_async).twice service.execute + + expect(commits_count).to eq(project.repository.count_commits_between(project.default_branch, branch)) end end @@ -412,9 +440,15 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do let(:branch) { 'fix' } it 'processes a limited number of commit messages' do + expect(project.repository) + .to receive(:commits_between) + .with(oldrev, newrev, limit: threshold_limit) + .and_call_original + expect(ProcessCommitWorker).to receive(:perform_async).twice service.execute + expect(commits_count).to eq(project.repository.count_commits_between(oldrev, newrev)) end end @@ -423,9 +457,13 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do let(:newrev) { Gitlab::Git::BLANK_SHA } it 'does not process commit messages' do + expect(project.repository).not_to receive(:commits) + expect(project.repository).not_to receive(:commits_between) expect(ProcessCommitWorker).not_to receive(:perform_async) service.execute + + expect(commits_count).to eq(0) end end diff --git a/spec/services/groups/group_links/create_service_spec.rb b/spec/services/groups/group_links/create_service_spec.rb index b1bb9a8de23..03dac14be54 100644 --- a/spec/services/groups/group_links/create_service_spec.rb +++ b/spec/services/groups/group_links/create_service_spec.rb @@ -6,18 +6,20 @@ RSpec.describe Groups::GroupLinks::CreateService, '#execute' do let(:parent_group_user) { create(:user) } let(:group_user) { create(:user) } let(:child_group_user) { create(:user) } + let(:prevent_sharing) { false } let_it_be(:group_parent) { create(:group, :private) } let_it_be(:group) { create(:group, :private, parent: group_parent) } let_it_be(:group_child) { create(:group, :private, parent: group) } - let_it_be(:shared_group_parent, refind: true) { create(:group, :private) } - let_it_be(:shared_group, refind: true) { create(:group, :private, parent: shared_group_parent) } - let_it_be(:shared_group_child) { create(:group, :private, parent: shared_group) } + let(:ns_for_parent) { create(:namespace_settings, prevent_sharing_groups_outside_hierarchy: prevent_sharing) } + let(:shared_group_parent) { create(:group, :private, namespace_settings: ns_for_parent) } + let(:shared_group) { create(:group, :private, parent: shared_group_parent) } + let(:shared_group_child) { create(:group, :private, parent: shared_group) } - let_it_be(:project_parent) { create(:project, group: shared_group_parent) } - let_it_be(:project) { create(:project, group: shared_group) } - let_it_be(:project_child) { create(:project, group: shared_group_child) } + let(:project_parent) { create(:project, group: shared_group_parent) } + let(:project) { create(:project, group: shared_group) } + let(:project_child) { create(:project, group: shared_group_child) } let(:opts) do { @@ -129,9 +131,7 @@ RSpec.describe Groups::GroupLinks::CreateService, '#execute' do end context 'sharing outside the hierarchy is disabled' do - before do - shared_group_parent.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: true) - end + let(:prevent_sharing) { true } it 'prevents sharing with a group outside the hierarchy' do result = subject.execute diff --git a/spec/services/groups/open_issues_count_service_spec.rb b/spec/services/groups/open_issues_count_service_spec.rb index fca09bfdebe..7dd8c2a59a0 100644 --- a/spec/services/groups/open_issues_count_service_spec.rb +++ b/spec/services/groups/open_issues_count_service_spec.rb @@ -3,12 +3,18 @@ require 'spec_helper' RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_caching do - let_it_be(:group) { create(:group, :public)} + let_it_be(:group) { create(:group, :public) } let_it_be(:project) { create(:project, :public, namespace: group) } + let_it_be(:admin) { create(:user, :admin) } let_it_be(:user) { create(:user) } - let_it_be(:issue) { create(:issue, :opened, project: project) } - let_it_be(:confidential) { create(:issue, :opened, confidential: true, project: project) } - let_it_be(:closed) { create(:issue, :closed, project: project) } + let_it_be(:banned_user) { create(:user, :banned) } + + before do + create(:issue, :opened, project: project) + create(:issue, :opened, confidential: true, project: project) + create(:issue, :opened, author: banned_user, project: project) + create(:issue, :closed, project: project) + end subject { described_class.new(group, user) } @@ -20,28 +26,42 @@ RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_cac it 'uses the IssuesFinder to scope issues' do expect(IssuesFinder) .to receive(:new) - .with(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true, public_only: true) + .with(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true, public_only: true, include_hidden: false) subject.count end end describe '#count' do - context 'when user is nil' do - it 'does not include confidential issues in the issue count' do - expect(described_class.new(group).count).to eq(1) + shared_examples 'counts public issues, does not count hidden or confidential' do + it 'counts only public issues' do + expect(subject.count).to eq(1) + end + + it 'uses PUBLIC_COUNT_WITHOUT_HIDDEN_KEY cache key' do + expect(subject.cache_key).to include('group_open_public_issues_without_hidden_count') end end + context 'when user is nil' do + let(:user) { nil } + + it_behaves_like 'counts public issues, does not count hidden or confidential' + end + context 'when user is provided' do context 'when user can read confidential issues' do before do group.add_reporter(user) end - it 'returns the right count with confidential issues' do + it 'includes confidential issues and does not include hidden issues in count' do expect(subject.count).to eq(2) end + + it 'uses TOTAL_COUNT_WITHOUT_HIDDEN_KEY cache key' do + expect(subject.cache_key).to include('group_open_issues_without_hidden_count') + end end context 'when user cannot read confidential issues' do @@ -49,8 +69,24 @@ RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_cac group.add_guest(user) end - it 'does not include confidential issues' do - expect(subject.count).to eq(1) + it_behaves_like 'counts public issues, does not count hidden or confidential' + end + + context 'when user is an admin' do + let(:user) { admin } + + context 'when admin mode is enabled', :enable_admin_mode do + it 'includes confidential and hidden issues in count' do + expect(subject.count).to eq(3) + end + + it 'uses TOTAL_COUNT_KEY cache key' do + expect(subject.cache_key).to include('group_open_issues_including_hidden_count') + end + end + + context 'when admin mode is disabled' do + it_behaves_like 'counts public issues, does not count hidden or confidential' end end @@ -61,11 +97,13 @@ RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_cac describe '#clear_all_cache_keys' do it 'calls `Rails.cache.delete` with the correct keys' do expect(Rails.cache).to receive(:delete) - .with(['groups', 'open_issues_count_service', 1, group.id, described_class::PUBLIC_COUNT_KEY]) + .with(['groups', 'open_issues_count_service', 1, group.id, described_class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY]) expect(Rails.cache).to receive(:delete) .with(['groups', 'open_issues_count_service', 1, group.id, described_class::TOTAL_COUNT_KEY]) + expect(Rails.cache).to receive(:delete) + .with(['groups', 'open_issues_count_service', 1, group.id, described_class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY]) - subject.clear_all_cache_keys + described_class.new(group).clear_all_cache_keys end end end diff --git a/spec/services/groups/update_shared_runners_service_spec.rb b/spec/services/groups/update_shared_runners_service_spec.rb index e941958eb8c..fe18277b5cd 100644 --- a/spec/services/groups/update_shared_runners_service_spec.rb +++ b/spec/services/groups/update_shared_runners_service_spec.rb @@ -55,6 +55,31 @@ RSpec.describe Groups::UpdateSharedRunnersService do expect(subject[:status]).to eq(:success) end end + + context 'when group has pending builds' do + let_it_be(:group) { create(:group, :shared_runners_disabled) } + let_it_be(:project) { create(:project, namespace: group, shared_runners_enabled: false) } + 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: false) } + + it 'updates pending builds for the group' do + subject + + expect(pending_build_1.reload.instance_runners_enabled).to be_truthy + expect(pending_build_2.reload.instance_runners_enabled).to be_truthy + end + + context 'when shared runners is not toggled' do + let(:params) { { shared_runners_setting: 'invalid_enabled' } } + + it 'does not update pending builds for the group' do + subject + + expect(pending_build_1.reload.instance_runners_enabled).to be_falsey + expect(pending_build_2.reload.instance_runners_enabled).to be_falsey + end + end + end end context 'disable shared Runners' do @@ -67,6 +92,19 @@ RSpec.describe Groups::UpdateSharedRunnersService do expect(subject[:status]).to eq(:success) end + + context 'when group has pending builds' do + let_it_be(:project) { create(:project, namespace: group) } + let_it_be(:pending_build_1) { create(:ci_pending_build, project: project, instance_runners_enabled: true) } + let_it_be(:pending_build_2) { create(:ci_pending_build, project: project, instance_runners_enabled: true) } + + it 'updates pending builds for the group' do + subject + + expect(pending_build_1.reload.instance_runners_enabled).to be_falsey + expect(pending_build_2.reload.instance_runners_enabled).to be_falsey + end + end end context 'allow descendants to override' do diff --git a/spec/services/issue_rebalancing_service_spec.rb b/spec/services/issue_rebalancing_service_spec.rb deleted file mode 100644 index 76ccb6d89ea..00000000000 --- a/spec/services/issue_rebalancing_service_spec.rb +++ /dev/null @@ -1,173 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe IssueRebalancingService do - let_it_be(:project, reload: true) { create(:project) } - let_it_be(:user) { project.creator } - let_it_be(:start) { RelativePositioning::START_POSITION } - let_it_be(:max_pos) { RelativePositioning::MAX_POSITION } - let_it_be(:min_pos) { RelativePositioning::MIN_POSITION } - let_it_be(:clump_size) { 300 } - - let_it_be(:unclumped, reload: true) do - (1..clump_size).to_a.map do |i| - create(:issue, project: project, author: user, relative_position: start + (1024 * i)) - end - end - - let_it_be(:end_clump, reload: true) do - (1..clump_size).to_a.map do |i| - create(:issue, project: project, author: user, relative_position: max_pos - i) - end - end - - let_it_be(:start_clump, reload: true) do - (1..clump_size).to_a.map do |i| - create(:issue, project: project, author: user, relative_position: min_pos + i) - 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 - end - - shared_examples 'IssueRebalancingService shared examples' do - it 'rebalances a set of issues with clumps at the end and start' do - all_issues = start_clump + unclumped + end_clump.reverse - service = described_class.new(Project.id_in([project.id])) - - expect { service.execute }.not_to change { issues_in_position_order.map(&:id) } - - 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(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) - end - - it 'is idempotent' do - service = described_class.new(Project.id_in(project)) - - expect do - service.execute - service.execute - end.not_to change { issues_in_position_order.map(&:id) } - end - - it 'does nothing if the feature flag is disabled' do - stub_feature_flags(rebalance_issues: false) - issue = project.issues.first - issue.project - issue.project.group - old_pos = issue.relative_position - - service = described_class.new(Project.id_in(project)) - - expect { service.execute }.not_to exceed_query_limit(0) - expect(old_pos).to eq(issue.reload.relative_position) - end - - it 'acts if the flag is enabled for the root namespace' do - issue = create(:issue, project: project, author: user, relative_position: max_pos) - stub_feature_flags(rebalance_issues: project.root_namespace) - - service = described_class.new(Project.id_in(project)) - - expect { service.execute }.to change { issue.reload.relative_position } - end - - it 'acts if the flag is enabled for the group' do - issue = create(:issue, project: project, author: user, relative_position: max_pos) - project.update!(group: create(:group)) - stub_feature_flags(rebalance_issues: issue.project.group) - - service = described_class.new(Project.id_in(project)) - - expect { service.execute }.to change { issue.reload.relative_position } - end - - it 'aborts if there are too many issues' do - base = double(count: 10_001) - - allow(Issue).to receive(:in_projects).and_return(base) - - expect { described_class.new(Project.id_in(project)).execute }.to raise_error(described_class::TooManyIssues) - end - end - - shared_examples 'rebalancing is retried on statement timeout exceptions' do - subject { described_class.new(Project.id_in(project)) } - - it 'retries update statement' do - call_count = 0 - allow(subject).to receive(:run_update_query) do - call_count += 1 - if call_count < 13 - raise(ActiveRecord::QueryCanceled) - else - call_count = 0 if call_count == 13 + 16 # 16 = 17 sub-batches - 1 call that succeeded as part of 5th batch - true - end - end - - # call math: - # batches start at 100 and are split in half after every 3 retries if ActiveRecord::StatementTimeout exception is raised. - # We raise ActiveRecord::StatementTimeout exception for 13 calls: - # 1. 100 => 3 calls - # 2. 100/2=50 => 3 calls + 3 above = 6 calls, raise ActiveRecord::StatementTimeout - # 3. 50/2=25 => 3 calls + 6 above = 9 calls, raise ActiveRecord::StatementTimeout - # 4. 25/2=12 => 3 calls + 9 above = 12 calls, raise ActiveRecord::StatementTimeout - # 5. 12/2=6 => 1 call + 12 above = 13 calls, run successfully - # - # so out of 100 elements we created batches of 6 items => 100/6 = 17 sub-batches of 6 or less elements - # - # project.issues.count: 900 issues, so 9 batches of 100 => 9 * (13+16) = 261 - expect(subject).to receive(:update_positions).exactly(261).times.and_call_original - - subject.execute - end - end - - context 'when issue_rebalancing_optimization feature flag is on' do - before do - stub_feature_flags(issue_rebalancing_optimization: true) - end - - it_behaves_like 'IssueRebalancingService shared examples' - - context 'when issue_rebalancing_with_retry feature flag is on' do - before do - stub_feature_flags(issue_rebalancing_with_retry: true) - end - - it_behaves_like 'IssueRebalancingService shared examples' - it_behaves_like 'rebalancing is retried on statement timeout exceptions' - end - end - - context 'when issue_rebalancing_optimization feature flag is off' do - before do - stub_feature_flags(issue_rebalancing_optimization: false) - end - - it_behaves_like 'IssueRebalancingService shared examples' - - context 'when issue_rebalancing_with_retry feature flag is on' do - before do - stub_feature_flags(issue_rebalancing_with_retry: true) - end - - it_behaves_like 'IssueRebalancingService shared examples' - it_behaves_like 'rebalancing is retried on statement timeout exceptions' - end - end -end diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index 3f506ec58b0..b96dd981e0f 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Issues::BuildService do + using RSpec::Parameterized::TableSyntax + let_it_be(:project) { create(:project, :repository) } let_it_be(:developer) { create(:user) } let_it_be(:guest) { create(:user) } @@ -144,6 +146,8 @@ RSpec.describe Issues::BuildService do issue = build_issue(milestone_id: milestone.id) expect(issue.milestone).to eq(milestone) + expect(issue.issue_type).to eq('issue') + expect(issue.work_item_type.base_type).to eq('issue') end it 'sets milestone to nil if it is not available for the project' do @@ -152,6 +156,15 @@ RSpec.describe Issues::BuildService do expect(issue.milestone).to be_nil end + + context 'when issue_type is incident' do + it 'sets the correct issue type' do + issue = build_issue(issue_type: 'incident') + + expect(issue.issue_type).to eq('incident') + expect(issue.work_item_type.base_type).to eq('incident') + end + end end context 'as guest' do @@ -165,28 +178,37 @@ RSpec.describe Issues::BuildService do end context 'setting issue type' do - it 'defaults to issue if issue_type not given' do - issue = build_issue + shared_examples 'builds an issue' do + specify do + issue = build_issue(issue_type: issue_type) - expect(issue).to be_issue + expect(issue.issue_type).to eq(resulting_issue_type) + expect(issue.work_item_type_id).to eq(work_item_type_id) + end end - it 'sets issue' do - issue = build_issue(issue_type: 'issue') + it 'cannot set invalid issue type' do + issue = build_issue(issue_type: 'project') expect(issue).to be_issue end - it 'sets incident' do - issue = build_issue(issue_type: 'incident') - - expect(issue).to be_incident - end - - it 'cannot set invalid type' do - issue = build_issue(issue_type: 'invalid type') - - expect(issue).to be_issue + context 'with a corresponding WorkItem::Type' do + let_it_be(:type_issue_id) { WorkItem::Type.default_issue_type.id } + let_it_be(:type_incident_id) { WorkItem::Type.default_by_type(:incident).id } + + where(:issue_type, :work_item_type_id, :resulting_issue_type) do + nil | ref(:type_issue_id) | 'issue' + 'issue' | ref(:type_issue_id) | 'issue' + 'incident' | ref(:type_incident_id) | 'incident' + 'test_case' | ref(:type_issue_id) | 'issue' # update once support for test_case is enabled + 'requirement' | ref(:type_issue_id) | 'issue' # update once support for requirement is enabled + 'invalid' | ref(:type_issue_id) | 'issue' + end + + with_them do + it_behaves_like 'builds an issue' + end end end end diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index b1d4877e138..14e6b44f7b0 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -58,8 +58,11 @@ RSpec.describe Issues::CloseService do end it 'refreshes the number of open issues', :use_clean_rails_memory_store_caching do - expect { service.execute(issue) } - .to change { project.open_issues_count }.from(1).to(0) + expect do + service.execute(issue) + + BatchLoader::Executor.clear_current + end.to change { project.open_issues_count }.from(1).to(0) end it 'invalidates counter cache for assignees' do @@ -222,7 +225,7 @@ RSpec.describe Issues::CloseService do it 'verifies the number of queries' do recorded = ActiveRecord::QueryRecorder.new { close_issue } - expected_queries = 25 + expected_queries = 27 expect(recorded.count).to be <= expected_queries expect(recorded.cached_count).to eq(0) diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 0e2b3b957a5..3988069d83a 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -43,10 +43,11 @@ RSpec.describe Issues::CreateService do expect(issue).to be_persisted expect(issue.title).to eq('Awesome issue') - expect(issue.assignees).to eq [assignee] - expect(issue.labels).to match_array labels - expect(issue.milestone).to eq milestone - expect(issue.due_date).to eq Date.tomorrow + expect(issue.assignees).to eq([assignee]) + expect(issue.labels).to match_array(labels) + expect(issue.milestone).to eq(milestone) + expect(issue.due_date).to eq(Date.tomorrow) + expect(issue.work_item_type.base_type).to eq('issue') end context 'when skip_system_notes is true' do @@ -91,7 +92,11 @@ RSpec.describe Issues::CreateService do end it 'refreshes the number of open issues', :use_clean_rails_memory_store_caching do - expect { issue }.to change { project.open_issues_count }.from(0).to(1) + expect do + issue + + BatchLoader::Executor.clear_current + end.to change { project.open_issues_count }.from(0).to(1) end context 'when current user cannot set issue metadata in the project' do diff --git a/spec/services/issues/relative_position_rebalancing_service_spec.rb b/spec/services/issues/relative_position_rebalancing_service_spec.rb new file mode 100644 index 00000000000..d5d81770817 --- /dev/null +++ b/spec/services/issues/relative_position_rebalancing_service_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Issues::RelativePositionRebalancingService, :clean_gitlab_redis_shared_state do + let_it_be(:project, reload: true) { create(:project) } + let_it_be(:user) { project.creator } + let_it_be(:start) { RelativePositioning::START_POSITION } + let_it_be(:max_pos) { RelativePositioning::MAX_POSITION } + let_it_be(:min_pos) { RelativePositioning::MIN_POSITION } + let_it_be(:clump_size) { 300 } + + let_it_be(:unclumped, reload: true) do + (1..clump_size).to_a.map do |i| + create(:issue, project: project, author: user, relative_position: start + (1024 * i)) + end + end + + let_it_be(:end_clump, reload: true) do + (1..clump_size).to_a.map do |i| + create(:issue, project: project, author: user, relative_position: max_pos - i) + end + end + + let_it_be(:start_clump, reload: true) do + (1..clump_size).to_a.map do |i| + create(:issue, project: project, author: user, relative_position: min_pos + i) + 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 + end + + subject(:service) { described_class.new(Project.id_in(project)) } + + context 'execute' do + it 're-balances a set of issues with clumps at the end and start' do + all_issues = start_clump + unclumped + end_clump.reverse + + expect { service.execute }.not_to change { issues_in_position_order.map(&:id) } + + 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(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 + end + + it 'is idempotent' do + expect do + service.execute + service.execute + end.not_to change { issues_in_position_order.map(&:id) } + end + + it 'does nothing if the feature flag is disabled' do + stub_feature_flags(rebalance_issues: false) + issue = project.issues.first + issue.project + issue.project.group + old_pos = issue.relative_position + + # fetching root namespace in the initializer triggers 2 queries: + # for fetching a random project from collection and fetching the root namespace. + expect { service.execute }.not_to exceed_query_limit(2) + expect(old_pos).to eq(issue.reload.relative_position) + end + + it 'acts if the flag is enabled for the root namespace' do + issue = create(:issue, project: project, author: user, relative_position: max_pos) + stub_feature_flags(rebalance_issues: project.root_namespace) + + expect { service.execute }.to change { issue.reload.relative_position } + end + + it 'acts if the flag is enabled for the group' do + issue = create(:issue, project: project, author: user, relative_position: max_pos) + project.update!(group: create(:group)) + stub_feature_flags(rebalance_issues: issue.project.group) + + expect { service.execute }.to change { issue.reload.relative_position } + end + + it 'aborts if there are too many rebalances running' do + caching = service.send(:caching) + allow(caching).to receive(:rebalance_in_progress?).and_return(false) + allow(caching).to receive(:concurrent_running_rebalances_count).and_return(10) + allow(service).to receive(:caching).and_return(caching) + + expect { service.execute }.to raise_error(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances) + expect(project.root_namespace.issue_repositioning_disabled?).to be false + end + + it 'resumes a started rebalance even if there are already too many rebalances running' do + Gitlab::Redis::SharedState.with do |redis| + redis.sadd("gitlab:issues-position-rebalances:running_rebalances", "#{::Gitlab::Issues::Rebalancing::State::PROJECT}/#{project.id}") + redis.sadd("gitlab:issues-position-rebalances:running_rebalances", "1/100") + end + + caching = service.send(:caching) + 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) + end + + context 're-balancing is retried on statement timeout exceptions' do + subject { service } + + it 'retries update statement' do + call_count = 0 + allow(subject).to receive(:run_update_query) do + call_count += 1 + if call_count < 13 + raise(ActiveRecord::QueryCanceled) + else + call_count = 0 if call_count == 13 + 16 # 16 = 17 sub-batches - 1 call that succeeded as part of 5th batch + true + end + end + + # call math: + # batches start at 100 and are split in half after every 3 retries if ActiveRecord::StatementTimeout exception is raised. + # We raise ActiveRecord::StatementTimeout exception for 13 calls: + # 1. 100 => 3 calls + # 2. 100/2=50 => 3 calls + 3 above = 6 calls, raise ActiveRecord::StatementTimeout + # 3. 50/2=25 => 3 calls + 6 above = 9 calls, raise ActiveRecord::StatementTimeout + # 4. 25/2=12 => 3 calls + 9 above = 12 calls, raise ActiveRecord::StatementTimeout + # 5. 12/2=6 => 1 call + 12 above = 13 calls, run successfully + # + # so out of 100 elements we created batches of 6 items => 100/6 = 17 sub-batches of 6 or less elements + # + # project.issues.count: 900 issues, so 9 batches of 100 => 9 * (13+16) = 261 + expect(subject).to receive(:update_positions).exactly(261).times.and_call_original + + subject.execute + end + end + + context 'when resuming a stopped rebalance' do + before do + service.send(:preload_issue_ids) + expect(service.send(:caching).get_cached_issue_ids(0, 300)).not_to be_empty + # simulate we already rebalanced half the issues + index = clump_size * 3 / 2 + 1 + service.send(:caching).cache_current_index(index) + end + + it 'rebalances the other half of issues' do + expect(subject).to receive(:update_positions_with_retry).exactly(5).and_call_original + + subject.execute + end + end + end +end diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb index d58c27289c2..86190c4e475 100644 --- a/spec/services/issues/reopen_service_spec.rb +++ b/spec/services/issues/reopen_service_spec.rb @@ -39,8 +39,11 @@ RSpec.describe Issues::ReopenService do it 'refreshes the number of opened issues' do service = described_class.new(project: project, current_user: user) - expect { service.execute(issue) } - .to change { project.open_issues_count }.from(0).to(1) + expect do + service.execute(issue) + + BatchLoader::Executor.clear_current + end.to change { project.open_issues_count }.from(0).to(1) end it 'deletes milestone issue counters cache' do diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 29ac7df88eb..331cf291f21 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -146,8 +146,11 @@ RSpec.describe Issues::UpdateService, :mailer do it 'refreshes the number of open issues when the issue is made confidential', :use_clean_rails_memory_store_caching do issue # make sure the issue is created first so our counts are correct. - expect { update_issue(confidential: true) } - .to change { project.open_issues_count }.from(1).to(0) + expect do + update_issue(confidential: true) + + BatchLoader::Executor.clear_current + end.to change { project.open_issues_count }.from(1).to(0) end it 'enqueues ConfidentialIssueWorker when an issue is made confidential' do @@ -189,6 +192,14 @@ RSpec.describe Issues::UpdateService, :mailer do expect(issue.labels.pluck(:title)).to eq(['incident']) end + it 'creates system note about issue type' do + update_issue(issue_type: 'incident') + + note = find_note('changed issue type to incident') + + expect(note).not_to eq(nil) + end + context 'for an issue with multiple labels' do let(:issue) { create(:incident, project: project, labels: [label_1]) } @@ -217,15 +228,19 @@ RSpec.describe Issues::UpdateService, :mailer do context 'from incident to issue' do let(:issue) { create(:incident, project: project) } + it 'changed from an incident to an issue type' do + expect { update_issue(issue_type: 'issue') } + .to change(issue, :issue_type).from('incident').to('issue') + .and(change { issue.work_item_type.base_type }.from('incident').to('issue')) + end + context 'for an incident with multiple labels' do let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) } - before do - update_issue(issue_type: 'issue') - end - it 'removes an `incident` label if one exists on the incident' do - expect(issue.labels).to eq([label_2]) + expect { update_issue(issue_type: 'issue') }.to change(issue, :label_ids) + .from(containing_exactly(label_1.id, label_2.id)) + .to([label_2.id]) end end @@ -233,12 +248,10 @@ RSpec.describe Issues::UpdateService, :mailer do let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) } let(:params) { { label_ids: [label_1.id, label_2.id], remove_label_ids: [] } } - before do - update_issue(issue_type: 'issue') - end - it 'adds an incident label id to remove_label_ids for it to be removed' do - expect(issue.label_ids).to contain_exactly(label_2.id) + expect { update_issue(issue_type: 'issue') }.to change(issue, :label_ids) + .from(containing_exactly(label_1.id, label_2.id)) + .to([label_2.id]) end end end diff --git a/spec/services/members/groups/bulk_creator_service_spec.rb b/spec/services/members/groups/bulk_creator_service_spec.rb new file mode 100644 index 00000000000..0623ae00080 --- /dev/null +++ b/spec/services/members/groups/bulk_creator_service_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::Groups::BulkCreatorService do + it_behaves_like 'bulk member creation' do + let_it_be(:source, reload: true) { create(:group, :public) } + let_it_be(:member_type) { GroupMember } + end +end diff --git a/spec/services/members/mailgun/process_webhook_service_spec.rb b/spec/services/members/mailgun/process_webhook_service_spec.rb new file mode 100644 index 00000000000..d6a21183395 --- /dev/null +++ b/spec/services/members/mailgun/process_webhook_service_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::Mailgun::ProcessWebhookService do + describe '#execute', :aggregate_failures do + let_it_be(:member) { create(:project_member, :invited) } + + let(:raw_invite_token) { member.raw_invite_token } + let(:payload) { { 'user-variables' => { ::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY => raw_invite_token } } } + + subject(:service) { described_class.new(payload).execute } + + it 'marks the member invite email success as false' do + expect(Gitlab::AppLogger).to receive(:info).with(/^UPDATED MEMBER INVITE_EMAIL_SUCCESS/).and_call_original + + expect { service }.to change { member.reload.invite_email_success }.from(true).to(false) + end + + context 'when member can not be found' do + let(:raw_invite_token) { '_foobar_' } + + it 'does not change member status' do + expect(Gitlab::AppLogger).not_to receive(:info).with(/^UPDATED MEMBER INVITE_EMAIL_SUCCESS/) + + expect { service }.not_to change { member.reload.invite_email_success } + end + end + + context 'when invite token is not found in payload' do + let(:payload) { {} } + + it 'does not change member status and logs an error' do + expect(Gitlab::AppLogger).not_to receive(:info).with(/^UPDATED MEMBER INVITE_EMAIL_SUCCESS/) + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + an_instance_of(described_class::ProcessWebhookServiceError)) + + expect { service }.not_to change { member.reload.invite_email_success } + end + end + end +end diff --git a/spec/services/members/projects/bulk_creator_service_spec.rb b/spec/services/members/projects/bulk_creator_service_spec.rb new file mode 100644 index 00000000000..7acb7d79fe7 --- /dev/null +++ b/spec/services/members/projects/bulk_creator_service_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::Projects::BulkCreatorService do + it_behaves_like 'bulk member creation' do + let_it_be(:source, reload: true) { create(:project, :public) } + let_it_be(:member_type) { ProjectMember } + end +end diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index b3af4d67896..e3f33304aab 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -402,21 +402,6 @@ RSpec.describe MergeRequests::MergeService do expect(Gitlab::AppLogger).to have_received(:error).with(a_string_matching(error_message)) end - it 'logs and saves error if there is a squash in progress' do - error_message = 'another squash is already in progress' - - allow_any_instance_of(MergeRequest).to receive(:squash_in_progress?).and_return(true) - merge_request.update!(squash: true) - - service.execute(merge_request) - - expect(merge_request).to be_open - expect(merge_request.merge_commit_sha).to be_nil - expect(merge_request.squash_commit_sha).to be_nil - expect(merge_request.merge_error).to include(error_message) - expect(Gitlab::AppLogger).to have_received(:error).with(a_string_matching(error_message)) - end - it 'logs and saves error if there is an PreReceiveError exception' do error_message = 'error message' diff --git a/spec/services/merge_requests/merge_to_ref_service_spec.rb b/spec/services/merge_requests/merge_to_ref_service_spec.rb index 8fc12c6c2b1..0a781aee704 100644 --- a/spec/services/merge_requests/merge_to_ref_service_spec.rb +++ b/spec/services/merge_requests/merge_to_ref_service_spec.rb @@ -37,34 +37,26 @@ RSpec.describe MergeRequests::MergeToRefService do expect(ref_head.id).to eq(result[:commit_id]) end - context 'cache_merge_to_ref_calls flag enabled', :use_clean_rails_memory_store_caching do + context 'cache_merge_to_ref_calls parameter', :use_clean_rails_memory_store_caching do before do - stub_feature_flags(cache_merge_to_ref_calls: true) - # warm the cache # - service.execute(merge_request) - end - - it 'caches the response', :request_store do - expect { 3.times { service.execute(merge_request) } } - .not_to change(Gitlab::GitalyClient, :get_request_count) + service.execute(merge_request, true) end - end - - context 'cache_merge_to_ref_calls flag disabled', :use_clean_rails_memory_store_caching do - before do - stub_feature_flags(cache_merge_to_ref_calls: false) - # warm the cache - # - service.execute(merge_request) + context 'when true' do + it 'caches the response', :request_store do + expect { 3.times { service.execute(merge_request, true) } } + .not_to change(Gitlab::GitalyClient, :get_request_count) + end end - it 'does not cache the response', :request_store do - expect(Gitlab::GitalyClient).to receive(:call).at_least(3).times.and_call_original + context 'when false' do + it 'does not cache the response', :request_store do + expect(Gitlab::GitalyClient).to receive(:call).at_least(3).times.and_call_original - 3.times { service.execute(merge_request) } + 3.times { service.execute(merge_request, false) } + end end end end diff --git a/spec/services/merge_requests/mergeability_check_service_spec.rb b/spec/services/merge_requests/mergeability_check_service_spec.rb index 65599b7e046..4f7be0f5965 100644 --- a/spec/services/merge_requests/mergeability_check_service_spec.rb +++ b/spec/services/merge_requests/mergeability_check_service_spec.rb @@ -132,6 +132,15 @@ RSpec.describe MergeRequests::MergeabilityCheckService, :clean_gitlab_redis_shar it_behaves_like 'mergeable merge request' + it 'calls MergeToRefService with cache parameter' do + service = instance_double(MergeRequests::MergeToRefService) + + expect(MergeRequests::MergeToRefService).to receive(:new).once { service } + expect(service).to receive(:execute).once.with(merge_request, true).and_return(success: true) + + described_class.new(merge_request).execute(recheck: true) + end + context 'when concurrent calls' do it 'waits first lock and returns "cached" result in subsequent calls' do threads = execute_within_threads(amount: 3) diff --git a/spec/services/merge_requests/squash_service_spec.rb b/spec/services/merge_requests/squash_service_spec.rb index 149748cdabc..09f83624e05 100644 --- a/spec/services/merge_requests/squash_service_spec.rb +++ b/spec/services/merge_requests/squash_service_spec.rb @@ -194,23 +194,6 @@ RSpec.describe MergeRequests::SquashService do expect(service.execute).to match(status: :error, message: a_string_including('squash')) end end - - context 'with an error in squash in progress check' do - before do - allow(repository).to receive(:squash_in_progress?) - .and_raise(Gitlab::Git::Repository::GitError, error) - end - - it 'logs the stage and output' do - expect(service).to receive(:log_error).with(exception: an_instance_of(Gitlab::Git::Repository::GitError), message: 'Failed to check squash in progress') - - service.execute - end - - it 'returns an error' do - expect(service.execute).to match(status: :error, message: 'An error occurred while checking whether another squash is in progress.') - end - end end context 'when any other exception is thrown' do diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 3c4d7d50002..a03f1f17b39 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -2623,7 +2623,7 @@ RSpec.describe NotificationService, :mailer do let_it_be(:user) { create(:user) } it 'sends the user an email' do - notification.user_deactivated(user.name, user.notification_email) + notification.user_deactivated(user.name, user.notification_email_or_default) should_only_email(user) end diff --git a/spec/services/packages/composer/version_parser_service_spec.rb b/spec/services/packages/composer/version_parser_service_spec.rb index 1a2f653c042..69253ff934e 100644 --- a/spec/services/packages/composer/version_parser_service_spec.rb +++ b/spec/services/packages/composer/version_parser_service_spec.rb @@ -12,6 +12,7 @@ RSpec.describe Packages::Composer::VersionParserService do where(:tagname, :branchname, :expected_version) do nil | 'master' | 'dev-master' nil | 'my-feature' | 'dev-my-feature' + nil | '12-feature' | 'dev-12-feature' nil | 'v1' | '1.x-dev' nil | 'v1.x' | '1.x-dev' nil | 'v1.7.x' | '1.7.x-dev' diff --git a/spec/services/packages/generic/create_package_file_service_spec.rb b/spec/services/packages/generic/create_package_file_service_spec.rb index 1c9eb53cfc7..9d6784b7721 100644 --- a/spec/services/packages/generic/create_package_file_service_spec.rb +++ b/spec/services/packages/generic/create_package_file_service_spec.rb @@ -105,6 +105,37 @@ RSpec.describe Packages::Generic::CreatePackageFileService do it { expect { execute_service }.to change { project.package_files.count }.by(1) } end end + + context 'with multiple files for the same package and the same pipeline' do + let(:file_2_params) { params.merge(file_name: 'myfile.tar.gz.2', file: file2) } + let(:file_3_params) { params.merge(file_name: 'myfile.tar.gz.3', file: file3) } + + let(:temp_file2) { Tempfile.new("test2") } + let(:temp_file3) { Tempfile.new("test3") } + + let(:file2) { UploadedFile.new(temp_file2.path, sha256: sha256) } + let(:file3) { UploadedFile.new(temp_file3.path, sha256: sha256) } + + before do + FileUtils.touch(temp_file2) + FileUtils.touch(temp_file3) + expect(::Packages::Generic::FindOrCreatePackageService).to receive(:new).with(project, user, package_params).and_return(package_service).twice + expect(package_service).to receive(:execute).and_return(package).twice + end + + after do + FileUtils.rm_f(temp_file2) + FileUtils.rm_f(temp_file3) + end + + it 'creates the build info only once' do + expect do + described_class.new(project, user, params).execute + described_class.new(project, user, file_2_params).execute + described_class.new(project, user, file_3_params).execute + end.to change { package.build_infos.count }.by(1) + end + end end end end diff --git a/spec/services/packages/maven/find_or_create_package_service_spec.rb b/spec/services/packages/maven/find_or_create_package_service_spec.rb index d8b48af0121..59f5677f526 100644 --- a/spec/services/packages/maven/find_or_create_package_service_spec.rb +++ b/spec/services/packages/maven/find_or_create_package_service_spec.rb @@ -98,6 +98,19 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do it 'creates a build_info' do expect { subject }.to change { Packages::BuildInfo.count }.by(1) end + + context 'with multiple files for the same package and the same pipeline' do + let(:file_2_params) { params.merge(file_name: 'test2.jar') } + let(:file_3_params) { params.merge(file_name: 'test3.jar') } + + it 'creates a single build info' do + expect do + described_class.new(project, user, params).execute + described_class.new(project, user, file_2_params).execute + described_class.new(project, user, file_3_params).execute + end.to change { ::Packages::BuildInfo.count }.by(1) + end + end end context 'when package duplicates are not allowed' do diff --git a/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb b/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb index 66ff6a8d03f..d682ee12ed5 100644 --- a/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb +++ b/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_redis_shared_state do include ExclusiveLeaseHelpers - let(:package) { create(:nuget_package, :processing, :with_symbol_package) } + let!(:package) { create(:nuget_package, :processing, :with_symbol_package) } let(:package_file) { package.package_files.first } let(:service) { described_class.new(package_file) } let(:package_name) { 'DummyProject.DummyPackage' } @@ -63,234 +63,213 @@ RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_ end end - shared_examples 'handling all conditions' do - context 'with no existing package' do - let(:package_id) { package.id } + context 'with no existing package' do + let(:package_id) { package.id } + + it 'updates package and package file', :aggregate_failures do + expect { subject } + .to not_change { ::Packages::Package.count } + .and change { Packages::Dependency.count }.by(1) + .and change { Packages::DependencyLink.count }.by(1) + .and change { ::Packages::Nuget::Metadatum.count }.by(0) + + expect(package.reload.name).to eq(package_name) + expect(package.version).to eq(package_version) + expect(package).to be_default + expect(package_file.reload.file_name).to eq(package_file_name) + # hard reset needed to properly reload package_file.file + expect(Packages::PackageFile.find(package_file.id).file.size).not_to eq 0 + end - it 'updates package and package file', :aggregate_failures do - expect { subject } - .to not_change { ::Packages::Package.count } - .and change { Packages::Dependency.count }.by(1) - .and change { Packages::DependencyLink.count }.by(1) - .and change { ::Packages::Nuget::Metadatum.count }.by(0) + it_behaves_like 'taking the lease' - expect(package.reload.name).to eq(package_name) - expect(package.version).to eq(package_version) - expect(package).to be_default - expect(package_file.reload.file_name).to eq(package_file_name) - # hard reset needed to properly reload package_file.file - expect(Packages::PackageFile.find(package_file.id).file.size).not_to eq 0 - end + it_behaves_like 'not updating the package if the lease is taken' + end - it_behaves_like 'taking the lease' + context 'with existing package' do + let!(:existing_package) { create(:nuget_package, project: package.project, name: package_name, version: package_version) } + let(:package_id) { existing_package.id } - it_behaves_like 'not updating the package if the lease is taken' - end + it 'link existing package and updates package file', :aggregate_failures do + expect(service).to receive(:try_obtain_lease).and_call_original - context 'with existing package' do - let!(:existing_package) { create(:nuget_package, project: package.project, name: package_name, version: package_version) } - let(:package_id) { existing_package.id } + expect { subject } + .to change { ::Packages::Package.count }.by(-1) + .and change { Packages::Dependency.count }.by(0) + .and change { Packages::DependencyLink.count }.by(0) + .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(0) + .and change { ::Packages::Nuget::Metadatum.count }.by(0) + expect(package_file.reload.file_name).to eq(package_file_name) + expect(package_file.package).to eq(existing_package) + end - it 'link existing package and updates package file', :aggregate_failures do - expect(service).to receive(:try_obtain_lease).and_call_original + it_behaves_like 'taking the lease' - expect { subject } - .to change { ::Packages::Package.count }.by(-1) - .and change { Packages::Dependency.count }.by(0) - .and change { Packages::DependencyLink.count }.by(0) - .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(0) - .and change { ::Packages::Nuget::Metadatum.count }.by(0) - expect(package_file.reload.file_name).to eq(package_file_name) - expect(package_file.package).to eq(existing_package) - end + it_behaves_like 'not updating the package if the lease is taken' + end - it_behaves_like 'taking the lease' + context 'with a nuspec file with metadata' do + let(:nuspec_filepath) { 'packages/nuget/with_metadata.nuspec' } + let(:expected_tags) { %w(foo bar test tag1 tag2 tag3 tag4 tag5) } - it_behaves_like 'not updating the package if the lease is taken' + before do + allow_next_instance_of(Packages::Nuget::MetadataExtractionService) do |service| + allow(service) + .to receive(:nuspec_file_content).and_return(fixture_file(nuspec_filepath)) + end end - context 'with a nuspec file with metadata' do - let(:nuspec_filepath) { 'packages/nuget/with_metadata.nuspec' } - let(:expected_tags) { %w(foo bar test tag1 tag2 tag3 tag4 tag5) } + it 'creates tags' do + expect(service).to receive(:try_obtain_lease).and_call_original + expect { subject }.to change { ::Packages::Tag.count }.by(8) + expect(package.reload.tags.map(&:name)).to contain_exactly(*expected_tags) + end - before do - allow_next_instance_of(Packages::Nuget::MetadataExtractionService) do |service| - allow(service) - .to receive(:nuspec_file_content).and_return(fixture_file(nuspec_filepath)) - end - end + context 'with existing package and tags' do + let!(:existing_package) { create(:nuget_package, project: package.project, name: 'DummyProject.WithMetadata', version: '1.2.3') } + let!(:tag1) { create(:packages_tag, package: existing_package, name: 'tag1') } + let!(:tag2) { create(:packages_tag, package: existing_package, name: 'tag2') } + let!(:tag3) { create(:packages_tag, package: existing_package, name: 'tag_not_in_metadata') } - it 'creates tags' do + it 'creates tags and deletes those not in metadata' do expect(service).to receive(:try_obtain_lease).and_call_original - expect { subject }.to change { ::Packages::Tag.count }.by(8) - expect(package.reload.tags.map(&:name)).to contain_exactly(*expected_tags) + expect { subject }.to change { ::Packages::Tag.count }.by(5) + expect(existing_package.tags.map(&:name)).to contain_exactly(*expected_tags) end + end - context 'with existing package and tags' do - let!(:existing_package) { create(:nuget_package, project: package.project, name: 'DummyProject.WithMetadata', version: '1.2.3') } - let!(:tag1) { create(:packages_tag, package: existing_package, name: 'tag1') } - let!(:tag2) { create(:packages_tag, package: existing_package, name: 'tag2') } - let!(:tag3) { create(:packages_tag, package: existing_package, name: 'tag_not_in_metadata') } - - it 'creates tags and deletes those not in metadata' do - expect(service).to receive(:try_obtain_lease).and_call_original - expect { subject }.to change { ::Packages::Tag.count }.by(5) - expect(existing_package.tags.map(&:name)).to contain_exactly(*expected_tags) - end - end - - it 'creates nuget metadatum', :aggregate_failures do - expect { subject } - .to not_change { ::Packages::Package.count } - .and change { ::Packages::Nuget::Metadatum.count }.by(1) - - metadatum = package_file.reload.package.nuget_metadatum - expect(metadatum.license_url).to eq('https://opensource.org/licenses/MIT') - expect(metadatum.project_url).to eq('https://gitlab.com/gitlab-org/gitlab') - expect(metadatum.icon_url).to eq('https://opensource.org/files/osi_keyhole_300X300_90ppi_0.png') - end + it 'creates nuget metadatum', :aggregate_failures do + expect { subject } + .to not_change { ::Packages::Package.count } + .and change { ::Packages::Nuget::Metadatum.count }.by(1) - context 'with too long url' do - let_it_be(:too_long_url) { "http://localhost/#{'bananas' * 50}" } + metadatum = package_file.reload.package.nuget_metadatum + expect(metadatum.license_url).to eq('https://opensource.org/licenses/MIT') + expect(metadatum.project_url).to eq('https://gitlab.com/gitlab-org/gitlab') + expect(metadatum.icon_url).to eq('https://opensource.org/files/osi_keyhole_300X300_90ppi_0.png') + end - let(:metadata) { { package_name: package_name, package_version: package_version, license_url: too_long_url } } + context 'with too long url' do + let_it_be(:too_long_url) { "http://localhost/#{'bananas' * 50}" } - before do - allow(service).to receive(:metadata).and_return(metadata) - end + let(:metadata) { { package_name: package_name, package_version: package_version, license_url: too_long_url } } - it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError + before do + allow(service).to receive(:metadata).and_return(metadata) end + + it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError end + end - context 'with nuspec file with dependencies' do - let(:nuspec_filepath) { 'packages/nuget/with_dependencies.nuspec' } - let(:package_name) { 'Test.Package' } - let(:package_version) { '3.5.2' } - let(:package_file_name) { 'test.package.3.5.2.nupkg' } + context 'with nuspec file with dependencies' do + let(:nuspec_filepath) { 'packages/nuget/with_dependencies.nuspec' } + let(:package_name) { 'Test.Package' } + let(:package_version) { '3.5.2' } + let(:package_file_name) { 'test.package.3.5.2.nupkg' } - before do - allow_next_instance_of(Packages::Nuget::MetadataExtractionService) do |service| - allow(service) - .to receive(:nuspec_file_content).and_return(fixture_file(nuspec_filepath)) - end + before do + allow_next_instance_of(Packages::Nuget::MetadataExtractionService) do |service| + allow(service) + .to receive(:nuspec_file_content).and_return(fixture_file(nuspec_filepath)) end + end - it 'updates package and package file', :aggregate_failures do - expect { subject } - .to not_change { ::Packages::Package.count } - .and change { Packages::Dependency.count }.by(4) - .and change { Packages::DependencyLink.count }.by(4) - .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(2) - - expect(package.reload.name).to eq(package_name) - expect(package.version).to eq(package_version) - expect(package).to be_default - expect(package_file.reload.file_name).to eq(package_file_name) - # hard reset needed to properly reload package_file.file - expect(Packages::PackageFile.find(package_file.id).file.size).not_to eq 0 - end + it 'updates package and package file', :aggregate_failures do + expect { subject } + .to not_change { ::Packages::Package.count } + .and change { Packages::Dependency.count }.by(4) + .and change { Packages::DependencyLink.count }.by(4) + .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(2) + + expect(package.reload.name).to eq(package_name) + expect(package.version).to eq(package_version) + expect(package).to be_default + expect(package_file.reload.file_name).to eq(package_file_name) + # hard reset needed to properly reload package_file.file + expect(Packages::PackageFile.find(package_file.id).file.size).not_to eq 0 end + end - context 'with package file not containing a nuspec file' do - before do - allow_next_instance_of(Zip::File) do |file| - allow(file).to receive(:glob).and_return([]) - end + context 'with package file not containing a nuspec file' do + before do + allow_next_instance_of(Zip::File) do |file| + allow(file).to receive(:glob).and_return([]) end - - it_behaves_like 'raising an', ::Packages::Nuget::MetadataExtractionService::ExtractionError end - context 'with a symbol package' do - let(:package_file) { package.package_files.last } - let(:package_file_name) { 'dummyproject.dummypackage.1.0.0.snupkg' } + it_behaves_like 'raising an', ::Packages::Nuget::MetadataExtractionService::ExtractionError + end - context 'with no existing package' do - let(:package_id) { package.id } + context 'with a symbol package' do + let(:package_file) { package.package_files.last } + let(:package_file_name) { 'dummyproject.dummypackage.1.0.0.snupkg' } - it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError - end - - context 'with existing package' do - let!(:existing_package) { create(:nuget_package, project: package.project, name: package_name, version: package_version) } - let(:package_id) { existing_package.id } + context 'with no existing package' do + let(:package_id) { package.id } - it 'link existing package and updates package file', :aggregate_failures do - expect(service).to receive(:try_obtain_lease).and_call_original - expect(::Packages::Nuget::SyncMetadatumService).not_to receive(:new) - expect(::Packages::UpdateTagsService).not_to receive(:new) + it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError + end - expect { subject } - .to change { ::Packages::Package.count }.by(-1) - .and change { Packages::Dependency.count }.by(0) - .and change { Packages::DependencyLink.count }.by(0) - .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(0) - .and change { ::Packages::Nuget::Metadatum.count }.by(0) - expect(package_file.reload.file_name).to eq(package_file_name) - expect(package_file.package).to eq(existing_package) - end + context 'with existing package' do + let!(:existing_package) { create(:nuget_package, project: package.project, name: package_name, version: package_version) } + let(:package_id) { existing_package.id } - it_behaves_like 'taking the lease' + it 'link existing package and updates package file', :aggregate_failures do + expect(service).to receive(:try_obtain_lease).and_call_original + expect(::Packages::Nuget::SyncMetadatumService).not_to receive(:new) + expect(::Packages::UpdateTagsService).not_to receive(:new) - it_behaves_like 'not updating the package if the lease is taken' + expect { subject } + .to change { ::Packages::Package.count }.by(-1) + .and change { Packages::Dependency.count }.by(0) + .and change { Packages::DependencyLink.count }.by(0) + .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(0) + .and change { ::Packages::Nuget::Metadatum.count }.by(0) + expect(package_file.reload.file_name).to eq(package_file_name) + expect(package_file.package).to eq(existing_package) end - end - - context 'with an invalid package name' do - invalid_names = [ - '', - 'My/package', - '../../../my_package', - '%2e%2e%2fmy_package' - ] - invalid_names.each do |invalid_name| - before do - allow(service).to receive(:package_name).and_return(invalid_name) - end + it_behaves_like 'taking the lease' - it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError - end + it_behaves_like 'not updating the package if the lease is taken' end + end - context 'with an invalid package version' do - invalid_versions = [ - '', - '555', - '1.2', - '1./2.3', - '../../../../../1.2.3', - '%2e%2e%2f1.2.3' - ] - - invalid_versions.each do |invalid_version| - before do - allow(service).to receive(:package_version).and_return(invalid_version) - end - - it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError + context 'with an invalid package name' do + invalid_names = [ + '', + 'My/package', + '../../../my_package', + '%2e%2e%2fmy_package' + ] + + invalid_names.each do |invalid_name| + before do + allow(service).to receive(:package_name).and_return(invalid_name) end - end - end - context 'with packages_nuget_new_package_file_updater enabled' do - before do - expect(service).not_to receive(:legacy_execute) + it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError end - - it_behaves_like 'handling all conditions' end - context 'with packages_nuget_new_package_file_updater disabled' do - before do - stub_feature_flags(packages_nuget_new_package_file_updater: false) - expect(::Packages::UpdatePackageFileService) - .not_to receive(:new).with(package_file, instance_of(Hash)).and_call_original - expect(service).not_to receive(:new_execute) - end + context 'with an invalid package version' do + invalid_versions = [ + '', + '555', + '1.2', + '1./2.3', + '../../../../../1.2.3', + '%2e%2e%2f1.2.3' + ] + + invalid_versions.each do |invalid_version| + before do + allow(service).to receive(:package_version).and_return(invalid_version) + end - it_behaves_like 'handling all conditions' + it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError + end end end end diff --git a/spec/services/pages/delete_service_spec.rb b/spec/services/pages/delete_service_spec.rb index 295abe15bf0..e02e8e72e0b 100644 --- a/spec/services/pages/delete_service_spec.rb +++ b/spec/services/pages/delete_service_spec.rb @@ -12,27 +12,6 @@ RSpec.describe Pages::DeleteService do project.mark_pages_as_deployed end - it 'deletes published pages', :sidekiq_inline do - expect_next_instance_of(Gitlab::PagesTransfer) do |pages_transfer| - expect(pages_transfer).to receive(:rename_project).and_return true - end - - expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything) - - service.execute - end - - it "doesn't remove anything from the legacy storage if local_store is disabled", :sidekiq_inline do - allow(Settings.pages.local_store).to receive(:enabled).and_return(false) - - expect(project.pages_deployed?).to be(true) - expect(PagesWorker).not_to receive(:perform_in) - - service.execute - - expect(project.pages_deployed?).to be(false) - end - it 'marks pages as not deployed' do expect do service.execute diff --git a/spec/services/pages/legacy_storage_lease_spec.rb b/spec/services/pages/legacy_storage_lease_spec.rb deleted file mode 100644 index 092dce093ff..00000000000 --- a/spec/services/pages/legacy_storage_lease_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe ::Pages::LegacyStorageLease do - let(:project) { create(:project) } - - let(:implementation) do - Class.new do - include ::Pages::LegacyStorageLease - - attr_reader :project - - def initialize(project) - @project = project - end - - def execute - try_obtain_lease do - execute_unsafe - end - end - - def execute_unsafe - true - end - end - end - - let(:service) { implementation.new(project) } - - it 'allows method to be executed' do - expect(service).to receive(:execute_unsafe).and_call_original - - expect(service.execute).to eq(true) - end - - context 'when another service holds the lease for the same project' do - around do |example| - implementation.new(project).try_obtain_lease do - example.run - end - end - - it 'does not run guarded method' do - expect(service).not_to receive(:execute_unsafe) - - expect(service.execute).to eq(nil) - end - end - - context 'when another service holds the lease for the different project' do - around do |example| - implementation.new(create(:project)).try_obtain_lease do - example.run - end - end - - it 'allows method to be executed' do - expect(service).to receive(:execute_unsafe).and_call_original - - expect(service.execute).to eq(true) - end - end -end diff --git a/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb b/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb index 25f571a73d1..177467aac85 100644 --- a/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb +++ b/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb @@ -114,13 +114,5 @@ RSpec.describe Pages::MigrateLegacyStorageToDeploymentService do described_class.new(project).execute end.not_to change { project.pages_metadatum.reload.pages_deployment_id }.from(old_deployment.id) end - - it 'raises exception if exclusive lease is taken' do - described_class.new(project).try_obtain_lease do - expect do - described_class.new(project).execute - end.to raise_error(described_class::ExclusiveLeaseTakenError) - end - end end end diff --git a/spec/services/projects/batch_open_issues_count_service_spec.rb b/spec/services/projects/batch_open_issues_count_service_spec.rb index 82d50604309..17bd5f7a37b 100644 --- a/spec/services/projects/batch_open_issues_count_service_spec.rb +++ b/spec/services/projects/batch_open_issues_count_service_spec.rb @@ -5,52 +5,49 @@ require 'spec_helper' RSpec.describe Projects::BatchOpenIssuesCountService do let!(:project_1) { create(:project) } let!(:project_2) { create(:project) } + let!(:banned_user) { create(:user, :banned) } let(:subject) { described_class.new([project_1, project_2]) } - describe '#refresh_cache', :use_clean_rails_memory_store_caching do + describe '#refresh_cache_and_retrieve_data', :use_clean_rails_memory_store_caching do before do create(:issue, project: project_1) create(:issue, project: project_1, confidential: true) - + create(:issue, project: project_1, author: banned_user) create(:issue, project: project_2) create(:issue, project: project_2, confidential: true) + create(:issue, project: project_2, author: banned_user) end - context 'when cache is clean' do + context 'when cache is clean', :aggregate_failures do it 'refreshes cache keys correctly' do - subject.refresh_cache + expect(get_cache_key(project_1)).to eq(nil) + expect(get_cache_key(project_2)).to eq(nil) - # It does not update total issues cache - expect(Rails.cache.read(get_cache_key(subject, project_1))).to eq(nil) - expect(Rails.cache.read(get_cache_key(subject, project_2))).to eq(nil) + subject.count_service.new(project_1).refresh_cache + subject.count_service.new(project_2).refresh_cache - expect(Rails.cache.read(get_cache_key(subject, project_1, true))).to eq(1) - expect(Rails.cache.read(get_cache_key(subject, project_1, true))).to eq(1) - end - end - - context 'when issues count is already cached' do - before do - create(:issue, project: project_2) - subject.refresh_cache - end + expect(get_cache_key(project_1)).to eq(1) + expect(get_cache_key(project_2)).to eq(1) - it 'does update cache again' do - expect(Rails.cache).not_to receive(:write) + expect(get_cache_key(project_1, true)).to eq(2) + expect(get_cache_key(project_2, true)).to eq(2) - subject.refresh_cache + expect(get_cache_key(project_1, true, true)).to eq(3) + expect(get_cache_key(project_2, true, true)).to eq(3) end end end - def get_cache_key(subject, project, public_key = false) + def get_cache_key(project, with_confidential = false, with_hidden = false) service = subject.count_service.new(project) - if public_key - service.cache_key(service.class::PUBLIC_COUNT_KEY) + if with_confidential && with_hidden + Rails.cache.read(service.cache_key(service.class::TOTAL_COUNT_KEY)) + elsif with_confidential + Rails.cache.read(service.cache_key(service.class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY)) else - service.cache_key(service.class::TOTAL_COUNT_KEY) + Rails.cache.read(service.cache_key(service.class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY)) end end end diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index c3928563125..e15d9341fd1 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -86,7 +86,7 @@ RSpec.describe Projects::CreateService, '#execute' do subject(:project) { create_project(user, opts) } context "with 'topics' parameter" do - let(:opts) { { topics: 'topics' } } + let(:opts) { { name: 'topic-project', topics: 'topics' } } it 'keeps them as specified' do expect(project.topic_list).to eq(%w[topics]) @@ -94,7 +94,7 @@ RSpec.describe Projects::CreateService, '#execute' do end context "with 'topic_list' parameter" do - let(:opts) { { topic_list: 'topic_list' } } + let(:opts) { { name: 'topic-project', topic_list: 'topic_list' } } it 'keeps them as specified' do expect(project.topic_list).to eq(%w[topic_list]) @@ -102,7 +102,7 @@ RSpec.describe Projects::CreateService, '#execute' do end context "with 'tag_list' parameter (deprecated)" do - let(:opts) { { tag_list: 'tag_list' } } + let(:opts) { { name: 'topic-project', tag_list: 'tag_list' } } it 'keeps them as specified' do expect(project.topic_list).to eq(%w[tag_list]) @@ -346,6 +346,12 @@ RSpec.describe Projects::CreateService, '#execute' do expect(imported_project.import_data.data).to eq(import_data[:data]) expect(imported_project.import_url).to eq('http://import-url') end + + it 'tracks for the combined_registration experiment', :experiment do + expect(experiment(:combined_registration)).to track(:import_project).on_next_instance + + imported_project + end end context 'builds_enabled global setting' do @@ -601,6 +607,18 @@ RSpec.describe Projects::CreateService, '#execute' do MARKDOWN end end + + context 'and readme_template is specified' do + before do + opts[:readme_template] = "# GitLab\nThis is customized template." + end + + it_behaves_like 'creates README.md' + + it 'creates README.md with specified template' do + expect(project.repository.readme.data).to include('This is customized template.') + end + end end end diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index d710e4a777f..3f58fa46806 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -28,7 +28,8 @@ RSpec.describe Projects::ForkService do namespace: @from_namespace, star_count: 107, avatar: avatar, - description: 'wow such project') + description: 'wow such project', + external_authorization_classification_label: 'classification-label') @to_user = create(:user) @to_namespace = @to_user.namespace @from_project.add_user(@to_user, :developer) @@ -66,6 +67,7 @@ RSpec.describe Projects::ForkService do it { expect(to_project.description).to eq(@from_project.description) } it { expect(to_project.avatar.file).to be_exists } it { expect(to_project.ci_config_path).to eq(@from_project.ci_config_path) } + it { expect(to_project.external_authorization_classification_label).to eq(@from_project.external_authorization_classification_label) } # This test is here because we had a bug where the from-project lost its # avatar after being forked. diff --git a/spec/services/projects/group_links/destroy_service_spec.rb b/spec/services/projects/group_links/destroy_service_spec.rb index d65afb68bfe..5d07fd52230 100644 --- a/spec/services/projects/group_links/destroy_service_spec.rb +++ b/spec/services/projects/group_links/destroy_service_spec.rb @@ -20,54 +20,28 @@ RSpec.describe Projects::GroupLinks::DestroyService, '#execute' do group.add_maintainer(user) end - context 'when the feature flag `use_specialized_worker_for_project_auth_recalculation` is enabled' do - before do - stub_feature_flags(use_specialized_worker_for_project_auth_recalculation: true) - end - - it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do - expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) - .to receive(:perform_async).with(group_link.project.id) - - subject.execute(group_link) - end - - 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.execute(group_link) - end + it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) + .to receive(:perform_async).with(group_link.project.id) - it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do - expect { subject.execute(group_link) }.to( - change { Ability.allowed?(user, :read_project, project) } - .from(true).to(false)) - end + subject.execute(group_link) end - context 'when the feature flag `use_specialized_worker_for_project_auth_recalculation` is disabled' do - before do - stub_feature_flags(use_specialized_worker_for_project_auth_recalculation: false) - 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.execute(group_link) - end + subject.execute(group_link) + end - it 'updates project authorizations of users who had access to the project via the group share' do - expect { subject.execute(group_link) }.to( - change { Ability.allowed?(user, :read_project, project) } - .from(true).to(false)) - end + it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do + expect { subject.execute(group_link) }.to( + change { Ability.allowed?(user, :read_project, project) } + .from(true).to(false)) end end diff --git a/spec/services/projects/open_issues_count_service_spec.rb b/spec/services/projects/open_issues_count_service_spec.rb index c739fea5ecf..8710d0c0267 100644 --- a/spec/services/projects/open_issues_count_service_spec.rb +++ b/spec/services/projects/open_issues_count_service_spec.rb @@ -4,89 +4,102 @@ require 'spec_helper' RSpec.describe Projects::OpenIssuesCountService, :use_clean_rails_memory_store_caching do let(:project) { create(:project) } + let(:user) { create(:user) } + let(:banned_user) { create(:user, :banned) } - subject { described_class.new(project) } + subject { described_class.new(project, user) } it_behaves_like 'a counter caching service' + before do + create(:issue, :opened, project: project) + create(:issue, :opened, confidential: true, project: project) + create(:issue, :opened, author: banned_user, project: project) + create(:issue, :closed, project: project) + + described_class.new(project).refresh_cache + end + describe '#count' do - context 'when user is nil' do - it 'does not include confidential issues in the issue count' do - create(:issue, :opened, project: project) - create(:issue, :opened, confidential: true, project: project) + shared_examples 'counts public issues, does not count hidden or confidential' do + it 'counts only public issues' do + expect(subject.count).to eq(1) + end - expect(described_class.new(project).count).to eq(1) + it 'uses PUBLIC_COUNT_WITHOUT_HIDDEN_KEY cache key' do + expect(subject.cache_key).to include('project_open_public_issues_without_hidden_count') end end - context 'when user is provided' do - let(:user) { create(:user) } + context 'when user is nil' do + let(:user) { nil } + + it_behaves_like 'counts public issues, does not count hidden or confidential' + end + context 'when user is provided' do context 'when user can read confidential issues' do before do project.add_reporter(user) end - it 'returns the right count with confidential issues' do - create(:issue, :opened, project: project) - create(:issue, :opened, confidential: true, project: project) - - expect(described_class.new(project, user).count).to eq(2) + it 'includes confidential issues and does not include hidden issues in count' do + expect(subject.count).to eq(2) end - it 'uses total_open_issues_count cache key' do - expect(described_class.new(project, user).cache_key_name).to eq('total_open_issues_count') + it 'uses TOTAL_COUNT_WITHOUT_HIDDEN_KEY cache key' do + expect(subject.cache_key).to include('project_open_issues_without_hidden_count') end end - context 'when user cannot read confidential issues' do + context 'when user cannot read confidential or hidden issues' do before do project.add_guest(user) end - it 'does not include confidential issues' do - create(:issue, :opened, project: project) - create(:issue, :opened, confidential: true, project: project) + it_behaves_like 'counts public issues, does not count hidden or confidential' + end + + context 'when user is an admin' do + let_it_be(:user) { create(:user, :admin) } + + context 'when admin mode is enabled', :enable_admin_mode do + it 'includes confidential and hidden issues in count' do + expect(subject.count).to eq(3) + end - expect(described_class.new(project, user).count).to eq(1) + it 'uses TOTAL_COUNT_KEY cache key' do + expect(subject.cache_key).to include('project_open_issues_including_hidden_count') + end end - it 'uses public_open_issues_count cache key' do - expect(described_class.new(project, user).cache_key_name).to eq('public_open_issues_count') + context 'when admin mode is disabled' do + it_behaves_like 'counts public issues, does not count hidden or confidential' end end end + end - describe '#refresh_cache' do - before do - create(:issue, :opened, project: project) - create(:issue, :opened, project: project) - create(:issue, :opened, confidential: true, project: project) - end - - context 'when cache is empty' do - it 'refreshes cache keys correctly' do - subject.refresh_cache - - expect(Rails.cache.read(subject.cache_key(described_class::PUBLIC_COUNT_KEY))).to eq(2) - expect(Rails.cache.read(subject.cache_key(described_class::TOTAL_COUNT_KEY))).to eq(3) - end + describe '#refresh_cache', :aggregate_failures do + context 'when cache is empty' do + it 'refreshes cache keys correctly' do + expect(Rails.cache.read(described_class.new(project).cache_key(described_class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY))).to eq(1) + expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY))).to eq(2) + expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_KEY))).to eq(3) end + end - context 'when cache is outdated' do - before do - subject.refresh_cache - end - - it 'refreshes cache keys correctly' do - create(:issue, :opened, project: project) - create(:issue, :opened, confidential: true, project: project) + context 'when cache is outdated' do + it 'refreshes cache keys correctly' do + create(:issue, :opened, project: project) + create(:issue, :opened, confidential: true, project: project) + create(:issue, :opened, author: banned_user, project: project) - subject.refresh_cache + described_class.new(project).refresh_cache - expect(Rails.cache.read(subject.cache_key(described_class::PUBLIC_COUNT_KEY))).to eq(3) - expect(Rails.cache.read(subject.cache_key(described_class::TOTAL_COUNT_KEY))).to eq(5) - end + expect(Rails.cache.read(described_class.new(project).cache_key(described_class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY))).to eq(2) + expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY))).to eq(4) + expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_KEY))).to eq(6) end end end diff --git a/spec/services/projects/operations/update_service_spec.rb b/spec/services/projects/operations/update_service_spec.rb index 1d9d5f6e938..a71fafb2121 100644 --- a/spec/services/projects/operations/update_service_spec.rb +++ b/spec/services/projects/operations/update_service_spec.rb @@ -153,6 +153,7 @@ RSpec.describe Projects::Operations::UpdateService do { error_tracking_setting_attributes: { enabled: false, + integrated: true, api_host: 'http://gitlab.com/', token: 'token', project: { @@ -174,6 +175,7 @@ RSpec.describe Projects::Operations::UpdateService do project.reload expect(project.error_tracking_setting).not_to be_enabled + expect(project.error_tracking_setting.integrated).to be_truthy expect(project.error_tracking_setting.api_url).to eq( 'http://gitlab.com/api/0/projects/org/project/' ) @@ -206,6 +208,7 @@ RSpec.describe Projects::Operations::UpdateService do { error_tracking_setting_attributes: { enabled: true, + integrated: true, api_host: 'http://gitlab.com/', token: 'token', project: { @@ -222,6 +225,7 @@ RSpec.describe Projects::Operations::UpdateService do expect(result[:status]).to eq(:success) expect(project.error_tracking_setting).to be_enabled + expect(project.error_tracking_setting.integrated).to be_truthy expect(project.error_tracking_setting.api_url).to eq( 'http://gitlab.com/api/0/projects/org/project/' ) diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index b71677a5e8f..d96573e26af 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -292,10 +292,37 @@ RSpec.describe Projects::TransferService do end end - context 'target namespace allows developers to create projects' do + context 'target namespace matches current namespace' do + let(:group) { user.namespace } + + it 'does not allow project transfer' do + transfer_result = execute_transfer + + expect(transfer_result).to eq false + expect(project.namespace).to eq(user.namespace) + expect(project.errors[:new_namespace]).to include('Project is already in this namespace.') + end + end + + context 'when user does not own the project' do + let(:project) { create(:project, :repository, :legacy_storage) } + + before do + project.add_developer(user) + end + + it 'does not allow project transfer to the target namespace' do + transfer_result = execute_transfer + + expect(transfer_result).to eq false + expect(project.errors[:new_namespace]).to include("You don't have permission to transfer this project.") + end + end + + context 'when user can create projects in the target namespace' do let(:group) { create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) } - context 'the user is a member of the target namespace with developer permissions' do + context 'but has only developer permissions in the target namespace' do before do group.add_developer(user) end @@ -305,7 +332,7 @@ RSpec.describe Projects::TransferService do expect(transfer_result).to eq false expect(project.namespace).to eq(user.namespace) - expect(project.errors[:new_namespace]).to include('Transfer failed, please contact an admin.') + expect(project.errors[:new_namespace]).to include("You don't have permission to transfer projects into that namespace.") end end end diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index 0f21736eda0..6d0b75e0c95 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -18,17 +18,6 @@ RSpec.describe Projects::UpdatePagesService do subject { described_class.new(project, build) } - before do - stub_feature_flags(skip_pages_deploy_to_legacy_storage: false) - project.legacy_remove_pages - end - - context '::TMP_EXTRACT_PATH' do - subject { described_class::TMP_EXTRACT_PATH } - - it { is_expected.not_to match(Gitlab::PathRegex.namespace_format_regex) } - end - context 'for new artifacts' do context "for a valid job" do let!(:artifacts_archive) { create(:ci_job_artifact, :correct_checksum, file: file, job: build) } @@ -52,36 +41,6 @@ RSpec.describe Projects::UpdatePagesService do expect(project.pages_metadatum).to be_deployed expect(project.pages_metadatum.artifacts_archive).to eq(artifacts_archive) expect(project.pages_deployed?).to be_truthy - - # Check that all expected files are extracted - %w[index.html zero .hidden/file].each do |filename| - expect(File.exist?(File.join(project.pages_path, 'public', filename))).to be_truthy - end - end - - it 'creates a temporary directory with the project and build ID' do - expect(Dir).to receive(:mktmpdir).with("project-#{project.id}-build-#{build.id}-", anything).and_call_original - - subject.execute - end - - it "doesn't deploy to legacy storage if it's disabled" do - allow(Settings.pages.local_store).to receive(:enabled).and_return(false) - - expect(execute).to eq(:success) - expect(project.pages_deployed?).to be_truthy - - expect(File.exist?(File.join(project.pages_path, 'public', 'index.html'))).to eq(false) - end - - it "doesn't deploy to legacy storage if skip_pages_deploy_to_legacy_storage is enabled" do - allow(Settings.pages.local_store).to receive(:enabled).and_return(true) - stub_feature_flags(skip_pages_deploy_to_legacy_storage: true) - - expect(execute).to eq(:success) - expect(project.pages_deployed?).to be_truthy - - expect(File.exist?(File.join(project.pages_path, 'public', 'index.html'))).to eq(false) end it 'creates pages_deployment and saves it in the metadata' do @@ -99,16 +58,6 @@ RSpec.describe Projects::UpdatePagesService do expect(deployment.ci_build_id).to eq(build.id) end - it 'fails if another deployment is in progress' do - subject.try_obtain_lease do - expect do - execute - end.to raise_error("Failed to deploy pages - other deployment is in progress") - - expect(GenericCommitStatus.last.description).to eq("Failed to deploy pages - other deployment is in progress") - end - end - it 'does not fail if pages_metadata is absent' do project.pages_metadatum.destroy! project.reload @@ -156,47 +105,10 @@ RSpec.describe Projects::UpdatePagesService do expect(GenericCommitStatus.last.description).to eq("pages site contains 3 file entries, while limit is set to 2") end - it 'removes pages after destroy' do - expect(PagesWorker).to receive(:perform_in) - expect(project.pages_deployed?).to be_falsey - expect(Dir.exist?(File.join(project.pages_path))).to be_falsey - - expect(execute).to eq(:success) - - expect(project.pages_metadatum).to be_deployed - expect(project.pages_deployed?).to be_truthy - expect(Dir.exist?(File.join(project.pages_path))).to be_truthy - - project.destroy! - - expect(Dir.exist?(File.join(project.pages_path))).to be_falsey - expect(ProjectPagesMetadatum.find_by_project_id(project)).to be_nil - end - - context 'when using empty file' do - let(:file) { empty_file } - - it 'fails to extract' do - expect { execute } - .to raise_error(Projects::UpdatePagesService::FailedToExtractError) - end - end - - context 'when using pages with non-writeable public' do - let(:file) { fixture_file_upload("spec/fixtures/pages_non_writeable.zip") } - - context 'when using RubyZip' do - it 'succeeds to extract' do - expect(execute).to eq(:success) - expect(project.pages_metadatum).to be_deployed - end - end - end - context 'when timeout happens by DNS error' do before do allow_next_instance_of(described_class) do |instance| - allow(instance).to receive(:extract_zip_archive!).and_raise(SocketError) + allow(instance).to receive(:create_pages_deployment).and_raise(SocketError) end end @@ -209,24 +121,6 @@ RSpec.describe Projects::UpdatePagesService do end end - context 'when failed to extract zip artifacts' do - before do - expect_next_instance_of(described_class) do |instance| - expect(instance).to receive(:extract_zip_archive!) - .and_raise(Projects::UpdatePagesService::FailedToExtractError) - end - end - - it 'raises an error' do - expect { execute } - .to raise_error(Projects::UpdatePagesService::FailedToExtractError) - - build.reload - expect(deploy_status).to be_failed - expect(project.pages_metadatum).not_to be_deployed - end - end - context 'when missing artifacts metadata' do before do expect(build).to receive(:artifacts_metadata?).and_return(false) @@ -338,12 +232,6 @@ RSpec.describe Projects::UpdatePagesService do end end - it 'fails to remove project pages when no pages is deployed' do - expect(PagesWorker).not_to receive(:perform_in) - expect(project.pages_deployed?).to be_falsey - project.destroy! - end - it 'fails if no artifacts' do expect(execute).not_to eq(:success) end @@ -384,38 +272,6 @@ RSpec.describe Projects::UpdatePagesService do end end - context 'when file size is spoofed' do - let(:metadata) { spy('metadata') } - - include_context 'pages zip with spoofed size' - - before do - file = fixture_file_upload(fake_zip_path, 'pages.zip') - metafile = fixture_file_upload('spec/fixtures/pages.zip.meta') - - create(:ci_job_artifact, :archive, file: file, job: build) - create(:ci_job_artifact, :metadata, file: metafile, job: build) - - allow(build).to receive(:artifacts_metadata_entry).with('public/', recursive: true) - .and_return(metadata) - allow(metadata).to receive(:total_size).and_return(100) - - # to pass entries count check - root_metadata = double('root metadata') - allow(build).to receive(:artifacts_metadata_entry).with('', recursive: true) - .and_return(root_metadata) - allow(root_metadata).to receive_message_chain(:entries, :count).and_return(10) - end - - it 'raises an error' do - expect do - subject.execute - end.to raise_error(Projects::UpdatePagesService::FailedToExtractError, - 'Entry public/index.html should be 1B but is larger when inflated') - expect(deploy_status).to be_script_failure - end - end - context 'when retrying the job' do let!(:older_deploy_job) do create(:generic_commit_status, :failed, pipeline: pipeline, @@ -435,18 +291,6 @@ RSpec.describe Projects::UpdatePagesService do expect(older_deploy_job.reload).to be_retried end - - context 'when FF ci_fix_commit_status_retried is disabled' do - before do - stub_feature_flags(ci_fix_commit_status_retried: false) - end - - it 'does not mark older pages:deploy jobs retried' do - expect(execute).to eq(:success) - - expect(older_deploy_job.reload).not_to be_retried - end - end end private diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index c74a8295d0a..115f3098185 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -441,6 +441,30 @@ 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) } + + subject(:call_service) do + update_project(project, admin, shared_runners_enabled: shared_runners_enabled) + end + + context 'when shared runners is toggled' do + let(:shared_runners_enabled) { false } + + it 'updates ci pending builds' do + expect { call_service }.to change { pending_build.reload.instance_runners_enabled }.to(false) + end + end + + context 'when shared runners is not toggled' do + let(:shared_runners_enabled) { true } + + it 'updates ci pending builds' do + expect { call_service }.to not_change { pending_build.reload.instance_runners_enabled } + end + end + end + context 'with external authorization enabled' do before do enable_external_authorization_service_check diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index a1b726071d6..02997096021 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -624,6 +624,18 @@ RSpec.describe QuickActions::InterpretService do end end + shared_examples 'approve command unavailable' do + it 'is not part of the available commands' do + expect(service.available_commands(issuable)).not_to include(a_hash_including(name: :approve)) + end + end + + shared_examples 'unapprove command unavailable' do + it 'is not part of the available commands' do + expect(service.available_commands(issuable)).not_to include(a_hash_including(name: :unapprove)) + end + end + shared_examples 'shrug command' do it 'appends ¯\_(ツ)_/¯ to the comment' do new_content, _, _ = service.execute(content, issuable) @@ -2135,6 +2147,66 @@ RSpec.describe QuickActions::InterpretService do end end end + + context 'approve command' do + let(:merge_request) { create(:merge_request, source_project: project) } + let(:content) { '/approve' } + + it 'approves the current merge request' do + service.execute(content, merge_request) + + expect(merge_request.approved_by_users).to eq([developer]) + end + + context "when the user can't approve" do + before do + project.team.truncate + project.add_guest(developer) + end + + it 'does not approve the MR' do + service.execute(content, merge_request) + + expect(merge_request.approved_by_users).to be_empty + end + end + + it_behaves_like 'approve command unavailable' do + let(:issuable) { issue } + end + end + + context 'unapprove command' do + let!(:merge_request) { create(:merge_request, source_project: project) } + let(:content) { '/unapprove' } + + before do + service.execute('/approve', merge_request) + end + + it 'unapproves the current merge request' do + service.execute(content, merge_request) + + expect(merge_request.approved_by_users).to be_empty + end + + context "when the user can't unapprove" do + before do + project.team.truncate + project.add_guest(developer) + end + + it 'does not unapprove the MR' do + service.execute(content, merge_request) + + expect(merge_request.approved_by_users).to eq([developer]) + end + + it_behaves_like 'unapprove command unavailable' do + let(:issuable) { issue } + end + end + end end describe '#explain' do diff --git a/spec/services/repositories/changelog_service_spec.rb b/spec/services/repositories/changelog_service_spec.rb index 02d60f076ca..b547ae17317 100644 --- a/spec/services/repositories/changelog_service_spec.rb +++ b/spec/services/repositories/changelog_service_spec.rb @@ -76,7 +76,7 @@ RSpec.describe Repositories::ChangelogService do recorder = ActiveRecord::QueryRecorder.new { service.execute } changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data - expect(recorder.count).to eq(11) + expect(recorder.count).to eq(9) expect(changelog).to include('Title 1', 'Title 2') 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 05df4e49014..c2fe565938a 100644 --- a/spec/services/service_ping/submit_service_ping_service_spec.rb +++ b/spec/services/service_ping/submit_service_ping_service_spec.rb @@ -300,8 +300,32 @@ RSpec.describe ServicePing::SubmitService do end end - def stub_response(body:, status: 201) - stub_full_request(subject.send(:url), method: :post) + describe '#url' do + let(:url) { subject.url.to_s } + + context 'when Rails.env is production' do + before do + stub_rails_env('production') + end + + it 'points to the production Version app' do + expect(url).to eq("#{described_class::PRODUCTION_BASE_URL}/#{described_class::USAGE_DATA_PATH}") + end + end + + context 'when Rails.env is not production' do + before do + stub_rails_env('development') + end + + it 'points to the staging Version app' do + expect(url).to eq("#{described_class::STAGING_BASE_URL}/#{described_class::USAGE_DATA_PATH}") + end + end + end + + def stub_response(url: subject.url, body:, status: 201) + stub_full_request(url, method: :post) .to_return( headers: { 'Content-Type' => 'application/json' }, body: body.to_json, diff --git a/spec/services/suggestions/apply_service_spec.rb b/spec/services/suggestions/apply_service_spec.rb index 9cf794cde7e..dc330a5546f 100644 --- a/spec/services/suggestions/apply_service_spec.rb +++ b/spec/services/suggestions/apply_service_spec.rb @@ -70,7 +70,7 @@ RSpec.describe Suggestions::ApplyService do author = suggestions.first.note.author expect(user.commit_email).not_to eq(user.email) - expect(commit.author_email).to eq(author.commit_email) + expect(commit.author_email).to eq(author.commit_email_or_default) expect(commit.committer_email).to eq(user.commit_email) expect(commit.author_name).to eq(author.name) expect(commit.committer_name).to eq(user.name) @@ -79,7 +79,7 @@ RSpec.describe Suggestions::ApplyService do it 'tracks apply suggestion event' do expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) .to receive(:track_apply_suggestion_action) - .with(user: user) + .with(user: user, suggestions: suggestions) apply(suggestions) end @@ -333,9 +333,9 @@ RSpec.describe Suggestions::ApplyService do end it 'created commit by same author and committer' do - expect(user.commit_email).to eq(author.commit_email) + expect(user.commit_email).to eq(author.commit_email_or_default) expect(author).to eq(user) - expect(commit.author_email).to eq(author.commit_email) + expect(commit.author_email).to eq(author.commit_email_or_default) expect(commit.committer_email).to eq(user.commit_email) expect(commit.author_name).to eq(author.name) expect(commit.committer_name).to eq(user.name) @@ -350,7 +350,7 @@ RSpec.describe Suggestions::ApplyService do it 'created commit has authors info and commiters info' do expect(user.commit_email).not_to eq(user.email) expect(author).not_to eq(user) - expect(commit.author_email).to eq(author.commit_email) + expect(commit.author_email).to eq(author.commit_email_or_default) expect(commit.committer_email).to eq(user.commit_email) expect(commit.author_name).to eq(author.name) expect(commit.committer_name).to eq(user.name) @@ -359,7 +359,7 @@ RSpec.describe Suggestions::ApplyService do end context 'multiple suggestions' do - let(:author_emails) { suggestions.map {|s| s.note.author.commit_email } } + let(:author_emails) { suggestions.map {|s| s.note.author.commit_email_or_default } } let(:first_author) { suggestion.note.author } let(:commit) { project.repository.commit } @@ -369,8 +369,8 @@ RSpec.describe Suggestions::ApplyService do end it 'uses first authors information' do - expect(author_emails).to include(first_author.commit_email).exactly(3) - expect(commit.author_email).to eq(first_author.commit_email) + expect(author_emails).to include(first_author.commit_email_or_default).exactly(3) + expect(commit.author_email).to eq(first_author.commit_email_or_default) end end diff --git a/spec/services/suggestions/create_service_spec.rb b/spec/services/suggestions/create_service_spec.rb index 5148d6756fc..a4e62431128 100644 --- a/spec/services/suggestions/create_service_spec.rb +++ b/spec/services/suggestions/create_service_spec.rb @@ -159,7 +159,7 @@ RSpec.describe Suggestions::CreateService do it 'tracks add suggestion event' do expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) .to receive(:track_add_suggestion_action) - .with(user: note.author) + .with(note: note) subject.execute end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5aff5149dcf..1a421999ffb 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -793,4 +793,16 @@ RSpec.describe SystemNoteService do described_class.log_resolving_alert(alert, monitoring_tool) end end + + describe '.change_issue_type' do + let(:incident) { build(:incident) } + + it 'calls IssuableService' do + expect_next_instance_of(::SystemNotes::IssuablesService) do |service| + expect(service).to receive(:change_issue_type) + end + + described_class.change_issue_type(incident, author) + end + end end diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb index 1ea3c241d27..71a28a89cd8 100644 --- a/spec/services/system_notes/issuables_service_spec.rb +++ b/spec/services/system_notes/issuables_service_spec.rb @@ -773,4 +773,16 @@ RSpec.describe ::SystemNotes::IssuablesService do expect(event.state).to eq('closed') end end + + describe '#change_issue_type' do + let(:noteable) { create(:incident, project: project) } + + subject { service.change_issue_type } + + it_behaves_like 'a system note' do + let(:action) { 'issue_type' } + end + + it { expect(subject.note).to eq "changed issue type to incident" } + end end diff --git a/spec/services/todos/destroy/design_service_spec.rb b/spec/services/todos/destroy/design_service_spec.rb new file mode 100644 index 00000000000..61a6718dc9d --- /dev/null +++ b/spec/services/todos/destroy/design_service_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Todos::Destroy::DesignService do + let_it_be(:user) { create(:user) } + let_it_be(:user_2) { create(:user) } + let_it_be(:design) { create(:design) } + let_it_be(:design_2) { create(:design) } + let_it_be(:design_3) { create(:design) } + + let_it_be(:create_action) { create(:design_action, design: design)} + let_it_be(:create_action_2) { create(:design_action, design: design_2)} + + describe '#execute' do + before do + create(:todo, user: user, target: design) + create(:todo, user: user_2, target: design) + create(:todo, user: user, target: design_2) + create(:todo, user: user, target: design_3) + end + + subject { described_class.new([design.id, design_2.id, design_3.id]).execute } + + context 'when the design has been archived' do + let_it_be(:archive_action) { create(:design_action, design: design, event: :deletion)} + let_it_be(:archive_action_2) { create(:design_action, design: design_3, event: :deletion)} + + it 'removes todos for that design' do + expect { subject }.to change { Todo.count }.from(4).to(1) + end + end + + context 'when no design has been archived' do + it 'does not remove any todos' do + expect { subject }.not_to change { Todo.count }.from(4) + end + end + end +end diff --git a/spec/services/users/ban_service_spec.rb b/spec/services/users/ban_service_spec.rb index 6f49ee08782..79f3cbeb46d 100644 --- a/spec/services/users/ban_service_spec.rb +++ b/spec/services/users/ban_service_spec.rb @@ -50,7 +50,7 @@ RSpec.describe Users::BanService do response = ban_user expect(response[:status]).to eq(:error) - expect(response[:message]).to match(/State cannot transition/) + expect(response[:message]).to match('You cannot ban blocked users.') end it_behaves_like 'does not modify the BannedUser record or user state' diff --git a/spec/services/users/dismiss_group_callout_service_spec.rb b/spec/services/users/dismiss_group_callout_service_spec.rb new file mode 100644 index 00000000000..d74602a7606 --- /dev/null +++ b/spec/services/users/dismiss_group_callout_service_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::DismissGroupCalloutService do + describe '#execute' do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + + let(:params) { { feature_name: feature_name, group_id: group.id } } + let(:feature_name) { Users::GroupCallout.feature_names.each_key.first } + + subject(:execute) do + described_class.new( + container: nil, current_user: user, params: params + ).execute + end + + it_behaves_like 'dismissing user callout', Users::GroupCallout + + it 'sets the group_id' do + expect(execute.group_id).to eq(group.id) + end + end +end diff --git a/spec/services/users/dismiss_user_callout_service_spec.rb b/spec/services/users/dismiss_user_callout_service_spec.rb index 22f84a939f7..6bf9961eb74 100644 --- a/spec/services/users/dismiss_user_callout_service_spec.rb +++ b/spec/services/users/dismiss_user_callout_service_spec.rb @@ -3,25 +3,18 @@ require 'spec_helper' RSpec.describe Users::DismissUserCalloutService do - let(:user) { create(:user) } - - let(:service) do - described_class.new( - container: nil, current_user: user, params: { feature_name: UserCallout.feature_names.each_key.first } - ) - end - describe '#execute' do - subject(:execute) { service.execute } + let_it_be(:user) { create(:user) } - it 'returns a user callout' do - expect(execute).to be_an_instance_of(UserCallout) - end + let(:params) { { feature_name: feature_name } } + let(:feature_name) { UserCallout.feature_names.each_key.first } - it 'sets the dismisse_at attribute to current time' do - freeze_time do - expect(execute).to have_attributes(dismissed_at: Time.current) - end + subject(:execute) do + described_class.new( + container: nil, current_user: user, params: params + ).execute end + + it_behaves_like 'dismissing user callout', UserCallout end end diff --git a/spec/services/users/migrate_to_ghost_user_service_spec.rb b/spec/services/users/migrate_to_ghost_user_service_spec.rb index c9c8f9a74d3..c36889f20ec 100644 --- a/spec/services/users/migrate_to_ghost_user_service_spec.rb +++ b/spec/services/users/migrate_to_ghost_user_service_spec.rb @@ -92,23 +92,5 @@ RSpec.describe Users::MigrateToGhostUserService do let(:created_record) { create(:review, author: user) } end end - - context "when record migration fails with a rollback exception" do - before do - expect_any_instance_of(ActiveRecord::Associations::CollectionProxy) - .to receive(:update_all).and_raise(ActiveRecord::Rollback) - end - - context "for records that were already migrated" do - let!(:issue) { create(:issue, project: project, author: user) } - let!(:merge_request) { create(:merge_request, source_project: project, author: user, target_branch: "first") } - - it "reverses the migration" do - service.execute - - expect(issue.reload.author).to eq(user) - end - end - end end end diff --git a/spec/services/users/reject_service_spec.rb b/spec/services/users/reject_service_spec.rb index b0094a7c47e..5a243e876ac 100644 --- a/spec/services/users/reject_service_spec.rb +++ b/spec/services/users/reject_service_spec.rb @@ -27,7 +27,7 @@ RSpec.describe Users::RejectService do it 'returns error result' do expect(subject[:status]).to eq(:error) expect(subject[:message]) - .to match(/This user does not have a pending request/) + .to match(/User does not have a pending request/) end end end @@ -44,7 +44,7 @@ RSpec.describe Users::RejectService do it 'emails the user on rejection' do expect_next_instance_of(NotificationService) do |notification| - allow(notification).to receive(:user_admin_rejection).with(user.name, user.notification_email) + allow(notification).to receive(:user_admin_rejection).with(user.name, user.notification_email_or_default) end subject diff --git a/spec/services/users/unban_service_spec.rb b/spec/services/users/unban_service_spec.rb index b2b3140ccb3..d536baafdcc 100644 --- a/spec/services/users/unban_service_spec.rb +++ b/spec/services/users/unban_service_spec.rb @@ -50,7 +50,7 @@ RSpec.describe Users::UnbanService do response = unban_user expect(response[:status]).to eq(:error) - expect(response[:message]).to match(/State cannot transition/) + expect(response[:message]).to match('You cannot unban active users.') end it_behaves_like 'does not modify the BannedUser record or user state' diff --git a/spec/services/wiki_pages/event_create_service_spec.rb b/spec/services/wiki_pages/event_create_service_spec.rb index 6bc6a678189..8476f872e98 100644 --- a/spec/services/wiki_pages/event_create_service_spec.rb +++ b/spec/services/wiki_pages/event_create_service_spec.rb @@ -34,10 +34,6 @@ RSpec.describe WikiPages::EventCreateService do it 'does not create an event' do expect { response }.not_to change(Event, :count) end - - it 'does not create a metadata record' do - expect { response }.not_to change(WikiPage::Meta, :count) - end end it 'returns a successful response' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b95b7fad5a0..aa791d1d2e7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -160,11 +160,11 @@ RSpec.configure do |config| config.include GitlabRoutingHelper config.include StubExperiments config.include StubGitlabCalls - config.include StubGitlabData config.include NextFoundInstanceOf config.include NextInstanceOf config.include TestEnv config.include FileReadHelpers + config.include Database::MultipleDatabases config.include Devise::Test::ControllerHelpers, type: :controller config.include Devise::Test::ControllerHelpers, type: :view config.include Devise::Test::IntegrationHelpers, type: :feature @@ -221,6 +221,8 @@ RSpec.configure do |config| # Enable all features by default for testing # Reset any changes in after hook. stub_all_feature_flags + + TestEnv.seed_db end config.after(:all) do @@ -260,6 +262,9 @@ RSpec.configure do |config| # tests, until we introduce it in user settings stub_feature_flags(forti_token_cloud: false) + # Disable for now whilst we add more states + stub_feature_flags(restructured_mr_widget: false) + # These feature flag are by default disabled and used in disaster recovery mode stub_feature_flags(ci_queueing_disaster_recovery_disable_fair_scheduling: false) stub_feature_flags(ci_queueing_disaster_recovery_disable_quota: false) @@ -301,6 +306,15 @@ RSpec.configure do |config| # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321 stub_feature_flags(block_issue_repositioning: false) + # These are ops feature flags that are disabled by default + stub_feature_flags(disable_anonymous_search: false) + stub_feature_flags(disable_anonymous_project_search: false) + + # Disable the refactored top nav search until there is functionality + # Can be removed once all existing functionality has been replicated + # For more information check https://gitlab.com/gitlab-org/gitlab/-/issues/339348 + stub_feature_flags(new_header_search: false) + allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged) else unstub_all_feature_flags diff --git a/spec/support/database/ci_tables.rb b/spec/support/database/ci_tables.rb deleted file mode 100644 index 99fc7ac2501..00000000000 --- a/spec/support/database/ci_tables.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -# This module stores the CI-related database tables which are -# going to be moved to a separate database. -module Database - module CiTables - def self.include?(name) - ci_tables.include?(name) - end - - def self.ci_tables - @@ci_tables ||= Set.new.tap do |tables| # rubocop:disable Style/ClassVars - tables.merge(Ci::ApplicationRecord.descendants.map(&:table_name).compact) - - # It was decided that taggings/tags are best placed with CI - # https://gitlab.com/gitlab-org/gitlab/-/issues/333413 - tables.add('taggings') - tables.add('tags') - end - end - end -end diff --git a/spec/support/database/cross-join-allowlist.yml b/spec/support/database/cross-join-allowlist.yml new file mode 100644 index 00000000000..2b4cfc6773a --- /dev/null +++ b/spec/support/database/cross-join-allowlist.yml @@ -0,0 +1,196 @@ +- "./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/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/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/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/gitlab_schema.rb b/spec/support/database/gitlab_schema.rb new file mode 100644 index 00000000000..fe05fb998e6 --- /dev/null +++ b/spec/support/database/gitlab_schema.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# This module gathes information about table to schema mapping +# to understand table affinity +module Database + module GitlabSchema + def self.table_schemas(tables) + tables.map { |table| table_schema(table) }.to_set + end + + def self.table_schema(name) + tables_to_schema[name] || :undefined + end + + def self.tables_to_schema + @tables_to_schema ||= all_classes_with_schema.to_h do |klass| + [klass.table_name, klass.gitlab_schema] + end + end + + def self.all_classes_with_schema + ActiveRecord::Base.descendants.reject(&:abstract_class?).select(&:gitlab_schema?) # rubocop:disable Database/MultipleDatabases + end + end +end diff --git a/spec/support/database/multiple_databases.rb b/spec/support/database/multiple_databases.rb new file mode 100644 index 00000000000..8ce642a682c --- /dev/null +++ b/spec/support/database/multiple_databases.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Database + module MultipleDatabases + def skip_if_multiple_databases_not_setup + skip 'Skipping because multiple databases not set up' unless Gitlab::Database.has_config?(:ci) + end + end +end diff --git a/spec/support/database/prevent_cross_database_modification.rb b/spec/support/database/prevent_cross_database_modification.rb index 460ee99391b..b4c968e3c41 100644 --- a/spec/support/database/prevent_cross_database_modification.rb +++ b/spec/support/database/prevent_cross_database_modification.rb @@ -74,18 +74,20 @@ module Database return if cross_database_context[:transaction_depth_by_db].values.all?(&:zero?) - tables = PgQuery.parse(sql).dml_tables + parsed_query = PgQuery.parse(sql) + tables = sql.downcase.include?(' for update') ? parsed_query.tables : parsed_query.dml_tables return if tables.empty? cross_database_context[:modified_tables_by_db][database].merge(tables) all_tables = cross_database_context[:modified_tables_by_db].values.map(&:to_a).flatten + schemas = Database::GitlabSchema.table_schemas(all_tables) - unless PreventCrossJoins.only_ci_or_only_main?(all_tables) + if schemas.many? raise Database::PreventCrossDatabaseModification::CrossDatabaseModificationAcrossUnsupportedTablesError, - "Cross-database data modification queries (CI and Main) were detected within " \ - "a transaction '#{all_tables.join(", ")}' discovered" + "Cross-database data modification of '#{schemas.to_a.join(", ")}' were detected within " \ + "a transaction modifying the '#{all_tables.to_a.join(", ")}'" end end end diff --git a/spec/support/database/prevent_cross_joins.rb b/spec/support/database/prevent_cross_joins.rb index 789721ccd38..4b78aa9014c 100644 --- a/spec/support/database/prevent_cross_joins.rb +++ b/spec/support/database/prevent_cross_joins.rb @@ -11,7 +11,7 @@ # # class User # def ci_owned_runners -# ::Gitlab::Database.allow_cross_joins_across_databases!(url: link-to-issue-url) +# ::Gitlab::Database.allow_cross_joins_across_databases(url: link-to-issue-url) # # ... # end @@ -21,33 +21,43 @@ module Database module PreventCrossJoins CrossJoinAcrossUnsupportedTablesError = Class.new(StandardError) + ALLOW_THREAD_KEY = :allow_cross_joins_across_databases + def self.validate_cross_joins!(sql) - return if Thread.current[:allow_cross_joins_across_databases] + return if Thread.current[ALLOW_THREAD_KEY] + + # Allow spec/support/database_cleaner.rb queries to disable/enable triggers for many tables + # See https://gitlab.com/gitlab-org/gitlab/-/issues/339396 + return if sql.include?("DISABLE TRIGGER") || sql.include?("ENABLE TRIGGER") # PgQuery might fail in some cases due to limited nesting: # https://github.com/pganalyze/pg_query/issues/209 - tables = PgQuery.parse(sql).tables + # + # 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 - unless only_ci_or_only_main?(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(", ")}' discovered " \ - "when executing query '#{sql}'" + "Unsupported cross-join across '#{tables.join(", ")}' modifying '#{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 - # Returns true if a set includes only CI tables, or includes only non-CI tables - def self.only_ci_or_only_main?(tables) - tables.all? { |table| CiTables.include?(table) } || - tables.none? { |table| CiTables.include?(table) } - end - module SpecHelpers def with_cross_joins_prevented subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |event| ::Database::PreventCrossJoins.validate_cross_joins!(event.payload[:sql]) end - Thread.current[:allow_cross_joins_across_databases] = false + Thread.current[ALLOW_THREAD_KEY] = false yield ensure @@ -57,8 +67,12 @@ module Database module GitlabDatabaseMixin def allow_cross_joins_across_databases(url:) - Thread.current[:allow_cross_joins_across_databases] = true - super + old_value = Thread.current[ALLOW_THREAD_KEY] + Thread.current[ALLOW_THREAD_KEY] = true + + yield + ensure + Thread.current[ALLOW_THREAD_KEY] = old_value end end end @@ -67,11 +81,18 @@ end Gitlab::Database.singleton_class.prepend( Database::PreventCrossJoins::GitlabDatabaseMixin) +ALLOW_LIST = Set.new(YAML.load_file(File.join(__dir__, 'cross-join-allowlist.yml'))).freeze + RSpec.configure do |config| config.include(::Database::PreventCrossJoins::SpecHelpers) - # TODO: remove `:prevent_cross_joins` to enable the check by default - config.around(:each, :prevent_cross_joins) do |example| - with_cross_joins_prevented { example.run } + config.around do |example| + Thread.current[:has_cross_join_exception] = false + + if ALLOW_LIST.include?(example.file_path) + example.run + else + with_cross_joins_prevented { example.run } + end end end diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb index 01bf390d9e9..b31881e3082 100644 --- a/spec/support/database_cleaner.rb +++ b/spec/support/database_cleaner.rb @@ -14,7 +14,7 @@ RSpec.configure do |config| end config.append_after(:context, :migration) do - delete_from_all_tables! + 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: @@ -61,7 +61,7 @@ RSpec.configure do |config| example.run - delete_from_all_tables! + delete_from_all_tables!(except: ['work_item_types']) self.class.use_transactional_tests = true end diff --git a/spec/support/database_load_balancing.rb b/spec/support/database_load_balancing.rb index 03fa7886295..f22c69ea613 100644 --- a/spec/support/database_load_balancing.rb +++ b/spec/support/database_load_balancing.rb @@ -4,7 +4,10 @@ RSpec.configure do |config| config.before(:each, :db_load_balancing) do allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true) - proxy = ::Gitlab::Database::LoadBalancing::ConnectionProxy.new([Gitlab::Database.main.config['host']]) + 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) allow(ActiveRecord::Base).to receive(:load_balancing_proxy).and_return(proxy) diff --git a/spec/support/db_cleaner.rb b/spec/support/db_cleaner.rb index 155dc3c17d9..940ff2751d3 100644 --- a/spec/support/db_cleaner.rb +++ b/spec/support/db_cleaner.rb @@ -12,7 +12,7 @@ module DbCleaner end def deletion_except_tables - [] + ['work_item_types'] end def setup_database_cleaner diff --git a/spec/support/helpers/bare_repo_operations.rb b/spec/support/helpers/bare_repo_operations.rb index 98fa13db6c2..e29e12a15f6 100644 --- a/spec/support/helpers/bare_repo_operations.rb +++ b/spec/support/helpers/bare_repo_operations.rb @@ -17,26 +17,6 @@ class BareRepoOperations commit_id[0] end - # Based on https://stackoverflow.com/a/25556917/1856239 - def commit_file(file, dst_path, branch = 'master') - head_id = execute(['show', '--format=format:%H', '--no-patch', branch], allow_failure: true)[0] || Gitlab::Git::EMPTY_TREE_ID - - execute(['read-tree', '--empty']) - execute(['read-tree', head_id]) - - blob_id = execute(['hash-object', '--stdin', '-w']) do |stdin| - stdin.write(file.read) - end - - execute(['update-index', '--add', '--cacheinfo', '100644', blob_id[0], dst_path]) - - tree_id = execute(['write-tree']) - - commit_id = commit_tree(tree_id[0], "Add #{dst_path}", parent: head_id) - - execute(['update-ref', "refs/heads/#{branch}", commit_id]) - end - private def execute(args, allow_failure: false) diff --git a/spec/support/helpers/cycle_analytics_helpers.rb b/spec/support/helpers/cycle_analytics_helpers.rb index e48c8125d84..3ec52f8c832 100644 --- a/spec/support/helpers/cycle_analytics_helpers.rb +++ b/spec/support/helpers/cycle_analytics_helpers.rb @@ -23,12 +23,39 @@ module CycleAnalyticsHelpers end end + def select_event_label(sel) + page.within(sel) do + find('.dropdown-toggle').click + page.find(".dropdown-menu").all(".dropdown-item")[1].click + end + end + + def fill_in_custom_label_stage_fields + index = page.all('[data-testid="value-stream-stage-fields"]').length + last_stage = page.all('[data-testid="value-stream-stage-fields"]').last + + within last_stage do + find('[name*="custom-stage-name-"]').fill_in with: "Cool custom label stage - name #{index}" + select_dropdown_option_by_value "custom-stage-start-event-", :issue_label_added + select_dropdown_option_by_value "custom-stage-end-event-", :issue_label_removed + + select_event_label("[data-testid*='custom-stage-start-event-label-']") + select_event_label("[data-testid*='custom-stage-end-event-label-']") + end + end + def add_custom_stage_to_form page.find_button(s_('CreateValueStreamForm|Add another stage')).click fill_in_custom_stage_fields end + def add_custom_label_stage_to_form + page.find_button(s_('CreateValueStreamForm|Add another stage')).click + + fill_in_custom_label_stage_fields + end + def save_value_stream(custom_value_stream_name) fill_in 'create-value-stream-name', with: custom_value_stream_name @@ -44,12 +71,12 @@ module CycleAnalyticsHelpers save_value_stream(custom_value_stream_name) end - def wait_for_stages_to_load(selector = '.js-path-navigation') + def wait_for_stages_to_load(selector = '[data-testid="vsa-path-navigation"]') expect(page).to have_selector selector wait_for_requests end - def select_group(target_group, ready_selector = '.js-path-navigation') + def select_group(target_group, ready_selector = '[data-testid="vsa-path-navigation"]') visit group_analytics_cycle_analytics_path(target_group) wait_for_stages_to_load(ready_selector) diff --git a/spec/support/helpers/email_helpers.rb b/spec/support/helpers/email_helpers.rb index 6df33e68629..d0f6fd466d0 100644 --- a/spec/support/helpers/email_helpers.rb +++ b/spec/support/helpers/email_helpers.rb @@ -2,7 +2,7 @@ module EmailHelpers def sent_to_user(user, recipients: email_recipients) - recipients.count { |to| to == user.notification_email } + recipients.count { |to| to == user.notification_email_or_default } end def reset_delivered_emails! @@ -45,7 +45,7 @@ module EmailHelpers end def find_email_for(user) - ActionMailer::Base.deliveries.find { |d| d.to.include?(user.notification_email) } + ActionMailer::Base.deliveries.find { |d| d.to.include?(user.notification_email_or_default) } end def have_referable_subject(referable, include_project: true, reply: false) diff --git a/spec/support/helpers/features/members_table_helpers.rb b/spec/support/helpers/features/members_helpers.rb index 2e86e014a1b..2e86e014a1b 100644 --- a/spec/support/helpers/features/members_table_helpers.rb +++ b/spec/support/helpers/features/members_helpers.rb diff --git a/spec/support/helpers/javascript_fixtures_helpers.rb b/spec/support/helpers/javascript_fixtures_helpers.rb index 4c90b907d2d..5174c145a93 100644 --- a/spec/support/helpers/javascript_fixtures_helpers.rb +++ b/spec/support/helpers/javascript_fixtures_helpers.rb @@ -42,9 +42,12 @@ module JavaScriptFixturesHelpers # Public: Reads a GraphQL query from the filesystem as a string # - # query_path - file path to the GraphQL query, relative to `app/assets/javascripts` - def get_graphql_query_as_string(query_path) - path = Rails.root / 'app/assets/javascripts' / query_path + # query_path - file path to the GraphQL query, relative to `app/assets/javascripts`. + # ee - boolean, when true `query_path` will be looked up in `/ee`. + def get_graphql_query_as_string(query_path, ee: false) + base = (ee ? 'ee/' : '') + 'app/assets/javascripts' + + path = Rails.root / base / query_path queries = Gitlab::Graphql::Queries.find(path) if queries.length == 1 queries.first.text(mode: Gitlab.ee? ? :ee : :ce ) diff --git a/spec/support/helpers/live_debugger.rb b/spec/support/helpers/live_debugger.rb index f4199d518a3..d196a6dc746 100644 --- a/spec/support/helpers/live_debugger.rb +++ b/spec/support/helpers/live_debugger.rb @@ -16,7 +16,7 @@ module LiveDebugger puts "The current user credentials are: #{@current_user.username} / #{@current_user.password}" if @current_user puts "Press any key to resume the execution of the example!!" - `open #{current_url}` if is_headless_disabled? + `open #{current_url}` unless is_headless_disabled? loop until $stdin.getch diff --git a/spec/support/helpers/migrations_helpers.rb b/spec/support/helpers/migrations_helpers.rb index ef212938af5..7799e49d4c1 100644 --- a/spec/support/helpers/migrations_helpers.rb +++ b/spec/support/helpers/migrations_helpers.rb @@ -30,7 +30,7 @@ module MigrationsHelpers end end - klass.tap { Gitlab::Database::Partitioning::PartitionManager.new.sync_partitions } + klass.tap { Gitlab::Database::Partitioning.sync_partitions([klass]) } end def migrations_paths diff --git a/spec/support/helpers/reference_parser_helpers.rb b/spec/support/helpers/reference_parser_helpers.rb index a6a7948d9d9..b9796ebbe62 100644 --- a/spec/support/helpers/reference_parser_helpers.rb +++ b/spec/support/helpers/reference_parser_helpers.rb @@ -5,9 +5,10 @@ module ReferenceParserHelpers Nokogiri::HTML.fragment('<a></a>').children[0] end - def expect_gathered_references(result, visible, not_visible_count) + def expect_gathered_references(result, visible, nodes, visible_nodes) expect(result[:visible]).to eq(visible) - expect(result[:not_visible].count).to eq(not_visible_count) + expect(result[:nodes]).to eq(nodes) + expect(result[:visible_nodes]).to eq(visible_nodes) end RSpec.shared_examples 'no project N+1 queries' do diff --git a/spec/support/helpers/session_helpers.rb b/spec/support/helpers/session_helpers.rb new file mode 100644 index 00000000000..4ef099a393e --- /dev/null +++ b/spec/support/helpers/session_helpers.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module SessionHelpers + def expect_single_session_with_authenticated_ttl + expect_single_session_with_expiration(Settings.gitlab['session_expire_delay'] * 60) + end + + def expect_single_session_with_short_ttl + expect_single_session_with_expiration(Settings.gitlab['unauthenticated_session_expire_delay']) + end + + def expect_single_session_with_expiration(expiration) + session_keys = get_session_keys + + expect(session_keys.size).to eq(1) + expect(get_ttl(session_keys.first)).to be_within(5).of(expiration) + end + + def get_session_keys + Gitlab::Redis::SharedState.with { |redis| redis.scan_each(match: 'session:gitlab:*').to_a } + end + + def get_ttl(key) + Gitlab::Redis::SharedState.with { |redis| redis.ttl(key) } + end +end diff --git a/spec/support/helpers/stub_gitlab_calls.rb b/spec/support/helpers/stub_gitlab_calls.rb index 3824ff2b68d..5ab778c11cb 100644 --- a/spec/support/helpers/stub_gitlab_calls.rb +++ b/spec/support/helpers/stub_gitlab_calls.rb @@ -18,6 +18,10 @@ module StubGitlabCalls stub_ci_pipeline_yaml_file(gitlab_ci_yaml) end + def gitlab_ci_yaml + File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) + end + def stub_ci_pipeline_yaml_file(ci_yaml_content) allow_any_instance_of(Repository) .to receive(:gitlab_ci_yml_for) diff --git a/spec/support/helpers/stub_gitlab_data.rb b/spec/support/helpers/stub_gitlab_data.rb deleted file mode 100644 index ed518393c03..00000000000 --- a/spec/support/helpers/stub_gitlab_data.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module StubGitlabData - def gitlab_ci_yaml - File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) - end -end diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index aa5fcf222f2..badd4e8212c 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -452,6 +452,10 @@ module TestEnv example_group end + def seed_db + Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter.import + end + private # These are directories that should be preserved at cleanup time diff --git a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb index 8a88f0335a9..2fbc01a9195 100644 --- a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb +++ b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb @@ -32,7 +32,7 @@ RSpec.shared_examples "migrating a deleted user's associated records to the ghos expect(user).to be_blocked end - it 'migrates all associated fields to te "Ghost user"' do + it 'migrates all associated fields to the "Ghost user"' do service.execute migrated_record = record_class.find_by_id(record.id) @@ -46,40 +46,19 @@ RSpec.shared_examples "migrating a deleted user's associated records to the ghos context "when #{record_class_name} migration fails and is rolled back" do before do expect_any_instance_of(ActiveRecord::Associations::CollectionProxy) - .to receive(:update_all).and_raise(ActiveRecord::Rollback) + .to receive(:update_all).and_raise(ActiveRecord::StatementTimeout) end it 'rolls back the user block' do - service.execute + expect { service.execute }.to raise_error(ActiveRecord::StatementTimeout) expect(user.reload).not_to be_blocked end - it "doesn't unblock an previously-blocked user" do + it "doesn't unblock a previously-blocked user" do user.block - service.execute - - expect(user.reload).to be_blocked - end - end - - context "when #{record_class_name} migration fails with a non-rollback exception" do - before do - expect_any_instance_of(ActiveRecord::Associations::CollectionProxy) - .to receive(:update_all).and_raise(ArgumentError) - end - - it 'rolls back the user block' do - service.execute rescue nil - - expect(user.reload).not_to be_blocked - end - - it "doesn't unblock an previously-blocked user" do - user.block - - service.execute rescue nil + expect { service.execute }.to raise_error(ActiveRecord::StatementTimeout) expect(user.reload).to be_blocked end diff --git a/spec/support/shared_contexts/email_shared_context.rb b/spec/support/shared_contexts/email_shared_context.rb index 14c6c85cc43..0dc66eeb2ee 100644 --- a/spec/support/shared_contexts/email_shared_context.rb +++ b/spec/support/shared_contexts/email_shared_context.rb @@ -18,6 +18,15 @@ RSpec.shared_context :email_shared_context do end end +def email_fixture(path) + fixture_file(path).gsub('project_id', project.project_id.to_s) +end + +def service_desk_fixture(path, slug: nil, key: 'mykey') + slug ||= project.full_path_slug.to_s + fixture_file(path).gsub('project_slug', slug).gsub('project_key', key) +end + RSpec.shared_examples :reply_processing_shared_examples do context 'when the user could not be found' do before do diff --git a/spec/support/shared_contexts/finders/packages/npm/package_finder_shared_context.rb b/spec/support/shared_contexts/finders/packages/npm/package_finder_shared_context.rb new file mode 100644 index 00000000000..a2cb9d41f45 --- /dev/null +++ b/spec/support/shared_contexts/finders/packages/npm/package_finder_shared_context.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +RSpec.shared_context 'last_of_each_version setup context' do + let_it_be(:package1) { create(:npm_package, name: 'test', version: '1.2.3', project: project) } + let_it_be(:package2) { create(:npm_package, name: 'test2', version: '1.2.3', project: project) } + + let(:package_name) { 'test' } + let(:version) { '1.2.3' } + + before do + # create a duplicated package without triggering model validation errors + package2.update_column(:name, 'test') + end +end diff --git a/spec/support/shared_contexts/graphql/resolvers/runners_resolver_shared_context.rb b/spec/support/shared_contexts/graphql/resolvers/runners_resolver_shared_context.rb new file mode 100644 index 00000000000..aa857cfdb70 --- /dev/null +++ b/spec/support/shared_contexts/graphql/resolvers/runners_resolver_shared_context.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_context 'runners resolver setup' do + let_it_be(:user) { create_default(:user, :admin) } + let_it_be(:group) { create(:group, :public) } + let_it_be(:subgroup) { create(:group, :public, parent: group) } + let_it_be(:project) { create(:project, :public, group: group) } + + let_it_be(:inactive_project_runner) do + create(:ci_runner, :project, projects: [project], description: 'inactive project runner', token: 'abcdef', active: false, contacted_at: 1.minute.ago, tag_list: %w(project_runner)) + end + + let_it_be(:offline_project_runner) do + create(:ci_runner, :project, projects: [project], description: 'offline project runner', token: 'defghi', contacted_at: 1.day.ago, tag_list: %w(project_runner active_runner)) + end + + let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], token: 'mnopqr', description: 'group runner', contacted_at: 2.seconds.ago) } + let_it_be(:subgroup_runner) { create(:ci_runner, :group, groups: [subgroup], token: 'mnopqr', description: 'subgroup runner', contacted_at: 1.second.ago) } + let_it_be(:instance_runner) { create(:ci_runner, :instance, description: 'shared runner', token: 'stuvxz', contacted_at: 2.minutes.ago, tag_list: %w(instance_runner active_runner)) } +end diff --git a/spec/support/shared_contexts/issuable/merge_request_shared_context.rb b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb index 2c56411ca4c..b9cde12c537 100644 --- a/spec/support/shared_contexts/issuable/merge_request_shared_context.rb +++ b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb @@ -16,7 +16,7 @@ RSpec.shared_context 'merge request show action' do assign(:merge_request, merge_request) assign(:note, note) assign(:noteable, merge_request) - assign(:pipelines, []) + assign(:number_of_pipelines, 0) assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, merge_request)) preload_view_requirements(merge_request, note) diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index 8ae0885056e..2abc52fce85 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -118,7 +118,8 @@ RSpec.shared_context 'project navbar structure' do _('Access Tokens'), _('Repository'), _('CI/CD'), - _('Monitor') + _('Monitor'), + (s_('UsageQuota|Usage Quotas') if Feature.enabled?(:project_storage_ui, default_enabled: :yaml)) ] } ].compact diff --git a/spec/support/shared_contexts/pages_zip_with_spoofed_size_shared_context.rb b/spec/support/shared_contexts/pages_zip_with_spoofed_size_shared_context.rb deleted file mode 100644 index 4cec5ab3b74..00000000000 --- a/spec/support/shared_contexts/pages_zip_with_spoofed_size_shared_context.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -# the idea of creating zip archive with spoofed size is borrowed from -# https://github.com/rubyzip/rubyzip/pull/403/files#diff-118213fb4baa6404a40f89e1147661ebR88 -RSpec.shared_context 'pages zip with spoofed size' do - let(:real_zip_path) { Tempfile.new(['real', '.zip']).path } - let(:fake_zip_path) { Tempfile.new(['fake', '.zip']).path } - - before do - full_file_name = 'public/index.html' - true_size = 500_000 - fake_size = 1 - - ::Zip::File.open(real_zip_path, ::Zip::File::CREATE) do |zf| - zf.get_output_stream(full_file_name) do |os| - os.write 'a' * true_size - end - end - - compressed_size = nil - ::Zip::File.open(real_zip_path) do |zf| - a_entry = zf.find_entry(full_file_name) - compressed_size = a_entry.compressed_size - end - - true_size_bytes = [compressed_size, true_size, full_file_name.size].pack('LLS') - fake_size_bytes = [compressed_size, fake_size, full_file_name.size].pack('LLS') - - data = File.binread(real_zip_path) - data.gsub! true_size_bytes, fake_size_bytes - - File.open(fake_zip_path, 'wb') do |file| - file.write data - end - end - - after do - File.delete(real_zip_path) if File.exist?(real_zip_path) - File.delete(fake_zip_path) if File.exist?(fake_zip_path) - end -end diff --git a/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb b/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb index 815108be447..89f290d8d68 100644 --- a/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb +++ b/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb @@ -8,14 +8,20 @@ RSpec.shared_context 'npm api setup' do let_it_be(:group) { create(:group, name: 'test-group') } let_it_be(:namespace) { group } let_it_be(:project, reload: true) { create(:project, :public, namespace: namespace) } - let_it_be(:package, reload: true) { create(:npm_package, project: project, name: "@#{group.path}/scoped_package") } + let_it_be(:package1, reload: true) { create(:npm_package, project: project, name: "@#{group.path}/scoped_package", version: '1.2.4') } + let_it_be(:package, reload: true) { create(:npm_package, project: project, name: "@#{group.path}/scoped_package", version: '1.2.3') } let_it_be(:token) { create(:oauth_access_token, scopes: 'api', resource_owner: user) } let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } - let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running) } + let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running, project: project) } let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } let(:package_name) { package.name } + + before do + # create a duplicated package without triggering model validation errors + package1.update_column(:version, '1.2.3') + end end RSpec.shared_context 'set package name from package name type' do diff --git a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb index 6b49a415889..2b810e790f0 100644 --- a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb +++ b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb @@ -6,21 +6,25 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do let(:metrics_definitions) { standard_metrics + subscription_metrics + operational_metrics + optional_metrics } let(:standard_metrics) do [ - metric_attributes('uuid', "standard") + metric_attributes('uuid', 'standard'), + metric_attributes('recorded_at', 'standard'), + metric_attributes('settings.collected_data_categories', 'standard', 'object') ] end let(:operational_metrics) do [ - metric_attributes('counts.merge_requests', "operational"), + metric_attributes('counts.merge_requests', 'operational'), metric_attributes('counts.todos', "operational") ] end let(:optional_metrics) do [ - metric_attributes('counts.boards', "optional"), - metric_attributes('gitaly.filesystems', '').except('data_category') + metric_attributes('counts.boards', 'optional', 'number'), + metric_attributes('gitaly.filesystems', '').except('data_category'), + metric_attributes('usage_activity_by_stage.monitor.projects_with_enabled_alert_integrations_histogram', 'optional', 'object'), + metric_attributes('topology', 'optional', 'object') ] end @@ -34,10 +38,11 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do ) end - def metric_attributes(key_path, category) + def metric_attributes(key_path, category, value_type = 'string') { 'key_path' => key_path, - 'data_category' => category + 'data_category' => category, + 'value_type' => value_type } end end diff --git a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb index cadc753513d..1e303197990 100644 --- a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb +++ b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb @@ -3,14 +3,10 @@ RSpec.shared_examples 'multiple issue boards' do context 'authorized user' do before do - stub_feature_flags(board_new_list: false) - parent.add_maintainer(user) login_as(user) - stub_feature_flags(board_new_list: false) - visit boards_path wait_for_requests end @@ -79,13 +75,13 @@ RSpec.shared_examples 'multiple issue boards' do expect(page).to have_content(board2.name) end - click_button 'Add list' + click_button 'Create list' - wait_for_requests + click_button 'Select a label' - page.within '.dropdown-menu-issues-board-new' do - click_link planning.title - end + page.choose(planning.title) + + click_button 'Add to board' wait_for_requests diff --git a/spec/support/shared_examples/controllers/concerns/integrations_actions_shared_examples.rb b/spec/support/shared_examples/controllers/concerns/integrations_actions_shared_examples.rb new file mode 100644 index 00000000000..748a3acf17b --- /dev/null +++ b/spec/support/shared_examples/controllers/concerns/integrations_actions_shared_examples.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.shared_examples IntegrationsActions do + let(:integration) do + create(:datadog_integration, + integration_attributes.merge( + api_url: 'http://example.com', + api_key: 'secret' + ) + ) + end + + describe 'GET #edit' do + before do + get :edit, params: routing_params + end + + it 'assigns the integration' do + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:integration)).to eq(integration) + end + end + + describe 'PUT #update' do + let(:params) do + { + datadog_env: 'env', + datadog_service: 'service' + } + end + + before do + put :update, params: routing_params.merge(integration: params) + end + + it 'updates the integration with the provided params and redirects to the form' do + expect(response).to redirect_to(routing_params.merge(action: :edit)) + expect(integration.reload).to have_attributes(params) + end + + context 'when sending a password field' do + let(:params) { super().merge(api_key: 'new') } + + it 'updates the integration with the password and other params' do + expect(response).to be_redirect + expect(integration.reload).to have_attributes(params) + end + end + + context 'when sending a blank password field' do + let(:params) { super().merge(api_key: '') } + + it 'ignores the password field and saves the other params' do + expect(response).to be_redirect + expect(integration.reload).to have_attributes(params.merge(api_key: 'secret')) + end + end + end +end diff --git a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb index a9c6da7bc2b..0ffa32dec9e 100644 --- a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb +++ b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb @@ -82,16 +82,6 @@ RSpec.shared_examples 'a GitHub-ish import controller: GET status' do expect(json_response.dig("provider_repos", 1, "id")).to eq(org_repo.id) end - it "does not show already added project" do - project = create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'asd/vim') - stub_client(repos: [repo], orgs: [], each_page: [OpenStruct.new(objects: [repo])].to_enum) - - get :status, format: :json - - expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id) - expect(json_response.dig("provider_repos")).to eq([]) - end - it "touches the etag cache store" do stub_client(repos: [], orgs: [], each_page: []) diff --git a/spec/support/shared_examples/controllers/import_controller_status_shared_examples.rb b/spec/support/shared_examples/controllers/import_controller_status_shared_examples.rb index b9ae0e23e26..44baadaaade 100644 --- a/spec/support/shared_examples/controllers/import_controller_status_shared_examples.rb +++ b/spec/support/shared_examples/controllers/import_controller_status_shared_examples.rb @@ -19,14 +19,4 @@ RSpec.shared_examples 'import controller status' do expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id) expect(json_response.dig("provider_repos", 0, "id")).to eq(repo_id) end - - it "does not show already added project" do - project = create(:project, import_type: provider_name, namespace: user.namespace, import_status: :finished, import_source: import_source) - stub_client(client_repos_field => [repo]) - - get :status, format: :json - - expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id) - expect(json_response.dig("provider_repos")).to eq([]) - end end diff --git a/spec/support/shared_examples/controllers/issuable_anonymous_search_disabled_examples.rb b/spec/support/shared_examples/controllers/issuable_anonymous_search_disabled_examples.rb new file mode 100644 index 00000000000..e77acb93798 --- /dev/null +++ b/spec/support/shared_examples/controllers/issuable_anonymous_search_disabled_examples.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'issuable list with anonymous search disabled' do |action| + let(:controller_action) { :index } + let(:params_with_search) { params.merge(search: 'some search term') } + + context 'when disable_anonymous_search is enabled' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it 'shows a flash message' do + get controller_action, params: params_with_search + + expect(flash.now[:notice]).to eq('You must sign in to search for specific terms.') + end + + context 'when search param is not given' do + it 'does not show a flash message' do + get controller_action, params: params + + expect(flash.now[:notice]).to be_nil + end + end + + context 'when user is signed-in' do + it 'does not show a flash message' do + sign_in(create(:user)) + get controller_action, params: params_with_search + + expect(flash.now[:notice]).to be_nil + end + end + + context 'when format is not HTML' do + it 'does not show a flash message' do + get controller_action, params: params_with_search.merge(format: :atom) + + expect(flash.now[:notice]).to be_nil + end + end + end + + context 'when disable_anonymous_search is disabled' do + before do + stub_feature_flags(disable_anonymous_search: false) + end + + it 'does not show a flash message' do + get controller_action, params: params_with_search + + expect(flash.now[:notice]).to be_nil + end + end +end diff --git a/spec/support/shared_examples/features/atom/issuable_shared_examples.rb b/spec/support/shared_examples/features/atom/issuable_shared_examples.rb new file mode 100644 index 00000000000..17993830f37 --- /dev/null +++ b/spec/support/shared_examples/features/atom/issuable_shared_examples.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an authenticated issuable atom feed" do + it "renders atom feed with common issuable information" do + expect(response_headers['Content-Type']) + .to have_content('application/atom+xml') + expect(body).to have_selector('author email', text: issuable.author_public_email) + expect(body).to have_selector('assignees assignee email', text: issuable.assignees.first.public_email) + expect(body).to have_selector('assignee email', text: issuable.assignees.first.public_email) + expect(body).to have_selector('entry summary', text: issuable.title) + end +end diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb new file mode 100644 index 00000000000..2332285540a --- /dev/null +++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'edits content using the content editor' do + it 'formats text as bold using bubble menu' do + content_editor_testid = '[data-testid="content-editor"] [contenteditable]' + + expect(page).to have_css(content_editor_testid) + + find(content_editor_testid).send_keys 'Typing text in the content editor' + find(content_editor_testid).send_keys [:shift, :left] + + expect(page).to have_css('[data-testid="formatting-bubble-menu"]') + end +end diff --git a/spec/support/shared_examples/features/deploy_token_shared_examples.rb b/spec/support/shared_examples/features/deploy_token_shared_examples.rb index fd77297a490..e70f9b52c09 100644 --- a/spec/support/shared_examples/features/deploy_token_shared_examples.rb +++ b/spec/support/shared_examples/features/deploy_token_shared_examples.rb @@ -1,15 +1,22 @@ # frozen_string_literal: true RSpec.shared_examples 'a deploy token in settings' do - it 'view deploy tokens' do + it 'view deploy tokens', :js do + user.update!(time_display_relative: true) + + visit page_path + within('.deploy-tokens') do expect(page).to have_content(deploy_token.name) expect(page).to have_content('read_repository') expect(page).to have_content('read_registry') + expect(page).to have_content('in 4 days') end end it 'add a new deploy token' do + visit page_path + fill_in 'deploy_token_name', with: 'new_deploy_key' fill_in 'deploy_token_expires_at', with: (Date.today + 1.month).to_s fill_in 'deploy_token_username', with: 'deployer' @@ -24,4 +31,18 @@ RSpec.shared_examples 'a deploy token in settings' do expect(page).to have_selector("input[name='deploy-token'][readonly='readonly']") end end + + context 'when User#time_display_relative is false', :js do + before do + user.update!(time_display_relative: false) + end + + it 'shows absolute times for expires_at' do + visit page_path + + within('.deploy-tokens') do + expect(page).to have_content(deploy_token.expires_at.strftime('%b %d')) + end + end + end end diff --git a/spec/support/shared_examples/features/discussion_comments_shared_example.rb b/spec/support/shared_examples/features/discussion_comments_shared_example.rb index fb2e422559d..318ba67b9e9 100644 --- a/spec/support/shared_examples/features/discussion_comments_shared_example.rb +++ b/spec/support/shared_examples/features/discussion_comments_shared_example.rb @@ -7,7 +7,7 @@ RSpec.shared_examples 'thread comments for commit and snippet' do |resource_name let(:menu_selector) { "#{dropdown_selector} .dropdown-menu" } let(:submit_selector) { "#{form_selector} .js-comment-submit-button" } let(:close_selector) { "#{form_selector} .btn-comment-and-close" } - let(:comments_selector) { '.timeline > .note.timeline-entry' } + let(:comments_selector) { '.timeline > .note.timeline-entry:not(.being-posted)' } let(:comment) { 'My comment' } it 'clicking "Comment" will post a comment' do @@ -187,7 +187,7 @@ RSpec.shared_examples 'thread comments for issue, epic and merge request' do |re let(:toggle_selector) { "#{dropdown_selector} .dropdown-toggle-split" } let(:menu_selector) { "#{dropdown_selector} .dropdown-menu" } let(:close_selector) { "#{form_selector} .btn-comment-and-close" } - let(:comments_selector) { '.timeline > .note.timeline-entry' } + let(:comments_selector) { '.timeline > .note.timeline-entry:not(.being-posted)' } let(:comment) { 'My comment' } it 'clicking "Comment" will post a comment' do @@ -197,6 +197,8 @@ RSpec.shared_examples 'thread comments for issue, epic and merge request' do |re find(submit_button_selector).click + wait_for_all_requests + expect(page).to have_content(comment) new_comment = all(comments_selector).last diff --git a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb index c0cfc27ceaf..149486320ae 100644 --- a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb +++ b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb @@ -15,7 +15,7 @@ RSpec.shared_examples 'issuable invite members' do page.within '.dropdown-menu-user' do expect(page).to have_link('Invite Members') - expect(page).to have_selector('[data-track-event="click_invite_members"]') + expect(page).to have_selector('[data-track-action="click_invite_members"]') expect(page).to have_selector('[data-track-label="edit_assignee"]') end diff --git a/spec/support/shared_examples/features/manage_applications_shared_examples.rb b/spec/support/shared_examples/features/manage_applications_shared_examples.rb index 38bb87eaed2..0161899cb76 100644 --- a/spec/support/shared_examples/features/manage_applications_shared_examples.rb +++ b/spec/support/shared_examples/features/manage_applications_shared_examples.rb @@ -9,9 +9,11 @@ RSpec.shared_examples 'manage applications' do visit new_application_path expect(page).to have_content 'Add new application' + expect(find('#doorkeeper_application_expire_access_tokens')).to be_checked fill_in :doorkeeper_application_name, with: application_name fill_in :doorkeeper_application_redirect_uri, with: application_redirect_uri + uncheck :doorkeeper_application_expire_access_tokens check :doorkeeper_application_scopes_read_user click_on 'Save application' @@ -22,6 +24,8 @@ RSpec.shared_examples 'manage applications' do click_on 'Edit' + expect(find('#doorkeeper_application_expire_access_tokens')).not_to be_checked + application_name_changed = "#{application_name} changed" fill_in :doorkeeper_application_name, with: application_name_changed diff --git a/spec/support/shared_examples/features/rss_shared_examples.rb b/spec/support/shared_examples/features/rss_shared_examples.rb index c7c2aeea358..0991de21d8d 100644 --- a/spec/support/shared_examples/features/rss_shared_examples.rb +++ b/spec/support/shared_examples/features/rss_shared_examples.rb @@ -25,3 +25,23 @@ RSpec.shared_examples "it has an RSS button without a feed token" do .to have_css("a:has(.qa-rss-icon):not([href*='feed_token'])") # rubocop:disable QA/SelectorUsage end end + +RSpec.shared_examples "updates atom feed link" do |type| + it "for #{type}" do + sign_in(user) + visit path + + link = find_link('Subscribe to RSS feed') + params = CGI.parse(URI.parse(link[:href]).query) + auto_discovery_link = find("link[type='application/atom+xml']", visible: false) + auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) + + expected = { + 'feed_token' => [user.feed_token], + 'assignee_id' => [user.id.to_s] + } + + expect(params).to include(expected) + expect(auto_discovery_params).to include(expected) + end +end diff --git a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb index 9587da0233e..7ced8508a31 100644 --- a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb +++ b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb @@ -136,6 +136,14 @@ RSpec.shared_examples 'User updates wiki page' do expect(find('textarea#wiki_content').value).to eq('Updated Wiki Content') end end + + context 'when using the content editor' do + before do + click_button 'Use the new editor' + end + + it_behaves_like 'edits content using the content editor' + end end context 'when the page is in a subdir', :js do diff --git a/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb index 61feeff57bb..96df5a5f972 100644 --- a/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb +++ b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb @@ -157,7 +157,7 @@ RSpec.shared_examples 'User views a wiki page' do expect(page).to have_link('updated home', href: wiki_page_path(wiki, wiki_page, version_id: commit2, action: :diff)) end - it 'between the current and the previous version of a page' do + it 'between the current and the previous version of a page', :js do commit = wiki.commit visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff) @@ -169,7 +169,7 @@ RSpec.shared_examples 'User views a wiki page' do expect_diff_links(commit) end - it 'between two old versions of a page' do + it 'between two old versions of a page', :js do wiki_page.update(message: 'latest home change', content: 'updated [another link](other-page)') # rubocop:disable Rails/SaveBang: commit = wiki.commit('HEAD^') visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff) @@ -184,7 +184,7 @@ RSpec.shared_examples 'User views a wiki page' do expect_diff_links(commit) end - it 'for the oldest version of a page' do + it 'for the oldest version of a page', :js do commit = wiki.commit('HEAD^') visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff) diff --git a/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb b/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb new file mode 100644 index 00000000000..6342064beb8 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +shared_examples 'deployment metrics examples' do + def create_deployment(args) + project = args[:project] + environment = project.environments.production.first || create(:environment, :production, project: project) + create(:deployment, :success, args.merge(environment: environment)) + + # this is needed for the dora_deployment_frequency_in_vsa feature flag so we have aggregated data + ::Dora::DailyMetrics::RefreshWorker.new.perform(environment.id, Time.current.to_date.to_s) if Gitlab.ee? + end + + describe "#deploys" do + subject { stage_summary.third } + + context 'when from date is given' do + before do + travel_to(5.days.ago) { create_deployment(project: project) } + create_deployment(project: project) + end + + it "finds the number of deploys made created after the 'from date'" do + expect(subject[:value]).to eq('1') + end + + it 'returns the localized title' do + Gitlab::I18n.with_locale(:ru) do + expect(subject[:title]).to eq(n_('Deploy', 'Deploys', 1)) + end + end + end + + it "doesn't find commits from other projects" do + travel_to(5.days.from_now) do + create_deployment(project: create(:project, :repository)) + end + + expect(subject[:value]).to eq('-') + end + + context 'when `to` parameter is given' do + before do + travel_to(5.days.ago) { create_deployment(project: project) } + travel_to(5.days.from_now) { create_deployment(project: project) } + end + + it "doesn't find any record" do + options[:to] = Time.now + + expect(subject[:value]).to eq('-') + end + + it "finds records created between `from` and `to` range" do + options[:from] = 10.days.ago + options[:to] = 10.days.from_now + + expect(subject[:value]).to eq('2') + end + end + end + + describe '#deployment_frequency' do + subject { stage_summary.fourth[:value] } + + it 'includes the unit: `per day`' do + expect(stage_summary.fourth[:unit]).to eq _('per day') + end + + before do + travel_to(5.days.ago) { create_deployment(project: project) } + end + + it 'returns 0.0 when there were deploys but the frequency was too low' do + options[:from] = 30.days.ago + + # 1 deployment over 30 days + # frequency of 0.03, rounded off to 0.0 + expect(subject).to eq('0') + end + + it 'returns `-` when there were no deploys' do + options[:from] = 4.days.ago + + # 0 deployment in the last 4 days + expect(subject).to eq('-') + end + + context 'when `to` is nil' do + it 'includes range until now' do + options[:from] = 6.days.ago + options[:to] = nil + + # 1 deployment over 7 days + expect(subject).to eq('0.1') + end + end + + context 'when `to` is given' do + before do + travel_to(5.days.from_now) { create_deployment(project: project, finished_at: Time.zone.now) } + end + + it 'finds records created between `from` and `to` range' do + options[:from] = 10.days.ago + options[:to] = 10.days.from_now + + # 2 deployments over 20 days + expect(subject).to eq('0.1') + end + + context 'when `from` and `to` are within a day' do + it 'returns the number of deployments made on that day' do + freeze_time do + create_deployment(project: project, finished_at: Time.current) + options[:from] = Time.current.yesterday.beginning_of_day + options[:to] = Time.current.end_of_day + + expect(subject).to eq('0.5') + end + end + end + end + end +end diff --git a/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb index 89b793d5e16..708bc71ae96 100644 --- a/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb @@ -39,6 +39,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| allow(fake_duplicate_job).to receive(:scheduled?).and_return(false) allow(fake_duplicate_job).to receive(:check!).and_return('the jid') allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) + allow(fake_duplicate_job).to receive(:update_latest_wal_location!) allow(fake_duplicate_job).to receive(:options).and_return({}) job_hash = {} @@ -63,6 +64,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| .with(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DUPLICATE_KEY_TTL) .and_return('the jid')) allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) + allow(fake_duplicate_job).to receive(:update_latest_wal_location!) job_hash = {} expect(fake_duplicate_job).to receive(:duplicate?).and_return(true) @@ -83,6 +85,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| allow(fake_duplicate_job).to( receive(:check!).with(time_diff.to_i).and_return('the jid')) allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) + allow(fake_duplicate_job).to receive(:update_latest_wal_location!) job_hash = {} expect(fake_duplicate_job).to receive(:duplicate?).and_return(true) @@ -105,6 +108,13 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| allow(fake_duplicate_job).to receive(:options).and_return({}) allow(fake_duplicate_job).to receive(:existing_jid).and_return('the jid') allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) + allow(fake_duplicate_job).to receive(:update_latest_wal_location!) + end + + it 'updates latest wal location' do + expect(fake_duplicate_job).to receive(:update_latest_wal_location!) + + strategy.schedule({ 'jid' => 'new jid' }) {} end it 'drops the job' do @@ -136,4 +146,46 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| end end end + + describe '#perform' do + let(:proc) { -> {} } + let(:job) { { 'jid' => 'new jid', 'wal_locations' => { 'main' => '0/1234', 'ci' => '0/1234' } } } + let(:wal_locations) do + { + main: '0/D525E3A8', + ci: 'AB/12345' + } + end + + before do + allow(fake_duplicate_job).to receive(:delete!) + allow(fake_duplicate_job).to receive(:latest_wal_locations).and_return( wal_locations ) + end + + it 'updates job hash with dedup_wal_locations' do + strategy.perform(job) do + proc.call + end + + expect(job['dedup_wal_locations']).to eq(wal_locations) + end + + shared_examples 'does not update job hash' do + it 'does not update job hash with dedup_wal_locations' do + strategy.perform(job) do + proc.call + end + + expect(job).not_to include('dedup_wal_locations') + end + end + + context 'when latest_wal_location is empty' do + before do + allow(fake_duplicate_job).to receive(:latest_wal_locations).and_return( {} ) + end + + include_examples 'does not update job hash' + end + end end diff --git a/spec/support/shared_examples/mailers/notify_shared_examples.rb b/spec/support/shared_examples/mailers/notify_shared_examples.rb index b10ebb4d2a3..e1f7a9030e2 100644 --- a/spec/support/shared_examples/mailers/notify_shared_examples.rb +++ b/spec/support/shared_examples/mailers/notify_shared_examples.rb @@ -2,7 +2,7 @@ RSpec.shared_examples 'a multiple recipients email' do it 'is sent to the given recipient' do - is_expected.to deliver_to recipient.notification_email + is_expected.to deliver_to recipient.notification_email_or_default end end @@ -21,7 +21,7 @@ end RSpec.shared_examples 'an email sent to a user' do it 'is sent to user\'s global notification email address' do - expect(subject).to deliver_to(recipient.notification_email) + expect(subject).to deliver_to(recipient.notification_email_or_default) end context 'with group notification email' do @@ -227,7 +227,7 @@ RSpec.shared_examples 'a note email' do aggregate_failures do expect(sender.display_name).to eq("#{note_author.name} (@#{note_author.username})") expect(sender.address).to eq(gitlab_sender) - expect(subject).to deliver_to(recipient.notification_email) + expect(subject).to deliver_to(recipient.notification_email_or_default) end end diff --git a/spec/support/shared_examples/models/concerns/featurable_shared_examples.rb b/spec/support/shared_examples/models/concerns/featurable_shared_examples.rb new file mode 100644 index 00000000000..2f165ef604f --- /dev/null +++ b/spec/support/shared_examples/models/concerns/featurable_shared_examples.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'access level validation' do |features| + features.each do |feature| + it "does not allow public access level for #{feature}" do + field = "#{feature}_access_level".to_sym + container_features.update_attribute(field, ProjectFeature::PUBLIC) + + expect(container_features.valid?).to be_falsy, "#{field} failed" + end + end +end diff --git a/spec/support/shared_examples/models/concerns/sanitizable_shared_examples.rb b/spec/support/shared_examples/models/concerns/sanitizable_shared_examples.rb new file mode 100644 index 00000000000..ed94a71892d --- /dev/null +++ b/spec/support/shared_examples/models/concerns/sanitizable_shared_examples.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'sanitizable' do |factory, fields| + let(:attributes) { fields.to_h { |field| [field, input] } } + + it 'includes Sanitizable' do + expect(described_class).to include(Sanitizable) + end + + fields.each do |field| + subject do + record = build(factory, attributes) + record.valid? + + record.public_send(field) + end + + describe "##{field}" do + context 'when input includes javascript tags' do + let(:input) { 'hello<script>alert(1)</script>' } + + it 'gets sanitized' do + expect(subject).to eq('hello') + end + end + end + + describe "##{field} validation" do + context 'when input contains pre-escaped html entities' do + let_it_be(:input) { '<script>alert(1)</script>' } + + subject { build(factory, attributes) } + + it 'is not valid', :aggregate_failures do + expect(subject).not_to be_valid + expect(subject.errors.details[field].flat_map(&:values)).to include('cannot contain escaped HTML entities') + end + end + end + end +end diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb index c111d250d34..56c202cb228 100644 --- a/spec/support/shared_examples/models/member_shared_examples.rb +++ b/spec/support/shared_examples/models/member_shared_examples.rb @@ -300,8 +300,21 @@ RSpec.shared_examples_for "member creation" do end end end +end + +RSpec.shared_examples_for "bulk member creation" do + let_it_be(:user) { create(:user) } + let_it_be(:admin) { create(:admin) } + + describe '#execute' do + it 'raises an error when exiting_members is not passed in the args hash' do + expect do + described_class.new(source, user, :maintainer, current_user: user).execute + end.to raise_error(ArgumentError, 'existing_members must be included in the args hash') + end + end - describe '.add_users' do + describe '.add_users', :aggregate_failures do let_it_be(:user1) { create(:user) } let_it_be(:user2) { create(:user) } @@ -310,8 +323,8 @@ RSpec.shared_examples_for "member creation" do expect(members).to be_a Array expect(members.size).to eq(2) - expect(members.first).to be_a member_type - expect(members.first).to be_persisted + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) end it 'returns an empty array' do @@ -329,5 +342,42 @@ RSpec.shared_examples_for "member creation" do expect(members.size).to eq(4) expect(members.first).to be_invite end + + context 'with de-duplication' do + it 'with the same user by id and user' do + members = described_class.add_users(source, [user1.id, user1, user1.id, user2, user2.id, user2], :maintainer) + + expect(members).to be_a Array + expect(members.size).to eq(2) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end + + it 'with the same user sent more than once' do + members = described_class.add_users(source, [user1, user1], :maintainer) + + expect(members).to be_a Array + expect(members.size).to eq(1) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end + end + + context 'when a member already exists' do + before do + source.add_user(user1, :developer) + end + + it 'supports existing users as expected' do + user3 = create(:user) + + members = described_class.add_users(source, [user1.id, user2, user3.id], :maintainer) + + expect(members).to be_a Array + expect(members.size).to eq(3) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end + end end end diff --git a/spec/support/shared_examples/models/mentionable_shared_examples.rb b/spec/support/shared_examples/models/mentionable_shared_examples.rb index 07c5f730e95..e23658d1774 100644 --- a/spec/support/shared_examples/models/mentionable_shared_examples.rb +++ b/spec/support/shared_examples/models/mentionable_shared_examples.rb @@ -207,7 +207,7 @@ RSpec.shared_examples 'an editable mentionable' do end RSpec.shared_examples 'mentions in description' do |mentionable_type| - shared_examples 'when storing user mentions' do + context 'when storing user mentions' do before do mentionable.store_mentions! end @@ -238,26 +238,10 @@ RSpec.shared_examples 'mentions in description' do |mentionable_type| end end end - - context 'when store_mentions_without_subtransaction is enabled' do - before do - stub_feature_flags(store_mentions_without_subtransaction: true) - end - - it_behaves_like 'when storing user mentions' - end - - context 'when store_mentions_without_subtransaction is disabled' do - before do - stub_feature_flags(store_mentions_without_subtransaction: false) - end - - it_behaves_like 'when storing user mentions' - end end RSpec.shared_examples 'mentions in notes' do |mentionable_type| - shared_examples 'when mentionable notes contain mentions' do + context 'when mentionable notes contain mentions' do let(:user) { create(:user) } let(:user2) { create(:user) } let(:group) { create(:group) } @@ -277,22 +261,6 @@ RSpec.shared_examples 'mentions in notes' do |mentionable_type| expect(mentionable.referenced_groups(user)).to eq [group] end end - - context 'when store_mentions_without_subtransaction is enabled' do - before do - stub_feature_flags(store_mentions_without_subtransaction: true) - end - - it_behaves_like 'when mentionable notes contain mentions' - end - - context 'when store_mentions_without_subtransaction is disabled' do - before do - stub_feature_flags(store_mentions_without_subtransaction: false) - end - - it_behaves_like 'when mentionable notes contain mentions' - end end RSpec.shared_examples 'load mentions from DB' do |mentionable_type| diff --git a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb index 4d328c03641..74b1bacc560 100644 --- a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb +++ b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb @@ -31,6 +31,131 @@ RSpec.shared_examples 'namespace traversal scopes' do it { expect(subject.where_values_hash).not_to have_key(:type) } end + describe '.order_by_depth' do + subject { described_class.where(id: [group_1, nested_group_1, deep_nested_group_1]).order_by_depth(direction) } + + context 'ascending' do + let(:direction) { :asc } + + it { is_expected.to eq [deep_nested_group_1, nested_group_1, group_1] } + end + + context 'descending' do + let(:direction) { :desc } + + it { is_expected.to eq [group_1, nested_group_1, deep_nested_group_1] } + end + end + + describe '.normal_select' do + let(:query_result) { described_class.where(id: group_1).normal_select } + + subject { query_result.column_names } + + it { is_expected.to eq described_class.column_names } + end + + shared_examples '.self_and_ancestors' do + subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_ancestors } + + it { is_expected.to contain_exactly(group_1, nested_group_1, group_2, nested_group_2) } + + context 'when include_self is false' do + subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_ancestors(include_self: false) } + + it { is_expected.to contain_exactly(group_1, group_2) } + end + + context 'when hierarchy_order is ascending' do + subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_ancestors(hierarchy_order: :asc) } + + # Recursive order per level is not defined. + it { is_expected.to contain_exactly(nested_group_1, nested_group_2, group_1, group_2) } + it { expect(subject[0, 2]).to contain_exactly(nested_group_1, nested_group_2) } + it { expect(subject[2, 2]).to contain_exactly(group_1, group_2) } + end + + context 'when hierarchy_order is descending' do + subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_ancestors(hierarchy_order: :desc) } + + # Recursive order per level is not defined. + it { is_expected.to contain_exactly(nested_group_1, nested_group_2, group_1, group_2) } + it { expect(subject[0, 2]).to contain_exactly(group_1, group_2) } + it { expect(subject[2, 2]).to contain_exactly(nested_group_1, nested_group_2) } + end + end + + describe '.self_and_ancestors' do + context "use_traversal_ids_ancestor_scopes feature flag is true" do + before do + stub_feature_flags(use_traversal_ids: true) + stub_feature_flags(use_traversal_ids_for_ancestor_scopes: true) + end + + it_behaves_like '.self_and_ancestors' + + it 'not make recursive queries' do + expect { described_class.where(id: [nested_group_1]).self_and_ancestors.load }.not_to make_queries_matching(/WITH RECURSIVE/) + end + end + + context "use_traversal_ids_ancestor_scopes feature flag is false" do + before do + stub_feature_flags(use_traversal_ids_for_ancestor_scopes: false) + end + + it_behaves_like '.self_and_ancestors' + + it 'make recursive queries' do + expect { described_class.where(id: [nested_group_1]).self_and_ancestors.load }.to make_queries_matching(/WITH RECURSIVE/) + end + end + end + + shared_examples '.self_and_ancestor_ids' do + subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_ancestor_ids.pluck(:id) } + + it { is_expected.to contain_exactly(group_1.id, nested_group_1.id, group_2.id, nested_group_2.id) } + + context 'when include_self is false' do + subject do + described_class + .where(id: [nested_group_1, nested_group_2]) + .self_and_ancestor_ids(include_self: false) + .pluck(:id) + end + + it { is_expected.to contain_exactly(group_1.id, group_2.id) } + end + end + + describe '.self_and_ancestor_ids' do + context "use_traversal_ids_ancestor_scopes feature flag is true" do + before do + stub_feature_flags(use_traversal_ids: true) + stub_feature_flags(use_traversal_ids_for_ancestor_scopes: true) + end + + it_behaves_like '.self_and_ancestor_ids' + + it 'make recursive queries' do + expect { described_class.where(id: [nested_group_1]).self_and_ancestor_ids.load }.not_to make_queries_matching(/WITH RECURSIVE/) + end + end + + context "use_traversal_ids_ancestor_scopes feature flag is false" do + before do + stub_feature_flags(use_traversal_ids_for_ancestor_scopes: false) + end + + it_behaves_like '.self_and_ancestor_ids' + + it 'make recursive queries' do + expect { described_class.where(id: [nested_group_1]).self_and_ancestor_ids.load }.to make_queries_matching(/WITH RECURSIVE/) + end + end + end + describe '.self_and_descendants' do subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_descendants } diff --git a/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb index 1ad38a17f9c..acbcf4f7f3d 100644 --- a/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb @@ -36,8 +36,8 @@ RSpec.shared_examples 'process helm service index request' do |user_type, status expect(yaml_response.keys).to contain_exactly('apiVersion', 'entries', 'generated', 'serverInfo') expect(yaml_response['entries']).to be_a(Hash) - expect(yaml_response['entries'].keys).to contain_exactly(package.name) - expect(yaml_response['serverInfo']).to eq({ 'contextPath' => "/api/v4/projects/#{project.id}/packages/helm" }) + expect(yaml_response['entries'].keys).to contain_exactly(package.name, package2.name) + expect(yaml_response['serverInfo']).to eq({ 'contextPath' => "/api/v4/projects/#{project_id}/packages/helm" }) package_entry = yaml_response['entries'][package.name] @@ -45,6 +45,14 @@ RSpec.shared_examples 'process helm service index request' do |user_type, status expect(package_entry.first.keys).to contain_exactly('name', 'version', 'apiVersion', 'created', 'digest', 'urls') expect(package_entry.first['digest']).to eq('fd2b2fa0329e80a2a602c2bb3b40608bcd6ee5cf96cf46fd0d2800a4c129c9db') expect(package_entry.first['urls']).to eq(["charts/#{package.name}-#{package.version}.tgz"]) + + package_entry = yaml_response['entries'][package2.name] + + expect(package_entry.length).to eq(1) + expect(package_entry.first.keys).to contain_exactly('name', 'version', 'apiVersion', 'created', 'digest', 'urls', 'description') + expect(package_entry.first['digest']).to eq('file2') + expect(package_entry.first['description']).to eq('hello from stable channel') + expect(package_entry.first['urls']).to eq(['charts/filename2.tgz']) end end end @@ -174,6 +182,13 @@ RSpec.shared_examples 'process helm download content request' do |user_type, sta context "for user type #{user_type}" do before do project.send("add_#{user_type}", user) if user_type != :anonymous && user_type != :not_a_member + + expect_next_found_instance_of(::Packages::PackageFile) do |package_file| + expect(package_file).to receive(:file).and_wrap_original do |m, *args| + expect(package_file.id).to eq(package_file2.id) + m.call(*args) + end + end end it_behaves_like 'a package tracking event', 'API::HelmPackages', 'pull_package' @@ -189,7 +204,7 @@ end RSpec.shared_examples 'rejects helm access with unknown project id' do context 'with an unknown project' do - let(:project) { OpenStruct.new(id: 1234567890) } + let(:project_id) { 1234567890 } context 'as anonymous' do it_behaves_like 'rejects helm packages access', :anonymous, :unauthorized diff --git a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb index 0390e60747f..2af7b616659 100644 --- a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb @@ -21,11 +21,24 @@ RSpec.shared_examples 'handling get metadata requests' do |scope: :project| expect(response).to match_response_schema('public_api/v4/packages/npm_package') expect(json_response['name']).to eq(package.name) expect(json_response['versions'][package.version]).to match_schema('public_api/v4/packages/npm_package_version') - ::Packages::Npm::PackagePresenter::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type| + ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| expect(json_response.dig('versions', package.version, dependency_type.to_s)).to be_any end expect(json_response['dist-tags']).to match_schema('public_api/v4/packages/npm_package_tags') end + + it 'avoids N+1 database queries' do + control = ActiveRecord::QueryRecorder.new { get(url, headers: headers) } + + create_list(:npm_package, 5, project: project, name: package_name).each do |npm_package| + ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type| + create(:packages_dependency_link, package: package, dependency_type: dependency_type) + end + end + + # query count can slightly change between the examples so we're using a custom threshold + expect { get(url, headers: headers) }.not_to exceed_query_limit(control).with_threshold(4) + end end shared_examples 'reject metadata request' do |status:| diff --git a/spec/support/shared_examples/requests/api/packages_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_shared_examples.rb index ecde4ee8565..eb650b7a09f 100644 --- a/spec/support/shared_examples/requests/api/packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/packages_shared_examples.rb @@ -153,3 +153,15 @@ RSpec.shared_examples 'a package tracking event' do |category, action| expect_snowplow_event(category: category, action: action, **snowplow_gitlab_standard_context) end end + +RSpec.shared_examples 'not a package tracking event' do + before do + stub_feature_flags(collect_package_events: true) + end + + it 'does not create a gitlab tracking event', :snowplow, :aggregate_failures do + expect { subject }.not_to change { Packages::Event.count } + + expect_no_snowplow_event + end +end 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 95817624658..2a19ff6f590 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_setting_prefix: "throttle_authenticated_api", "throttle_authenticated_web", "throttle_protected_paths", "throttle_authenticated_packages_api", "throttle_authenticated_git_lfs", "throttle_authenticated_files_api" # * request_method # * request_args # * other_user_request_args @@ -14,7 +14,9 @@ RSpec.shared_examples 'rate-limited token-authenticated requests' do "throttle_protected_paths" => "throttle_authenticated_protected_paths_api", "throttle_authenticated_api" => "throttle_authenticated_api", "throttle_authenticated_web" => "throttle_authenticated_web", - "throttle_authenticated_packages_api" => "throttle_authenticated_packages_api" + "throttle_authenticated_packages_api" => "throttle_authenticated_packages_api", + "throttle_authenticated_git_lfs" => "throttle_authenticated_git_lfs", + "throttle_authenticated_files_api" => "throttle_authenticated_files_api" } end @@ -165,7 +167,7 @@ RSpec.shared_examples 'rate-limited token-authenticated requests' do end # Requires let variables: -# * throttle_setting_prefix: "throttle_authenticated_web" or "throttle_protected_paths" +# * throttle_setting_prefix: "throttle_authenticated_web", "throttle_protected_paths", "throttle_authenticated_git_lfs" # * user # * url_that_requires_authentication # * request_method @@ -176,7 +178,8 @@ RSpec.shared_examples 'rate-limited web authenticated requests' do let(:throttle_types) do { "throttle_protected_paths" => "throttle_authenticated_protected_paths_web", - "throttle_authenticated_web" => "throttle_authenticated_web" + "throttle_authenticated_web" => "throttle_authenticated_web", + "throttle_authenticated_git_lfs" => "throttle_authenticated_git_lfs" } end @@ -385,3 +388,194 @@ RSpec.shared_examples 'tracking when dry-run mode is set' do end end end + +# Requires let variables: +# * throttle_name: "throttle_unauthenticated_api", "throttle_unauthenticated_web" +# * throttle_setting_prefix: "throttle_unauthenticated_api", "throttle_unauthenticated" +# * url_that_does_not_require_authentication +# * url_that_is_not_matched +# * requests_per_period +# * period_in_seconds +# * period +RSpec.shared_examples 'rate-limited unauthenticated requests' do + before do + # Set low limits + settings_to_set[:"#{throttle_setting_prefix}_requests_per_period"] = requests_per_period + settings_to_set[:"#{throttle_setting_prefix}_period_in_seconds"] = period_in_seconds + end + + context 'when the throttle is enabled' do + before do + settings_to_set[:"#{throttle_setting_prefix}_enabled"] = true + stub_application_setting(settings_to_set) + end + + it 'rejects requests over the rate limit' do + # At first, allow requests under the rate limit. + requests_per_period.times do + get url_that_does_not_require_authentication + expect(response).to have_gitlab_http_status(:ok) + end + + # the last straw + expect_rejection { get url_that_does_not_require_authentication } + end + + context 'with custom response text' do + before do + stub_application_setting(rate_limiting_response_text: 'Custom response') + end + + it 'rejects requests over the rate limit' do + # At first, allow requests under the rate limit. + requests_per_period.times do + get url_that_does_not_require_authentication + expect(response).to have_gitlab_http_status(:ok) + end + + # the last straw + expect_rejection { get url_that_does_not_require_authentication } + expect(response.body).to eq("Custom response\n") + end + end + + it 'allows requests after throttling and then waiting for the next period' do + requests_per_period.times do + get url_that_does_not_require_authentication + expect(response).to have_gitlab_http_status(:ok) + end + + expect_rejection { get url_that_does_not_require_authentication } + + travel_to(period.from_now) do + requests_per_period.times do + get url_that_does_not_require_authentication + expect(response).to have_gitlab_http_status(:ok) + end + + expect_rejection { get url_that_does_not_require_authentication } + end + end + + it 'counts requests from different IPs separately' do + requests_per_period.times do + get url_that_does_not_require_authentication + expect(response).to have_gitlab_http_status(:ok) + end + + expect_next_instance_of(Rack::Attack::Request) do |instance| + expect(instance).to receive(:ip).at_least(:once).and_return('1.2.3.4') + end + + # would be over limit for the same IP + get url_that_does_not_require_authentication + expect(response).to have_gitlab_http_status(:ok) + end + + context 'when the request is not matched by the throttle' do + it 'does not throttle the requests' do + (1 + requests_per_period).times do + get url_that_is_not_matched + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + context 'when the request is to the api internal endpoints' do + it 'allows requests over the rate limit' do + (1 + requests_per_period).times do + get '/api/v4/internal/check', params: { secret_token: Gitlab::Shell.secret_token } + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + context 'when the request is authenticated by a runner token' do + let(:request_jobs_url) { '/api/v4/jobs/request' } + let(:runner) { create(:ci_runner) } + + it 'does not count as unauthenticated' do + (1 + requests_per_period).times do + post request_jobs_url, params: { token: runner.token } + expect(response).to have_gitlab_http_status(:no_content) + end + end + end + + context 'when the request is to a health endpoint' do + let(:health_endpoint) { '/-/metrics' } + + it 'does not throttle the requests' do + (1 + requests_per_period).times do + get health_endpoint + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + context 'when the request is to a container registry notification endpoint' do + let(:secret_token) { 'secret_token' } + let(:events) { [{ action: 'push' }] } + let(:registry_endpoint) { '/api/v4/container_registry_event/events' } + let(:registry_headers) { { 'Content-Type' => ::API::ContainerRegistryEvent::DOCKER_DISTRIBUTION_EVENTS_V1_JSON } } + + before do + allow(Gitlab.config.registry).to receive(:notification_secret) { secret_token } + + event = spy(:event) + allow(::ContainerRegistry::Event).to receive(:new).and_return(event) + allow(event).to receive(:supported?).and_return(true) + end + + it 'does not throttle the requests' do + (1 + requests_per_period).times do + post registry_endpoint, + params: { events: events }.to_json, + headers: registry_headers.merge('Authorization' => secret_token) + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + it 'logs RackAttack info into structured logs' do + requests_per_period.times do + get url_that_does_not_require_authentication + expect(response).to have_gitlab_http_status(:ok) + end + + arguments = a_hash_including({ + message: 'Rack_Attack', + env: :throttle, + remote_ip: '127.0.0.1', + request_method: 'GET', + path: url_that_does_not_require_authentication, + matched: throttle_name + }) + + expect(Gitlab::AuthLogger).to receive(:error).with(arguments) + + get url_that_does_not_require_authentication + end + + it_behaves_like 'tracking when dry-run mode is set' do + def do_request + get url_that_does_not_require_authentication + end + end + end + + context 'when the throttle is disabled' do + before do + settings_to_set[:"#{throttle_setting_prefix}_enabled"] = false + stub_application_setting(settings_to_set) + end + + it 'allows requests over the rate limit' do + (1 + requests_per_period).times do + get url_that_does_not_require_authentication + expect(response).to have_gitlab_http_status(:ok) + end + end + end +end diff --git a/spec/support/shared_examples/services/dependency_proxy_ttl_policies_shared_examples.rb b/spec/support/shared_examples/services/dependency_proxy_ttl_policies_shared_examples.rb new file mode 100644 index 00000000000..f6692646ca8 --- /dev/null +++ b/spec/support/shared_examples/services/dependency_proxy_ttl_policies_shared_examples.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'updating the dependency proxy image ttl policy attributes' do |from: {}, to:| + it_behaves_like 'not creating the dependency proxy image ttl policy' + + it 'updates the dependency proxy image ttl policy' do + expect { subject } + .to change { group.dependency_proxy_image_ttl_policy.reload.enabled }.from(from[:enabled]).to(to[:enabled]) + .and change { group.dependency_proxy_image_ttl_policy.reload.ttl }.from(from[:ttl]).to(to[:ttl]) + end +end + +RSpec.shared_examples 'not creating the dependency proxy image ttl policy' do + it "doesn't create the dependency proxy image ttl policy" do + expect { subject }.not_to change { DependencyProxy::ImageTtlGroupPolicy.count } + end +end + +RSpec.shared_examples 'creating the dependency proxy image ttl policy' do + it 'creates a new package setting' do + expect { subject }.to change { DependencyProxy::ImageTtlGroupPolicy.count }.by(1) + end + + it 'saves the settings' do + subject + + expect(group.dependency_proxy_image_ttl_policy).to have_attributes( + enabled: ttl_policy[:enabled], + ttl: ttl_policy[:ttl] + ) + end + + it_behaves_like 'returning a success' +end diff --git a/spec/support/shared_examples/services/incident_shared_examples.rb b/spec/support/shared_examples/services/incident_shared_examples.rb index 9fced12b543..0277cce975a 100644 --- a/spec/support/shared_examples/services/incident_shared_examples.rb +++ b/spec/support/shared_examples/services/incident_shared_examples.rb @@ -13,6 +13,7 @@ RSpec.shared_examples 'incident issue' do it 'has incident as issue type' do expect(issue.issue_type).to eq('incident') + expect(issue.work_item_type.base_type).to eq('incident') end end @@ -41,6 +42,7 @@ RSpec.shared_examples 'not an incident issue' do it 'has not incident as issue type' do expect(issue.issue_type).not_to eq('incident') + expect(issue.work_item_type.base_type).not_to eq('incident') end it 'has not an incident label' do diff --git a/spec/support/shared_examples/services/users/dismiss_user_callout_service_shared_examples.rb b/spec/support/shared_examples/services/users/dismiss_user_callout_service_shared_examples.rb new file mode 100644 index 00000000000..09820593cdb --- /dev/null +++ b/spec/support/shared_examples/services/users/dismiss_user_callout_service_shared_examples.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.shared_examples_for 'dismissing user callout' do |model| + it 'creates a new user callout' do + expect { execute }.to change { model.count }.by(1) + end + + it 'returns a user callout' do + expect(execute).to be_an_instance_of(model) + end + + it 'sets the dismissed_at attribute to current time' do + freeze_time do + expect(execute).to have_attributes(dismissed_at: Time.current) + end + end + + it 'updates an existing callout dismissed_at time' do + freeze_time do + old_time = 1.day.ago + new_time = Time.current + attributes = params.merge(dismissed_at: old_time, user: user) + existing_callout = create("#{model.name.split('::').last.underscore}".to_sym, attributes) + + expect { execute }.to change { existing_callout.reload.dismissed_at }.from(old_time).to(new_time) + end + end + + it 'does not update an invalid record with dismissed_at time', :aggregate_failures do + callout = described_class.new( + container: nil, current_user: user, params: { feature_name: nil } + ).execute + + expect(callout.dismissed_at).to be_nil + expect(callout).to be_invalid + end +end diff --git a/spec/support/shared_examples/work_item_base_types_importer.rb b/spec/support/shared_examples/work_item_base_types_importer.rb new file mode 100644 index 00000000000..7d652be8d05 --- /dev/null +++ b/spec/support/shared_examples/work_item_base_types_importer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'work item base types importer' do + it 'creates all base work item types' do + # Fixtures need to run on a pristine DB, but the test suite preloads the base types before(:suite) + WorkItem::Type.delete_all + + expect { subject }.to change(WorkItem::Type, :count).from(0).to(WorkItem::Type::BASE_TYPES.count) + end +end diff --git a/spec/support_specs/database/prevent_cross_database_modification_spec.rb b/spec/support_specs/database/prevent_cross_database_modification_spec.rb index 4fd55d59db0..e86559bb14a 100644 --- a/spec/support_specs/database/prevent_cross_database_modification_spec.rb +++ b/spec/support_specs/database/prevent_cross_database_modification_spec.rb @@ -66,7 +66,7 @@ RSpec.describe 'Database::PreventCrossDatabaseModification' do pipeline.touch end end - end.to raise_error /Cross-database data modification queries/ + end.to raise_error /Cross-database data modification/ end end @@ -84,7 +84,7 @@ RSpec.describe 'Database::PreventCrossDatabaseModification' do context 'when data modification happens in a transaction' do it 'raises error' do Project.transaction do - expect { run_queries }.to raise_error /Cross-database data modification queries/ + expect { run_queries }.to raise_error /Cross-database data modification/ end end @@ -93,12 +93,31 @@ RSpec.describe 'Database::PreventCrossDatabaseModification' do Project.transaction(requires_new: true) do project.touch Project.transaction(requires_new: true) do - expect { pipeline.touch }.to raise_error /Cross-database data modification queries/ + expect { pipeline.touch }.to raise_error /Cross-database data modification/ end end end end end + + context 'when executing a SELECT FOR UPDATE query' do + def run_queries + project.touch + pipeline.lock! + end + + context 'outside transaction' do + it { expect { run_queries }.not_to raise_error } + end + + context 'when data modification happens in a transaction' do + it 'raises error' do + Project.transaction do + expect { run_queries }.to raise_error /Cross-database data modification/ + end + end + end + end end context 'when CI association is modified through project' do @@ -127,7 +146,7 @@ RSpec.describe 'Database::PreventCrossDatabaseModification' do ApplicationRecord.transaction do create(:ci_pipeline) end - end.to raise_error /Cross-database data modification queries/ + end.to raise_error /Cross-database data modification/ end it 'skips raising error on factory creation' do diff --git a/spec/support_specs/database/prevent_cross_joins_spec.rb b/spec/support_specs/database/prevent_cross_joins_spec.rb index dd4ed9c40b8..e9a95fe77a5 100644 --- a/spec/support_specs/database/prevent_cross_joins_spec.rb +++ b/spec/support_specs/database/prevent_cross_joins_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Database::PreventCrossJoins do - context 'when running in :prevent_cross_joins scope', :prevent_cross_joins do + context 'when running in a default scope' do context 'when only non-CI tables are used' do it 'does not raise exception' do expect { main_only_query }.not_to raise_error @@ -24,23 +24,33 @@ RSpec.describe Database::PreventCrossJoins do context 'when allow_cross_joins_across_databases is used' do it 'does not raise exception' do - Gitlab::Database.allow_cross_joins_across_databases(url: 'http://issue-url') + expect { main_and_ci_query_allowlisted }.not_to raise_error + end + end - expect { main_and_ci_query }.not_to raise_error + context 'when allow_cross_joins_across_databases is used' do + it 'does not raise exception' do + expect { main_and_ci_query_allowlist_nested }.not_to raise_error end end end end - context 'when running in a default scope' do - context 'when CI and non-CI tables are used' do - it 'does not raise exception' do - expect { main_and_ci_query }.not_to raise_error - end + private + + def main_and_ci_query_allowlisted + Gitlab::Database.allow_cross_joins_across_databases(url: 'http://issue-url') do + main_and_ci_query end end - private + def main_and_ci_query_allowlist_nested + Gitlab::Database.allow_cross_joins_across_databases(url: 'http://issue-url') do + main_and_ci_query_allowlisted + + main_and_ci_query + end + end def main_only_query Issue.joins(:project).last diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb index 8e98a42510e..91cd09fc6e6 100644 --- a/spec/tasks/gitlab/db_rake_spec.rb +++ b/spec/tasks/gitlab/db_rake_spec.rb @@ -129,13 +129,14 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do let(:output) { StringIO.new } before do - structure_files = %w[db/structure.sql db/ci_structure.sql] + structure_files = %w[structure.sql ci_structure.sql] allow(File).to receive(:open).and_call_original - structure_files.each do |structure_file| + structure_files.each do |structure_file_name| + structure_file = File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, structure_file_name) stub_file_read(structure_file, content: input) - allow(File).to receive(:open).with(Rails.root.join(structure_file).to_s, any_args).and_yield(output) + allow(File).to receive(:open).with(structure_file.to_s, any_args).and_yield(output) end end diff --git a/spec/tasks/gitlab/product_intelligence_rake_spec.rb b/spec/tasks/gitlab/product_intelligence_rake_spec.rb deleted file mode 100644 index 029e181ad06..00000000000 --- a/spec/tasks/gitlab/product_intelligence_rake_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -require 'rake_helper' - -RSpec.describe 'gitlab:product_intelligence:activate_metrics', :silence_stdout do - def fake_metric(key_path, milestone: 'test_milestone', status: 'implemented') - Gitlab::Usage::MetricDefinition.new(key_path, { key_path: key_path, milestone: milestone, status: status }) - end - - before do - Rake.application.rake_require 'tasks/gitlab/product_intelligence' - stub_warn_user_is_not_gitlab - end - - describe 'activate_metrics' do - it 'fails if the MILESTONE env var is not set' do - stub_env('MILESTONE' => nil) - - expect { run_rake_task('gitlab:product_intelligence:activate_metrics') }.to raise_error(RuntimeError, 'Please supply the MILESTONE env var') - end - - context 'with MILESTONE env var' do - subject do - updated_metrics = [] - - file = double('file') - allow(file).to receive(:<<) { |contents| updated_metrics << YAML.safe_load(contents) } - allow(File).to receive(:open).and_yield(file) - - stub_env('MILESTONE' => 'test_milestone') - run_rake_task('gitlab:product_intelligence:activate_metrics') - - updated_metrics - end - - let(:metric_definitions) do - { - matching_metric: fake_metric('matching_metric'), - matching_metric2: fake_metric('matching_metric2'), - other_status_metric: fake_metric('other_status_metric', status: 'deprecated'), - other_milestone_metric: fake_metric('other_milestone_metric', milestone: 'other_milestone') - } - end - - before do - allow(Gitlab::Usage::MetricDefinition).to receive(:definitions).and_return(metric_definitions) - end - - context 'with metric matching status and milestone' do - it 'updates matching_metric yaml file' do - expect(subject).to eq([ - { 'key_path' => 'matching_metric', 'milestone' => 'test_milestone', 'status' => 'data_available' }, - { 'key_path' => 'matching_metric2', 'milestone' => 'test_milestone', 'status' => 'data_available' } - ]) - end - end - - context 'without metrics definitions' do - let(:metric_definitions) { {} } - - it 'runs successfully with no updates' do - expect(subject).to eq([]) - end - end - - context 'without matching metrics' do - let(:metric_definitions) do - { - other_status_metric: fake_metric('other_status_metric', status: 'deprecated'), - other_milestone_metric: fake_metric('other_milestone_metric', milestone: 'other_milestone') - } - end - - it 'runs successfully with no updates' do - expect(subject).to eq([]) - end - end - end - end -end diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb index f52c5e02544..c7715eb43fc 100644 --- a/spec/tooling/danger/project_helper_spec.rb +++ b/spec/tooling/danger/project_helper_spec.rb @@ -60,7 +60,6 @@ RSpec.describe Tooling::Danger::ProjectHelper do 'app/views/foo' | [:frontend] 'public/foo' | [:frontend] 'scripts/frontend/foo' | [:frontend] - 'spec/javascripts/foo' | [:frontend] 'spec/frontend/bar' | [:frontend] 'spec/frontend_integration/bar' | [:frontend] 'vendor/assets/foo' | [:frontend] @@ -73,7 +72,6 @@ RSpec.describe Tooling::Danger::ProjectHelper do 'ee/app/assets/foo' | [:frontend] 'ee/app/views/foo' | [:frontend] - 'ee/spec/javascripts/foo' | [:frontend] 'ee/spec/frontend/bar' | [:frontend] 'ee/spec/frontend_integration/bar' | [:frontend] @@ -220,7 +218,7 @@ RSpec.describe Tooling::Danger::ProjectHelper do describe '.local_warning_message' do it 'returns an informational message with rules that can run' do - expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changelog, database, documentation, duplicate_yarn_dependencies, eslint, gitaly, karma, pajamas, pipeline, prettier, product_intelligence, utility_css, vue_shared_documentation') + expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changelog, database, documentation, duplicate_yarn_dependencies, eslint, gitaly, pajamas, pipeline, prettier, product_intelligence, utility_css, vue_shared_documentation') end end diff --git a/spec/tooling/graphql/docs/renderer_spec.rb b/spec/tooling/graphql/docs/renderer_spec.rb index de5ec928921..1c9605304ff 100644 --- a/spec/tooling/graphql/docs/renderer_spec.rb +++ b/spec/tooling/graphql/docs/renderer_spec.rb @@ -535,8 +535,8 @@ RSpec.describe Tooling::Graphql::Docs::Renderer do | Name | Type | Description | | ---- | ---- | ----------- | - | <a id="timeframeend"></a>`end` | [`Date!`](#date) | The end of the range. | - | <a id="timeframestart"></a>`start` | [`Date!`](#date) | The start of the range. | + | <a id="timeframeend"></a>`end` | [`Date!`](#date) | End of the range. | + | <a id="timeframestart"></a>`start` | [`Date!`](#date) | Start of the range. | DOC end diff --git a/spec/validators/gitlab/utils/zoom_url_validator_spec.rb b/spec/validators/gitlab/zoom_url_validator_spec.rb index 392d8b3a2fe..308a6be78eb 100644 --- a/spec/validators/gitlab/utils/zoom_url_validator_spec.rb +++ b/spec/validators/gitlab/zoom_url_validator_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Utils::ZoomUrlValidator do +RSpec.describe Gitlab::ZoomUrlValidator do let(:zoom_meeting) { build(:zoom_meeting) } describe 'validations' do diff --git a/spec/views/groups/group_members/index.html.haml_spec.rb b/spec/views/groups/group_members/index.html.haml_spec.rb new file mode 100644 index 00000000000..8e190c24495 --- /dev/null +++ b/spec/views/groups/group_members/index.html.haml_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'groups/group_members/index', :aggregate_failures do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + + before do + allow(view).to receive(:group_members_app_data).and_return({}) + allow(view).to receive(:current_user).and_return(user) + assign(:group, group) + assign(:group_member, build(:group_member, group: group)) + end + + context 'when user can invite members for the group' do + before do + group.add_owner(user) + end + + context 'when modal is enabled' do + it 'renders as expected' do + render + + expect(rendered).to have_content('Group members') + expect(rendered).to have_content('You can invite a new member') + + expect(rendered).to have_selector('.js-invite-group-trigger') + expect(rendered).to have_selector('.js-invite-members-trigger') + expect(response).to render_template(partial: 'groups/_invite_members_modal') + + expect(rendered).not_to have_selector('#invite-member-tab') + expect(rendered).not_to have_selector('#invite-group-tab') + expect(response).not_to render_template(partial: 'shared/members/_invite_group') + end + end + + context 'when modal is not enabled' do + before do + stub_feature_flags(invite_members_group_modal: false) + end + + it 'renders as expected' do + render + + expect(rendered).to have_content('Group members') + expect(rendered).to have_content('You can invite a new member') + + expect(rendered).to have_selector('#invite-member-tab') + expect(rendered).to have_selector('#invite-group-tab') + expect(response).to render_template(partial: 'shared/members/_invite_group') + + expect(rendered).not_to have_selector('.js-invite-group-trigger') + expect(rendered).not_to have_selector('.js-invite-members-trigger') + expect(response).not_to render_template(partial: 'groups/_invite_members_modal') + end + end + end + + context 'when user can not invite members for the group' do + it 'renders as expected', :aggregate_failures do + render + + expect(rendered).not_to have_content('Group members') + expect(rendered).not_to have_content('You can invite a new member') + end + end +end diff --git a/spec/views/help/instance_configuration.html.haml_spec.rb b/spec/views/help/instance_configuration.html.haml_spec.rb index 7b431bb4180..c4542046a9d 100644 --- a/spec/views/help/instance_configuration.html.haml_spec.rb +++ b/spec/views/help/instance_configuration.html.haml_spec.rb @@ -9,6 +9,7 @@ RSpec.describe 'help/instance_configuration' do let(:ssh_settings) { settings[:ssh_algorithms_hashes] } before do + create(:plan, name: 'plan1', title: 'Plan 1') assign(:instance_configuration, instance_configuration) end @@ -17,7 +18,9 @@ RSpec.describe 'help/instance_configuration' do expect(rendered).to have_link(nil, href: '#ssh-host-keys-fingerprints') if ssh_settings.any? expect(rendered).to have_link(nil, href: '#gitlab-pages') - expect(rendered).to have_link(nil, href: '#gitlab-ci') + expect(rendered).to have_link(nil, href: '#size-limits') + expect(rendered).to have_link(nil, href: '#package-registry') + expect(rendered).to have_link(nil, href: '#rate-limits') end it 'has several sections' do @@ -25,7 +28,9 @@ RSpec.describe 'help/instance_configuration' do expect(rendered).to have_css('h2#ssh-host-keys-fingerprints') if ssh_settings.any? expect(rendered).to have_css('h2#gitlab-pages') - expect(rendered).to have_css('h2#gitlab-ci') + expect(rendered).to have_css('h2#size-limits') + expect(rendered).to have_css('h2#package-registry') + expect(rendered).to have_css('h2#rate-limits') end end end diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb index 3afebfbedab..adfe1cee6d6 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -68,8 +68,8 @@ RSpec.describe 'layouts/nav/sidebar/_project' do end describe 'Learn GitLab' do - it 'has a link to the learn GitLab experiment' do - allow(view).to receive(:learn_gitlab_experiment_enabled?).and_return(true) + it 'has a link to the learn GitLab' do + allow(view).to receive(:learn_gitlab_enabled?).and_return(true) allow_next_instance_of(LearnGitlab::Onboarding) do |onboarding| expect(onboarding).to receive(:completed_percentage).and_return(20) end @@ -968,6 +968,32 @@ RSpec.describe 'layouts/nav/sidebar/_project' do end end end + + describe 'Usage Quotas' do + context 'with project_storage_ui feature flag enabled' do + before do + stub_feature_flags(project_storage_ui: true) + end + + it 'has a link to Usage Quotas' do + render + + expect(rendered).to have_link('Usage Quotas', href: project_usage_quotas_path(project)) + end + end + + context 'with project_storage_ui feature flag disabled' do + before do + stub_feature_flags(project_storage_ui: false) + end + + it 'does not have a link to Usage Quotas' do + render + + expect(rendered).not_to have_link('Usage Quotas', href: project_usage_quotas_path(project)) + end + end + end end describe 'Hidden menus' do diff --git a/spec/views/profiles/notifications/show.html.haml_spec.rb b/spec/views/profiles/notifications/show.html.haml_spec.rb new file mode 100644 index 00000000000..9cdf8124fcf --- /dev/null +++ b/spec/views/profiles/notifications/show.html.haml_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'profiles/notifications/show' do + let(:groups) { GroupsFinder.new(user).execute.page(1) } + let(:user) { create(:user) } + + before do + assign(:group_notifications, []) + assign(:project_notifications, []) + assign(:user, user) + assign(:user_groups, groups) + allow(controller).to receive(:current_user).and_return(user) + allow(view).to receive(:experiment_enabled?) + end + + context 'when there is no database value for User#notification_email' do + let(:option_default) { _('Use primary email (%{email})') % { email: user.email } } + let(:option_primary_email) { user.email } + let(:options) { [option_default, option_primary_email] } + + it 'displays the correct elements' do + render + + expect(rendered).to have_select('user_notification_email', options: options, selected: nil) + end + end +end diff --git a/spec/views/projects/diffs/_stats.html.haml_spec.rb b/spec/views/projects/diffs/_stats.html.haml_spec.rb deleted file mode 100644 index f0580b50349..00000000000 --- a/spec/views/projects/diffs/_stats.html.haml_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'projects/diffs/_stats.html.haml' do - let(:project) { create(:project, :repository) } - let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') } - - def render_view - render partial: "projects/diffs/stats", locals: { diff_files: commit.diffs.diff_files } - end - - context 'when the commit contains several changes' do - it 'uses plural for additions' do - render_view - - expect(rendered).to have_text('additions') - end - - it 'uses plural for deletions' do - render_view - end - end - - context 'when the commit contains no addition and no deletions' do - let(:commit) { project.commit('4cd80ccab63c82b4bad16faa5193fbd2aa06df40') } - - it 'uses plural for additions' do - render_view - - expect(rendered).to have_text('additions') - end - - it 'uses plural for deletions' do - render_view - - expect(rendered).to have_text('deletions') - end - end - - context 'when the commit contains exactly one addition and one deletion' do - let(:commit) { project.commit('08f22f255f082689c0d7d39d19205085311542bc') } - - it 'uses singular for additions' do - render_view - - expect(rendered).to have_text('addition') - expect(rendered).not_to have_text('additions') - end - - it 'uses singular for deletions' do - render_view - - expect(rendered).to have_text('deletion') - expect(rendered).not_to have_text('deletions') - end - end -end diff --git a/spec/views/projects/empty.html.haml_spec.rb b/spec/views/projects/empty.html.haml_spec.rb index 70da4fc9e27..416dfc10174 100644 --- a/spec/views/projects/empty.html.haml_spec.rb +++ b/spec/views/projects/empty.html.haml_spec.rb @@ -53,7 +53,7 @@ RSpec.describe 'projects/empty' do it 'shows invite members info', :aggregate_failures do render - expect(rendered).to have_selector('[data-track-event=render]') + expect(rendered).to have_selector('[data-track-action=render]') expect(rendered).to have_selector('[data-track-label=invite_members_empty_project]') expect(rendered).to have_content('Invite your team') expect(rendered).to have_content('Add members to this project and start collaborating with your team.') diff --git a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb index fd77c4eb372..f0273c1716f 100644 --- a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb @@ -21,7 +21,7 @@ RSpec.describe 'projects/merge_requests/_commits.html.haml', :sidekiq_might_not_ controller.prepend_view_path('app/views/projects') assign(:merge_request, merge_request) - assign(:commits, merge_request.commits) + assign(:commits, merge_request.commits(load_from_gitaly: true)) assign(:hidden_commit_count, 0) end @@ -34,6 +34,12 @@ RSpec.describe 'projects/merge_requests/_commits.html.haml', :sidekiq_might_not_ expect(rendered).to have_link(href: href) end + it 'shows signature verification badge' do + render + + expect(rendered).to have_css('.gpg-status-box') + end + context 'when there are hidden commits' do before do assign(:hidden_commit_count, 1) diff --git a/spec/views/projects/project_members/index.html.haml_spec.rb b/spec/views/projects/project_members/index.html.haml_spec.rb new file mode 100644 index 00000000000..b9b0d57bcb5 --- /dev/null +++ b/spec/views/projects/project_members/index.html.haml_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'projects/project_members/index', :aggregate_failures do + let_it_be(:user) { create(:user) } + let_it_be(:source) { create(:project, :empty_repo) } + let_it_be(:project) { ProjectPresenter.new(source, current_user: user) } + + before do + allow(view).to receive(:project_members_app_data_json).and_return({}) + allow(view).to receive(:current_user).and_return(user) + assign(:project, project) + assign(:project_member, build(:project_member, project: source)) + end + + context 'when user can invite members for the project' do + before do + project.add_maintainer(user) + end + + context 'when modal is enabled' do + it 'renders as expected' do + render + + expect(rendered).to have_content('Project members') + expect(rendered).to have_content('You can invite a new member') + expect(rendered).to have_selector('.js-import-a-project-modal') + expect(rendered).to have_selector('.js-invite-group-trigger') + expect(rendered).to have_selector('.js-invite-members-trigger') + expect(rendered).not_to have_content('Members can be added by project') + expect(response).to render_template(partial: 'projects/_invite_members_modal') + end + + context 'when project is not allowed to share with group' do + before do + project.namespace.share_with_group_lock = true + end + + it 'renders as expected' do + render + + expect(rendered).not_to have_selector('.js-invite-group-trigger') + end + end + end + + context 'when modal is not enabled' do + before do + stub_feature_flags(invite_members_group_modal: false) + end + + it 'renders as expected' do + render + + expect(rendered).to have_content('Project members') + expect(rendered).to have_content('You can invite a new member') + expect(rendered).not_to have_selector('.js-invite-group-trigger') + expect(rendered).not_to have_selector('.js-invite-members-trigger') + expect(rendered).not_to have_content('Members can be added by project') + expect(response).not_to render_template(partial: 'projects/_invite_members_modal') + expect(response).to render_template(partial: 'shared/members/_invite_member') + end + + context 'when project can not be shared' do + before do + project.namespace.share_with_group_lock = true + end + + it 'renders as expected' do + render + + expect(rendered).to have_content('Project members') + expect(rendered).to have_content('You can invite a new member') + expect(response).not_to render_template(partial: 'projects/_invite_members_modal') + end + end + end + end + + context 'when user can not invite members or group for the project' do + context 'when project can be shared' do + it 'renders as expected', :aggregate_failures do + render + + expect(rendered).to have_content('Project members') + expect(rendered).not_to have_content('You can invite a new member') + expect(rendered).not_to have_selector('.js-import-a-project-modal') + expect(rendered).not_to have_selector('.js-invite-group-trigger') + expect(rendered).not_to have_selector('.js-invite-members-trigger') + expect(rendered).to have_content('Members can be added by project') + expect(response).not_to render_template(partial: 'projects/_invite_members_modal') + end + end + end +end diff --git a/spec/views/search/_results.html.haml_spec.rb b/spec/views/search/_results.html.haml_spec.rb index ecfcf74edc1..dcf1f46b46c 100644 --- a/spec/views/search/_results.html.haml_spec.rb +++ b/spec/views/search/_results.html.haml_spec.rb @@ -74,7 +74,7 @@ RSpec.describe 'search/_results' do it 'renders the click text event tracking attributes' do render - expect(rendered).to have_selector('[data-track-event=click_text]') + expect(rendered).to have_selector('[data-track-action=click_text]') expect(rendered).to have_selector('[data-track-property=search_result]') end end @@ -83,7 +83,7 @@ RSpec.describe 'search/_results' do it 'does not render the click text event tracking attributes' do render - expect(rendered).not_to have_selector('[data-track-event=click_text]') + expect(rendered).not_to have_selector('[data-track-action=click_text]') expect(rendered).not_to have_selector('[data-track-property=search_result]') end end @@ -105,7 +105,7 @@ RSpec.describe 'search/_results' do it 'renders the click text event tracking attributes' do render - expect(rendered).to have_selector('[data-track-event=click_text]') + expect(rendered).to have_selector('[data-track-action=click_text]') expect(rendered).to have_selector('[data-track-property=search_result]') end end @@ -114,7 +114,7 @@ RSpec.describe 'search/_results' do it 'does not render the click text event tracking attributes' do render - expect(rendered).not_to have_selector('[data-track-event=click_text]') + expect(rendered).not_to have_selector('[data-track-action=click_text]') expect(rendered).not_to have_selector('[data-track-property=search_result]') end end diff --git a/spec/views/shared/access_tokens/_table.html.haml_spec.rb b/spec/views/shared/access_tokens/_table.html.haml_spec.rb index 489675b5683..0a23768b4f1 100644 --- a/spec/views/shared/access_tokens/_table.html.haml_spec.rb +++ b/spec/views/shared/access_tokens/_table.html.haml_spec.rb @@ -19,7 +19,6 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do allow(view).to receive(:personal_access_token_expiration_enforced?).and_return(token_expiry_enforced?) allow(view).to receive(:show_profile_token_expiry_notification?).and_return(true) - allow(view).to receive(:distance_of_time_in_words_to_now).and_return('4 days') if project project.add_maintainer(user) @@ -140,7 +139,6 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do # Expiry expect(rendered).to have_content 'Expired', count: 2 - expect(rendered).to have_content 'In 4 days' # Revoke buttons expect(rendered).to have_link 'Revoke', href: 'path/', class: 'btn-danger-secondary', count: 1 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 027ce3b7f89..0da58343773 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 @@ -51,20 +51,5 @@ RSpec.describe AuthorizedProjectUpdate::UserRefreshFromReplicaWorker do execute_worker end end - - context 'when the feature flag `user_refresh_from_replica_worker_uses_replica_db` is disabled' do - before do - stub_feature_flags(user_refresh_from_replica_worker_uses_replica_db: false) - end - - it 'calls Users::RefreshAuthorizedProjectsService' do - source = 'AuthorizedProjectUpdate::UserRefreshFromReplicaWorker' - expect_next_instance_of(Users::RefreshAuthorizedProjectsService, user, { source: source }) do |service| - expect(service).to receive(:execute) - end - - execute_worker - end - end end end diff --git a/spec/workers/background_migration_worker_spec.rb b/spec/workers/background_migration_worker_spec.rb index 4575c270042..7892eb89e80 100644 --- a/spec/workers/background_migration_worker_spec.rb +++ b/spec/workers/background_migration_worker_spec.rb @@ -14,7 +14,17 @@ RSpec.describe BackgroundMigrationWorker, :clean_gitlab_redis_shared_state do describe '#perform' do before do allow(worker).to receive(:jid).and_return(1) - expect(worker).to receive(:always_perform?).and_return(false) + allow(worker).to receive(:always_perform?).and_return(false) + end + + it 'can run scheduled job and retried job concurrently' do + expect(Gitlab::BackgroundMigration) + .to receive(:perform) + .with('Foo', [10, 20]) + .exactly(2).time + + worker.perform('Foo', [10, 20]) + worker.perform('Foo', [10, 20], described_class::MAX_LEASE_ATTEMPTS - 1) end context 'when lease can be obtained' do @@ -39,7 +49,7 @@ RSpec.describe BackgroundMigrationWorker, :clean_gitlab_redis_shared_state do before do expect(Gitlab::BackgroundMigration).not_to receive(:perform) - worker.lease_for('Foo').try_obtain + worker.lease_for('Foo', false).try_obtain end it 'reschedules the migration and decrements the lease_attempts' do @@ -51,6 +61,10 @@ RSpec.describe BackgroundMigrationWorker, :clean_gitlab_redis_shared_state do end context 'when lease_attempts is 1' do + before do + worker.lease_for('Foo', true).try_obtain + end + it 'reschedules the migration and decrements the lease_attempts' do expect(described_class) .to receive(:perform_in) @@ -61,6 +75,10 @@ RSpec.describe BackgroundMigrationWorker, :clean_gitlab_redis_shared_state do end context 'when lease_attempts is 0' do + before do + worker.lease_for('Foo', true).try_obtain + end + it 'gives up performing the migration' do expect(described_class).not_to receive(:perform_in) expect(Sidekiq.logger).to receive(:warn).with( diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb index 205bf23f36d..b67c5c62f76 100644 --- a/spec/workers/bulk_import_worker_spec.rb +++ b/spec/workers/bulk_import_worker_spec.rb @@ -84,17 +84,20 @@ RSpec.describe BulkImportWorker do expect { subject.perform(bulk_import.id) } .to change(BulkImports::Tracker, :count) - .by(BulkImports::Stage.pipelines.size * 2) + .by(BulkImports::Groups::Stage.pipelines.size * 2) expect(entity_1.trackers).not_to be_empty expect(entity_2.trackers).not_to be_empty end context 'when there are created entities to process' do - it 'marks a batch of entities as started, enqueues EntityWorker, ExportRequestWorker and reenqueues' do + let_it_be(:bulk_import) { create(:bulk_import, :created) } + + before do stub_const("#{described_class}::DEFAULT_BATCH_SIZE", 1) + end - bulk_import = create(:bulk_import, :created) + it 'marks a batch of entities as started, enqueues EntityWorker, ExportRequestWorker and reenqueues' do create(:bulk_import_entity, :created, bulk_import: bulk_import) create(:bulk_import_entity, :created, bulk_import: bulk_import) @@ -106,6 +109,16 @@ RSpec.describe BulkImportWorker do expect(bulk_import.entities.map(&:status_name)).to contain_exactly(:created, :started) end + + context 'when there are project entities to process' do + it 'does not enqueue ExportRequestWorker' do + create(:bulk_import_entity, :created, :project_entity, bulk_import: bulk_import) + + expect(BulkImports::ExportRequestWorker).not_to receive(:perform_async) + + subject.perform(bulk_import.id) + end + end end context 'when exception occurs' do diff --git a/spec/workers/bulk_imports/pipeline_worker_spec.rb b/spec/workers/bulk_imports/pipeline_worker_spec.rb index 972a4158194..56f28654ac5 100644 --- a/spec/workers/bulk_imports/pipeline_worker_spec.rb +++ b/spec/workers/bulk_imports/pipeline_worker_spec.rb @@ -21,6 +21,10 @@ RSpec.describe BulkImports::PipelineWorker do before do stub_const('FakePipeline', pipeline_class) + + allow(BulkImports::Groups::Stage) + .to receive(:pipelines) + .and_return([[0, pipeline_class]]) end it 'runs the given pipeline successfully' do @@ -30,12 +34,6 @@ RSpec.describe BulkImports::PipelineWorker do pipeline_name: 'FakePipeline' ) - expect(BulkImports::Stage) - .to receive(:pipeline_exists?) - .with('FakePipeline') - .twice - .and_return(true) - expect_next_instance_of(Gitlab::Import::Logger) do |logger| expect(logger) .to receive(:info) @@ -110,7 +108,7 @@ RSpec.describe BulkImports::PipelineWorker do expect(Gitlab::ErrorTracking) .to receive(:track_exception) .with( - instance_of(NameError), + instance_of(BulkImports::Error), entity_id: entity.id, pipeline_name: pipeline_tracker.pipeline_name ) @@ -157,10 +155,10 @@ RSpec.describe BulkImports::PipelineWorker do before do stub_const('NdjsonPipeline', ndjson_pipeline) - allow(BulkImports::Stage) - .to receive(:pipeline_exists?) - .with('NdjsonPipeline') - .and_return(true) + + allow(BulkImports::Groups::Stage) + .to receive(:pipelines) + .and_return([[0, ndjson_pipeline]]) end it 'runs the pipeline successfully' do diff --git a/spec/workers/ci/external_pull_requests/create_pipeline_worker_spec.rb b/spec/workers/ci/external_pull_requests/create_pipeline_worker_spec.rb new file mode 100644 index 00000000000..116a0e4d035 --- /dev/null +++ b/spec/workers/ci/external_pull_requests/create_pipeline_worker_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::ExternalPullRequests::CreatePipelineWorker do + let_it_be(:project) { create(:project, :auto_devops, :repository) } + let_it_be(:user) { project.owner } + let_it_be(:external_pull_request) do + branch = project.repository.branches.last + create(:external_pull_request, project: project, source_branch: branch.name, source_sha: branch.target) + end + + let(:worker) { described_class.new } + + describe '#perform' do + let(:project_id) { project.id } + let(:user_id) { user.id } + let(:external_pull_request_id) { external_pull_request.id } + + subject(:perform) { worker.perform(project_id, user_id, external_pull_request_id) } + + it 'creates the pipeline' do + pipeline = perform.payload + + expect(pipeline).to be_valid + expect(pipeline).to be_persisted + expect(pipeline).to be_external_pull_request_event + expect(pipeline.project).to eq(project) + expect(pipeline.user).to eq(user) + expect(pipeline.external_pull_request).to eq(external_pull_request) + expect(pipeline.status).to eq('created') + expect(pipeline.ref).to eq(external_pull_request.source_branch) + expect(pipeline.sha).to eq(external_pull_request.source_sha) + expect(pipeline.source_sha).to eq(external_pull_request.source_sha) + expect(pipeline.target_sha).to eq(external_pull_request.target_sha) + end + + shared_examples_for 'not calling service' do + it 'does not call the service' do + expect(Ci::CreatePipelineService).not_to receive(:new) + perform + end + end + + context 'when the project not found' do + let(:project_id) { non_existing_record_id } + + it_behaves_like 'not calling service' + end + + context 'when the user not found' do + let(:user_id) { non_existing_record_id } + + it_behaves_like 'not calling service' + end + + context 'when the pull request not found' do + let(:external_pull_request_id) { non_existing_record_id } + + it_behaves_like 'not calling service' + end + + context 'when the pull request does not belong to the project' do + let(:external_pull_request_id) { create(:external_pull_request).id } + + it_behaves_like 'not calling service' + end + end +end diff --git a/spec/workers/concerns/worker_attributes_spec.rb b/spec/workers/concerns/worker_attributes_spec.rb index d4b17c65f46..ad9d5eeccbe 100644 --- a/spec/workers/concerns/worker_attributes_spec.rb +++ b/spec/workers/concerns/worker_attributes_spec.rb @@ -35,45 +35,17 @@ RSpec.describe WorkerAttributes do end end - context 'when job is idempotent' do - context 'when data_consistency is not :always' do - it 'raise exception' do - worker.idempotent! - - expect { worker.data_consistency(:sticky) } - .to raise_error("Class can't be marked as idempotent if data_consistency is not set to :always") - end - end - - context 'when feature_flag is provided' do - before do - stub_feature_flags(test_feature_flag: false) - skip_feature_flags_yaml_validation - skip_default_enabled_yaml_check - end - - it 'returns correct feature flag value' do - worker.data_consistency(:sticky, feature_flag: :test_feature_flag) - - expect(worker.get_data_consistency_feature_flag_enabled?).not_to be_truthy - end + context 'when feature_flag is provided' do + before do + stub_feature_flags(test_feature_flag: false) + skip_feature_flags_yaml_validation + skip_default_enabled_yaml_check end - end - end - - describe '.idempotent!' do - it 'sets `idempotent` attribute of the worker class to true' do - worker.idempotent! - expect(worker.send(:class_attributes)[:idempotent]).to eq(true) - end - - context 'when data consistency is not :always' do - it 'raise exception' do - worker.data_consistency(:sticky) + it 'returns correct feature flag value' do + worker.data_consistency(:sticky, feature_flag: :test_feature_flag) - expect { worker.idempotent! } - .to raise_error("Class can't be marked as idempotent if data_consistency is not set to :always") + expect(worker.get_data_consistency_feature_flag_enabled?).not_to be_truthy end end end diff --git a/spec/workers/database/partition_management_worker_spec.rb b/spec/workers/database/partition_management_worker_spec.rb index 01b7f209b2d..9ded36743a8 100644 --- a/spec/workers/database/partition_management_worker_spec.rb +++ b/spec/workers/database/partition_management_worker_spec.rb @@ -6,16 +6,14 @@ RSpec.describe Database::PartitionManagementWorker do describe '#perform' do subject { described_class.new.perform } - let(:manager) { instance_double('PartitionManager', sync_partitions: nil) } let(:monitoring) { instance_double('PartitionMonitoring', report_metrics: nil) } before do - allow(Gitlab::Database::Partitioning::PartitionManager).to receive(:new).and_return(manager) allow(Gitlab::Database::Partitioning::PartitionMonitoring).to receive(:new).and_return(monitoring) end - it 'delegates to PartitionManager' do - expect(manager).to receive(:sync_partitions) + it 'delegates to Partitioning' do + expect(Gitlab::Database::Partitioning).to receive(:sync_partitions) subject end diff --git a/spec/workers/deployments/finished_worker_spec.rb b/spec/workers/deployments/finished_worker_spec.rb deleted file mode 100644 index d0a26ae1547..00000000000 --- a/spec/workers/deployments/finished_worker_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Deployments::FinishedWorker do - let(:worker) { described_class.new } - - describe '#perform' do - before do - allow(ProjectServiceWorker).to receive(:perform_async) - end - - it 'links merge requests to the deployment' do - deployment = create(:deployment) - service = instance_double(Deployments::LinkMergeRequestsService) - - expect(Deployments::LinkMergeRequestsService) - .to receive(:new) - .with(deployment) - .and_return(service) - - expect(service).to receive(:execute) - - worker.perform(deployment.id) - end - - it 'executes project services for deployment_hooks' do - deployment = create(:deployment) - project = deployment.project - service = create(:service, type: 'SlackService', project: project, deployment_events: true, active: true) - - worker.perform(deployment.id) - - expect(ProjectServiceWorker).to have_received(:perform_async).with(service.id, an_instance_of(Hash)) - end - - it 'does not execute an inactive service' do - deployment = create(:deployment) - project = deployment.project - create(:service, type: 'SlackService', project: project, deployment_events: true, active: false) - - worker.perform(deployment.id) - - expect(ProjectServiceWorker).not_to have_received(:perform_async) - end - - it 'does nothing if a deployment with the given id does not exist' do - worker.perform(0) - - expect(ProjectServiceWorker).not_to have_received(:perform_async) - end - - it 'execute webhooks' do - deployment = create(:deployment) - project = deployment.project - web_hook = create(:project_hook, deployment_events: true, project: project) - - expect_next_instance_of(WebHookService, web_hook, an_instance_of(Hash), "deployment_hooks") do |service| - expect(service).to receive(:async_execute) - end - - worker.perform(deployment.id) - end - end -end diff --git a/spec/workers/deployments/hooks_worker_spec.rb b/spec/workers/deployments/hooks_worker_spec.rb index 5d8edf85dd9..b4a91cff2ac 100644 --- a/spec/workers/deployments/hooks_worker_spec.rb +++ b/spec/workers/deployments/hooks_worker_spec.rb @@ -52,7 +52,6 @@ RSpec.describe Deployments::HooksWorker do it_behaves_like 'worker with data consistency', described_class, - feature_flag: :load_balancing_for_deployments_hooks_worker, data_consistency: :delayed end end diff --git a/spec/workers/deployments/success_worker_spec.rb b/spec/workers/deployments/success_worker_spec.rb deleted file mode 100644 index d9996e66919..00000000000 --- a/spec/workers/deployments/success_worker_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Deployments::SuccessWorker do - subject { described_class.new.perform(deployment&.id) } - - context 'when successful deployment' do - let(:deployment) { create(:deployment, :success) } - - it 'executes Deployments::UpdateEnvironmentService' do - expect(Deployments::UpdateEnvironmentService) - .to receive(:new).with(deployment).and_call_original - - subject - end - end - - context 'when canceled deployment' do - let(:deployment) { create(:deployment, :canceled) } - - it 'does not execute Deployments::UpdateEnvironmentService' do - expect(Deployments::UpdateEnvironmentService).not_to receive(:new) - - subject - end - end - - context 'when deploy record does not exist' do - let(:deployment) { nil } - - it 'does not execute Deployments::UpdateEnvironmentService' do - expect(Deployments::UpdateEnvironmentService).not_to receive(:new) - - subject - end - end -end diff --git a/spec/workers/environments/auto_stop_worker_spec.rb b/spec/workers/environments/auto_stop_worker_spec.rb new file mode 100644 index 00000000000..1983cfa18ea --- /dev/null +++ b/spec/workers/environments/auto_stop_worker_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Environments::AutoStopWorker do + include CreateEnvironmentsHelpers + + subject { worker.perform(environment_id) } + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } } + + before_all do + project.repository.add_branch(developer, 'review/feature', 'master') + end + + let!(:environment) { create_review_app(user, project, 'review/feature').environment } + let(:environment_id) { environment.id } + let(:worker) { described_class.new } + let(:user) { developer } + + it 'stops the environment' do + expect { subject } + .to change { Environment.find_by_name('review/feature').state } + .from('available').to('stopped') + end + + it 'executes the stop action' do + expect { subject } + .to change { Ci::Build.find_by_name('stop_review_app').status } + .from('manual').to('pending') + end + + context 'when user does not have a permission to play the stop action' do + let(:user) { reporter } + + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + + context 'when the environment has already been stopped' do + before do + environment.stop! + end + + it 'does not execute the stop action' do + expect { subject } + .not_to change { Ci::Build.find_by_name('stop_review_app').status } + end + end + + context 'when there are no deployments and associted stop actions' do + let!(:environment) { create(:environment) } + + it 'stops the environment' do + subject + + expect(environment.reload).to be_stopped + end + end + + context 'when there are no corresponding environment record' do + let!(:environment) { double(:environment, id: non_existing_record_id) } + + it 'ignores the invalid record' do + expect { subject }.not_to raise_error + end + end +end diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb index ea1f0153f83..235a1f6e3dd 100644 --- a/spec/workers/every_sidekiq_worker_spec.rb +++ b/spec/workers/every_sidekiq_worker_spec.rb @@ -436,6 +436,7 @@ RSpec.describe 'Every Sidekiq worker' do 'TodosDestroyer::ConfidentialEpicWorker' => 3, 'TodosDestroyer::ConfidentialIssueWorker' => 3, 'TodosDestroyer::DestroyedIssuableWorker' => 3, + 'TodosDestroyer::DestroyedDesignsWorker' => 3, 'TodosDestroyer::EntityLeaveWorker' => 3, 'TodosDestroyer::GroupPrivateWorker' => 3, 'TodosDestroyer::PrivateFeaturesWorker' => 3, @@ -452,6 +453,7 @@ RSpec.describe 'Every Sidekiq worker' do 'WaitForClusterCreationWorker' => 3, 'WebHookWorker' => 4, 'WebHooks::DestroyWorker' => 3, + 'WebHooks::LogExecutionWorker' => 3, 'Wikis::GitGarbageCollectWorker' => false, 'X509CertificateRevokeWorker' => 3 } diff --git a/spec/workers/expire_job_cache_worker_spec.rb b/spec/workers/expire_job_cache_worker_spec.rb index cbd9dd39336..6b14ccea105 100644 --- a/spec/workers/expire_job_cache_worker_spec.rb +++ b/spec/workers/expire_job_cache_worker_spec.rb @@ -13,44 +13,9 @@ RSpec.describe ExpireJobCacheWorker do let(:job_args) { job.id } - include_examples 'an idempotent worker' do - it 'invalidates Etag caching for the job path' do - job_path = "/#{project.full_path}/builds/#{job.id}.json" - - spy_store = Gitlab::EtagCaching::Store.new - - allow(Gitlab::EtagCaching::Store).to receive(:new) { spy_store } - - expect(spy_store).to receive(:touch) - .exactly(worker_exec_times).times - .with(job_path) - .and_call_original - - expect(ExpirePipelineCacheWorker).to receive(:perform_async) - .with(pipeline.id) - .exactly(worker_exec_times).times - - subject - end - end - - it 'does not perform extra queries', :aggregate_failures do - worker = described_class.new - recorder = ActiveRecord::QueryRecorder.new { worker.perform(job.id) } - - occurences = recorder.data.values.flat_map {|v| v[:occurrences]} - project_queries = occurences.select {|s| s.include?('FROM "projects"')} - namespace_queries = occurences.select {|s| s.include?('FROM "namespaces"')} - route_queries = occurences.select {|s| s.include?('FROM "routes"')} - - # This worker is run 1 million times an hour, so we need to save as much - # queries as possible. - expect(recorder.count).to be <= 1 - - expect(project_queries.size).to eq(0) - expect(namespace_queries.size).to eq(0) - expect(route_queries.size).to eq(0) - end + it_behaves_like 'worker with data consistency', + described_class, + data_consistency: :delayed end context 'when there is no job in the pipeline' do diff --git a/spec/workers/expire_pipeline_cache_worker_spec.rb b/spec/workers/expire_pipeline_cache_worker_spec.rb index 8c24aaa985b..f4c4df2e752 100644 --- a/spec/workers/expire_pipeline_cache_worker_spec.rb +++ b/spec/workers/expire_pipeline_cache_worker_spec.rb @@ -18,23 +18,6 @@ RSpec.describe ExpirePipelineCacheWorker do subject.perform(pipeline.id) end - it 'does not perform extra queries', :aggregate_failures do - recorder = ActiveRecord::QueryRecorder.new { subject.perform(pipeline.id) } - - project_queries = recorder.data.values.flat_map {|v| v[:occurrences]}.select {|s| s.include?('FROM "projects"')} - namespace_queries = recorder.data.values.flat_map {|v| v[:occurrences]}.select {|s| s.include?('FROM "namespaces"')} - route_queries = recorder.data.values.flat_map {|v| v[:occurrences]}.select {|s| s.include?('FROM "routes"')} - - # This worker is run 1 million times an hour, so we need to save as much - # queries as possible. - expect(recorder.count).to be <= 6 - - # These arises from #update_etag_cache - expect(project_queries.size).to eq(1) - expect(namespace_queries.size).to eq(1) - expect(route_queries.size).to eq(1) - end - it "doesn't do anything if the pipeline not exist" do expect_any_instance_of(Ci::ExpirePipelineCacheService).not_to receive(:execute) expect_any_instance_of(Gitlab::EtagCaching::Store).not_to receive(:touch) diff --git a/spec/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker_spec.rb index f2a28ec40b8..c0dd4f488cc 100644 --- a/spec/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker_spec.rb +++ b/spec/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportIssuesAndDiffNotesWorker do it 'imports the issues and diff notes' do client = double(:client) - described_class::IMPORTERS.each do |klass| + worker.importers(project).each do |klass| importer = double(:importer) waiter = Gitlab::JobWaiter.new(2, '123') @@ -31,4 +31,45 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportIssuesAndDiffNotesWorker do worker.import(client, project) end end + + describe '#importers' do + context 'when project group is present' do + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group, projects: [project]) } + + context 'when feature flag github_importer_single_endpoint_notes_import is enabled' do + it 'includes single endpoint diff notes importer' do + project = create(:project) + group = create(:group, projects: [project]) + + stub_feature_flags(github_importer_single_endpoint_notes_import: group) + + expect(worker.importers(project)).to contain_exactly( + Gitlab::GithubImport::Importer::IssuesImporter, + Gitlab::GithubImport::Importer::SingleEndpointDiffNotesImporter + ) + end + end + + context 'when feature flag github_importer_single_endpoint_notes_import is disabled' do + it 'includes default diff notes importer' do + stub_feature_flags(github_importer_single_endpoint_notes_import: false) + + expect(worker.importers(project)).to contain_exactly( + Gitlab::GithubImport::Importer::IssuesImporter, + Gitlab::GithubImport::Importer::DiffNotesImporter + ) + end + end + end + + context 'when project group is missing' do + it 'includes default diff notes importer' do + expect(worker.importers(project)).to contain_exactly( + Gitlab::GithubImport::Importer::IssuesImporter, + Gitlab::GithubImport::Importer::DiffNotesImporter + ) + end + end + end end diff --git a/spec/workers/gitlab/github_import/stage/import_notes_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_notes_worker_spec.rb index 73b19239f4a..f9f21e4dfa2 100644 --- a/spec/workers/gitlab/github_import/stage/import_notes_worker_spec.rb +++ b/spec/workers/gitlab/github_import/stage/import_notes_worker_spec.rb @@ -8,18 +8,21 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportNotesWorker do describe '#import' do it 'imports all the notes' do - importer = double(:importer) client = double(:client) - waiter = Gitlab::JobWaiter.new(2, '123') - expect(Gitlab::GithubImport::Importer::NotesImporter) - .to receive(:new) - .with(project, client) - .and_return(importer) + worker.importers(project).each do |klass| + importer = double(:importer) + waiter = Gitlab::JobWaiter.new(2, '123') - expect(importer) - .to receive(:execute) - .and_return(waiter) + expect(klass) + .to receive(:new) + .with(project, client) + .and_return(importer) + + expect(importer) + .to receive(:execute) + .and_return(waiter) + end expect(Gitlab::GithubImport::AdvanceStageWorker) .to receive(:perform_async) @@ -28,4 +31,43 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportNotesWorker do worker.import(client, project) end end + + describe '#importers' do + context 'when project group is present' do + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group, projects: [project]) } + + context 'when feature flag github_importer_single_endpoint_notes_import is enabled' do + it 'includes single endpoint mr and issue notes importers' do + project = create(:project) + group = create(:group, projects: [project]) + + stub_feature_flags(github_importer_single_endpoint_notes_import: group) + + expect(worker.importers(project)).to contain_exactly( + Gitlab::GithubImport::Importer::SingleEndpointMergeRequestNotesImporter, + Gitlab::GithubImport::Importer::SingleEndpointIssueNotesImporter + ) + end + end + + context 'when feature flag github_importer_single_endpoint_notes_import is disabled' do + it 'includes default notes importer' do + stub_feature_flags(github_importer_single_endpoint_notes_import: false) + + expect(worker.importers(project)).to contain_exactly( + Gitlab::GithubImport::Importer::NotesImporter + ) + end + end + end + + context 'when project group is missing' do + it 'includes default diff notes importer' do + expect(worker.importers(project)).to contain_exactly( + Gitlab::GithubImport::Importer::NotesImporter + ) + end + end + end end diff --git a/spec/workers/issue_rebalancing_worker_spec.rb b/spec/workers/issue_rebalancing_worker_spec.rb index b6e9429d78e..cba42a1577e 100644 --- a/spec/workers/issue_rebalancing_worker_spec.rb +++ b/spec/workers/issue_rebalancing_worker_spec.rb @@ -8,41 +8,29 @@ RSpec.describe IssueRebalancingWorker do let_it_be(:project) { create(:project, group: group) } let_it_be(:issue) { create(:issue, project: project) } - context 'when block_issue_repositioning is enabled' do - before do - stub_feature_flags(block_issue_repositioning: group) - end - - it 'does not run an instance of IssueRebalancingService' do - expect(IssueRebalancingService).not_to receive(:new) - - described_class.new.perform(nil, issue.project_id) - end - end - shared_examples 'running the worker' do - it 'runs an instance of IssueRebalancingService' do + it 'runs an instance of Issues::RelativePositionRebalancingService' do service = double(execute: nil) service_param = arguments.second.present? ? kind_of(Project.id_in([project]).class) : kind_of(group&.all_projects.class) - expect(IssueRebalancingService).to receive(:new).with(service_param).and_return(service) + expect(Issues::RelativePositionRebalancingService).to receive(:new).with(service_param).and_return(service) described_class.new.perform(*arguments) end - it 'anticipates there being too many issues' do + it 'anticipates there being too many concurent rebalances' do service = double service_param = arguments.second.present? ? kind_of(Project.id_in([project]).class) : kind_of(group&.all_projects.class) - allow(service).to receive(:execute).and_raise(IssueRebalancingService::TooManyIssues) - expect(IssueRebalancingService).to receive(:new).with(service_param).and_return(service) - expect(Gitlab::ErrorTracking).to receive(:log_exception).with(IssueRebalancingService::TooManyIssues, include(project_id: arguments.second, root_namespace_id: arguments.third)) + allow(service).to receive(:execute).and_raise(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances) + expect(Issues::RelativePositionRebalancingService).to receive(:new).with(service_param).and_return(service) + expect(Gitlab::ErrorTracking).to receive(:log_exception).with(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances, include(project_id: arguments.second, root_namespace_id: arguments.third)) described_class.new.perform(*arguments) end it 'takes no action if the value is nil' do - expect(IssueRebalancingService).not_to receive(:new) + expect(Issues::RelativePositionRebalancingService).not_to receive(:new) expect(Gitlab::ErrorTracking).not_to receive(:log_exception) described_class.new.perform # all arguments are nil @@ -52,7 +40,7 @@ RSpec.describe IssueRebalancingWorker do shared_examples 'safely handles non-existent ids' do it 'anticipates the inability to find the issue' do expect(Gitlab::ErrorTracking).to receive(:log_exception).with(ArgumentError, include(project_id: arguments.second, root_namespace_id: arguments.third)) - expect(IssueRebalancingService).not_to receive(:new) + expect(Issues::RelativePositionRebalancingService).not_to receive(:new) described_class.new.perform(*arguments) end diff --git a/spec/workers/namespaceless_project_destroy_worker_spec.rb b/spec/workers/namespaceless_project_destroy_worker_spec.rb index cd66af82364..93e8415f3bb 100644 --- a/spec/workers/namespaceless_project_destroy_worker_spec.rb +++ b/spec/workers/namespaceless_project_destroy_worker_spec.rb @@ -48,12 +48,6 @@ RSpec.describe NamespacelessProjectDestroyWorker do subject.perform(project.id) end - - it 'does not do anything in Project#legacy_remove_pages method' do - expect(Gitlab::PagesTransfer).not_to receive(:new) - - subject.perform(project.id) - end end context 'project forked from another' do diff --git a/spec/workers/packages/helm/extraction_worker_spec.rb b/spec/workers/packages/helm/extraction_worker_spec.rb index 258413a3410..daebbda3077 100644 --- a/spec/workers/packages/helm/extraction_worker_spec.rb +++ b/spec/workers/packages/helm/extraction_worker_spec.rb @@ -23,10 +23,10 @@ RSpec.describe Packages::Helm::ExtractionWorker, type: :worker do subject { described_class.new.perform(channel, package_file_id) } - shared_examples 'handling error' do + shared_examples 'handling error' do |error_class = Packages::Helm::ExtractFileMetadataService::ExtractionError| it 'mark the package as errored', :aggregate_failures do expect(Gitlab::ErrorTracking).to receive(:log_exception).with( - instance_of(Packages::Helm::ExtractFileMetadataService::ExtractionError), + instance_of(error_class), project_id: package_file.package.project_id ) expect { subject } @@ -88,5 +88,15 @@ RSpec.describe Packages::Helm::ExtractionWorker, type: :worker do it_behaves_like 'handling error' end + + context 'with an invalid Chart.yaml' do + before do + expect_next_instance_of(Gem::Package::TarReader::Entry) do |entry| + expect(entry).to receive(:read).and_return('{}') + end + end + + it_behaves_like 'handling error', ActiveRecord::RecordInvalid + end end end diff --git a/spec/workers/pages_remove_worker_spec.rb b/spec/workers/pages_remove_worker_spec.rb index 864aa763fa9..9d49088b371 100644 --- a/spec/workers/pages_remove_worker_spec.rb +++ b/spec/workers/pages_remove_worker_spec.rb @@ -3,23 +3,9 @@ require 'spec_helper' RSpec.describe PagesRemoveWorker do - let(:project) { create(:project, path: "my.project")} - let!(:domain) { create(:pages_domain, project: project) } - - subject { described_class.new.perform(project.id) } - - before do - project.mark_pages_as_deployed - end - - it 'deletes published pages' do - expect(project.pages_deployed?).to be(true) - - expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true - expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything) - - subject - - expect(project.reload.pages_deployed?).to be(false) + 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/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index c111c3164eb..ddd295215a1 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -22,6 +22,8 @@ RSpec.describe PostReceive do create(:project, :repository, auto_cancel_pending_pipelines: 'disabled') end + let(:job_args) { [gl_repository, key_id, base64_changes] } + def perform(changes: base64_changes) described_class.new.perform(gl_repository, key_id, changes) end @@ -282,6 +284,8 @@ RSpec.describe PostReceive do end end end + + it_behaves_like 'an idempotent worker' end describe '#process_wiki_changes' do @@ -352,6 +356,8 @@ RSpec.describe PostReceive do perform end end + + it_behaves_like 'an idempotent worker' end context 'webhook' do @@ -458,6 +464,8 @@ RSpec.describe PostReceive do end end end + + it_behaves_like 'an idempotent worker' end context 'with PersonalSnippet' do @@ -484,5 +492,7 @@ RSpec.describe PostReceive do described_class.new.perform(gl_repository, key_id, base64_changes) end + + it_behaves_like 'an idempotent worker' end end diff --git a/spec/workers/purge_dependency_proxy_cache_worker_spec.rb b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb index 53f8d1bf5ba..393745958be 100644 --- a/spec/workers/purge_dependency_proxy_cache_worker_spec.rb +++ b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb @@ -11,14 +11,9 @@ RSpec.describe PurgeDependencyProxyCacheWorker do subject { described_class.new.perform(user.id, group_id) } - before do - stub_config(dependency_proxy: { enabled: true }) - group.create_dependency_proxy_setting!(enabled: true) - end - describe '#perform' do - shared_examples 'returns nil' do - it 'returns nil', :aggregate_failures do + shared_examples 'not removing blobs and manifests' do + it 'does not remove blobs and manifests', :aggregate_failures do expect { subject }.not_to change { group.dependency_proxy_blobs.size } expect { subject }.not_to change { group.dependency_proxy_manifests.size } expect(subject).to be_nil @@ -43,26 +38,26 @@ RSpec.describe PurgeDependencyProxyCacheWorker do end context 'when admin mode is disabled' do - it_behaves_like 'returns nil' + it_behaves_like 'not removing blobs and manifests' end end context 'a non-admin user' do let(:user) { create(:user) } - it_behaves_like 'returns nil' + it_behaves_like 'not removing blobs and manifests' end context 'an invalid user id' do let(:user) { double('User', id: 99999 ) } - it_behaves_like 'returns nil' + it_behaves_like 'not removing blobs and manifests' end context 'an invalid group' do let(:group_id) { 99999 } - it_behaves_like 'returns nil' + it_behaves_like 'not removing blobs and manifests' end end end diff --git a/spec/workers/stuck_ci_jobs_worker_spec.rb b/spec/workers/stuck_ci_jobs_worker_spec.rb index 84b2d87494e..e0a5d3c6c1c 100644 --- a/spec/workers/stuck_ci_jobs_worker_spec.rb +++ b/spec/workers/stuck_ci_jobs_worker_spec.rb @@ -5,311 +5,50 @@ require 'spec_helper' RSpec.describe StuckCiJobsWorker do include ExclusiveLeaseHelpers - let!(:runner) { create :ci_runner } - let!(:job) { create :ci_build, runner: runner } - let(:worker_lease_key) { StuckCiJobsWorker::EXCLUSIVE_LEASE_KEY } + let(:worker_lease_key) { StuckCiJobsWorker::EXCLUSIVE_LEASE_KEY } let(:worker_lease_uuid) { SecureRandom.uuid } - let(:created_at) { } - let(:updated_at) { } + let(:worker2) { described_class.new } subject(:worker) { described_class.new } before do stub_exclusive_lease(worker_lease_key, worker_lease_uuid) - job_attributes = { status: status } - job_attributes[:created_at] = created_at if created_at - job_attributes[:updated_at] = updated_at if updated_at - job.update!(job_attributes) end - shared_examples 'job is dropped' do - it "changes status" do - worker.perform - 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 - - worker.perform - job.reload - - expect(job).to be_failed - expect(job).to be_data_integrity_failure + 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 - end - end - shared_examples 'job is unchanged' do - before do worker.perform - job.reload end - it "doesn't change status" do - expect(job.status).to eq(status) - end - end - - context 'when job is pending' do - let(:status) { 'pending' } - - context 'when job is not stuck' do - before do - allow_any_instance_of(Ci::Build).to receive(:stuck?).and_return(false) - end - - context 'when job was updated_at more than 1 day ago' do - let(:updated_at) { 1.5.days.ago } - - context 'when created_at is the same as updated_at' do - let(:created_at) { 1.5.days.ago } - - it_behaves_like 'job is dropped' - end - - context 'when created_at is before updated_at' do - let(:created_at) { 3.days.ago } - - it_behaves_like 'job is dropped' - end - - context 'when created_at is outside lookback window' do - let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } - - it_behaves_like 'job is unchanged' - end - end - - context 'when job was updated less than 1 day ago' do - let(:updated_at) { 6.hours.ago } - - context 'when created_at is the same as updated_at' do - let(:created_at) { 1.5.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 - - context 'when created_at is outside lookback window' do - let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } - - it_behaves_like 'job is unchanged' - end - end - - context 'when job was updated more than 1 hour ago' do - let(:updated_at) { 2.hours.ago } - - context 'when created_at is the same as updated_at' do - let(:created_at) { 2.hours.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 - - context 'when created_at is outside lookback window' do - let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } - - it_behaves_like 'job is unchanged' - end - end - end - - context 'when job is stuck' do - before do - allow_any_instance_of(Ci::Build).to receive(:stuck?).and_return(true) - end - - context 'when job was updated_at more than 1 hour ago' do - let(:updated_at) { 1.5.hours.ago } - - context 'when created_at is the same as updated_at' do - let(:created_at) { 1.5.hours.ago } - - it_behaves_like 'job is dropped' - end - - context 'when created_at is before updated_at' do - let(:created_at) { 3.days.ago } - - it_behaves_like 'job is dropped' - end - - context 'when created_at is outside lookback window' do - let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } - - it_behaves_like 'job is unchanged' - end - end - - context 'when job was updated in less than 1 hour ago' do - let(:updated_at) { 30.minutes.ago } - - context 'when created_at is the same as updated_at' do - let(:created_at) { 30.minutes.ago } - - it_behaves_like 'job is unchanged' - end - - context 'when created_at is before updated_at' do - let(:created_at) { 2.days.ago } - - it_behaves_like 'job is unchanged' - end + 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) - context 'when created_at is outside lookback window' do - let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } - - it_behaves_like 'job is unchanged' - end - end - end - end - - context 'when job is running' do - let(:status) { 'running' } - - context 'when job was updated_at more than an hour ago' do - let(:updated_at) { 2.hours.ago } - - it_behaves_like 'job is dropped' - end - - context 'when job was updated in less than 1 hour ago' do - let(:updated_at) { 30.minutes.ago } - - it_behaves_like 'job is unchanged' - end - end - - %w(success skipped failed canceled).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 + worker.perform - context 'when created_at is outside lookback window' do - let(:created_at) { described_class::BUILD_LOOKBACK - 1.day } + stub_exclusive_lease_taken(worker_lease_key) - it_behaves_like 'job is unchanged' + worker2.perform end - end - end - - context 'for deleted project' do - let(:status) { 'running' } - let(:updated_at) { 2.days.ago } - - before do - job.project.update!(pending_delete: true) - end - - it 'does drop job' do - expect_any_instance_of(Ci::Build).to receive(:drop).and_call_original - worker.perform - end - 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 + it 'can execute in sequence' do + expect(worker).to receive(:remove_lease).at_least(:once) + expect(worker2).to receive(:remove_lease).at_least(:once) worker.perform - job.reload - - expect(Ci::Build.scheduled.count).to eq(0) - expect(job).to be_failed - expect(job).to be_stale_schedule + worker2.perform 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 + it 'cancels exclusive leases after worker perform' do + expect_to_cancel_exclusive_lease(worker_lease_key, worker_lease_uuid) worker.perform - - 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 { worker.perform }.not_to raise_error end end end - - describe 'exclusive lease' do - let(:status) { 'running' } - let(:updated_at) { 2.days.ago } - let(:worker2) { described_class.new } - - it 'is guard by exclusive lease when executed concurrently' do - expect(worker).to receive(:drop).at_least(:once).and_call_original - expect(worker2).not_to receive(:drop) - - worker.perform - - stub_exclusive_lease_taken(worker_lease_key) - - worker2.perform - end - - it 'can be executed in sequence' do - expect(worker).to receive(:drop).at_least(:once).and_call_original - expect(worker2).to receive(:drop).at_least(:once).and_call_original - - worker.perform - worker2.perform - end - - it 'cancels exclusive leases after worker perform' do - expect_to_cancel_exclusive_lease(worker_lease_key, worker_lease_uuid) - - worker.perform - end - end end diff --git a/spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb b/spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb new file mode 100644 index 00000000000..113faeb0d2f --- /dev/null +++ b/spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TodosDestroyer::DestroyedDesignsWorker do + let(:service) { double } + + it 'calls the Todos::Destroy::DesignService with design_ids parameter' do + expect(::Todos::Destroy::DesignService).to receive(:new).with([1, 5]).and_return(service) + expect(service).to receive(:execute) + + described_class.new.perform([1, 5]) + end +end |