diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-04-20 10:00:54 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-04-20 10:00:54 +0000 |
commit | 3cccd102ba543e02725d247893729e5c73b38295 (patch) | |
tree | f36a04ec38517f5deaaacb5acc7d949688d1e187 /spec | |
parent | 205943281328046ef7b4528031b90fbda70c75ac (diff) | |
download | gitlab-ce-3cccd102ba543e02725d247893729e5c73b38295.tar.gz |
Add latest changes from gitlab-org/gitlab@14-10-stable-eev14.10.0-rc42
Diffstat (limited to 'spec')
1201 files changed, 36257 insertions, 16548 deletions
diff --git a/spec/commands/sidekiq_cluster/cli_spec.rb b/spec/commands/sidekiq_cluster/cli_spec.rb index 2cb3f67b03d..bbf5f2bc4d9 100644 --- a/spec/commands/sidekiq_cluster/cli_spec.rb +++ b/spec/commands/sidekiq_cluster/cli_spec.rb @@ -41,6 +41,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo end let(:supervisor) { instance_double(Gitlab::SidekiqCluster::SidekiqProcessSupervisor) } + let(:metrics_cleanup_service) { instance_double(Prometheus::CleanupMultiprocDirService, execute: nil) } before do stub_env('RAILS_ENV', 'test') @@ -54,6 +55,8 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo allow(Gitlab::ProcessManagement).to receive(:write_pid) allow(Gitlab::SidekiqCluster::SidekiqProcessSupervisor).to receive(:instance).and_return(supervisor) allow(supervisor).to receive(:supervise) + + allow(Prometheus::CleanupMultiprocDirService).to receive(:new).and_return(metrics_cleanup_service) end after do @@ -300,6 +303,13 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo allow(Gitlab::SidekiqCluster).to receive(:start).and_return([]) end + it 'wipes the metrics directory before starting workers' do + expect(metrics_cleanup_service).to receive(:execute).ordered + expect(Gitlab::SidekiqCluster).to receive(:start).ordered.and_return([]) + + cli.run(%w(foo)) + end + context 'when there are no sidekiq_health_checks settings set' do let(:sidekiq_exporter_enabled) { true } @@ -379,7 +389,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo with_them do specify do if start_metrics_server - expect(MetricsServer).to receive(:fork).with('sidekiq', metrics_dir: metrics_dir, wipe_metrics_dir: true, reset_signals: trapped_signals) + expect(MetricsServer).to receive(:fork).with('sidekiq', metrics_dir: metrics_dir, reset_signals: trapped_signals) else expect(MetricsServer).not_to receive(:fork) end diff --git a/spec/components/diffs/overflow_warning_component_spec.rb b/spec/components/diffs/overflow_warning_component_spec.rb new file mode 100644 index 00000000000..ee4014ee492 --- /dev/null +++ b/spec/components/diffs/overflow_warning_component_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Diffs::OverflowWarningComponent, type: :component do + include RepoHelpers + + subject(:component) do + described_class.new( + diffs: diffs, + diff_files: diff_files, + project: project, + commit: commit, + merge_request: merge_request + ) + end + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:repository) { project.repository } + let_it_be(:commit) { project.commit(sample_commit.id) } + let_it_be(:diffs) { commit.raw_diffs } + let_it_be(:diff) { diffs.first } + let_it_be(:diff_refs) { commit.diff_refs } + let_it_be(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } + let_it_be(:diff_files) { [diff_file] } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + + let(:expected_button_classes) do + "btn gl-alert-action btn-default gl-button btn-default-secondary" + end + + describe "rendered component" do + subject { rendered_component } + + context "on a commit page" do + before do + with_controller_class Projects::CommitController do + render_inline component + end + end + + it { is_expected.to include(component.message) } + + it "links to the diff" do + expect(component.diff_link).to eq( + ActionController::Base.helpers.link_to( + _("Plain diff"), + project_commit_path(project, commit, format: :diff), + class: expected_button_classes + ) + ) + + is_expected.to include(component.diff_link) + end + + it "links to the patch" do + expect(component.patch_link).to eq( + ActionController::Base.helpers.link_to( + _("Email patch"), + project_commit_path(project, commit, format: :patch), + class: expected_button_classes + ) + ) + + is_expected.to include(component.patch_link) + end + end + + context "on a merge request page and the merge request is persisted" do + before do + with_controller_class Projects::MergeRequests::DiffsController do + render_inline component + end + end + + it { is_expected.to include(component.message) } + + it "links to the diff" do + expect(component.diff_link).to eq( + ActionController::Base.helpers.link_to( + _("Plain diff"), + merge_request_path(merge_request, format: :diff), + class: expected_button_classes + ) + ) + + is_expected.to include(component.diff_link) + end + + it "links to the patch" do + expect(component.patch_link).to eq( + ActionController::Base.helpers.link_to( + _("Email patch"), + merge_request_path(merge_request, format: :patch), + class: expected_button_classes + ) + ) + + is_expected.to include(component.patch_link) + end + end + + context "both conditions fail" do + before do + allow(component).to receive(:commit?).and_return(false) + allow(component).to receive(:merge_request?).and_return(false) + render_inline component + end + + it { is_expected.to include(component.message) } + it { is_expected.not_to include(expected_button_classes) } + it { is_expected.not_to include("Plain diff") } + it { is_expected.not_to include("Email patch") } + end + end + + describe "#message" do + subject { component.message } + + it { is_expected.to be_a(String) } + + it "is HTML-safe" do + expect(subject.html_safe?).to be_truthy + end + end + + describe "#diff_link" do + subject { component.diff_link } + + before do + allow(component).to receive(:link_to).and_return("foo") + render_inline component + end + + it "is a string when on a commit page" do + allow(component).to receive(:commit?).and_return(true) + + is_expected.to eq("foo") + end + + it "is a string when on a merge request page" do + allow(component).to receive(:commit?).and_return(false) + allow(component).to receive(:merge_request?).and_return(true) + + is_expected.to eq("foo") + end + + it "is nil in other situations" do + allow(component).to receive(:commit?).and_return(false) + allow(component).to receive(:merge_request?).and_return(false) + + is_expected.to be_nil + end + end + + describe "#patch_link" do + subject { component.patch_link } + + before do + allow(component).to receive(:link_to).and_return("foo") + render_inline component + end + + it "is a string when on a commit page" do + allow(component).to receive(:commit?).and_return(true) + + is_expected.to eq("foo") + end + + it "is a string when on a merge request page" do + allow(component).to receive(:commit?).and_return(false) + allow(component).to receive(:merge_request?).and_return(true) + + is_expected.to eq("foo") + end + + it "is nil in other situations" do + allow(component).to receive(:commit?).and_return(false) + allow(component).to receive(:merge_request?).and_return(false) + + is_expected.to be_nil + end + end +end diff --git a/spec/components/diffs/stats_component_spec.rb b/spec/components/diffs/stats_component_spec.rb new file mode 100644 index 00000000000..2e5a5f2ca26 --- /dev/null +++ b/spec/components/diffs/stats_component_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Diffs::StatsComponent, type: :component do + include RepoHelpers + + subject(:component) do + described_class.new(diff_files: diff_files) + end + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:repository) { project.repository } + let_it_be(:commit) { project.commit(sample_commit.id) } + let_it_be(:diffs) { commit.raw_diffs } + let_it_be(:diff) { diffs.first } + let_it_be(:diff_refs) { commit.diff_refs } + let_it_be(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } + let_it_be(:diff_files) { [diff_file] } + + describe "rendered component" do + subject { rendered_component } + + let(:element) { page.find(".js-diff-stats-dropdown") } + + before do + render_inline component + end + + it { is_expected.to have_selector(".js-diff-stats-dropdown") } + + it "renders the data attributes" do + expect(element["data-changed"]).to eq("1") + expect(element["data-added"]).to eq("10") + expect(element["data-deleted"]).to eq("3") + + expect(Gitlab::Json.parse(element["data-files"])).to eq([{ + "href" => "##{Digest::SHA1.hexdigest(diff_file.file_path)}", + "title" => diff_file.new_path, + "name" => diff_file.file_path, + "path" => diff_file.file_path, + "icon" => "file-modified", + "iconColor" => "", + "added" => diff_file.added_lines, + "removed" => diff_file.removed_lines + }]) + end + end + + describe "#diff_file_path_text" do + it "returns full path by default" do + expect(subject.diff_file_path_text(diff_file)).to eq(diff_file.new_path) + end + + it "returns truncated path" do + expect(subject.diff_file_path_text(diff_file, max: 10)).to eq("...open.rb") + end + + it "returns the path if max is oddly small" do + expect(subject.diff_file_path_text(diff_file, max: 3)).to eq(diff_file.new_path) + end + + it "returns the path if max is oddly large" do + expect(subject.diff_file_path_text(diff_file, max: 100)).to eq(diff_file.new_path) + end + end +end diff --git a/spec/components/pajamas/alert_component_spec.rb b/spec/components/pajamas/alert_component_spec.rb new file mode 100644 index 00000000000..628d715ff64 --- /dev/null +++ b/spec/components/pajamas/alert_component_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true +require "spec_helper" + +RSpec.describe Pajamas::AlertComponent, :aggregate_failures, type: :component do + context 'with content' do + before do + render_inline(described_class.new) { '_content_' } + end + + it 'has content' do + expect(rendered_component).to have_text('_content_') + end + end + + context 'with defaults' do + before do + render_inline described_class.new + end + + it 'does not set a title' do + expect(rendered_component).not_to have_selector('.gl-alert-title') + expect(rendered_component).to have_selector('.gl-alert-icon-no-title') + end + + it 'renders the default variant' do + expect(rendered_component).to have_selector('.gl-alert-info') + expect(rendered_component).to have_selector("[data-testid='information-o-icon']") + end + + it 'renders a dismiss button' do + expect(rendered_component).to have_selector('.gl-dismiss-btn.js-close') + expect(rendered_component).to have_selector("[data-testid='close-icon']") + end + end + + context 'with custom options' do + context 'with simple options' do + context 'without dismissible content' do + before do + render_inline described_class.new( + title: '_title_', + dismissible: false, + alert_class: '_alert_class_', + alert_data: { + feature_id: '_feature_id_', + dismiss_endpoint: '_dismiss_endpoint_' + } + ) + end + + it 'sets the title' do + expect(rendered_component).to have_selector('.gl-alert-title') + expect(rendered_component).to have_content('_title_') + expect(rendered_component).not_to have_selector('.gl-alert-icon-no-title') + end + + it 'sets to not be dismissible' do + expect(rendered_component).not_to have_selector('.gl-dismiss-btn.js-close') + expect(rendered_component).not_to have_selector("[data-testid='close-icon']") + end + + it 'sets the alert_class' do + expect(rendered_component).to have_selector('._alert_class_') + end + + it 'sets the alert_data' do + expect(rendered_component).to have_selector('[data-feature-id="_feature_id_"][data-dismiss-endpoint="_dismiss_endpoint_"]') + end + end + end + + context 'with dismissible content' do + before do + render_inline described_class.new( + close_button_class: '_close_button_class_', + close_button_data: { + testid: '_close_button_testid_' + } + ) + end + + it 'renders a dismiss button and data' do + expect(rendered_component).to have_selector('.gl-dismiss-btn.js-close._close_button_class_') + expect(rendered_component).to have_selector("[data-testid='close-icon']") + expect(rendered_component).to have_selector('[data-testid="_close_button_testid_"]') + end + end + + context 'with setting variant type' do + where(:variant) { [:warning, :success, :danger, :tip] } + + before do + render_inline described_class.new(variant: variant) + end + + with_them do + it 'renders the variant' do + expect(rendered_component).to have_selector(".gl-alert-#{variant}") + expect(rendered_component).to have_selector("[data-testid='#{described_class::ICONS[variant]}-icon']") + end + end + end + end +end diff --git a/spec/controllers/concerns/import_url_params_spec.rb b/spec/controllers/concerns/import_url_params_spec.rb index ddffb243f7a..170263d10a4 100644 --- a/spec/controllers/concerns/import_url_params_spec.rb +++ b/spec/controllers/concerns/import_url_params_spec.rb @@ -55,4 +55,22 @@ RSpec.describe ImportUrlParams do end end end + + context 'url with provided mixed credentials' do + let(:params) do + ActionController::Parameters.new(project: { + import_url: 'https://user@url.com', + import_url_user: '', import_url_password: 'password' + }) + end + + describe '#import_url_params' do + it 'returns import_url built from both url and hash credentials' do + expect(import_url_params).to eq( + import_url: 'https://user:password@url.com', + import_type: 'git' + ) + end + end + end end diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb index c3f6c653376..bf578489916 100644 --- a/spec/controllers/explore/projects_controller_spec.rb +++ b/spec/controllers/explore/projects_controller_spec.rb @@ -112,6 +112,13 @@ RSpec.describe Explore::ProjectsController do expect(response).to have_gitlab_http_status(:ok) expect(response).to render_template('topic') end + + it 'finds topic by case insensitive name' do + get :topic, params: { topic_name: 'TOPIC1' } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('topic') + end end end end diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb index dbaed8aaa19..4de31e2e135 100644 --- a/spec/controllers/graphql_controller_spec.rb +++ b/spec/controllers/graphql_controller_spec.rb @@ -134,6 +134,47 @@ RSpec.describe GraphqlController do post :execute end + + it 'calls the track gitlab cli when trackable method' do + agent = 'GLab - GitLab CLI' + request.env['HTTP_USER_AGENT'] = agent + + expect(Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter) + .to receive(:track_api_request_when_trackable).with(user_agent: agent, user: user) + + post :execute + end + + it "assigns username in ApplicationContext" do + post :execute + + expect(Gitlab::ApplicationContext.current).to include('meta.user' => user.username) + end + end + + context 'when 2FA is required for the user' do + let(:user) { create(:user, last_activity_on: Date.yesterday) } + + before do + group = create(:group, require_two_factor_authentication: true) + group.add_developer(user) + + sign_in(user) + end + + it 'does not redirect if 2FA is enabled' do + expect(controller).not_to receive(:redirect_to) + + post :execute + + expect(response).to have_gitlab_http_status(:unauthorized) + + expected_message = "Authentication error: " \ + "enable 2FA in your profile settings to continue using GitLab: %{mfa_help_page}" % + { mfa_help_page: EnforcesTwoFactorAuthentication::MFA_HELP_PAGE } + + expect(json_response).to eq({ 'errors' => [{ 'message' => expected_message }] }) + end end context 'when user uses an API token' do @@ -189,6 +230,12 @@ RSpec.describe GraphqlController do expect(assigns(:context)[:is_sessionless_user]).to be true end + it "assigns username in ApplicationContext" do + subject + + expect(Gitlab::ApplicationContext.current).to include('meta.user' => user.username) + end + it 'calls the track api when trackable method' do agent = 'vs-code-gitlab-workflow/3.11.1 VSCode/1.52.1 Node.js/12.14.1 (darwin; x64)' request.env['HTTP_USER_AGENT'] = agent @@ -208,6 +255,16 @@ RSpec.describe GraphqlController do subject end + + it 'calls the track gitlab cli when trackable method' do + agent = 'GLab - GitLab CLI' + request.env['HTTP_USER_AGENT'] = agent + + expect(Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter) + .to receive(:track_api_request_when_trackable).with(user_agent: agent, user: user) + + subject + end end context 'when user is not logged in' do @@ -222,6 +279,12 @@ RSpec.describe GraphqlController do expect(assigns(:context)[:is_sessionless_user]).to be false end + + it "does not assign a username in ApplicationContext" do + subject + + expect(Gitlab::ApplicationContext.current.key?('meta.user')).to be false + end end it 'includes request object in context' do diff --git a/spec/controllers/groups/group_links_controller_spec.rb b/spec/controllers/groups/group_links_controller_spec.rb index fafe9715946..28febd786de 100644 --- a/spec/controllers/groups/group_links_controller_spec.rb +++ b/spec/controllers/groups/group_links_controller_spec.rb @@ -35,120 +35,6 @@ RSpec.describe Groups::GroupLinksController do end end - describe '#create' do - let(:shared_with_group_id) { shared_with_group.id } - let(:shared_group_access) { GroupGroupLink.default_access } - - subject do - post(:create, - params: { group_id: shared_group, - shared_with_group_id: shared_with_group_id, - shared_group_access: shared_group_access }) - end - - shared_examples 'creates group group link' do - it 'links group with selected group' do - expect { subject }.to change { shared_with_group.shared_groups.include?(shared_group) }.from(false).to(true) - end - - it 'redirects to group links page' do - subject - - expect(response).to(redirect_to(group_group_members_path(shared_group))) - end - - it 'allows access for group member' do - expect { subject }.to( - change { group_member.can?(:read_group, shared_group) }.from(false).to(true)) - end - end - - context 'when user has correct access to both groups' do - before do - shared_with_group.add_developer(user) - shared_group.add_owner(user) - end - - context 'when default access level is requested' do - include_examples 'creates group group link' - end - - context 'when owner access is requested' do - let(:shared_group_access) { Gitlab::Access::OWNER } - - before do - shared_with_group.add_owner(group_member) - end - - include_examples 'creates group group link' - - it 'allows admin access for group member' do - expect { subject }.to( - change { group_member.can?(:admin_group, shared_group) }.from(false).to(true)) - end - end - - it 'updates project permissions', :sidekiq_inline do - expect { subject }.to change { group_member.can?(:read_project, project) }.from(false).to(true) - end - - context 'when shared with group id is not present' do - let(:shared_with_group_id) { nil } - - it 'redirects to group links page' do - subject - - expect(response).to(redirect_to(group_group_members_path(shared_group))) - expect(flash[:alert]).to eq('Please select a group.') - end - end - - context 'when link is not persisted in the database' do - before do - allow(::Groups::GroupLinks::CreateService).to( - receive_message_chain(:new, :execute) - .and_return({ status: :error, - http_status: 409, - message: 'error' })) - end - - it 'redirects to group links page' do - subject - - expect(response).to(redirect_to(group_group_members_path(shared_group))) - expect(flash[:alert]).to eq('error') - end - end - end - - context 'when user does not have access to the group' do - before do - shared_group.add_owner(user) - end - - it 'renders 404' do - subject - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when user does not have admin access to the shared group' do - before do - shared_with_group.add_developer(user) - shared_group.add_developer(user) - end - - it 'renders 404' do - subject - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - include_examples 'placeholder is passed as `id` parameter', :create - end - describe '#update' do let!(:link) do create(:group_group_link, { shared_group: shared_group, @@ -193,7 +79,8 @@ RSpec.describe Groups::GroupLinksController do subject - expect(json_response).to eq({ "expires_in" => "about 1 month", "expires_soon" => false }) + expect(json_response).to eq({ "expires_in" => controller.helpers.time_ago_with_tooltip(expiry_date), + "expires_soon" => false }) end end diff --git a/spec/controllers/groups/runners_controller_spec.rb b/spec/controllers/groups/runners_controller_spec.rb index b4950b93a3f..a53f09e2afc 100644 --- a/spec/controllers/groups/runners_controller_spec.rb +++ b/spec/controllers/groups/runners_controller_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Groups::RunnersController do sign_in(user) end - describe '#index' do + describe '#index', :snowplow do context 'when user is owner' do before do group.add_owner(user) @@ -30,6 +30,12 @@ RSpec.describe Groups::RunnersController do expect(response).to render_template(:index) expect(assigns(:group_runners_limited_count)).to be(2) end + + it 'tracks the event' do + get :index, params: { group_id: group } + + expect_snowplow_event(category: described_class.name, action: 'index', user: user, namespace: group) + end end context 'when user is not owner' do @@ -42,6 +48,12 @@ RSpec.describe Groups::RunnersController do expect(response).to have_gitlab_http_status(:not_found) end + + it 'does not track the event' do + get :index, params: { group_id: group } + + expect_no_snowplow_event + end end end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index a82c5681911..be30011905c 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -509,6 +509,14 @@ RSpec.describe GroupsController, factory_default: :keep do expect(assigns(:issues)).to eq([issue_1]) end end + + it 'saves the sort order to user preferences' do + stub_feature_flags(vue_issues_list: true) + + get :issues, params: { id: group.to_param, sort: 'priority' } + + expect(user.reload.user_preference.issues_sort).to eq('priority') + end end describe 'GET #merge_requests', :sidekiq_might_not_need_inline do @@ -1076,19 +1084,6 @@ RSpec.describe GroupsController, factory_default: :keep do enable_admin_mode!(admin) end - context 'when the group export feature flag is not enabled' do - before do - sign_in(admin) - stub_feature_flags(group_import_export: false) - end - - it 'returns a not found error' do - post :export, params: { id: group.to_param } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - context 'when the user does not have permission to export the group' do before do sign_in(guest) @@ -1189,19 +1184,6 @@ RSpec.describe GroupsController, factory_default: :keep do end end - context 'when the group export feature flag is not enabled' do - before do - sign_in(admin) - stub_feature_flags(group_import_export: false) - end - - it 'returns a not found error' do - post :export, params: { id: group.to_param } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - context 'when the user does not have the required permissions' do before do sign_in(guest) diff --git a/spec/controllers/help_controller_spec.rb b/spec/controllers/help_controller_spec.rb index 4e2123c8cc4..70dc710f604 100644 --- a/spec/controllers/help_controller_spec.rb +++ b/spec/controllers/help_controller_spec.rb @@ -142,11 +142,11 @@ RSpec.describe HelpController do context 'for Markdown formats' do subject { get :show, params: { path: path }, format: :md } - let(:path) { 'ssh/index' } + let(:path) { 'user/ssh' } context 'when requested file exists' do before do - expect_file_read(File.join(Rails.root, 'doc/ssh/index.md'), content: fixture_file('blockquote_fence_after.md')) + expect_file_read(File.join(Rails.root, 'doc/user/ssh.md'), content: fixture_file('blockquote_fence_after.md')) subject end @@ -257,7 +257,7 @@ RSpec.describe HelpController do it 'always renders not found' do get :show, params: { - path: 'ssh/index' + path: 'user/ssh' }, format: :foo expect(response).to be_not_found diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb index 91e43adc472..6d24830af27 100644 --- a/spec/controllers/import/bitbucket_controller_spec.rb +++ b/spec/controllers/import/bitbucket_controller_spec.rb @@ -26,31 +26,55 @@ RSpec.describe Import::BitbucketController do session[:oauth_request_token] = {} end - it "updates access token" do - expires_at = Time.current + 1.day - expires_in = 1.day - access_token = double(token: token, - secret: secret, - expires_at: expires_at, - expires_in: expires_in, - refresh_token: refresh_token) - allow_any_instance_of(OAuth2::Client) - .to receive(:get_token) - .with(hash_including( - 'grant_type' => 'authorization_code', - 'code' => code, - redirect_uri: users_import_bitbucket_callback_url), - {}) - .and_return(access_token) - stub_omniauth_provider('bitbucket') - - get :callback, params: { code: code } - - expect(session[:bitbucket_token]).to eq(token) - expect(session[:bitbucket_refresh_token]).to eq(refresh_token) - expect(session[:bitbucket_expires_at]).to eq(expires_at) - expect(session[:bitbucket_expires_in]).to eq(expires_in) - expect(controller).to redirect_to(status_import_bitbucket_url) + context "when auth state param is invalid" do + let(:random_key) { "pure_random" } + let(:external_bitbucket_auth_url) { "http://fake.bitbucket.host/url" } + + it "redirects to external auth url" do + allow(SecureRandom).to receive(:base64).and_return(random_key) + allow_next_instance_of(OAuth2::Client) do |client| + allow(client).to receive_message_chain(:auth_code, :authorize_url) + .with(redirect_uri: users_import_bitbucket_callback_url, state: random_key) + .and_return(external_bitbucket_auth_url) + end + + get :callback, params: { code: code, state: "invalid-token" } + + expect(controller).to redirect_to(external_bitbucket_auth_url) + end + end + + context "when auth state param is valid" do + before do + session[:bitbucket_auth_state] = 'state' + end + + it "updates access token" do + expires_at = Time.current + 1.day + expires_in = 1.day + access_token = double(token: token, + secret: secret, + expires_at: expires_at, + expires_in: expires_in, + refresh_token: refresh_token) + allow_any_instance_of(OAuth2::Client) + .to receive(:get_token) + .with(hash_including( + 'grant_type' => 'authorization_code', + 'code' => code, + redirect_uri: users_import_bitbucket_callback_url), + {}) + .and_return(access_token) + stub_omniauth_provider('bitbucket') + + get :callback, params: { code: code, state: 'state' } + + expect(session[:bitbucket_token]).to eq(token) + expect(session[:bitbucket_refresh_token]).to eq(refresh_token) + expect(session[:bitbucket_expires_at]).to eq(expires_at) + expect(session[:bitbucket_expires_in]).to eq(expires_in) + expect(controller).to redirect_to(status_import_bitbucket_url) + end end end @@ -59,46 +83,68 @@ RSpec.describe Import::BitbucketController do @repo = double(name: 'vim', slug: 'vim', owner: 'asd', full_name: 'asd/vim', clone_url: 'http://test.host/demo/url.git', 'valid?' => true) @invalid_repo = double(name: 'mercurialrepo', slug: 'mercurialrepo', owner: 'asd', full_name: 'asd/mercurialrepo', clone_url: 'http://test.host/demo/mercurialrepo.git', 'valid?' => false) allow(controller).to receive(:provider_url).and_return('http://demobitbucket.org') + end - assign_session_tokens + context "when token does not exists" do + let(:random_key) { "pure_random" } + let(:external_bitbucket_auth_url) { "http://fake.bitbucket.host/url" } + + it 'redirects to authorize url with state included' do + allow(SecureRandom).to receive(:base64).and_return(random_key) + allow_next_instance_of(OAuth2::Client) do |client| + allow(client).to receive_message_chain(:auth_code, :authorize_url) + .with(redirect_uri: users_import_bitbucket_callback_url, state: random_key) + .and_return(external_bitbucket_auth_url) + end + + get :status, format: :json + + expect(controller).to redirect_to(external_bitbucket_auth_url) + end end - it_behaves_like 'import controller status' do + context "when token is valid" do before do - allow(controller).to receive(:provider_url).and_return('http://demobitbucket.org') + assign_session_tokens end - let(:repo) { @repo } - let(:repo_id) { @repo.full_name } - let(:import_source) { @repo.full_name } - let(:provider_name) { 'bitbucket' } - let(:client_repos_field) { :repos } - end + it_behaves_like 'import controller status' do + before do + allow(controller).to receive(:provider_url).and_return('http://demobitbucket.org') + end - it 'returns invalid repos' do - allow_any_instance_of(Bitbucket::Client).to receive(:repos).and_return([@repo, @invalid_repo]) + let(:repo) { @repo } + let(:repo_id) { @repo.full_name } + let(:import_source) { @repo.full_name } + let(:provider_name) { 'bitbucket' } + let(:client_repos_field) { :repos } + end - get :status, format: :json + it 'returns invalid repos' do + allow_any_instance_of(Bitbucket::Client).to receive(:repos).and_return([@repo, @invalid_repo]) - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['incompatible_repos'].length).to eq(1) - expect(json_response.dig("incompatible_repos", 0, "id")).to eq(@invalid_repo.full_name) - expect(json_response['provider_repos'].length).to eq(1) - expect(json_response.dig("provider_repos", 0, "id")).to eq(@repo.full_name) - end + get :status, format: :json - context 'when filtering' do - let(:filter) { '<html>test</html>' } - let(:expected_filter) { 'test' } + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['incompatible_repos'].length).to eq(1) + expect(json_response.dig("incompatible_repos", 0, "id")).to eq(@invalid_repo.full_name) + expect(json_response['provider_repos'].length).to eq(1) + expect(json_response.dig("provider_repos", 0, "id")).to eq(@repo.full_name) + end - subject { get :status, params: { filter: filter }, as: :json } + context 'when filtering' do + let(:filter) { '<html>test</html>' } + let(:expected_filter) { 'test' } - it 'passes sanitized filter param to bitbucket client' do - expect_next_instance_of(Bitbucket::Client) do |client| - expect(client).to receive(:repos).with(filter: expected_filter).and_return([@repo]) - end + subject { get :status, params: { filter: filter }, as: :json } - subject + it 'passes sanitized filter param to bitbucket client' do + expect_next_instance_of(Bitbucket::Client) do |client| + expect(client).to receive(:repos).with(filter: expected_filter).and_return([@repo]) + end + + subject + end end end end diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb index fd380f9b763..ef66124bff1 100644 --- a/spec/controllers/import/github_controller_spec.rb +++ b/spec/controllers/import/github_controller_spec.rb @@ -82,11 +82,33 @@ RSpec.describe Import::GithubController do expect(controller).to redirect_to(new_import_url) expect(flash[:alert]).to eq('Access denied to your GitHub account.') end + + it "includes namespace_id from session if it is present" do + namespace_id = 1 + session[:namespace_id] = 1 + + get :callback, params: { state: valid_auth_state } + + expect(controller).to redirect_to(status_import_github_url(namespace_id: namespace_id)) + end end end describe "POST personal_access_token" do it_behaves_like 'a GitHub-ish import controller: POST personal_access_token' + + it 'passes namespace_id param as query param if it was present' do + namespace_id = 5 + status_import_url = public_send("status_import_#{provider}_url", { namespace_id: namespace_id }) + + allow_next_instance_of(Gitlab::LegacyGithubImport::Client) do |client| + allow(client).to receive(:user).and_return(true) + end + + post :personal_access_token, params: { personal_access_token: 'some-token', namespace_id: 5 } + + expect(controller).to redirect_to(status_import_url) + end end describe "GET status" do @@ -258,7 +280,9 @@ RSpec.describe Import::GithubController do context 'when user input contains colons and spaces' do before do - allow(controller).to receive(:client_repos).and_return([]) + allow_next_instance_of(Gitlab::GithubImport::Client) do |client| + allow(client).to receive(:search_repos_by_name).and_return(items: []) + end end it 'sanitizes user input' do @@ -293,6 +317,22 @@ RSpec.describe Import::GithubController do end describe "GET realtime_changes" do + let(:user) { create(:user) } + it_behaves_like 'a GitHub-ish import controller: GET realtime_changes' + + before do + assign_session_token(provider) + end + + it 'includes stats in response' do + create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'example/repo') + + get :realtime_changes + + expect(json_response[0]).to include('stats') + expect(json_response[0]['stats']).to include('fetched') + expect(json_response[0]['stats']).to include('imported') + end end end diff --git a/spec/controllers/jira_connect/events_controller_spec.rb b/spec/controllers/jira_connect/events_controller_spec.rb index 2129b24b2fb..5e90ceb0f9c 100644 --- a/spec/controllers/jira_connect/events_controller_spec.rb +++ b/spec/controllers/jira_connect/events_controller_spec.rb @@ -114,17 +114,6 @@ RSpec.describe JiraConnect::EventsController do base_url: base_url ) end - - context 'when the `jira_connect_installation_update` feature flag is disabled' do - before do - stub_feature_flags(jira_connect_installation_update: false) - end - - it 'does not update the installation', :aggregate_failures do - expect { subject }.not_to change { installation.reload.attributes } - expect(response).to have_gitlab_http_status(:ok) - end - end end context 'when the new base_url is invalid' do diff --git a/spec/controllers/jira_connect/subscriptions_controller_spec.rb b/spec/controllers/jira_connect/subscriptions_controller_spec.rb index f548c1f399d..e9c94f09c99 100644 --- a/spec/controllers/jira_connect/subscriptions_controller_spec.rb +++ b/spec/controllers/jira_connect/subscriptions_controller_spec.rb @@ -75,6 +75,18 @@ RSpec.describe JiraConnect::SubscriptionsController do expect(json_response).to include('login_path' => nil) end end + + context 'with context qsh' do + # The JSON endpoint will be requested by frontend using a JWT that Atlassian provides via Javascript. + # This JWT will likely use a context-qsh because Atlassian don't know for which endpoint it will be used. + # Read more about context JWT here: https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/ + + let(:qsh) { 'context-qsh' } + + specify do + expect(response).to have_gitlab_http_status(:ok) + end + end end end end @@ -102,7 +114,7 @@ RSpec.describe JiraConnect::SubscriptionsController do end context 'with valid JWT' do - let(:claims) { { iss: installation.client_key, sub: 1234 } } + let(:claims) { { iss: installation.client_key, sub: 1234, qsh: '123' } } let(:jwt) { Atlassian::Jwt.encode(claims, installation.shared_secret) } let(:jira_user) { { 'groups' => { 'items' => [{ 'name' => jira_group_name }] } } } let(:jira_group_name) { 'site-admins' } @@ -158,7 +170,7 @@ RSpec.describe JiraConnect::SubscriptionsController do .stub_request(:get, "#{installation.base_url}/rest/api/3/user?accountId=1234&expand=groups") .to_return(body: jira_user.to_json, status: 200, headers: { 'Content-Type' => 'application/json' }) - delete :destroy, params: { jwt: jwt, id: subscription.id } + delete :destroy, params: { jwt: jwt, id: subscription.id, format: :json } end context 'without JWT' do @@ -170,7 +182,7 @@ RSpec.describe JiraConnect::SubscriptionsController do end context 'with valid JWT' do - let(:claims) { { iss: installation.client_key, sub: 1234 } } + let(:claims) { { iss: installation.client_key, sub: 1234, qsh: '123' } } let(:jwt) { Atlassian::Jwt.encode(claims, installation.shared_secret) } it 'deletes the subscription' do diff --git a/spec/controllers/oauth/jira/authorizations_controller_spec.rb b/spec/controllers/oauth/jira_dvcs/authorizations_controller_spec.rb index f4a335b30f4..496ef7859f9 100644 --- a/spec/controllers/oauth/jira/authorizations_controller_spec.rb +++ b/spec/controllers/oauth/jira_dvcs/authorizations_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Oauth::Jira::AuthorizationsController do +RSpec.describe Oauth::JiraDvcs::AuthorizationsController do describe 'GET new' do it 'redirects to OAuth authorization with correct params' do get :new, params: { client_id: 'client-123', scope: 'foo', redirect_uri: 'http://example.com/' } @@ -10,7 +10,7 @@ RSpec.describe Oauth::Jira::AuthorizationsController do expect(response).to redirect_to(oauth_authorization_url(client_id: 'client-123', response_type: 'code', scope: 'foo', - redirect_uri: oauth_jira_callback_url)) + redirect_uri: oauth_jira_dvcs_callback_url)) end it 'replaces the GitHub "repo" scope with "api"' do @@ -19,7 +19,7 @@ RSpec.describe Oauth::Jira::AuthorizationsController do expect(response).to redirect_to(oauth_authorization_url(client_id: 'client-123', response_type: 'code', scope: 'api', - redirect_uri: oauth_jira_callback_url)) + redirect_uri: oauth_jira_dvcs_callback_url)) end end diff --git a/spec/controllers/profiles/accounts_controller_spec.rb b/spec/controllers/profiles/accounts_controller_spec.rb index 011528016ce..1b4b67eeaff 100644 --- a/spec/controllers/profiles/accounts_controller_spec.rb +++ b/spec/controllers/profiles/accounts_controller_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Profiles::AccountsController do end end - [:twitter, :facebook, :google_oauth2, :gitlab, :github, :bitbucket, :crowd, :auth0, :authentiq, :dingtalk].each do |provider| + [:twitter, :facebook, :google_oauth2, :gitlab, :github, :bitbucket, :crowd, :auth0, :authentiq, :dingtalk, :alicloud].each do |provider| describe "#{provider} provider" do let(:user) { create(:omniauth_user, provider: provider.to_s) } diff --git a/spec/controllers/profiles/keys_controller_spec.rb b/spec/controllers/profiles/keys_controller_spec.rb index 66f6135df1e..63818337722 100644 --- a/spec/controllers/profiles/keys_controller_spec.rb +++ b/spec/controllers/profiles/keys_controller_spec.rb @@ -17,7 +17,25 @@ RSpec.describe Profiles::KeysController do post :create, params: { key: build(:key, expires_at: expires_at).attributes } end.to change { Key.count }.by(1) - expect(Key.last.expires_at).to be_like_time(expires_at) + key = Key.last + expect(key.expires_at).to be_like_time(expires_at) + expect(key.fingerprint_md5).to be_present + expect(key.fingerprint_sha256).to be_present + end + + context 'with FIPS mode', :fips_mode do + it 'creates a new key without MD5 fingerprint' do + expires_at = 3.days.from_now + + expect do + post :create, params: { key: build(:rsa_key_4096, expires_at: expires_at).attributes } + end.to change { Key.count }.by(1) + + key = Key.last + expect(key.expires_at).to be_like_time(expires_at) + expect(key.fingerprint_md5).to be_nil + expect(key.fingerprint_sha256).to be_present + end end end end diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb index b7870a63f9d..7add3a72337 100644 --- a/spec/controllers/profiles/preferences_controller_spec.rb +++ b/spec/controllers/profiles/preferences_controller_spec.rb @@ -46,6 +46,8 @@ RSpec.describe Profiles::PreferencesController do it "changes the user's preferences" do prefs = { color_scheme_id: '1', + diffs_deletion_color: '#123456', + diffs_addition_color: '#abcdef', dashboard: 'stars', theme_id: '2', first_day_of_week: '1', @@ -84,5 +86,27 @@ RSpec.describe Profiles::PreferencesController do expect(response.parsed_body['type']).to eq('alert') end end + + context 'on invalid diffs colors setting' do + it 'responds with error for diffs_deletion_color' do + prefs = { diffs_deletion_color: '#1234567' } + + go params: prefs + + expect(response).to have_gitlab_http_status(:bad_request) + expect(response.parsed_body['message']).to eq _('Failed to save preferences.') + expect(response.parsed_body['type']).to eq('alert') + end + + it 'responds with error for diffs_addition_color' do + prefs = { diffs_addition_color: '#1234567' } + + go params: prefs + + expect(response).to have_gitlab_http_status(:bad_request) + expect(response.parsed_body['message']).to eq _('Failed to save preferences.') + expect(response.parsed_body['type']).to eq('alert') + end + end end end diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb index 47086ccdd2c..33cba675777 100644 --- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb +++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb @@ -104,17 +104,29 @@ RSpec.describe Profiles::TwoFactorAuthsController do expect(subject).to receive(:build_qr_code).and_return(code) get :show - expect(assigns[:qr_code]).to eq code + expect(assigns[:qr_code]).to eq(code) end - it 'generates a unique otp_secret every time the page is loaded' do - expect(User).to receive(:generate_otp_secret).with(32).and_call_original.twice + it 'generates a single otp_secret with multiple page loads', :freeze_time do + expect(User).to receive(:generate_otp_secret).with(32).and_call_original.once + + user.update!(otp_secret: nil, otp_secret_expires_at: nil) 2.times do get :show end end + it 'generates a new otp_secret once the ttl has expired' do + expect(User).to receive(:generate_otp_secret).with(32).and_call_original.once + + user.update!(otp_secret: "FT7KAVNU63YZH7PBRVPVL7CPSAENXY25", otp_secret_expires_at: 2.minutes.from_now) + + travel_to(10.minutes.from_now) do + get :show + end + end + it_behaves_like 'user must first verify their primary email address' do let(:go) { get :show } end @@ -183,7 +195,12 @@ RSpec.describe Profiles::TwoFactorAuthsController do expect(subject).to receive(:build_qr_code).and_return(code) go - expect(assigns[:qr_code]).to eq code + expect(assigns[:qr_code]).to eq(code) + end + + it 'assigns account_string' do + go + expect(assigns[:account_string]).to eq("#{Gitlab.config.gitlab.host}:#{user.email}") end it 'renders show' do diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb index f410c16b30b..d51880b282d 100644 --- a/spec/controllers/projects/artifacts_controller_spec.rb +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -323,6 +323,7 @@ RSpec.describe Projects::ArtifactsController do subject expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Gitlab-Workhorse-Detect-Content-Type']).to eq('true') expect(send_data).to start_with('artifacts-entry:') expect(params.keys).to eq(%w(Archive Entry)) @@ -338,7 +339,7 @@ RSpec.describe Projects::ArtifactsController do def params @params ||= begin - base64_params = send_data.sub(/\Aartifacts\-entry:/, '') + base64_params = send_data.delete_prefix('artifacts-entry:') Gitlab::Json.parse(Base64.urlsafe_decode64(base64_params)) end end diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index ea22e6b6f10..1580ad9361d 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -688,21 +688,23 @@ RSpec.describe Projects::BranchesController do end context 'when gitaly is not available' do + let(:request) { get :index, format: :html, params: { namespace_id: project.namespace, project_id: project } } + before do allow_next_instance_of(Gitlab::GitalyClient::RefService) do |ref_service| allow(ref_service).to receive(:local_branches).and_raise(GRPC::DeadlineExceeded) end - - get :index, format: :html, params: { - namespace_id: project.namespace, project_id: project - } end - it 'returns with a status 200' do - expect(response).to have_gitlab_http_status(:ok) + it 'returns with a status 503' do + request + + expect(response).to have_gitlab_http_status(:service_unavailable) end it 'sets gitaly_unavailable variable' do + request + expect(assigns[:gitaly_unavailable]).to be_truthy end end diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index 72fee40a6e9..a72c98552a5 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -60,6 +60,22 @@ RSpec.describe Projects::CommitController do end end + context 'with valid page' do + it 'responds with 200' do + go(id: commit.id, page: 1) + + expect(response).to be_ok + end + end + + context 'with invalid page' do + it 'does not return an error' do + go(id: commit.id, page: ['invalid']) + + expect(response).to be_ok + end + end + it 'handles binary files' do go(id: TestEnv::BRANCH_SHA['binary-encoding'], format: 'html') @@ -212,6 +228,21 @@ RSpec.describe Projects::CommitController do end end + context 'when the revert commit is missing' do + it 'renders the 404 page' do + post(:revert, + params: { + namespace_id: project.namespace, + project_id: project, + start_branch: 'master', + id: '1234567890' + }) + + expect(response).not_to be_successful + expect(response).to have_gitlab_http_status(:not_found) + end + end + context 'when the revert was successful' do it 'redirects to the commits page' do post(:revert, @@ -269,6 +300,21 @@ RSpec.describe Projects::CommitController do end end + context 'when the cherry-pick commit is missing' do + it 'renders the 404 page' do + post(:cherry_pick, + params: { + namespace_id: project.namespace, + project_id: project, + start_branch: 'master', + id: '1234567890' + }) + + expect(response).not_to be_successful + expect(response).to have_gitlab_http_status(:not_found) + end + end + context 'when the cherry-pick was successful' do it 'redirects to the commits page' do post(:cherry_pick, diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb index 62b93a2728b..9821618df8d 100644 --- a/spec/controllers/projects/compare_controller_spec.rb +++ b/spec/controllers/projects/compare_controller_spec.rb @@ -58,11 +58,13 @@ RSpec.describe Projects::CompareController do from_project_id: from_project_id, from: from_ref, to: to_ref, - w: whitespace + w: whitespace, + page: page } end let(:whitespace) { nil } + let(:page) { nil } context 'when the refs exist in the same project' do context 'when we set the white space param' do @@ -196,6 +198,34 @@ RSpec.describe Projects::CompareController do expect(response).to have_gitlab_http_status(:found) end end + + context 'when page is valid' do + let(:from_project_id) { nil } + let(:from_ref) { '08f22f25' } + let(:to_ref) { '66eceea0' } + let(:page) { 1 } + + it 'shows the diff' do + show_request + + expect(response).to be_successful + expect(assigns(:diffs).diff_files.first).to be_present + expect(assigns(:commits).length).to be >= 1 + end + end + + context 'when page is not valid' do + let(:from_project_id) { nil } + let(:from_ref) { '08f22f25' } + let(:to_ref) { '66eceea0' } + let(:page) { ['invalid'] } + + it 'does not return an error' do + show_request + + expect(response).to be_successful + end + end end describe 'GET diff_for_path' do diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index fdfc21887a6..f4cad5790a3 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -254,38 +254,54 @@ RSpec.describe Projects::EnvironmentsController do end describe 'PATCH #stop' do + subject { patch :stop, params: environment_params(format: :json) } + context 'when env not available' do it 'returns 404' do allow_any_instance_of(Environment).to receive(:available?) { false } - patch :stop, params: environment_params(format: :json) + subject expect(response).to have_gitlab_http_status(:not_found) end end context 'when stop action' do - it 'returns action url' do + it 'returns action url for single stop action' do action = create(:ci_build, :manual) allow_any_instance_of(Environment) - .to receive_messages(available?: true, stop_with_action!: action) + .to receive_messages(available?: true, stop_with_actions!: [action]) - patch :stop, params: environment_params(format: :json) + subject expect(response).to have_gitlab_http_status(:ok) expect(json_response).to eq( { 'redirect_url' => project_job_url(project, action) }) end + + it 'returns environment url for multiple stop actions' do + actions = create_list(:ci_build, 2, :manual) + + allow_any_instance_of(Environment) + .to receive_messages(available?: true, stop_with_actions!: actions) + + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq( + { 'redirect_url' => + project_environment_url(project, environment) }) + end end context 'when no stop action' do it 'returns env url' do allow_any_instance_of(Environment) - .to receive_messages(available?: true, stop_with_action!: nil) + .to receive_messages(available?: true, stop_with_actions!: nil) - patch :stop, params: environment_params(format: :json) + subject expect(response).to have_gitlab_http_status(:ok) expect(json_response).to eq( diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb index ea15d483c90..96705d82ac5 100644 --- a/spec/controllers/projects/group_links_controller_spec.rb +++ b/spec/controllers/projects/group_links_controller_spec.rb @@ -18,136 +18,6 @@ RSpec.describe Projects::GroupLinksController do travel_back end - describe '#create' do - shared_context 'link project to group' do - before do - post(:create, params: { - namespace_id: project.namespace, - project_id: project, - link_group_id: group.id, - link_group_access: ProjectGroupLink.default_access - }) - end - end - - context 'when project is not allowed to be shared with a group' do - before do - group.update!(share_with_group_lock: false) - end - - include_context 'link project to group' - - it 'responds with status 404' do - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when user has access to group they want to link project to' do - before do - group.add_developer(user) - end - - include_context 'link project to group' - - it 'links project with selected group' do - expect(group.shared_projects).to include project - end - - it 'redirects to project group links page' do - expect(response).to redirect_to( - project_project_members_path(project) - ) - end - end - - context 'when user doers not have access to group they want to link to' do - include_context 'link project to group' - - it 'renders 404' do - expect(response).to have_gitlab_http_status(:not_found) - end - - it 'does not share project with that group' do - expect(group.shared_projects).not_to include project - end - end - - context 'when user does not have access to the public group' do - let(:group) { create(:group, :public) } - - include_context 'link project to group' - - it 'renders 404' do - expect(response).to have_gitlab_http_status(:not_found) - end - - it 'does not share project with that group' do - expect(group.shared_projects).not_to include project - end - end - - context 'when project group id equal link group id' do - before do - group2.add_developer(user) - - post(:create, params: { - namespace_id: project.namespace, - project_id: project, - link_group_id: group2.id, - link_group_access: ProjectGroupLink.default_access - }) - end - - it 'does not share project with selected group' do - expect(group2.shared_projects).not_to include project - end - - it 'redirects to project group links page' do - expect(response).to redirect_to( - project_project_members_path(project) - ) - end - end - - context 'when link group id is not present' do - before do - post(:create, params: { - namespace_id: project.namespace, - project_id: project, - link_group_access: ProjectGroupLink.default_access - }) - end - - it 'redirects to project group links page' do - expect(response).to redirect_to( - project_project_members_path(project) - ) - expect(flash[:alert]).to eq('Please select a group.') - end - end - - context 'when link is not persisted in the database' do - before do - allow(::Projects::GroupLinks::CreateService).to receive_message_chain(:new, :execute) - .and_return({ status: :error, http_status: 409, message: 'error' }) - - post(:create, params: { - namespace_id: project.namespace, - project_id: project, - link_group_id: group.id, - link_group_access: ProjectGroupLink.default_access - }) - end - - it 'redirects to project group links page' do - expect(response).to redirect_to( - project_project_members_path(project) - ) - expect(flash[:alert]).to eq('error') - end - end - end - describe '#update' do let_it_be(:link) do create( diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 9d3711d8a96..ce0af784cdf 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -148,6 +148,13 @@ RSpec.describe Projects::IssuesController do allow(Kaminari.config).to receive(:default_per_page).and_return(1) end + it 'redirects to last page when out of bounds on non-html requests' do + get :index, params: params.merge(page: last_page + 1), format: 'atom' + + expect(response).to have_gitlab_http_status(:redirect) + expect(response).to redirect_to(action: 'index', format: 'atom', page: last_page, state: 'opened') + end + it 'does not use pagination if disabled' do allow(controller).to receive(:pagination_disabled?).and_return(true) diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index ed68d6a87b8..e9f1232b5e7 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -796,7 +796,7 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do retried_build = Ci::Build.last - Ci::RetryBuildService.clone_accessors.each do |accessor| + Ci::Build.clone_accessors.each do |accessor| expect(job.read_attribute(accessor)) .to eq(retried_build.read_attribute(accessor)), "Mismatched attribute on \"#{accessor}\". " \ diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index 2df31904380..07874c8a8af 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -423,7 +423,21 @@ RSpec.describe Projects::NotesController do end context 'when creating a confidential note' do - let(:extra_request_params) { { format: :json } } + let(:project) { create(:project) } + let(:note_params) do + { note: note_text, noteable_id: issue.id, noteable_type: 'Issue' }.merge(extra_note_params) + end + + let(:request_params) do + { + note: note_params, + namespace_id: project.namespace, + project_id: project, + target_type: 'issue', + target_id: issue.id, + format: :json + } + end context 'when `confidential` parameter is not provided' do it 'sets `confidential` to `false` in JSON response' do diff --git a/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb b/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb index a655c742973..fc741d0f3f6 100644 --- a/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb +++ b/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb @@ -41,17 +41,5 @@ RSpec.describe Projects::Packages::InfrastructureRegistryController do it_behaves_like 'returning response status', :not_found end - - context 'with package file pending destruction' do - let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: terraform_module) } - - let(:terraform_module_package_file) { terraform_module.package_files.first } - - it 'does not return them' do - subject - - expect(assigns(:package_files)).to contain_exactly(terraform_module_package_file) - end - end end end diff --git a/spec/controllers/projects/pipelines/tests_controller_spec.rb b/spec/controllers/projects/pipelines/tests_controller_spec.rb index e6ff3a487ac..113781bab7c 100644 --- a/spec/controllers/projects/pipelines/tests_controller_spec.rb +++ b/spec/controllers/projects/pipelines/tests_controller_spec.rb @@ -40,28 +40,56 @@ RSpec.describe Projects::Pipelines::TestsController do let(:suite_name) { 'test' } let(:build_ids) { pipeline.latest_builds.pluck(:id) } - before do - build = main_pipeline.builds.last - build.update_column(:finished_at, 1.day.ago) # Just to be sure we are included in the report window - - # The JUnit fixture for the given build has 3 failures. - # This service will create 1 test case failure record for each. - Ci::TestFailureHistoryService.new(main_pipeline).execute + context 'when artifacts are expired' do + before do + pipeline.job_artifacts.first.update!(expire_at: Date.yesterday) + end + + it 'renders not_found errors', :aggregate_failures do + get_tests_show_json(build_ids) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['errors']).to eq('Test report artifacts have expired') + end + + context 'when ci_test_report_artifacts_expired is disabled' do + before do + stub_feature_flags(ci_test_report_artifacts_expired: false) + end + it 'renders test suite', :aggregate_failures do + get_tests_show_json(build_ids) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq('test') + end + end end - it 'renders test suite data' do - get_tests_show_json(build_ids) - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['name']).to eq('test') - - # Each test failure in this pipeline has a matching failure in the default branch - recent_failures = json_response['test_cases'].map { |tc| tc['recent_failures'] } - expect(recent_failures).to eq([ - { 'count' => 1, 'base_branch' => 'master' }, - { 'count' => 1, 'base_branch' => 'master' }, - { 'count' => 1, 'base_branch' => 'master' } - ]) + context 'when artifacts are not expired' do + before do + build = main_pipeline.builds.last + build.update_column(:finished_at, 1.day.ago) # Just to be sure we are included in the report window + + # The JUnit fixture for the given build has 3 failures. + # This service will create 1 test case failure record for each. + Ci::TestFailureHistoryService.new(main_pipeline).execute + end + + it 'renders test suite data', :aggregate_failures do + get_tests_show_json(build_ids) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq('test') + expect(json_response['artifacts_expired']).to be_falsey + + # Each test failure in this pipeline has a matching failure in the default branch + recent_failures = json_response['test_cases'].map { |tc| tc['recent_failures'] } + expect(recent_failures).to eq([ + { 'count' => 1, 'base_branch' => 'master' }, + { 'count' => 1, 'base_branch' => 'master' }, + { 'count' => 1, 'base_branch' => 'master' } + ]) + end end end diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb index 35e5422d072..7e96c59fbb1 100644 --- a/spec/controllers/projects/services_controller_spec.rb +++ b/spec/controllers/projects/services_controller_spec.rb @@ -359,10 +359,9 @@ RSpec.describe Projects::ServicesController do def prometheus_integration_as_data pi = project.prometheus_integration.reload attrs = pi.attributes.except('encrypted_properties', - 'encrypted_properties_iv', - 'encrypted_properties_tmp') + 'encrypted_properties_iv') - [attrs, pi.encrypted_properties_tmp] + [attrs, pi.properties] end end diff --git a/spec/controllers/projects/static_site_editor_controller_spec.rb b/spec/controllers/projects/static_site_editor_controller_spec.rb index 26161b5fb5c..e1f25589eeb 100644 --- a/spec/controllers/projects/static_site_editor_controller_spec.rb +++ b/spec/controllers/projects/static_site_editor_controller_spec.rb @@ -76,12 +76,11 @@ RSpec.describe Projects::StaticSiteEditorController do get :show, params: default_params end - it 'increases the views counter' do - expect(Gitlab::UsageDataCounters::StaticSiteEditorCounter).to have_received(:increment_views_count) - end + it 'redirects to the Web IDE' do + get :show, params: default_params - it 'renders the edit page' do - expect(response).to render_template(:show) + expected_path_regex = %r[-/ide/project/#{project.full_path}/edit/master/-/README.md] + expect(response).to redirect_to(expected_path_regex) end it 'assigns ref and path variables' do @@ -96,62 +95,6 @@ RSpec.describe Projects::StaticSiteEditorController do expect(response).to have_gitlab_http_status(:not_found) end end - - context 'when invalid config file' do - let(:service_response) { ServiceResponse.error(message: 'invalid') } - - it 'redirects to project page and flashes error message' do - expect(response).to redirect_to(project_path(project)) - expect(controller).to set_flash[:alert].to('invalid') - end - end - - context 'with a service response payload containing multiple data types' do - let(:data) do - { - a_string: 'string', - an_array: [ - { - foo: 'bar' - } - ], - an_integer: 123, - a_hash: { - a_deeper_hash: { - foo: 'bar' - } - }, - a_boolean: true, - a_nil: nil - } - end - - let(:assigns_data) { assigns(:data) } - - it 'leaves data values which are strings as strings' do - expect(assigns_data[:a_string]).to eq('string') - end - - it 'leaves data values which are integers as integers' do - expect(assigns_data[:an_integer]).to eq(123) - end - - it 'serializes data values which are booleans to JSON' do - expect(assigns_data[:a_boolean]).to eq('true') - end - - it 'serializes data values which are arrays to JSON' do - expect(assigns_data[:an_array]).to eq('[{"foo":"bar"}]') - end - - it 'serializes data values which are hashes to JSON' do - expect(assigns_data[:a_hash]).to eq('{"a_deeper_hash":{"foo":"bar"}}') - end - - it 'serializes data values which are nil to an empty string' do - expect(assigns_data[:a_nil]).to eq('') - end - end end end end diff --git a/spec/controllers/projects/todos_controller_spec.rb b/spec/controllers/projects/todos_controller_spec.rb index 9a73417ffdb..d87f4258b9c 100644 --- a/spec/controllers/projects/todos_controller_spec.rb +++ b/spec/controllers/projects/todos_controller_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Projects::TodosController do let(:issue) { create(:issue, project: project) } let(:merge_request) { create(:merge_request, source_project: project) } - let(:design) { create(:design, project: project, issue: issue) } + let(:design) { create(:design, :with_versions, project: project, issue: issue) } let(:parent) { project } shared_examples 'issuable todo actions' do diff --git a/spec/controllers/projects/usage_quotas_controller_spec.rb b/spec/controllers/projects/usage_quotas_controller_spec.rb index 6125ba13f96..2831de00348 100644 --- a/spec/controllers/projects/usage_quotas_controller_spec.rb +++ b/spec/controllers/projects/usage_quotas_controller_spec.rb @@ -4,17 +4,44 @@ require 'spec_helper' RSpec.describe Projects::UsageQuotasController do let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project, namespace: user.namespace) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } describe 'GET #index' do render_views - it 'does not render search settings partial' do + subject { get(:index, params: { namespace_id: project.namespace, project_id: project }) } + + before do sign_in(user) - get(:index, params: { namespace_id: user.namespace, project_id: project }) + end + + context 'when user does not have read_usage_quotas permission' do + before do + project.add_developer(user) + end + + it 'renders not_found' do + subject + + expect(response).to render_template('errors/not_found') + expect(response).not_to render_template('shared/search_settings') + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user has read_usage_quotas permission' do + before do + project.add_maintainer(user) + end + + it 'renders index with 200 status code' do + subject - expect(response).to render_template('index') - expect(response).not_to render_template('shared/search_settings') + expect(response).to render_template('index') + expect(response).not_to render_template('shared/search_settings') + expect(response).to have_gitlab_http_status(:ok) + end end end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index c098ea71f7a..07bd198137a 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -473,28 +473,6 @@ RSpec.describe ProjectsController do end end end - - context 'with new_project_sast_enabled', :experiment do - let(:params) do - { - path: 'foo', - description: 'bar', - namespace_id: user.namespace.id, - initialize_with_sast: '1' - } - end - - it 'tracks an event on project creation' do - expect(experiment(:new_project_sast_enabled)).to track(:created, - property: 'blank', - checked: true, - project: an_instance_of(Project), - namespace: user.namespace - ).on_next_instance.with_context(user: user) - - post :create, params: { project: params } - end - end end describe 'GET edit' do @@ -1159,16 +1137,15 @@ RSpec.describe ProjectsController do context 'when gitaly is unavailable' do before do expect_next_instance_of(TagsFinder) do |finder| - allow(finder).to receive(:execute).and_raise(Gitlab::Git::CommandError) + allow(finder).to receive(:execute).and_raise(Gitlab::Git::CommandError, 'something went wrong') end end - it 'gets an empty list of tags' do + it 'responds with 503 error' do get :refs, params: { namespace_id: project.namespace, id: project, ref: "123456" } - expect(json_response["Branches"]).to include("master") - expect(json_response["Tags"]).to eq([]) - expect(json_response["Commits"]).to include("123456") + expect(response).to have_gitlab_http_status(:service_unavailable) + expect(json_response['error']).to eq 'Unable to load refs' end end @@ -1466,14 +1443,15 @@ RSpec.describe ProjectsController do end describe '#download_export', :clean_gitlab_redis_rate_limiting do + let(:project) { create(:project, :with_export, service_desk_enabled: false) } let(:action) { :download_export } context 'object storage enabled' do context 'when project export is enabled' do - it 'returns 302' do + it 'returns 200' do get action, params: { namespace_id: project.namespace, id: project } - expect(response).to have_gitlab_http_status(:found) + expect(response).to have_gitlab_http_status(:ok) end end @@ -1513,14 +1491,37 @@ RSpec.describe ProjectsController do expect(response.body).to eq('This endpoint has been requested too many times. Try again later.') expect(response).to have_gitlab_http_status(:too_many_requests) end + end + + context 'applies correct scope when throttling', :clean_gitlab_redis_rate_limiting do + before do + stub_application_setting(project_download_export_limit: 1) + end - it 'applies correct scope when throttling' do + it 'applies throttle per namespace' do expect(Gitlab::ApplicationRateLimiter) .to receive(:throttled?) - .with(:project_download_export, scope: [user, project]) + .with(:project_download_export, scope: [user, project.namespace]) post action, params: { namespace_id: project.namespace, id: project } end + + it 'throttles downloads within same namespaces' do + # simulate prior request to the same namespace, which increments the rate limit counter for that scope + Gitlab::ApplicationRateLimiter.throttled?(:project_download_export, scope: [user, project.namespace]) + + get action, params: { namespace_id: project.namespace, id: project } + expect(response).to have_gitlab_http_status(:too_many_requests) + end + + it 'allows downloads from different namespaces' do + # simulate prior request to a different namespace, which increments the rate limit counter for that scope + Gitlab::ApplicationRateLimiter.throttled?(:project_download_export, + scope: [user, create(:project, :with_export).namespace]) + + get action, params: { namespace_id: project.namespace, id: project } + expect(response).to have_gitlab_http_status(:ok) + end end end end diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index 9482448fc03..4abcd414e51 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -211,6 +211,7 @@ RSpec.describe SearchController do :global_search_merge_requests_tab | 'merge_requests' :global_search_wiki_tab | 'wiki_blobs' :global_search_commits_tab | 'commits' + :global_search_users_tab | 'users' end with_them do diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 03d053e6f97..877ca7cd6c6 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -193,6 +193,10 @@ RSpec.describe SessionsController do end context 'with reCAPTCHA' do + before do + stub_feature_flags(arkose_labs_login_challenge: false) + end + def unsuccesful_login(user_params, sesion_params: {}) # Without this, `verify_recaptcha` arbitrarily returns true in test env Recaptcha.configuration.skip_verify_env.delete('test') @@ -234,7 +238,7 @@ RSpec.describe SessionsController do unsuccesful_login(user_params) - expect(response).to render_template(:new) + expect(response).to redirect_to new_user_session_path expect(flash[:alert]).to include _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.') expect(subject.current_user).to be_nil end @@ -258,7 +262,7 @@ RSpec.describe SessionsController do it 'displays an error when the reCAPTCHA is not solved' do unsuccesful_login(user_params, sesion_params: { failed_login_attempts: 6 }) - expect(response).to render_template(:new) + expect(response).to redirect_to new_user_session_path expect(flash[:alert]).to include _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.') expect(subject.current_user).to be_nil end @@ -278,7 +282,7 @@ RSpec.describe SessionsController do it 'displays an error when the reCAPTCHA is not solved' do unsuccesful_login(user_params) - expect(response).to render_template(:new) + expect(response).to redirect_to new_user_session_path expect(flash[:alert]).to include _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.') expect(subject.current_user).to be_nil end diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 8442c214cd3..ffcd759435c 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -701,6 +701,24 @@ RSpec.describe UploadsController do end end end + + context 'when viewing alert metric images' do + let!(:user) { create(:user) } + let!(:project) { create(:project) } + let(:alert) { create(:alert_management_alert, project: project) } + let(:metric_image) { create(:alert_metric_image, alert: alert) } + + before do + project.add_developer(user) + sign_in(user) + end + + it "responds with status 200" do + get :show, params: { model: "alert_management_metric_image", mounted_as: 'file', id: metric_image.id, filename: metric_image.filename } + + expect(response).to have_gitlab_http_status(:ok) + end + end end def post_authorize(verified: true) diff --git a/spec/db/migration_spec.rb b/spec/db/migration_spec.rb new file mode 100644 index 00000000000..ac649925751 --- /dev/null +++ b/spec/db/migration_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Migrations Validation' do + using RSpec::Parameterized::TableSyntax + + # The range describes the timestamps that given migration helper can be used + let(:all_migration_classes) do + { + 2022_01_26_21_06_58.. => Gitlab::Database::Migration[2.0], + 2021_09_01_15_33_24.. => Gitlab::Database::Migration[1.0], + 2021_05_31_05_39_16..2021_09_01_15_33_24 => ActiveRecord::Migration[6.1], + ..2021_05_31_05_39_16 => ActiveRecord::Migration[6.0] + } + end + + where(:migration) do + Gitlab::Database.database_base_models.flat_map do |_, model| + model.connection.migration_context.migrations + end.uniq + end + + with_them do + let(:migration_instance) { migration.send(:migration) } + let(:allowed_migration_classes) { all_migration_classes.select { |r, _| r.include?(migration.version) }.values } + + it 'uses one of the allowed migration classes' do + expect(allowed_migration_classes).to include(be > migration_instance.class) + end + end +end diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index 177a565bbc0..04f73050ea5 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -10,6 +10,10 @@ RSpec.describe 'Database schema' do let(:tables) { connection.tables } let(:columns_name_with_jsonb) { retrieve_columns_name_with_jsonb } + IGNORED_INDEXES_ON_FKS = { + issues: %w[work_item_type_id] + }.with_indifferent_access.freeze + # List of columns historically missing a FK, don't add more columns # See: https://docs.gitlab.com/ee/development/foreign_keys.html#naming-foreign-keys IGNORED_FK_COLUMNS = { @@ -18,7 +22,7 @@ RSpec.describe 'Database schema' do approvals: %w[user_id], approver_groups: %w[target_id], approvers: %w[target_id user_id], - analytics_cycle_analytics_aggregations: %w[last_full_run_issues_id last_full_run_merge_requests_id last_incremental_issues_id last_incremental_merge_requests_id], + analytics_cycle_analytics_aggregations: %w[last_full_issues_id last_full_merge_requests_id last_incremental_issues_id last_full_run_issues_id last_full_run_merge_requests_id last_incremental_merge_requests_id], analytics_cycle_analytics_merge_request_stage_events: %w[author_id group_id merge_request_id milestone_id project_id stage_event_hash_id state_id], analytics_cycle_analytics_issue_stage_events: %w[author_id group_id issue_id milestone_id project_id stage_event_hash_id state_id], audit_events: %w[author_id entity_id target_id], @@ -115,6 +119,7 @@ RSpec.describe 'Database schema' do columns.first.chomp end foreign_keys_columns = all_foreign_keys.map(&:column) + required_indexed_columns = foreign_keys_columns - ignored_index_columns(table) # Add the primary key column to the list of indexed columns because # postgres and mysql both automatically create an index on the primary @@ -122,7 +127,7 @@ RSpec.describe 'Database schema' do # automatically generated indexes (like the primary key index). first_indexed_column.push(primary_key_column) - expect(first_indexed_column.uniq).to include(*foreign_keys_columns) + expect(first_indexed_column.uniq).to include(*required_indexed_columns) end end @@ -175,18 +180,16 @@ RSpec.describe 'Database schema' do 'PrometheusAlert' => %w[operator] }.freeze - context 'for enums' do - ApplicationRecord.descendants.each do |model| - # skip model if it is an abstract class as it would not have an associated DB table - next if model.abstract_class? + context 'for enums', :eager_load do + # skip model if it is an abstract class as it would not have an associated DB table + let(:models) { ApplicationRecord.descendants.reject(&:abstract_class?) } - describe model do - let(:ignored_enums) { ignored_limit_enums(model.name) } - let(:enums) { model.defined_enums.keys - ignored_enums } + it 'uses smallint for enums in all models', :aggregate_failures do + models.each do |model| + ignored_enums = ignored_limit_enums(model.name) + enums = model.defined_enums.keys - ignored_enums - it 'uses smallint for enums' do - expect(model).to use_smallint_for_enums(enums) - end + expect(model).to use_smallint_for_enums(enums) end end end @@ -305,8 +308,12 @@ RSpec.describe 'Database schema' do @models_by_table_name ||= ApplicationRecord.descendants.reject(&:abstract_class).group_by(&:table_name) end - def ignored_fk_columns(column) - IGNORED_FK_COLUMNS.fetch(column, []) + def ignored_fk_columns(table) + IGNORED_FK_COLUMNS.fetch(table, []) + end + + def ignored_index_columns(table) + IGNORED_INDEXES_ON_FKS.fetch(table, []) end def ignored_limit_enums(model) diff --git a/spec/deprecation_toolkit_env.rb b/spec/deprecation_toolkit_env.rb index 5e7ff34463c..fa4fdf805ec 100644 --- a/spec/deprecation_toolkit_env.rb +++ b/spec/deprecation_toolkit_env.rb @@ -56,11 +56,8 @@ module DeprecationToolkitEnv # In this case, we recommend to add a silence together with an issue to patch or update # the dependency causing the problem. # See https://gitlab.com/gitlab-org/gitlab/-/commit/aea37f506bbe036378998916d374966c031bf347#note_647515736 - # - # - ruby/lib/grpc/generic/interceptors.rb: https://gitlab.com/gitlab-org/gitlab/-/issues/339305 def self.allowed_kwarg_warning_paths %w[ - ruby/lib/grpc/generic/interceptors.rb ] end diff --git a/spec/events/ci/pipeline_created_event_spec.rb b/spec/events/ci/pipeline_created_event_spec.rb new file mode 100644 index 00000000000..191c2e450dc --- /dev/null +++ b/spec/events/ci/pipeline_created_event_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::PipelineCreatedEvent do + using RSpec::Parameterized::TableSyntax + + where(:data, :valid) do + { pipeline_id: 1 } | true + { pipeline_id: nil } | false + { pipeline_id: "test" } | false + {} | false + { job_id: 1 } | false + end + + with_them do + let(:event) { described_class.new(data: data) } + + it 'validates the data according to the schema' do + if valid + expect { event }.not_to raise_error + else + expect { event }.to raise_error(Gitlab::EventStore::InvalidEvent) + end + end + end +end diff --git a/spec/experiments/ios_specific_templates_experiment_spec.rb b/spec/experiments/ios_specific_templates_experiment_spec.rb new file mode 100644 index 00000000000..4d02381dbde --- /dev/null +++ b/spec/experiments/ios_specific_templates_experiment_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe IosSpecificTemplatesExperiment do + subject do + described_class.new(actor: user, project: project) do |e| + e.candidate { true } + end.run + end + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :auto_devops_disabled) } + + let!(:project_setting) { create(:project_setting, project: project, target_platforms: target_platforms) } + let(:target_platforms) { %w(ios) } + + before do + stub_experiments(ios_specific_templates: :candidate) + project.add_developer(user) if user + end + + it { is_expected.to be true } + + describe 'skipping the experiment' do + context 'no actor' do + let_it_be(:user) { nil } + + it { is_expected.to be_falsey } + end + + context 'actor cannot create pipelines' do + before do + project.add_guest(user) + end + + it { is_expected.to be_falsey } + end + + context 'targeting a non iOS platform' do + let(:target_platforms) { [] } + + it { is_expected.to be_falsey } + end + + context 'project has a ci.yaml file' do + before do + allow(project).to receive(:has_ci?).and_return(true) + end + + it { is_expected.to be_falsey } + end + + context 'project has pipelines' do + before do + create(:ci_pipeline, project: project) + end + + it { is_expected.to be_falsey } + end + end +end diff --git a/spec/experiments/new_project_sast_enabled_experiment_spec.rb b/spec/experiments/new_project_sast_enabled_experiment_spec.rb deleted file mode 100644 index 041e5dfa469..00000000000 --- a/spec/experiments/new_project_sast_enabled_experiment_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe NewProjectSastEnabledExperiment do - it "defines the expected behaviors and variants" do - expect(subject.variant_names).to match_array([ - :candidate, - :free_indicator, - :unchecked_candidate, - :unchecked_free_indicator - ]) - end - - it "publishes to the database" do - expect(subject).to receive(:publish_to_database) - - subject.publish - end -end diff --git a/spec/experiments/video_tutorials_continuous_onboarding_experiment_spec.rb b/spec/experiments/video_tutorials_continuous_onboarding_experiment_spec.rb new file mode 100644 index 00000000000..596791308a4 --- /dev/null +++ b/spec/experiments/video_tutorials_continuous_onboarding_experiment_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe VideoTutorialsContinuousOnboardingExperiment do + it "defines a control and candidate" do + expect(subject.behaviors.keys).to match_array(%w[control candidate]) + end +end diff --git a/spec/factories/alert_management/metric_images.rb b/spec/factories/alert_management/metric_images.rb new file mode 100644 index 00000000000..d7d8182af3e --- /dev/null +++ b/spec/factories/alert_management/metric_images.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :alert_metric_image, class: 'AlertManagement::MetricImage' do + association :alert, factory: :alert_management_alert + url { generate(:url) } + + trait :local do + file_store { ObjectStorage::Store::LOCAL } + end + + after(:build) do |image| + image.file = fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') + end + end +end diff --git a/spec/factories/application_settings.rb b/spec/factories/application_settings.rb index 8ac003d0a98..c28d3c20a86 100644 --- a/spec/factories/application_settings.rb +++ b/spec/factories/application_settings.rb @@ -4,5 +4,6 @@ FactoryBot.define do factory :application_setting do default_projects_limit { 42 } import_sources { [] } + restricted_visibility_levels { [] } end end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 011021f6320..56c12d73a3b 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -10,7 +10,7 @@ FactoryBot.define do options do { - image: 'ruby:2.7', + image: 'image:1.0', services: ['postgres'], script: ['ls -a'] } @@ -175,6 +175,58 @@ FactoryBot.define do end end + trait :prepare_staging do + name { 'prepare staging' } + environment { 'staging' } + + options do + { + script: %w(ls), + environment: { name: 'staging', action: 'prepare' } + } + end + + set_expanded_environment_name + end + + trait :start_staging do + name { 'start staging' } + environment { 'staging' } + + options do + { + script: %w(ls), + environment: { name: 'staging', action: 'start' } + } + end + + set_expanded_environment_name + end + + trait :stop_staging do + name { 'stop staging' } + environment { 'staging' } + + options do + { + script: %w(ls), + environment: { name: 'staging', action: 'stop' } + } + end + + set_expanded_environment_name + end + + trait :set_expanded_environment_name do + after(:build) do |build, evaluator| + build.assign_attributes( + metadata_attributes: { + expanded_environment_name: build.expanded_environment_name + } + ) + end + end + trait :allowed_to_fail do allow_failure { true } end @@ -455,7 +507,7 @@ FactoryBot.define do trait :extended_options do options do { - image: { name: 'ruby:2.7', entrypoint: '/bin/sh' }, + image: { name: 'image:1.0', entrypoint: '/bin/sh' }, services: ['postgres', { name: 'docker:stable-dind', entrypoint: '/bin/sh', command: 'sleep 30', alias: 'docker' }, { name: 'mysql:latest', variables: { MYSQL_ROOT_PASSWORD: 'root123.' } }], script: %w(echo), after_script: %w(ls date), @@ -497,6 +549,22 @@ FactoryBot.define do options { {} } end + trait :coverage_report_cobertura do + options do + { + artifacts: { + expire_in: '7d', + reports: { + coverage_report: { + coverage_format: 'cobertura', + path: 'cobertura.xml' + } + } + } + } + end + end + # TODO: move Security traits to ee_ci_build # https://gitlab.com/gitlab-org/gitlab/-/issues/210486 trait :dast do diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb index 77b07c4a404..cdbcdced5f4 100644 --- a/spec/factories/ci/job_artifacts.rb +++ b/spec/factories/ci/job_artifacts.rb @@ -302,6 +302,56 @@ FactoryBot.define do end end + # Bandit reports are correctly de-duplicated when ran in the same pipeline + # as a corresponding semgrep report. + # This report does not include signature tracking. + trait :sast_bandit do + file_type { :sast } + file_format { :raw } + + after(:build) do |artifact, _| + artifact.file = fixture_file_upload( + Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-bandit.json'), 'application/json') + end + end + + # Equivalent Semgrep report for :sast_bandit report. + # This report includes signature tracking. + trait :sast_semgrep_for_bandit do + file_type { :sast } + file_format { :raw } + + after(:build) do |artifact, _| + artifact.file = fixture_file_upload( + Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json'), 'application/json') + end + end + + # Gosec reports are not correctly de-duplicated when ran in the same pipeline + # as a corresponding semgrep report. + # This report includes signature tracking. + trait :sast_gosec do + file_type { :sast } + file_format { :raw } + + after(:build) do |artifact, _| + artifact.file = fixture_file_upload( + Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-gosec.json'), 'application/json') + end + end + + # Equivalent Semgrep report for :sast_gosec report. + # This report includes signature tracking. + trait :sast_semgrep_for_gosec do + file_type { :sast } + file_format { :raw } + + after(:build) do |artifact, _| + artifact.file = fixture_file_upload( + Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json'), 'application/json') + end + end + trait :common_security_report do file_format { :raw } file_type { :dependency_scanning } diff --git a/spec/factories/custom_emoji.rb b/spec/factories/custom_emoji.rb index 88e50eafa7c..09ac4a79a85 100644 --- a/spec/factories/custom_emoji.rb +++ b/spec/factories/custom_emoji.rb @@ -3,9 +3,8 @@ FactoryBot.define do factory :custom_emoji, class: 'CustomEmoji' do sequence(:name) { |n| "custom_emoji#{n}" } - namespace group file { 'https://gitlab.com/images/partyparrot.png' } - creator { namespace.owner } + creator factory: :user end end diff --git a/spec/factories/events.rb b/spec/factories/events.rb index d182dc9f95f..403165a3935 100644 --- a/spec/factories/events.rb +++ b/spec/factories/events.rb @@ -59,6 +59,11 @@ FactoryBot.define do target { design } end + factory :design_updated_event, traits: [:has_design] do + action { :updated } + target { design } + end + factory :project_created_event do project factory: :project action { :created } diff --git a/spec/factories/gitlab/database/background_migration/batched_migrations.rb b/spec/factories/gitlab/database/background_migration/batched_migrations.rb index 79b4447b76e..5ff90ff44b9 100644 --- a/spec/factories/gitlab/database/background_migration/batched_migrations.rb +++ b/spec/factories/gitlab/database/background_migration/batched_migrations.rb @@ -13,12 +13,24 @@ FactoryBot.define do total_tuple_count { 10_000 } pause_ms { 100 } - trait :finished do - status { :finished } + trait(:paused) do + status { 0 } end - trait :failed do - status { :failed } + trait(:active) do + status { 1 } + end + + trait(:finished) do + status { 3 } + end + + trait(:failed) do + status { 4 } + end + + trait(:finalizing) do + status { 5 } end end end diff --git a/spec/factories/go_module_versions.rb b/spec/factories/go_module_versions.rb index 145e6c95921..bdbd5a4423a 100644 --- a/spec/factories/go_module_versions.rb +++ b/spec/factories/go_module_versions.rb @@ -5,12 +5,10 @@ FactoryBot.define do skip_create initialize_with do - p = attributes[:params] - s = Packages::SemVer.parse(p.semver, prefixed: true) + s = Packages::SemVer.parse(semver, prefixed: true) + raise ArgumentError, "invalid sematic version: #{semver.inspect}" if !s && semver - raise ArgumentError, "invalid sematic version: '#{p.semver}'" if !s && p.semver - - new(p.mod, p.type, p.commit, name: p.name, semver: s, ref: p.ref) + new(mod, type, commit, name: name, semver: s, ref: ref) end mod { association(:go_module) } @@ -20,8 +18,6 @@ FactoryBot.define do semver { nil } ref { nil } - params { OpenStruct.new(mod: mod, type: type, commit: commit, name: name, semver: semver, ref: ref) } - trait :tagged do ref { mod.project.repository.find_tag(name) } commit { ref.dereferenced_target } @@ -36,8 +32,8 @@ FactoryBot.define do .max_by(&:to_s) .to_s end - - params { OpenStruct.new(mod: mod, type: :ref, commit: commit, semver: name, ref: ref) } + type { :ref } + semver { name } end end end diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index aa264ad3377..152ae061605 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -118,14 +118,5 @@ FactoryBot.define do create(:crm_settings, group: group, enabled: true) end end - - trait :test_group do - path { "test-group-fulfillment#{SecureRandom.hex(4)}" } - created_at { 4.days.ago } - - after(:create) do |group| - group.add_owner(create(:user, email: "test-user-#{SecureRandom.hex(4)}@test.com")) - end - end end end diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index 0ffa15ad403..3945637c2c3 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -189,7 +189,7 @@ FactoryBot.define do end trait :chat_notification do - webhook { 'https://example.com/webhook' } + sequence(:webhook) { |n| "https://example.com/webhook/#{n}" } end trait :inactive do diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb index 26c858665a8..8c714f7736f 100644 --- a/spec/factories/issues.rb +++ b/spec/factories/issues.rb @@ -58,6 +58,11 @@ FactoryBot.define do end end + trait :task do + issue_type { :task } + association :work_item_type, :default, :task + end + factory :incident do issue_type { :incident } association :work_item_type, :default, :incident diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb index 2af1c6cc62d..6b800e3d790 100644 --- a/spec/factories/keys.rb +++ b/spec/factories/keys.rb @@ -19,6 +19,12 @@ FactoryBot.define do user end + factory :personal_key_4096 do + user + + key { SSHData::PrivateKey::RSA.generate(4096, unsafe_allow_small_key: true).public_key.openssh(comment: 'dummy@gitlab.com') } + end + factory :another_key do factory :another_deploy_key, class: 'DeployKey' end @@ -74,6 +80,8 @@ FactoryBot.define do qpPN5jAskkAUzOh5L/M+dmq2jNn03U9xwORCYPZj+fFM9bL99/0knsV0ypZDZyWH dummy@gitlab.com KEY end + + factory :rsa_deploy_key_5120, class: 'DeployKey' end factory :rsa_key_8192 do diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index 26804b38db8..e897a5e022a 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -65,11 +65,12 @@ FactoryBot.define do transient do merged_by { author } + merged_at { nil } end after(:build) do |merge_request, evaluator| metrics = merge_request.build_metrics - metrics.merged_at = 1.week.from_now + metrics.merged_at = evaluator.merged_at || 1.week.from_now metrics.merged_by = evaluator.merged_by metrics.pipeline = create(:ci_empty_pipeline) end diff --git a/spec/factories/project_statistics.rb b/spec/factories/project_statistics.rb index ee2ad507c2d..53107879d77 100644 --- a/spec/factories/project_statistics.rb +++ b/spec/factories/project_statistics.rb @@ -24,6 +24,7 @@ FactoryBot.define do project_statistics.snippets_size = evaluator.size_multiplier * 6 project_statistics.pipeline_artifacts_size = evaluator.size_multiplier * 7 project_statistics.uploads_size = evaluator.size_multiplier * 8 + project_statistics.container_registry_size = evaluator.size_multiplier * 9 end end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index ef1313541f8..b3395758729 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -59,7 +59,7 @@ FactoryBot.define do builds_access_level = [evaluator.builds_access_level, evaluator.repository_access_level].min merge_requests_access_level = [evaluator.merge_requests_access_level, evaluator.repository_access_level].min - hash = { + project_feature_hash = { wiki_access_level: evaluator.wiki_access_level, builds_access_level: builds_access_level, snippets_access_level: evaluator.snippets_access_level, @@ -75,7 +75,16 @@ FactoryBot.define do security_and_compliance_access_level: evaluator.security_and_compliance_access_level } - project.build_project_feature(hash) + project_namespace_hash = { + name: evaluator.name, + path: evaluator.path, + parent: evaluator.namespace, + shared_runners_enabled: evaluator.shared_runners_enabled, + visibility_level: evaluator.visibility_level + } + + project.build_project_namespace(project_namespace_hash) + project.build_project_feature(project_feature_hash) end after(:create) do |project, evaluator| diff --git a/spec/factories/work_items/work_item_types.rb b/spec/factories/work_items/work_item_types.rb index 0920b36bcbd..1b6137503d3 100644 --- a/spec/factories/work_items/work_item_types.rb +++ b/spec/factories/work_items/work_item_types.rb @@ -37,5 +37,10 @@ FactoryBot.define do base_type { WorkItems::Type.base_types[:requirement] } icon_name { 'issue-type-requirements' } end + + trait :task do + base_type { WorkItems::Type.base_types[:task] } + icon_name { 'issue-type-task' } + end end end diff --git a/spec/fast_spec_helper.rb b/spec/fast_spec_helper.rb index ce3c9af22f1..6cbe97fb3f3 100644 --- a/spec/fast_spec_helper.rb +++ b/spec/fast_spec_helper.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -# $" is $LOADED_FEATURES, but RuboCop didn't like it if $".include?(File.expand_path('spec_helper.rb', __dir__)) # There's no need to load anything here if spec_helper is already loaded # because spec_helper is more extensive than fast_spec_helper diff --git a/spec/features/admin/admin_broadcast_messages_spec.rb b/spec/features/admin/admin_broadcast_messages_spec.rb index e40f4c4678c..875eb9dd0ce 100644 --- a/spec/features/admin/admin_broadcast_messages_spec.rb +++ b/spec/features/admin/admin_broadcast_messages_spec.rb @@ -22,9 +22,8 @@ RSpec.describe 'Admin Broadcast Messages' do it 'creates a customized broadcast banner message' do fill_in 'broadcast_message_message', with: 'Application update from **4:00 CST to 5:00 CST**' - fill_in 'broadcast_message_color', with: '#f2dede' fill_in 'broadcast_message_target_path', with: '*/user_onboarded' - fill_in 'broadcast_message_font', with: '#b94a48' + select 'light-indigo', from: 'broadcast_message_theme' select Date.today.next_year.year, from: 'broadcast_message_ends_at_1i' check 'Guest' check 'Owner' @@ -35,7 +34,7 @@ RSpec.describe 'Admin Broadcast Messages' do expect(page).to have_content 'Guest, Owner' expect(page).to have_content '*/user_onboarded' expect(page).to have_selector 'strong', text: '4:00 CST to 5:00 CST' - expect(page).to have_selector %(div[style="background-color: #f2dede; color: #b94a48"]) + expect(page).to have_selector %(.light-indigo[role=alert]) end it 'creates a customized broadcast notification message' do @@ -90,7 +89,7 @@ RSpec.describe 'Admin Broadcast Messages' do fill_in 'broadcast_message_message', with: "Live **Markdown** previews. :tada:" select 'Notification', from: 'broadcast_message_broadcast_type' - page.within('.js-broadcast-notification-message-preview') do + page.within('#broadcast-message-preview') do expect(page).to have_selector('strong', text: 'Markdown') expect(page).to have_emoji('tada') end diff --git a/spec/features/admin/admin_dev_ops_report_spec.rb b/spec/features/admin/admin_dev_ops_reports_spec.rb index cee79f8f440..bf32819cb52 100644 --- a/spec/features/admin/admin_dev_ops_report_spec.rb +++ b/spec/features/admin/admin_dev_ops_reports_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'DevOps Report page', :js do end it 'has dismissable intro callout' do - visit admin_dev_ops_report_path + visit admin_dev_ops_reports_path expect(page).to have_content 'Introducing Your DevOps Report' @@ -32,13 +32,13 @@ RSpec.describe 'DevOps Report page', :js do end it 'shows empty state' do - visit admin_dev_ops_report_path + visit admin_dev_ops_reports_path expect(page).to have_text('Service ping is off') end it 'hides the intro callout' do - visit admin_dev_ops_report_path + visit admin_dev_ops_reports_path expect(page).not_to have_content 'Introducing Your DevOps Report' end @@ -48,7 +48,7 @@ RSpec.describe 'DevOps Report page', :js do it 'shows empty state' do stub_application_setting(usage_ping_enabled: true) - visit admin_dev_ops_report_path + visit admin_dev_ops_reports_path expect(page).to have_content('Data is still calculating') end @@ -59,7 +59,7 @@ RSpec.describe 'DevOps Report page', :js do stub_application_setting(usage_ping_enabled: true) create(:dev_ops_report_metric) - visit admin_dev_ops_report_path + visit admin_dev_ops_reports_path expect(page).to have_selector('[data-testid="devops-score-app"]') end diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index 3f0c7e64a1f..7fe49c2571c 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -3,65 +3,71 @@ require 'spec_helper' RSpec.describe "Admin Runners" do - include StubENV - include Spec::Support::Helpers::ModalHelpers + include Spec::Support::Helpers::Features::RunnersHelpers + + let_it_be(:admin) { create(:admin) } before do - stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') - admin = create(:admin) sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) wait_for_requests end - describe "Runners page", :js do + describe "Admin Runners page", :js do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } let_it_be(:namespace) { create(:namespace) } let_it_be(:project) { create(:project, namespace: namespace, creator: user) } - context "when there are runners" do - it 'has all necessary texts' do - create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: Time.zone.now) - create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.week.ago) - create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.year.ago) - + context "runners registration" do + before do visit admin_runners_path - - expect(page).to have_text "Register an instance runner" - expect(page).to have_text "Online runners 1" - expect(page).to have_text "Offline runners 2" - expect(page).to have_text "Stale runners 1" end - it 'with an instance runner shows an instance badge' do - runner = create(:ci_runner, :instance) + it_behaves_like "shows and resets runner registration token" do + let(:dropdown_text) { 'Register an instance runner' } + let(:registration_token) { Gitlab::CurrentSettings.runners_registration_token } + end + end - visit admin_runners_path + context "when there are runners" do + context "with an instance runner" do + let!(:instance_runner) { create(:ci_runner, :instance) } - within "[data-testid='runner-row-#{runner.id}']" do - expect(page).to have_selector '.badge', text: 'shared' + before do + visit admin_runners_path end - end - it 'with a group runner shows a group badge' do - runner = create(:ci_runner, :group, groups: [group]) + it_behaves_like 'shows runner in list' do + let(:runner) { instance_runner } + end - visit admin_runners_path + it_behaves_like 'pauses, resumes and deletes a runner' do + let(:runner) { instance_runner } + end - within "[data-testid='runner-row-#{runner.id}']" do - expect(page).to have_selector '.badge', text: 'group' + it 'shows an instance badge' do + within_runner_row(instance_runner.id) do + expect(page).to have_selector '.badge', text: 'shared' + end end end - it 'with a project runner shows a project badge' do - runner = create(:ci_runner, :project, projects: [project]) + context "with multiple runners" do + before do + create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: Time.zone.now) + create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.week.ago) + create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.year.ago) - visit admin_runners_path + visit admin_runners_path + end - within "[data-testid='runner-row-#{runner.id}']" do - expect(page).to have_selector '.badge', text: 'specific' + it 'has all necessary texts' do + expect(page).to have_text "Register an instance runner" + expect(page).to have_text "Online runners 1" + expect(page).to have_text "Offline runners 2" + expect(page).to have_text "Stale runners 1" end end @@ -73,44 +79,8 @@ RSpec.describe "Admin Runners" do visit admin_runners_path - within "[data-testid='runner-row-#{runner.id}'] [data-label='Jobs']" do - expect(page).to have_content '2' - end - end - - describe 'delete runner' do - let!(:runner) { create(:ci_runner, description: 'runner-foo') } - - before do - visit admin_runners_path - - within "[data-testid='runner-row-#{runner.id}']" do - click_on 'Delete runner' - end - end - - it 'shows a confirmation modal' do - expect(page).to have_text "Delete runner ##{runner.id} (#{runner.short_sha})?" - expect(page).to have_text "Are you sure you want to continue?" - end - - it 'deletes a runner' do - within '.modal' do - click_on 'Delete runner' - end - - expect(page.find('.gl-toast')).to have_text(/Runner .+ deleted/) - expect(page).not_to have_content 'runner-foo' - end - - it 'cancels runner deletion' do - within '.modal' do - click_on 'Cancel' - end - - wait_for_requests - - expect(page).to have_content 'runner-foo' + within_runner_row(runner.id) do + expect(find("[data-label='Jobs']")).to have_content '2' end end @@ -154,35 +124,69 @@ RSpec.describe "Admin Runners" do end end + describe 'filter by paused' do + before do + create(:ci_runner, :instance, description: 'runner-active') + create(:ci_runner, :instance, description: 'runner-paused', active: false) + + visit admin_runners_path + end + + it 'shows all runners' do + expect(page).to have_link('All 2') + + expect(page).to have_content 'runner-active' + expect(page).to have_content 'runner-paused' + end + + it 'shows paused runners' do + input_filtered_search_filter_is_only('Paused', 'Yes') + + expect(page).to have_link('All 1') + + expect(page).not_to have_content 'runner-active' + expect(page).to have_content 'runner-paused' + end + + it 'shows active runners' do + input_filtered_search_filter_is_only('Paused', 'No') + + expect(page).to have_link('All 1') + + expect(page).to have_content 'runner-active' + expect(page).not_to have_content 'runner-paused' + end + end + describe 'filter by status' do let!(:never_contacted) { create(:ci_runner, :instance, description: 'runner-never-contacted', contacted_at: nil) } before do create(:ci_runner, :instance, description: 'runner-1', contacted_at: Time.zone.now) create(:ci_runner, :instance, description: 'runner-2', contacted_at: Time.zone.now) - create(:ci_runner, :instance, description: 'runner-paused', active: false, contacted_at: Time.zone.now) + create(:ci_runner, :instance, description: 'runner-offline', contacted_at: 1.week.ago) visit admin_runners_path end it 'shows all runners' do + expect(page).to have_link('All 4') + expect(page).to have_content 'runner-1' expect(page).to have_content 'runner-2' - expect(page).to have_content 'runner-paused' + expect(page).to have_content 'runner-offline' expect(page).to have_content 'runner-never-contacted' - - expect(page).to have_link('All 4') end it 'shows correct runner when status matches' do - input_filtered_search_filter_is_only('Status', 'Active') + input_filtered_search_filter_is_only('Status', 'Online') - expect(page).to have_link('All 3') + expect(page).to have_link('All 2') expect(page).to have_content 'runner-1' expect(page).to have_content 'runner-2' - expect(page).to have_content 'runner-never-contacted' - expect(page).not_to have_content 'runner-paused' + expect(page).not_to have_content 'runner-offline' + expect(page).not_to have_content 'runner-never-contacted' end it 'shows no runner when status does not match' do @@ -194,15 +198,15 @@ RSpec.describe "Admin Runners" do end it 'shows correct runner when status is selected and search term is entered' do - input_filtered_search_filter_is_only('Status', 'Active') + input_filtered_search_filter_is_only('Status', 'Online') input_filtered_search_keys('runner-1') expect(page).to have_link('All 1') expect(page).to have_content 'runner-1' expect(page).not_to have_content 'runner-2' + expect(page).not_to have_content 'runner-offline' expect(page).not_to have_content 'runner-never-contacted' - expect(page).not_to have_content 'runner-paused' end it 'shows correct runner when status filter is entered' do @@ -216,7 +220,7 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-paused' expect(page).to have_content 'runner-never-contacted' - within "[data-testid='runner-row-#{never_contacted.id}']" do + within_runner_row(never_contacted.id) do expect(page).to have_selector '.badge', text: 'never contacted' end end @@ -308,7 +312,7 @@ RSpec.describe "Admin Runners" do visit admin_runners_path - input_filtered_search_filter_is_only('Status', 'Active') + input_filtered_search_filter_is_only('Paused', 'No') expect(page).to have_content 'runner-project' expect(page).to have_content 'runner-group' @@ -330,6 +334,17 @@ RSpec.describe "Admin Runners" do create(:ci_runner, :instance, description: 'runner-red', tag_list: ['red']) end + it 'shows tags suggestions' do + visit admin_runners_path + + open_filtered_search_suggestions('Tags') + + page.within(search_bar_selector) do + expect(page).to have_content 'blue' + expect(page).to have_content 'red' + end + end + it 'shows correct runner when tag matches' do visit admin_runners_path @@ -403,15 +418,7 @@ RSpec.describe "Admin Runners" do visit admin_runners_path end - it 'has all necessary texts including no runner message' do - expect(page).to have_text "Register an instance runner" - - expect(page).to have_text "Online runners 0" - expect(page).to have_text "Offline runners 0" - expect(page).to have_text "Stale runners 0" - - expect(page).to have_text 'No runners found' - end + it_behaves_like "shows no runners" it 'shows tabs with total counts equal to 0' do expect(page).to have_link('All 0') @@ -427,65 +434,17 @@ RSpec.describe "Admin Runners" do expect(page).to have_current_path(admin_runners_path('status[]': 'NEVER_CONTACTED') ) end - end - describe 'runners registration' do - let!(:token) { Gitlab::CurrentSettings.runners_registration_token } - - before do - visit admin_runners_path + it 'updates ACTIVE runner status to paused=false' do + visit admin_runners_path('status[]': 'ACTIVE') - click_on 'Register an instance runner' + expect(page).to have_current_path(admin_runners_path('paused[]': 'false') ) end - describe 'show registration instructions' do - before do - click_on 'Show runner installation and registration instructions' - - wait_for_requests - end - - it 'opens runner installation modal' do - expect(page).to have_text "Install a runner" - - expect(page).to have_text "Environment" - expect(page).to have_text "Architecture" - expect(page).to have_text "Download and install binary" - end - - it 'dismisses runner installation modal' do - within_modal do - click_button('Close', match: :first) - end - - expect(page).not_to have_text "Install a runner" - end - end + it 'updates PAUSED runner status to paused=true' do + visit admin_runners_path('status[]': 'PAUSED') - it 'has a registration token' do - click_on 'Click to reveal' - expect(page.find('[data-testid="token-value"]')).to have_content(token) - end - - describe 'reset registration token' do - let(:page_token) { find('[data-testid="token-value"]').text } - - before do - click_on 'Reset registration token' - - within_modal do - click_button('Reset token', match: :first) - end - - wait_for_requests - end - - it 'changes registration token' do - click_on 'Register an instance runner' - - click_on 'Click to reveal' - expect(page_token).not_to eq token - end + expect(page).to have_current_path(admin_runners_path('paused[]': 'true') ) end end end @@ -637,47 +596,4 @@ RSpec.describe "Admin Runners" do end end end - - private - - def search_bar_selector - '[data-testid="runners-filtered-search"]' - end - - # The filters must be clicked first to be able to receive events - # See: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1493 - def focus_filtered_search - page.within(search_bar_selector) do - page.find('.gl-filtered-search-term-token').click - end - end - - def input_filtered_search_keys(search_term) - focus_filtered_search - - page.within(search_bar_selector) do - page.find('input').send_keys(search_term) - click_on 'Search' - end - - wait_for_requests - end - - def input_filtered_search_filter_is_only(filter, value) - focus_filtered_search - - page.within(search_bar_selector) do - click_on filter - - # For OPERATOR_IS_ONLY, clicking the filter - # immediately preselects "=" operator - - page.find('input').send_keys(value) - page.find('input').send_keys(:enter) - - click_on 'Search' - end - - wait_for_requests - 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 d05a09a79ef..432721d63ad 100644 --- a/spec/features/admin/admin_sees_background_migrations_spec.rb +++ b/spec/features/admin/admin_sees_background_migrations_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe "Admin > Admin sees background migrations" do let_it_be(:admin) { create(:admin) } - let_it_be(:active_migration) { create(:batched_background_migration, table_name: 'active', status: :active) } - let_it_be(:failed_migration) { create(:batched_background_migration, table_name: 'failed', status: :failed, total_tuple_count: 100) } - let_it_be(:finished_migration) { create(:batched_background_migration, table_name: 'finished', status: :finished) } + let_it_be(:active_migration) { create(:batched_background_migration, :active, table_name: 'active') } + let_it_be(:failed_migration) { create(:batched_background_migration, :failed, table_name: 'failed', total_tuple_count: 100) } + let_it_be(:finished_migration) { create(:batched_background_migration, :finished, table_name: 'finished') } before_all do create(:batched_background_migration_job, :failed, batched_migration: failed_migration, batch_size: 10, min_value: 6, max_value: 15, attempts: 3) @@ -81,7 +81,7 @@ RSpec.describe "Admin > Admin sees background migrations" do 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.status_name.to_s) click_button('Retry') expect(page).not_to have_content(failed_migration.job_class_name) @@ -106,7 +106,7 @@ RSpec.describe "Admin > Admin sees background migrations" do expect(page).to have_content(finished_migration.job_class_name) expect(page).to have_content(finished_migration.table_name) expect(page).to have_content('100.00%') - expect(page).to have_content(finished_migration.status.humanize) + expect(page).to have_content(finished_migration.status_name.to_s) end end end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index df93bd773a6..4cdc3df978d 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -34,16 +34,16 @@ RSpec.describe 'Admin updates settings' do it 'uncheck all restricted visibility levels' do page.within('.as-visibility-access') do - find('#application_setting_visibility_level_0').set(false) - find('#application_setting_visibility_level_10').set(false) - find('#application_setting_visibility_level_20').set(false) + find('#application_setting_restricted_visibility_levels_0').set(false) + find('#application_setting_restricted_visibility_levels_10').set(false) + find('#application_setting_restricted_visibility_levels_20').set(false) click_button 'Save changes' end expect(page).to have_content "Application settings saved successfully" - expect(find('#application_setting_visibility_level_0')).not_to be_checked - expect(find('#application_setting_visibility_level_10')).not_to be_checked - expect(find('#application_setting_visibility_level_20')).not_to be_checked + expect(find('#application_setting_restricted_visibility_levels_0')).not_to be_checked + expect(find('#application_setting_restricted_visibility_levels_10')).not_to be_checked + expect(find('#application_setting_restricted_visibility_levels_20')).not_to be_checked end it 'modify import sources' do @@ -311,7 +311,9 @@ RSpec.describe 'Admin updates settings' do end context 'CI/CD page' do - it 'change CI/CD settings' do + let_it_be(:default_plan) { create(:default_plan) } + + it 'changes CI/CD settings' do visit ci_cd_admin_application_settings_path page.within('.as-ci-cd') do @@ -329,6 +331,33 @@ RSpec.describe 'Admin updates settings' do expect(page).to have_content "Application settings saved successfully" end + it 'changes CI/CD limits', :aggregate_failures do + visit ci_cd_admin_application_settings_path + + page.within('.as-ci-cd') do + fill_in 'plan_limits_ci_pipeline_size', with: 10 + fill_in 'plan_limits_ci_active_jobs', with: 20 + fill_in 'plan_limits_ci_active_pipelines', with: 25 + fill_in 'plan_limits_ci_project_subscriptions', with: 30 + fill_in 'plan_limits_ci_pipeline_schedules', with: 40 + fill_in 'plan_limits_ci_needs_size_limit', with: 50 + fill_in 'plan_limits_ci_registered_group_runners', with: 60 + fill_in 'plan_limits_ci_registered_project_runners', with: 70 + click_button 'Save Default limits' + end + + limits = default_plan.reload.limits + expect(limits.ci_pipeline_size).to eq(10) + expect(limits.ci_active_jobs).to eq(20) + expect(limits.ci_active_pipelines).to eq(25) + expect(limits.ci_project_subscriptions).to eq(30) + expect(limits.ci_pipeline_schedules).to eq(40) + expect(limits.ci_needs_size_limit).to eq(50) + expect(limits.ci_registered_group_runners).to eq(60) + expect(limits.ci_registered_project_runners).to eq(70) + expect(page).to have_content 'Application limits saved successfully' + end + context 'Runner Registration' do context 'when feature is enabled' do before do @@ -421,7 +450,7 @@ RSpec.describe 'Admin updates settings' do visit ci_cd_admin_application_settings_path page.within('.as-registry') do - find('#application_setting_container_registry_expiration_policies_caching.form-check-input').click + find('#application_setting_container_registry_expiration_policies_caching').click click_button 'Save changes' end @@ -489,8 +518,8 @@ RSpec.describe 'Admin updates settings' do page.within('.as-spam') do fill_in 'reCAPTCHA site key', with: 'key' fill_in 'reCAPTCHA private key', with: 'key' - check 'Enable reCAPTCHA' - check 'Enable reCAPTCHA for login' + find('#application_setting_recaptcha_enabled').set(true) + find('#application_setting_login_recaptcha_protection_enabled').set(true) fill_in 'IP addresses per user', with: 15 check 'Enable Spam Check via external API endpoint' fill_in 'URL of the external Spam Check endpoint', with: 'grpc://www.example.com/spamcheck' @@ -825,31 +854,45 @@ RSpec.describe 'Admin updates settings' do before do stub_usage_data_connections stub_database_flavor_check - - visit service_usage_data_admin_application_settings_path end - it 'loads usage ping payload on click', :js do - expected_payload_content = /(?=.*"uuid")(?=.*"hostname")/m + context 'when service data cached', :clean_gitlab_redis_cache do + before do + allow(Rails.cache).to receive(:exist?).with('usage_data').and_return(true) - expect(page).not_to have_content expected_payload_content + visit service_usage_data_admin_application_settings_path + end - click_button('Preview payload') + it 'loads usage ping payload on click', :js do + expected_payload_content = /(?=.*"uuid")(?=.*"hostname")/m - wait_for_requests + expect(page).not_to have_content expected_payload_content - expect(page).to have_button 'Hide payload' - expect(page).to have_content expected_payload_content - end + click_button('Preview payload') - it 'generates usage ping payload on button click', :js do - expect_next_instance_of(Admin::ApplicationSettingsController) do |instance| - expect(instance).to receive(:usage_data).and_call_original + wait_for_requests + + expect(page).to have_button 'Hide payload' + expect(page).to have_content expected_payload_content + end + + it 'generates usage ping payload on button click', :js do + expect_next_instance_of(Admin::ApplicationSettingsController) do |instance| + expect(instance).to receive(:usage_data).and_call_original + end + + click_button('Download payload') + + wait_for_requests end + end - click_button('Download payload') + context 'when service data not cached' do + it 'renders missing cache information' do + visit service_usage_data_admin_application_settings_path - wait_for_requests + expect(page).to have_text('Service Ping payload not found in the application cache') + end end end end diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb index 6643ebe82e6..15bc2318022 100644 --- a/spec/features/admin/admin_users_impersonation_tokens_spec.rb +++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb @@ -36,14 +36,14 @@ RSpec.describe 'Admin > Users > Impersonation Tokens', :js do click_on "1" # Scopes - check "api" + check "read_api" check "read_user" 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('api') + expect(active_impersonation_tokens).to have_text('read_api') expect(active_impersonation_tokens).to have_text('read_user') expect(PersonalAccessTokensFinder.new(impersonation: true).execute.count).to equal(1) expect(created_impersonation_token).not_to be_empty diff --git a/spec/features/admin/clusters/eks_spec.rb b/spec/features/admin/clusters/eks_spec.rb index 71d2bba73b1..4667f9c20a1 100644 --- a/spec/features/admin/clusters/eks_spec.rb +++ b/spec/features/admin/clusters/eks_spec.rb @@ -15,8 +15,8 @@ RSpec.describe 'Instance-level AWS EKS Cluster', :js do before do visit admin_clusters_path - click_button 'Actions' - click_link 'Create a new cluster' + click_button(class: 'dropdown-toggle-split') + click_link 'Create a cluster (deprecated)' end context 'when user creates a cluster on AWS EKS' do diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 5dd627f3b76..bf976168bbe 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -282,7 +282,7 @@ RSpec.describe 'Project issue boards', :js do it 'shows issue count on the list' do page.within(find(".board:nth-child(2)")) do expect(page.find('[data-testid="board-items-count"]')).to have_text(total_planning_issues) - expect(page).not_to have_selector('.js-max-issue-size') + expect(page).not_to have_selector('.max-issue-size') end end end diff --git a/spec/features/boards/focus_mode_spec.rb b/spec/features/boards/focus_mode_spec.rb index 2bd1e625236..453a8d8870b 100644 --- a/spec/features/boards/focus_mode_spec.rb +++ b/spec/features/boards/focus_mode_spec.rb @@ -12,6 +12,6 @@ RSpec.describe 'Issue Boards focus mode', :js do end it 'shows focus mode button to anonymous users' do - expect(page).to have_selector('.js-focus-mode-btn') + expect(page).to have_button _('Toggle focus mode') end end diff --git a/spec/features/boards/multi_select_spec.rb b/spec/features/boards/multi_select_spec.rb index 9148fb23214..cad303a14e5 100644 --- a/spec/features/boards/multi_select_spec.rb +++ b/spec/features/boards/multi_select_spec.rb @@ -72,7 +72,7 @@ RSpec.describe 'Multi Select Issue', :js do wait_for_requests - page.within(all('.js-board-list')[2]) do + page.within(all('.board-list')[2]) do expect(find('.board-card:nth-child(1)')).to have_content(issue1.title) expect(find('.board-card:nth-child(2)')).to have_content(issue2.title) end @@ -87,7 +87,7 @@ RSpec.describe 'Multi Select Issue', :js do wait_for_requests - page.within(all('.js-board-list')[2]) do + page.within(all('.board-list')[2]) do expect(find('.board-card:nth-child(1)')).to have_content(issue1.title) expect(find('.board-card:nth-child(2)')).to have_content(issue2.title) expect(find('.board-card:nth-child(3)')).to have_content(issue3.title) @@ -102,7 +102,7 @@ RSpec.describe 'Multi Select Issue', :js do wait_for_requests - page.within(all('.js-board-list')[1]) do + page.within(all('.board-list')[1]) do expect(find('.board-card:nth-child(1)')).to have_content(issue1.title) expect(find('.board-card:nth-child(2)')).to have_content(issue2.title) expect(find('.board-card:nth-child(3)')).to have_content(issue5.title) diff --git a/spec/features/clusters/create_agent_spec.rb b/spec/features/clusters/create_agent_spec.rb index e03126d344e..c7326204bf6 100644 --- a/spec/features/clusters/create_agent_spec.rb +++ b/spec/features/clusters/create_agent_spec.rb @@ -25,7 +25,7 @@ RSpec.describe 'Cluster agent registration', :js do end it 'allows the user to select an agent to install, and displays the resulting agent token' do - click_button('Actions') + click_button('Connect a cluster') expect(page).to have_content('Register') click_button('Select an agent') @@ -34,7 +34,7 @@ RSpec.describe 'Cluster agent registration', :js do expect(page).to have_content('You cannot see this token again after you close this window.') expect(page).to have_content('example-agent-token') - expect(page).to have_content('docker run --pull=always --rm') + expect(page).to have_content('helm upgrade --install') within find('.modal-footer') do click_button('Close') diff --git a/spec/features/commit_spec.rb b/spec/features/commit_spec.rb index 3fd613ce393..c9fa10d58e6 100644 --- a/spec/features/commit_spec.rb +++ b/spec/features/commit_spec.rb @@ -33,6 +33,10 @@ RSpec.describe 'Commit' do it "reports the correct number of total changes" do expect(page).to have_content("Changes #{commit.diffs.size}") end + + it 'renders diff stats', :js do + expect(page).to have_selector(".diff-stats") + end end describe "pagination" do diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index db841ffc627..4b38df175e2 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -10,6 +10,7 @@ RSpec.describe 'Commits' do before do sign_in(user) stub_ci_pipeline_to_return_yaml_file + stub_feature_flags(pipeline_tabs_vue: false) end let(:creator) { create(:user, developer_projects: [project]) } @@ -93,6 +94,7 @@ RSpec.describe 'Commits' do context 'Download artifacts', :js do before do + stub_feature_flags(pipeline_tabs_vue: false) create(:ci_job_artifact, :archive, file: artifacts_file, job: build) end @@ -122,6 +124,7 @@ RSpec.describe 'Commits' do context "when logged as reporter", :js do before do + stub_feature_flags(pipeline_tabs_vue: false) project.add_reporter(user) create(:ci_job_artifact, :archive, file: artifacts_file, job: build) visit builds_project_pipeline_path(project, pipeline) diff --git a/spec/features/error_tracking/user_searches_sentry_errors_spec.rb b/spec/features/error_tracking/user_searches_sentry_errors_spec.rb index 89bf79ebb81..40718deed75 100644 --- a/spec/features/error_tracking/user_searches_sentry_errors_spec.rb +++ b/spec/features/error_tracking/user_searches_sentry_errors_spec.rb @@ -30,7 +30,7 @@ RSpec.describe 'When a user searches for Sentry errors', :js, :use_clean_rails_m expect(results.count).to be(3) end - find('.gl-form-input').set('NotFound').native.send_keys(:return) + find('.filtered-search-input-container .gl-form-input').set('NotFound').native.send_keys(:return) page.within(find('.gl-table')) do results = page.all('.table-row') diff --git a/spec/features/groups/clusters/eks_spec.rb b/spec/features/groups/clusters/eks_spec.rb index 3cca2d0919c..0e64a2faf3e 100644 --- a/spec/features/groups/clusters/eks_spec.rb +++ b/spec/features/groups/clusters/eks_spec.rb @@ -20,8 +20,8 @@ RSpec.describe 'Group AWS EKS Cluster', :js do before do visit group_clusters_path(group) - click_button 'Actions' - click_link 'Create a new cluster' + click_button(class: 'dropdown-toggle-split') + click_link 'Create a cluster (deprecated)' end context 'when user creates a cluster on AWS EKS' do diff --git a/spec/features/groups/clusters/user_spec.rb b/spec/features/groups/clusters/user_spec.rb index 2ed6ddc09ab..74ea72b238f 100644 --- a/spec/features/groups/clusters/user_spec.rb +++ b/spec/features/groups/clusters/user_spec.rb @@ -25,7 +25,7 @@ RSpec.describe 'User Cluster', :js do before do visit group_clusters_path(group) - click_link 'Connect with a certificate' + click_link 'Connect a cluster (deprecated)' end context 'when user filled form with valid parameters' do @@ -119,7 +119,6 @@ RSpec.describe 'User Cluster', :js do it 'user sees creation form with the successful message' do expect(page).to have_content('Kubernetes cluster integration was successfully removed.') - expect(page).to have_link('Connect with a certificate') end end end diff --git a/spec/features/groups/group_runners_spec.rb b/spec/features/groups/group_runners_spec.rb new file mode 100644 index 00000000000..1d821edefa3 --- /dev/null +++ b/spec/features/groups/group_runners_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Group Runners" do + include Spec::Support::Helpers::Features::RunnersHelpers + + let_it_be(:group_owner) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + + before do + group.add_owner(group_owner) + sign_in(group_owner) + end + + describe "Group runners page", :js do + let!(:group_registration_token) { group.runners_token } + + context "runners registration" do + before do + visit group_runners_path(group) + end + + it_behaves_like "shows and resets runner registration token" do + let(:dropdown_text) { 'Register a group runner' } + let(:registration_token) { group_registration_token } + end + end + + context "with no runners" do + before do + visit group_runners_path(group) + end + + it_behaves_like "shows no runners" + + it 'shows tabs with total counts equal to 0' do + expect(page).to have_link('All 0') + expect(page).to have_link('Group 0') + expect(page).to have_link('Project 0') + end + end + + context "with an online group runner" do + let!(:group_runner) do + create(:ci_runner, :group, groups: [group], description: 'runner-foo', contacted_at: Time.zone.now) + end + + before do + visit group_runners_path(group) + end + + it_behaves_like 'shows runner in list' do + let(:runner) { group_runner } + end + + it_behaves_like 'pauses, resumes and deletes a runner' do + let(:runner) { group_runner } + end + + it 'shows a group badge' do + within_runner_row(group_runner.id) do + expect(page).to have_selector '.badge', text: 'group' + end + end + + it 'can edit runner information' do + within_runner_row(group_runner.id) do + expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, group_runner)) + end + end + end + + context "with an online project runner" do + let!(:project_runner) do + create(:ci_runner, :project, projects: [project], description: 'runner-bar', contacted_at: Time.zone.now) + end + + before do + visit group_runners_path(group) + end + + it_behaves_like 'shows runner in list' do + let(:runner) { project_runner } + end + + it_behaves_like 'pauses, resumes and deletes a runner' do + let(:runner) { project_runner } + end + + it 'shows a project (specific) badge' do + within_runner_row(project_runner.id) do + expect(page).to have_selector '.badge', text: 'specific' + end + end + + it 'can edit runner information' do + within_runner_row(project_runner.id) do + expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, project_runner)) + end + end + end + + context 'with a multi-project runner' do + let(:project) { create(:project, group: group) } + let(:project_2) { create(:project, group: group) } + let!(:runner) { create(:ci_runner, :project, projects: [project, project_2], description: 'group-runner') } + + it 'user cannot remove the project runner' do + visit group_runners_path(group) + + within_runner_row(runner.id) do + expect(page).to have_button 'Delete runner', disabled: true + end + end + end + + context 'filtered search' do + before do + visit group_runners_path(group) + end + + it 'allows user to search by paused and status', :js do + focus_filtered_search + + page.within(search_bar_selector) do + expect(page).to have_link('Paused') + expect(page).to have_content('Status') + end + end + end + end + + describe "Group runner edit page", :js do + let!(:runner) do + create(:ci_runner, :group, groups: [group], description: 'runner-foo', contacted_at: Time.zone.now) + end + + it 'user edits the runner to be protected' do + visit edit_group_runner_path(group, runner) + + expect(page.find_field('runner[access_level]')).not_to be_checked + + check 'runner_access_level' + click_button 'Save changes' + + expect(page).to have_content 'Protected Yes' + end + + context 'when a runner has a tag' do + before do + runner.update!(tag_list: ['tag']) + end + + it 'user edits runner not to run untagged jobs' do + visit edit_group_runner_path(group, runner) + + expect(page.find_field('runner[run_untagged]')).to be_checked + + uncheck 'runner_run_untagged' + click_button 'Save changes' + + expect(page).to have_content 'Can run untagged jobs No' + end + end + end +end diff --git a/spec/features/groups/import_export/export_file_spec.rb b/spec/features/groups/import_export/export_file_spec.rb index 9feb8085e66..e3cb1ad77a7 100644 --- a/spec/features/groups/import_export/export_file_spec.rb +++ b/spec/features/groups/import_export/export_file_spec.rb @@ -26,22 +26,6 @@ RSpec.describe 'Group Export', :js do end end - context 'when the group import/export FF is disabled' do - before do - stub_feature_flags(group_import_export: false) - - group.add_owner(user) - sign_in(user) - end - - it 'does not show the group export options' do - visit edit_group_path(group) - - expect(page).to have_content('Advanced') - expect(page).not_to have_content('Export group') - end - end - context 'when the signed in user does not have the required permission level' do before do group.add_guest(user) diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb index 5ab5a7ea716..5a9223d9ee8 100644 --- a/spec/features/groups/members/manage_groups_spec.rb +++ b/spec/features/groups/members/manage_groups_spec.rb @@ -3,7 +3,6 @@ require 'spec_helper' RSpec.describe 'Groups > Members > Manage groups', :js do - include Select2Helper include Spec::Support::Helpers::Features::MembersHelpers include Spec::Support::Helpers::Features::InviteMembersModalHelper include Spec::Support::Helpers::ModalHelpers @@ -119,16 +118,92 @@ RSpec.describe 'Groups > Members > Manage groups', :js do describe 'group search results' do let_it_be(:group, refind: true) { create(:group) } - let_it_be(:group_within_hierarchy) { create(:group, parent: group) } - let_it_be(:group_outside_hierarchy) { create(:group) } - before_all do - group.add_owner(user) - group_within_hierarchy.add_owner(user) - group_outside_hierarchy.add_owner(user) + context 'with instance admin considerations' do + let_it_be(:group_to_share) { create(:group) } + + context 'when user is an admin' do + let_it_be(:admin) { create(:admin) } + + before do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + end + + it 'shows groups where the admin has no direct membership' do + visit group_group_members_path(group) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + expect_not_to_have_group(group) + end + end + + it 'shows groups where the admin has at least guest level membership' do + group_to_share.add_guest(admin) + + visit group_group_members_path(group) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + expect_not_to_have_group(group) + end + end + end + + context 'when user is not an admin' do + before do + group.add_owner(user) + end + + it 'shows groups where the user has no direct membership' do + visit group_group_members_path(group) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_not_to_have_group(group_to_share) + expect_not_to_have_group(group) + end + end + + it 'shows groups where the user has at least guest level membership' do + group_to_share.add_guest(user) + + visit group_group_members_path(group) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + expect_not_to_have_group(group) + end + end + end end - context 'when the invite members group modal is enabled' do + context 'when user is not an admin and there are hierarchy considerations' do + let_it_be(:group_within_hierarchy) { create(:group, parent: group) } + let_it_be(:group_outside_hierarchy) { create(:group) } + + before_all do + group.add_owner(user) + group_within_hierarchy.add_owner(user) + group_outside_hierarchy.add_owner(user) + end + it 'does not show self or ancestors', :aggregate_failures do group_sibbling = create(:group, parent: group) group_sibbling.add_owner(user) @@ -139,46 +214,46 @@ RSpec.describe 'Groups > Members > Manage groups', :js do click_on 'Select a group' wait_for_requests - page.within('[data-testid="group-select-dropdown"]') do - expect(page).to have_selector("[entity-id='#{group_outside_hierarchy.id}']") - expect(page).to have_selector("[entity-id='#{group_sibbling.id}']") - expect(page).not_to have_selector("[entity-id='#{group.id}']") - expect(page).not_to have_selector("[entity-id='#{group_within_hierarchy.id}']") + page.within(group_dropdown_selector) do + expect_to_have_group(group_outside_hierarchy) + expect_to_have_group(group_sibbling) + expect_not_to_have_group(group) + expect_not_to_have_group(group_within_hierarchy) end end - end - context 'when sharing with groups outside the hierarchy is enabled' do - it 'shows groups within and outside the hierarchy in search results' do - visit group_group_members_path(group) + context 'when sharing with groups outside the hierarchy is enabled' do + it 'shows groups within and outside the hierarchy in search results' do + visit group_group_members_path(group) - click_on 'Invite a group' - click_on 'Select a group' + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests - expect(page).to have_text group_within_hierarchy.name - expect(page).to have_text group_outside_hierarchy.name + page.within(group_dropdown_selector) do + expect_to_have_group(group_within_hierarchy) + expect_to_have_group(group_outside_hierarchy) + end + end end - end - context 'when sharing with groups outside the hierarchy is disabled' do - before do - group.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: true) - end + context 'when sharing with groups outside the hierarchy is disabled' do + before do + group.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: true) + end - it 'shows only groups within the hierarchy in search results' do - visit group_group_members_path(group) + it 'shows only groups within the hierarchy in search results' do + visit group_group_members_path(group) - click_on 'Invite a group' - click_on 'Select a group' + click_on 'Invite a group' + click_on 'Select a group' - expect(page).to have_text group_within_hierarchy.name - expect(page).not_to have_text group_outside_hierarchy.name + page.within(group_dropdown_selector) do + expect_to_have_group(group_within_hierarchy) + expect_not_to_have_group(group_outside_hierarchy) + end + end end end end - - def click_groups_tab - expect(page).to have_link 'Groups' - click_link "Groups" - end end diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb index 533d2118b30..468001c3be6 100644 --- a/spec/features/groups/members/manage_members_spec.rb +++ b/spec/features/groups/members/manage_members_spec.rb @@ -42,46 +42,6 @@ RSpec.describe 'Groups > Members > Manage members' do end end - it 'add user to group', :js, :snowplow, :aggregate_failures do - group.add_owner(user1) - - visit group_group_members_path(group) - - invite_member(user2.name, role: 'Reporter') - - page.within(second_row) do - expect(page).to have_content(user2.name) - expect(page).to have_button('Reporter') - end - - expect_snowplow_event( - category: 'Members::CreateService', - action: 'create_member', - label: 'group-members-page', - property: 'existing_user', - user: user1 - ) - end - - it 'do not disclose email addresses', :js do - group.add_owner(user1) - create(:user, email: 'undisclosed_email@gitlab.com', name: "Jane 'invisible' Doe") - - visit group_group_members_path(group) - - click_on 'Invite members' - find('[data-testid="members-token-select-input"]').set('@gitlab.com') - - wait_for_requests - - expect(page).to have_content('No matches found') - - find('[data-testid="members-token-select-input"]').set('undisclosed_email@gitlab.com') - wait_for_requests - - expect(page).to have_content('Invite "undisclosed_email@gitlab.com" by email') - end - it 'remove user from group', :js do group.add_owner(user1) group.add_developer(user2) @@ -106,43 +66,29 @@ RSpec.describe 'Groups > Members > Manage members' do end end - it 'add yourself to group when already an owner', :js, :aggregate_failures do - group.add_owner(user1) - - visit group_group_members_path(group) - - invite_member(user1.name, role: 'Reporter') - - page.within(first_row) do - expect(page).to have_content(user1.name) - expect(page).to have_content('Owner') - end - end + context 'when inviting' do + it 'add yourself to group when already an owner', :js do + group.add_owner(user1) - it 'invite user to group', :js, :snowplow do - group.add_owner(user1) + visit group_group_members_path(group) - visit group_group_members_path(group) + invite_member(user1.name, role: 'Reporter', refresh: false) - invite_member('test@example.com', role: 'Reporter') + expect(page).to have_selector(invite_modal_selector) + expect(page).to have_content("not authorized to update member") - expect(page).to have_link 'Invited' - click_link 'Invited' + page.refresh - aggregate_failures do - page.within(members_table) do - expect(page).to have_content('test@example.com') - expect(page).to have_content('Invited') - expect(page).to have_button('Reporter') + page.within find_member_row(user1) do + expect(page).to have_content('Owner') end + end - expect_snowplow_event( - category: 'Members::InviteService', - action: 'create_member', - label: 'group-members-page', - property: 'net_new_user', - user: user1 - ) + it_behaves_like 'inviting members', 'group-members-page' do + let_it_be(:entity) { group } + let_it_be(:members_page_path) { group_group_members_path(entity) } + let_it_be(:subentity) { create(:group, parent: group) } + let_it_be(:subentity_members_page_path) { group_group_members_path(subentity) } end end @@ -169,4 +115,57 @@ RSpec.describe 'Groups > Members > Manage members' do end end end + + describe 'member search results', :js do + before do + group.add_owner(user1) + end + + it 'does not disclose email addresses' do + create(:user, email: 'undisclosed_email@gitlab.com', name: "Jane 'invisible' Doe") + + visit group_group_members_path(group) + + click_on 'Invite members' + find(member_dropdown_selector).set('@gitlab.com') + + wait_for_requests + + expect(page).to have_content('No matches found') + + find(member_dropdown_selector).set('undisclosed_email@gitlab.com') + wait_for_requests + + expect(page).to have_content('Invite "undisclosed_email@gitlab.com" by email') + end + + it 'does not show project_bots', :aggregate_failures do + internal_project_bot = create(:user, :project_bot, name: '_internal_project_bot_') + project = create(:project, group: group) + project.add_maintainer(internal_project_bot) + + external_group = create(:group) + external_project_bot = create(:user, :project_bot, name: '_external_project_bot_') + external_project = create(:project, group: external_group) + external_project.add_maintainer(external_project_bot) + external_project.add_maintainer(user1) + + visit group_group_members_path(group) + + click_on 'Invite members' + + page.within invite_modal_selector do + field = find(member_dropdown_selector) + field.native.send_keys :tab + field.click + + wait_for_requests + + expect(page).to have_content(user1.name) + expect(page).to have_content(user2.name) + expect(page).not_to have_content(internal_project_bot.name) + expect(page).not_to have_content(external_project_bot.name) + end + end + end end diff --git a/spec/features/groups/members/sort_members_spec.rb b/spec/features/groups/members/sort_members_spec.rb index 03758e0d401..bf8e64fa1e2 100644 --- a/spec/features/groups/members/sort_members_spec.rb +++ b/spec/features/groups/members/sort_members_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe 'Groups > Members > Sort members', :js do include Spec::Support::Helpers::Features::MembersHelpers - let(:owner) { create(:user, name: 'John Doe') } - let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) } + let(:owner) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) } + let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) } let(:group) { create(:group) } before do @@ -50,6 +50,42 @@ RSpec.describe 'Groups > Members > Sort members', :js do expect_sort_by('Max role', :desc) end + it 'sorts by user created on ascending' do + visit_members_list(sort: :oldest_created_user) + + expect(first_row.text).to include(owner.name) + expect(second_row.text).to include(developer.name) + + expect_sort_by('Created on', :asc) + end + + it 'sorts by user created on descending' do + visit_members_list(sort: :recent_created_user) + + expect(first_row.text).to include(developer.name) + expect(second_row.text).to include(owner.name) + + expect_sort_by('Created on', :desc) + end + + it 'sorts by last activity ascending' do + visit_members_list(sort: :oldest_last_activity) + + expect(first_row.text).to include(developer.name) + expect(second_row.text).to include(owner.name) + + expect_sort_by('Last activity', :asc) + end + + it 'sorts by last activity descending' do + visit_members_list(sort: :recent_last_activity) + + expect(first_row.text).to include(owner.name) + expect(second_row.text).to include(developer.name) + + expect_sort_by('Last activity', :desc) + end + it 'sorts by access granted ascending' do visit_members_list(sort: :last_joined) diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb index 4edf27e8fa4..42eaa8358a1 100644 --- a/spec/features/groups/milestone_spec.rb +++ b/spec/features/groups/milestone_spec.rb @@ -66,7 +66,7 @@ RSpec.describe 'Group milestones' do context 'when no milestones' do it 'renders no milestones text' do visit group_milestones_path(group) - expect(page).to have_content('No milestones to show') + expect(page).to have_content('Use milestones to track issues and merge requests') end end diff --git a/spec/features/groups/milestones_sorting_spec.rb b/spec/features/groups/milestones_sorting_spec.rb index a06e64fdee0..22d7ff91d41 100644 --- a/spec/features/groups/milestones_sorting_spec.rb +++ b/spec/features/groups/milestones_sorting_spec.rb @@ -17,7 +17,7 @@ RSpec.describe 'Milestones sorting', :js do sign_in(user) end - it 'visit group milestones and sort by due_date_asc' do + it 'visit group milestones and sort by due_date_asc', :js do visit group_milestones_path(group) expect(page).to have_button('Due soon') @@ -27,13 +27,13 @@ RSpec.describe 'Milestones sorting', :js do expect(page.all('ul.content-list > li strong > a').map(&:text)).to eq(['v2.0', 'v2.0', 'v3.0', 'v1.0', 'v1.0']) end - click_button 'Due soon' + within '[data-testid=milestone_sort_by_dropdown]' do + click_button 'Due soon' + expect(find('.gl-new-dropdown-contents').all('.gl-new-dropdown-item-text-wrapper p').map(&:text)).to eq(['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending']) - expect(find('ul.dropdown-menu-sort li').all('a').map(&:text)).to eq(['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending']) - - click_link 'Due later' - - expect(page).to have_button('Due later') + click_button 'Due later' + expect(page).to have_button('Due later') + end # assert descending sorting within '.milestones' do diff --git a/spec/features/groups/settings/ci_cd_spec.rb b/spec/features/groups/settings/ci_cd_spec.rb index 8851aeb6381..c5ad524e647 100644 --- a/spec/features/groups/settings/ci_cd_spec.rb +++ b/spec/features/groups/settings/ci_cd_spec.rb @@ -5,52 +5,73 @@ require 'spec_helper' RSpec.describe 'Group CI/CD settings' do include WaitForRequests - let(:user) { create(:user) } - let(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:group, reload: true) { create(:group) } - before do + before_all do group.add_owner(user) + end + + before do sign_in(user) end - describe 'new group runners view banner' do - it 'displays banner' do - visit group_settings_ci_cd_path(group) + describe 'Runners section' do + let(:shared_runners_toggle) { page.find('[data-testid="enable-runners-toggle"]') } + + context 'with runner_list_group_view_vue_ui enabled' do + before do + visit group_settings_ci_cd_path(group) + end + + it 'displays the new group runners view banner' do + expect(page).to have_content(s_('Runners|New group runners view')) + expect(page).to have_link(href: group_runners_path(group)) + end - expect(page).to have_content(s_('Runners|New group runners view')) - expect(page).to have_link(href: group_runners_path(group)) + it 'has "Enable shared runners for this group" toggle', :js do + expect(shared_runners_toggle).to have_content(_('Enable shared runners for this group')) + end end - it 'does not display banner' do - stub_feature_flags(runner_list_group_view_vue_ui: false) + context 'with runner_list_group_view_vue_ui disabled' do + before do + stub_feature_flags(runner_list_group_view_vue_ui: false) - visit group_settings_ci_cd_path(group) + visit group_settings_ci_cd_path(group) + end - expect(page).not_to have_content(s_('Runners|New group runners view')) - expect(page).not_to have_link(href: group_runners_path(group)) - end - end + it 'does not display the new group runners view banner' do + expect(page).not_to have_content(s_('Runners|New group runners view')) + expect(page).not_to have_link(href: group_runners_path(group)) + end - describe 'runners registration token' do - let!(:token) { group.runners_token } + it 'has "Enable shared runners for this group" toggle', :js do + expect(shared_runners_toggle).to have_content(_('Enable shared runners for this group')) + end - before do - visit group_settings_ci_cd_path(group) - end + context 'with runners registration token' do + let!(:token) { group.runners_token } - it 'has a registration token' do - expect(page.find('#registration_token')).to have_content(token) - end + before do + visit group_settings_ci_cd_path(group) + end - describe 'reload registration token' do - let(:page_token) { find('#registration_token').text } + it 'displays the registration token' do + expect(page.find('#registration_token')).to have_content(token) + end - before do - click_button 'Reset registration token' - end + describe 'reload registration token' do + let(:page_token) { find('#registration_token').text } + + before do + click_button 'Reset registration token' + end - it 'changes registration token' do - expect(page_token).not_to eq token + it 'changes the registration token' do + expect(page_token).not_to eq token + end + end end end end diff --git a/spec/features/issuables/shortcuts_issuable_spec.rb b/spec/features/issuables/shortcuts_issuable_spec.rb index 7e8f39c47a7..528420062dd 100644 --- a/spec/features/issuables/shortcuts_issuable_spec.rb +++ b/spec/features/issuables/shortcuts_issuable_spec.rb @@ -15,12 +15,20 @@ RSpec.describe 'Blob shortcuts', :js do end shared_examples "quotes the selected text" do - it "quotes the selected text", :quarantine do - select_element('.note-text') + it 'quotes the selected text in main comment form', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/356388' do + select_element('#notes-list .note:first-child .note-text') find('body').native.send_key('r') expect(find('.js-main-target-form .js-vue-comment-form').value).to include(note_text) end + + it 'quotes the selected text in the discussion reply form', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/356388' do + find('#notes-list .note:first-child .js-reply-button').click + select_element('#notes-list .note:first-child .note-text') + find('body').native.send_key('r') + + expect(find('#notes-list .note:first-child .js-vue-markdown-field .js-gfm-input').value).to include(note_text) + end end describe 'pressing "r"' do diff --git a/spec/features/issues/incident_issue_spec.rb b/spec/features/issues/incident_issue_spec.rb index 2956ddede2e..a2519a44604 100644 --- a/spec/features/issues/incident_issue_spec.rb +++ b/spec/features/issues/incident_issue_spec.rb @@ -56,5 +56,37 @@ RSpec.describe 'Incident Detail', :js do end end end + + context 'when on summary tab' do + before do + click_link 'Summary' + end + + it 'shows the summary tab with all components' do + page.within('.issuable-details') do + hidden_items = find_all('.js-issue-widgets') + + # Linked Issues/MRs and comment box + expect(hidden_items.count).to eq(2) + + expect(hidden_items).to all(be_visible) + end + end + end + + context 'when on alert details tab' do + before do + click_link 'Alert details' + end + + it 'does not show the linked issues and notes/comment components' do + page.within('.issuable-details') do + hidden_items = find_all('.js-issue-widgets') + + # Linked Issues/MRs and comment box are hidden on page + expect(hidden_items.count).to eq(0) + end + end + end end end diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb index 446f13dc4d0..8a5e33ba18c 100644 --- a/spec/features/issues/user_creates_issue_spec.rb +++ b/spec/features/issues/user_creates_issue_spec.rb @@ -71,6 +71,12 @@ RSpec.describe "User creates issue" do expect(preview).to have_css("gl-emoji") expect(textarea).not_to be_visible + + click_button("Write") + fill_in("Description", with: "/confidential") + click_button("Preview") + + expect(form).to have_content('Makes this issue confidential.') end end end diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb index 8c906e6a27c..3b440002cb5 100644 --- a/spec/features/issues/user_edits_issue_spec.rb +++ b/spec/features/issues/user_edits_issue_spec.rb @@ -35,6 +35,12 @@ RSpec.describe "Issues > User edits issue", :js do end expect(form).to have_button("Write") + + click_button("Write") + fill_in("Description", with: "/confidential") + click_button("Preview") + + expect(form).to have_content('Makes this issue confidential.') end it 'allows user to select unassigned' do diff --git a/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb b/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb index 6473fe01052..311818d2d15 100644 --- a/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb +++ b/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb @@ -6,6 +6,10 @@ RSpec.describe 'Issues > Real-time sidebar', :js do let_it_be(:project) { create(:project, :public) } let_it_be(:issue) { create(:issue, project: project) } let_it_be(:user) { create(:user) } + let_it_be(:label) { create(:label, project: project, name: 'Development') } + + let(:labels_widget) { find('[data-testid="sidebar-labels"]') } + let(:labels_value) { find('[data-testid="value-wrapper"]') } before_all do project.add_developer(user) @@ -32,4 +36,37 @@ RSpec.describe 'Issues > Real-time sidebar', :js do expect(page.find('.assignee')).to have_content user.name end end + + it 'updates the label in real-time' do + Capybara::Session.new(:other_session) + + using_session :other_session do + visit project_issue_path(project, issue) + wait_for_requests + expect(labels_value).to have_content('None') + end + + sign_in(user) + + visit project_issue_path(project, issue) + wait_for_requests + expect(labels_value).to have_content('None') + + page.within(labels_widget) do + click_on 'Edit' + end + + wait_for_all_requests + + click_button label.name + click_button 'Close' + + wait_for_requests + + expect(labels_value).to have_content(label.name) + + using_session :other_session do + expect(labels_value).to have_content(label.name) + end + end end diff --git a/spec/features/jira_connect/subscriptions_spec.rb b/spec/features/jira_connect/subscriptions_spec.rb index 0b7321bf271..94c293c88b9 100644 --- a/spec/features/jira_connect/subscriptions_spec.rb +++ b/spec/features/jira_connect/subscriptions_spec.rb @@ -36,7 +36,7 @@ RSpec.describe 'Subscriptions Content Security Policy' do it 'appends to CSP directives' do visit jira_connect_subscriptions_path(jwt: jwt) - is_expected.to include("frame-ancestors 'self' https://*.atlassian.net") + is_expected.to include("frame-ancestors 'self' https://*.atlassian.net https://*.jira.com") is_expected.to include("script-src 'self' https://some-cdn.test https://connect-cdn.atl-paas.net") is_expected.to include("style-src 'self' https://some-cdn.test 'unsafe-inline'") end diff --git a/spec/features/jira_oauth_provider_authorize_spec.rb b/spec/features/jira_oauth_provider_authorize_spec.rb index daecae56101..a216d2d44b2 100644 --- a/spec/features/jira_oauth_provider_authorize_spec.rb +++ b/spec/features/jira_oauth_provider_authorize_spec.rb @@ -4,13 +4,13 @@ require 'spec_helper' RSpec.describe 'JIRA OAuth Provider' do describe 'JIRA DVCS OAuth Authorization' do - let(:application) { create(:oauth_application, redirect_uri: oauth_jira_callback_url, scopes: 'read_user') } + let(:application) { create(:oauth_application, redirect_uri: oauth_jira_dvcs_callback_url, scopes: 'read_user') } before do sign_in(user) - visit oauth_jira_authorize_path(client_id: application.uid, - redirect_uri: oauth_jira_callback_url, + visit oauth_jira_dvcs_authorize_path(client_id: application.uid, + redirect_uri: oauth_jira_dvcs_callback_url, response_type: 'code', state: 'my_state', scope: 'read_user') diff --git a/spec/features/merge_request/user_merges_merge_request_spec.rb b/spec/features/merge_request/user_merges_merge_request_spec.rb index d1be93cae02..a861ca2eea5 100644 --- a/spec/features/merge_request/user_merges_merge_request_spec.rb +++ b/spec/features/merge_request/user_merges_merge_request_spec.rb @@ -10,7 +10,7 @@ RSpec.describe "User merges a merge request", :js do end shared_examples "fast forward merge a merge request" do - it "merges a merge request", :sidekiq_might_not_need_inline do + it "merges a merge request", :sidekiq_inline do expect(page).to have_content("Fast-forward merge without a merge commit").and have_button("Merge") page.within(".mr-state-widget") do @@ -42,4 +42,23 @@ RSpec.describe "User merges a merge request", :js do it_behaves_like "fast forward merge a merge request" end end + + context 'sidebar merge requests counter' do + let(:project) { create(:project, :public, :repository) } + let!(:merge_request) { create(:merge_request, source_project: project) } + + it 'decrements the open MR count', :sidekiq_inline do + create(:merge_request, source_project: project, source_branch: 'branch-1') + + visit(merge_request_path(merge_request)) + + expect(page).to have_css('.js-merge-counter', text: '2') + + page.within(".mr-state-widget") do + click_button("Merge") + end + + expect(page).to have_css('.js-merge-counter', text: '1') + end + end end diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb index 1779567624c..ad602afe68a 100644 --- a/spec/features/merge_request/user_posts_notes_spec.rb +++ b/spec/features/merge_request/user_posts_notes_spec.rb @@ -169,7 +169,7 @@ RSpec.describe 'Merge request > User posts notes', :js do end page.within('.modal') do - click_button('OK', match: :first) + click_button('Cancel editing', match: :first) end expect(find('.js-note-text').text).to eq '' diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb index 27f7c699c50..c9b21d4a4ae 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -17,6 +17,9 @@ RSpec.describe 'Merge request > User sees merge widget', :js do project.add_maintainer(user) project_only_mwps.add_maintainer(user) sign_in(user) + + stub_feature_flags(refactor_mr_widgets_extensions: false) + stub_feature_flags(refactor_mr_widgets_extensions_user: false) end context 'new merge request', :sidekiq_might_not_need_inline do diff --git a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb index beb658bb7a0..f77a42ee506 100644 --- a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb +++ b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb @@ -379,4 +379,41 @@ RSpec.describe 'User comments on a diff', :js do end end end + + context 'failed to load metadata' do + let(:dummy_controller) do + Class.new(Projects::MergeRequests::DiffsController) do + def diffs_metadata + render json: '', status: :internal_server_error + end + end + end + + before do + stub_const('Projects::MergeRequests::DiffsController', dummy_controller) + + click_diff_line(find_by_scrolling("[id='#{sample_compare.changes[1][:line_code]}']")) + + page.within('.js-discussion-note-form') do + fill_in('note_note', with: "```suggestion\n# change to a comment\n```") + click_button('Add comment now') + end + + wait_for_requests + + visit(project_merge_request_path(project, merge_request)) + + wait_for_requests + end + + it 'displays an error' do + page.within('.discussion-notes') do + click_button('Apply suggestion') + + wait_for_requests + + expect(page).to have_content('Unable to fully load the default commit message. You can still apply this suggestion and the commit message will be correct.') + end + end + end end diff --git a/spec/features/merge_requests/user_mass_updates_spec.rb b/spec/features/merge_requests/user_mass_updates_spec.rb index f781ba0827c..a15b6072e70 100644 --- a/spec/features/merge_requests/user_mass_updates_spec.rb +++ b/spec/features/merge_requests/user_mass_updates_spec.rb @@ -70,7 +70,7 @@ RSpec.describe 'Merge requests > User mass updates', :js do it 'updates merge request with assignee' do change_assignee(user.name) - expect(find('.issuable-meta a.author-link')[:title]).to eq "Attention requested from assignee #{user.name}, go to their profile." + expect(find('.issuable-meta a.author-link')[:title]).to eq "Attention requested from assignee #{user.name}" end end end diff --git a/spec/features/milestones/user_deletes_milestone_spec.rb b/spec/features/milestones/user_deletes_milestone_spec.rb index ede9faed876..40626407642 100644 --- a/spec/features/milestones/user_deletes_milestone_spec.rb +++ b/spec/features/milestones/user_deletes_milestone_spec.rb @@ -21,7 +21,7 @@ RSpec.describe "User deletes milestone", :js do click_button("Delete") click_button("Delete milestone") - expect(page).to have_content("No milestones to show") + expect(page).to have_content("Use milestones to track issues and merge requests over a fixed period of time") visit(activity_project_path(project)) diff --git a/spec/features/oauth_login_spec.rb b/spec/features/oauth_login_spec.rb index 93674057fed..ea5bb8c33b2 100644 --- a/spec/features/oauth_login_spec.rb +++ b/spec/features/oauth_login_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'OAuth Login', :js, :allow_forgery_protection do end providers = [:github, :twitter, :bitbucket, :gitlab, :google_oauth2, - :facebook, :cas3, :auth0, :authentiq, :salesforce, :dingtalk] + :facebook, :cas3, :auth0, :authentiq, :salesforce, :dingtalk, :alicloud] around do |example| with_omniauth_full_host { example.run } diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index f1e5658cd7b..8cbc0491441 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -47,14 +47,14 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do click_on "1" # Scopes - check "api" + check "read_api" check "read_user" 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('api') + expect(active_personal_access_tokens).to have_text('read_api') expect(active_personal_access_tokens).to have_text('read_user') expect(created_personal_access_token).not_to be_empty end diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index 026da5814e3..4b6ed458c68 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -8,8 +8,6 @@ RSpec.describe 'User edit profile' do let(:user) { create(:user) } before do - stub_feature_flags(improved_emoji_picker: false) - sign_in(user) visit(profile_path) end @@ -169,10 +167,9 @@ RSpec.describe 'User edit profile' do context 'user status', :js do def select_emoji(emoji_name, is_modal = false) - emoji_menu_class = is_modal ? '.js-modal-status-emoji-menu' : '.js-status-emoji-menu' - toggle_button = find('.js-toggle-emoji-menu') + toggle_button = find('.emoji-menu-toggle-button') toggle_button.click - emoji_button = find(%Q{#{emoji_menu_class} .js-emoji-btn gl-emoji[data-name="#{emoji_name}"]}) + emoji_button = find("gl-emoji[data-name=\"#{emoji_name}\"]") emoji_button.click end @@ -207,7 +204,7 @@ RSpec.describe 'User edit profile' do end it 'adds message and emoji to user status' do - emoji = 'tanabata_tree' + emoji = '8ball' message = 'Playing outside' select_emoji(emoji) fill_in 'js-status-message-field', with: message @@ -356,7 +353,7 @@ RSpec.describe 'User edit profile' do end it 'adds emoji to user status' do - emoji = 'biohazard' + emoji = '8ball' open_user_status_modal select_emoji(emoji, true) set_user_status_in_modal @@ -387,18 +384,18 @@ RSpec.describe 'User edit profile' do it 'opens the emoji modal again after closing it' do open_user_status_modal - select_emoji('biohazard', true) + select_emoji('8ball', true) - find('.js-toggle-emoji-menu').click + find('.emoji-menu-toggle-button').click - expect(page).to have_selector('.emoji-menu') + expect(page).to have_selector('.emoji-picker-emoji') end it 'does not update the awards panel emoji' do project.add_maintainer(user) visit(project_issue_path(project, issue)) - emoji = 'biohazard' + emoji = '8ball' open_user_status_modal select_emoji(emoji, true) @@ -420,7 +417,7 @@ RSpec.describe 'User edit profile' do end it 'adds message and emoji to user status' do - emoji = 'tanabata_tree' + emoji = '8ball' message = 'Playing outside' open_user_status_modal select_emoji(emoji, true) @@ -495,9 +492,7 @@ RSpec.describe 'User edit profile' do open_user_status_modal find('.js-status-message-field').native.send_keys(message) - within('.js-toggle-emoji-menu') do - expect(page).to have_emoji('speech_balloon') - end + expect(page).to have_emoji('speech_balloon') end context 'note header' do diff --git a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb index e19e29bf63a..4c61e8d45e4 100644 --- a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb +++ b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb @@ -18,14 +18,6 @@ RSpec.describe 'User visits the profile preferences page', :js do end describe 'User changes their syntax highlighting theme', :js do - it 'creates a flash message' do - choose 'user_color_scheme_id_5' - - wait_for_requests - - expect_preferences_saved_message - end - it 'updates their preference' do choose 'user_color_scheme_id_5' diff --git a/spec/features/projects/blobs/balsamiq_spec.rb b/spec/features/projects/blobs/balsamiq_spec.rb deleted file mode 100644 index bce60856544..00000000000 --- a/spec/features/projects/blobs/balsamiq_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Balsamiq file blob', :js do - let(:project) { create(:project, :public, :repository) } - - before do - visit project_blob_path(project, 'add-balsamiq-file/files/images/balsamiq.bmpr') - - wait_for_requests - end - - it 'displays Balsamiq file content' do - expect(page).to have_content("Mobile examples") - end -end diff --git a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb index 11e2d24c36a..9b0edcd09d2 100644 --- a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb +++ b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb @@ -39,7 +39,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do find('#L3').click find("#L5").click - expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5"))) + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5"))) end it 'with initial fragment hash, changes fragment hash if line number clicked' do @@ -50,7 +50,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do find('#L3').click find("#L5").click - expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5"))) + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5"))) end end @@ -75,7 +75,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do find('#L3').click find("#L5").click - expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5"))) + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5"))) end it 'with initial fragment hash, changes fragment hash if line number clicked' do @@ -86,7 +86,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do find('#L3').click find("#L5").click - expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5"))) + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5"))) end end end diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb index 363d08da024..d906bb396be 100644 --- a/spec/features/projects/branches_spec.rb +++ b/spec/features/projects/branches_spec.rb @@ -36,6 +36,8 @@ RSpec.describe 'Branches' do expect(page).to have_content(sorted_branches(repository, count: 5, sort_by: :updated_desc, state: 'active')) expect(page).to have_content(sorted_branches(repository, count: 4, sort_by: :updated_asc, state: 'stale')) + expect(page).to have_button('Copy branch name') + expect(page).to have_link('Show more active branches', href: project_branches_filtered_path(project, state: 'active')) expect(page).not_to have_content('Show more stale branches') end @@ -197,14 +199,6 @@ RSpec.describe 'Branches' do project.add_maintainer(user) end - describe 'Initial branches page' do - it 'shows description for admin' do - visit project_branches_filtered_path(project, state: 'all') - - expect(page).to have_content("Protected branches can be managed in project settings") - end - end - it 'shows the merge request button' do visit project_branches_path(project) diff --git a/spec/features/projects/cluster_agents_spec.rb b/spec/features/projects/cluster_agents_spec.rb index e9162359940..5d931afe4a7 100644 --- a/spec/features/projects/cluster_agents_spec.rb +++ b/spec/features/projects/cluster_agents_spec.rb @@ -27,7 +27,6 @@ RSpec.describe 'ClusterAgents', :js do end it 'displays empty state', :aggregate_failures do - expect(page).to have_content('Install a new agent') expect(page).to have_selector('.empty-state') end end diff --git a/spec/features/projects/clusters/eks_spec.rb b/spec/features/projects/clusters/eks_spec.rb index 0dd6effe551..7e599ff1198 100644 --- a/spec/features/projects/clusters/eks_spec.rb +++ b/spec/features/projects/clusters/eks_spec.rb @@ -20,7 +20,7 @@ RSpec.describe 'AWS EKS Cluster', :js do visit project_clusters_path(project) click_button(class: 'dropdown-toggle-split') - click_link 'Create a new cluster' + click_link 'Create a cluster (certificate - deprecated)' end context 'when user creates a cluster on AWS EKS' do diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index 90d7e2d02e9..491121a3743 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -135,7 +135,7 @@ RSpec.describe 'Gcp Cluster', :js do visit project_clusters_path(project) click_button(class: 'dropdown-toggle-split') - click_link 'Connect with a certificate' + click_link 'Connect a cluster (certificate - deprecated)' end it 'user sees the "Environment scope" field' do @@ -154,7 +154,6 @@ RSpec.describe 'Gcp Cluster', :js do it 'user sees creation form with the successful message' do expect(page).to have_content('Kubernetes cluster integration was successfully removed.') - expect(page).to have_link('Connect with a certificate') end end end @@ -220,6 +219,6 @@ RSpec.describe 'Gcp Cluster', :js do def visit_create_cluster_page click_button(class: 'dropdown-toggle-split') - click_link 'Create a new cluster' + click_link 'Create a cluster (certificate - deprecated)' end end diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb index 3fd78d338da..b6bfaa3a9b9 100644 --- a/spec/features/projects/clusters/user_spec.rb +++ b/spec/features/projects/clusters/user_spec.rb @@ -25,8 +25,8 @@ RSpec.describe 'User Cluster', :js do before do visit project_clusters_path(project) - click_link 'Certificate' - click_link 'Connect with a certificate' + click_button(class: 'dropdown-toggle-split') + click_link 'Connect a cluster (certificate - deprecated)' end context 'when user filled form with valid parameters' do @@ -108,7 +108,6 @@ RSpec.describe 'User Cluster', :js do it 'user sees creation form with the successful message' do expect(page).to have_content('Kubernetes cluster integration was successfully removed.') - expect(page).to have_link('Connect with a certificate') end end end diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb index b9a544144c3..0ecd7795964 100644 --- a/spec/features/projects/clusters_spec.rb +++ b/spec/features/projects/clusters_spec.rb @@ -20,7 +20,6 @@ RSpec.describe 'Clusters', :js do end it 'sees empty state' do - expect(page).to have_link('Connect with a certificate') expect(page).to have_selector('.empty-state') end end @@ -222,11 +221,11 @@ RSpec.describe 'Clusters', :js do visit project_clusters_path(project) click_button(class: 'dropdown-toggle-split') - click_link 'Create a new cluster' + click_link 'Create a cluster (certificate - deprecated)' end def visit_connect_cluster_page click_button(class: 'dropdown-toggle-split') - click_link 'Connect with a certificate' + click_link 'Connect a cluster (certificate - deprecated)' end end diff --git a/spec/features/projects/commits/multi_view_diff_spec.rb b/spec/features/projects/commits/multi_view_diff_spec.rb index ecdd398c739..009dd05c6d1 100644 --- a/spec/features/projects/commits/multi_view_diff_spec.rb +++ b/spec/features/projects/commits/multi_view_diff_spec.rb @@ -27,17 +27,11 @@ RSpec.describe 'Multiple view Diffs', :js do context 'when :rendered_diffs_viewer is off' do context 'and diff does not have ipynb' do - include_examples "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b' + it_behaves_like "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b' end context 'and diff has ipynb' do - include_examples "no multiple viewers", '5d6ed1503801ca9dc28e95eeb85a7cf863527aee' - - it 'shows the transformed diff' do - diff = page.find('.diff-file, .file-holder', match: :first) - - expect(diff['innerHTML']).to include('%% Cell type:markdown id:0aac5da7-745c-4eda-847a-3d0d07a1bb9b tags:') - end + it_behaves_like "no multiple viewers", '5d6ed1503801ca9dc28e95eeb85a7cf863527aee' end end @@ -45,14 +39,28 @@ RSpec.describe 'Multiple view Diffs', :js do let(:feature_flag_on) { true } context 'and diff does not include ipynb' do - include_examples "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b' - end + it_behaves_like "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b' - context 'and opening a diff with ipynb' do - context 'but the changes are not renderable' do - include_examples "no multiple viewers", 'a867a602d2220e5891b310c07d174fbe12122830' + context 'and in inline diff' do + let(:ref) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + + it 'does not change display for non-ipynb' do + expect(page).to have_selector line_with_content('new', 1) + end end + context 'and in parallel diff' do + let(:ref) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + + it 'does not change display for non-ipynb' do + page.find('#parallel-diff-btn').click + + expect(page).to have_selector line_with_content('new', 1) + end + end + end + + context 'and opening a diff with ipynb' do it 'loads the rendered diff as hidden' do diff = page.find('.diff-file, .file-holder', match: :first) @@ -76,10 +84,55 @@ RSpec.describe 'Multiple view Diffs', :js do expect(classes_for_element(diff, 'toHideBtn')).not_to include('selected') expect(classes_for_element(diff, 'toShowBtn')).to include('selected') end + + it 'transforms the diff' do + diff = page.find('.diff-file, .file-holder', match: :first) + + expect(diff['innerHTML']).to include('%% Cell type:markdown id:0aac5da7-745c-4eda-847a-3d0d07a1bb9b tags:') + end + + context 'on parallel view' do + before do + page.find('#parallel-diff-btn').click + end + + it 'lines without mapping cannot receive comments' do + expect(page).not_to have_selector('td.line_content.nomappinginraw ~ td.diff-line-num > .add-diff-note') + expect(page).to have_selector('td.line_content:not(.nomappinginraw) ~ td.diff-line-num > .add-diff-note') + end + + it 'lines numbers without mapping are empty' do + expect(page).not_to have_selector('td.nomappinginraw + td.diff-line-num') + expect(page).to have_selector('td.nomappinginraw + td.diff-line-num', visible: false) + end + + it 'transforms the diff' do + diff = page.find('.diff-file, .file-holder', match: :first) + + expect(diff['innerHTML']).to include('%% Cell type:markdown id:0aac5da7-745c-4eda-847a-3d0d07a1bb9b tags:') + end + end + + context 'on inline view' do + it 'lines without mapping cannot receive comments' do + expect(page).not_to have_selector('tr.line_holder[class$="nomappinginraw"] > td.diff-line-num > .add-diff-note') + expect(page).to have_selector('tr.line_holder:not([class$="nomappinginraw"]) > td.diff-line-num > .add-diff-note') + end + + it 'lines numbers without mapping are empty' do + elements = page.all('tr.line_holder[class$="nomappinginraw"] > td.diff-line-num').map { |e| e.text(:all) } + + expect(elements).to all(be == "") + end + end end end def classes_for_element(node, data_diff_entity, visible: true) node.find("[data-diff-toggle-entity=\"#{data_diff_entity}\"]", visible: visible)[:class] end + + def line_with_content(old_or_new, line_number) + "td.#{old_or_new}_line.diff-line-num[data-linenumber=\"#{line_number}\"] > a[data-linenumber=\"#{line_number}\"]" + end end diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 99137018d6b..6cf59394af7 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -70,7 +70,7 @@ RSpec.describe 'Environments page', :js do it 'shows no environments' do visit_environments(project, scope: 'stopped') - expect(page).to have_content('You don\'t have any environments right now') + expect(page).to have_content(s_('Environments|You don\'t have any stopped environments.')) end end @@ -99,7 +99,7 @@ RSpec.describe 'Environments page', :js do it 'shows no environments' do visit_environments(project, scope: 'available') - expect(page).to have_content('You don\'t have any environments right now') + expect(page).to have_content(s_('Environments|You don\'t have any environments.')) end end @@ -120,7 +120,7 @@ RSpec.describe 'Environments page', :js do end it 'does not show environments and counters are set to zero' do - expect(page).to have_content('You don\'t have any environments right now') + expect(page).to have_content(s_('Environments|You don\'t have any environments.')) expect(page).to have_link("#{_('Available')} 0") expect(page).to have_link("#{_('Stopped')} 0") diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 1e5c5d33ad9..c7fbaa85483 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -24,9 +24,9 @@ RSpec.describe 'Import/Export - project import integration test', :js do context 'when selecting the namespace' do let(:user) { create(:admin) } let!(:namespace) { user.namespace } - let(:randomHex) { SecureRandom.hex } - let(:project_name) { 'Test Project Name' + randomHex } - let(:project_path) { 'test-project-name' + randomHex } + let(:random_hex) { SecureRandom.hex } + let(:project_name) { 'Test Project Name' + random_hex } + let(:project_path) { 'test-project-name' + random_hex } it 'user imports an exported project successfully', :sidekiq_might_not_need_inline do visit new_project_path diff --git a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb index 762f9c33510..48ae70d3ec9 100644 --- a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb +++ b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb @@ -10,8 +10,8 @@ RSpec.describe 'User uploads new design', :js do let(:issue) { create(:issue, project: project) } before do - # Cause of raising query limiting threshold https://gitlab.com/gitlab-org/gitlab/-/issues/347334 - stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 102) + # Cause of raising query limiting threshold https://gitlab.com/gitlab-org/gitlab/-/issues/358845 + stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 106) sign_in(user) enable_design_management(feature_enabled) diff --git a/spec/features/projects/jobs/user_browses_jobs_spec.rb b/spec/features/projects/jobs/user_browses_jobs_spec.rb index fde6240d373..3b70d177fce 100644 --- a/spec/features/projects/jobs/user_browses_jobs_spec.rb +++ b/spec/features/projects/jobs/user_browses_jobs_spec.rb @@ -67,19 +67,8 @@ RSpec.describe 'User browses jobs' do expect(page.find('[data-testid="jobs-all-tab"] .badge').text).to include('0') end - it 'shows a tab for Pending jobs and count' do - expect(page.find('[data-testid="jobs-pending-tab"]').text).to include('Pending') - expect(page.find('[data-testid="jobs-pending-tab"] .badge').text).to include('0') - end - - it 'shows a tab for Running jobs and count' do - expect(page.find('[data-testid="jobs-running-tab"]').text).to include('Running') - expect(page.find('[data-testid="jobs-running-tab"] .badge').text).to include('0') - end - it 'shows a tab for Finished jobs and count' do expect(page.find('[data-testid="jobs-finished-tab"]').text).to include('Finished') - expect(page.find('[data-testid="jobs-finished-tab"] .badge').text).to include('0') end it 'updates the content when tab is clicked' do diff --git a/spec/features/projects/members/groups_with_access_list_spec.rb b/spec/features/projects/members/groups_with_access_list_spec.rb index 6adc3503492..9bd6476f836 100644 --- a/spec/features/projects/members/groups_with_access_list_spec.rb +++ b/spec/features/projects/members/groups_with_access_list_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe 'Projects > Members > Groups with access list', :js do include Spec::Support::Helpers::Features::MembersHelpers include Spec::Support::Helpers::ModalHelpers + include Spec::Support::Helpers::Features::InviteMembersModalHelper let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group, :public) } @@ -95,8 +96,4 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do expect(members_table).to have_content(group.full_name) end end - - def click_groups_tab - click_link 'Groups' - end end diff --git a/spec/features/projects/members/invite_group_spec.rb b/spec/features/projects/members/invite_group_spec.rb index 9c256504934..a48229249e0 100644 --- a/spec/features/projects/members/invite_group_spec.rb +++ b/spec/features/projects/members/invite_group_spec.rb @@ -17,20 +17,18 @@ RSpec.describe 'Project > Members > Invite group', :js do visit project_project_members_path(project) - expect(page).to have_selector('button[data-test-id="invite-group-button"]') + expect(page).to have_selector(invite_group_selector) end - it 'does not display the button when visiting the page not signed in' do + it 'does not display the button when visiting the page not signed in' do project = create(:project, namespace: create(:group)) visit project_project_members_path(project) - expect(page).not_to have_selector('button[data-test-id="invite-group-button"]') + expect(page).not_to have_selector(invite_group_selector) end describe 'Share with group lock' do - let(:invite_group_selector) { 'button[data-test-id="invite-group-button"]' } - shared_examples 'the project can be shared with groups' do it 'the "Invite a group" button exists' do visit project_project_members_path(project) @@ -158,21 +156,95 @@ RSpec.describe 'Project > Members > Invite group', :js do describe 'the groups dropdown' do let_it_be(:parent_group) { create(:group, :public) } let_it_be(:project_group) { create(:group, :public, parent: parent_group) } - let_it_be(:public_sub_subgroup) { create(:group, :public, parent: project_group) } - let_it_be(:public_sibbling_group) { create(:group, :public, parent: parent_group) } - let_it_be(:private_sibbling_group) { create(:group, :private, parent: parent_group) } - let_it_be(:private_membership_group) { create(:group, :private) } - let_it_be(:public_membership_group) { create(:group, :public) } let_it_be(:project) { create(:project, group: project_group) } - before do - private_membership_group.add_guest(maintainer) - public_membership_group.add_maintainer(maintainer) + context 'with instance admin considerations' do + let_it_be(:group_to_share) { create(:group) } - sign_in(maintainer) + context 'when user is an admin' do + let_it_be(:admin) { create(:admin) } + + before do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + end + + it 'shows groups where the admin has no direct membership' do + visit project_project_members_path(project) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + end + end + + it 'shows groups where the admin has at least guest level membership' do + group_to_share.add_guest(admin) + + visit project_project_members_path(project) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + end + end + end + + context 'when user is not an admin' do + before do + project.add_maintainer(maintainer) + sign_in(maintainer) + end + + it 'does not show groups where the user has no direct membership' do + visit project_project_members_path(project) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_not_to_have_group(group_to_share) + end + end + + it 'shows groups where the user has at least guest level membership' do + group_to_share.add_guest(maintainer) + + visit project_project_members_path(project) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + end + end + end end context 'for a project in a nested group' do + let_it_be(:public_sub_subgroup) { create(:group, :public, parent: project_group) } + let_it_be(:public_sibbling_group) { create(:group, :public, parent: parent_group) } + let_it_be(:private_sibbling_group) { create(:group, :private, parent: parent_group) } + let_it_be(:private_membership_group) { create(:group, :private) } + let_it_be(:public_membership_group) { create(:group, :public) } + let_it_be(:project) { create(:project, group: project_group) } + + before do + private_membership_group.add_guest(maintainer) + public_membership_group.add_maintainer(maintainer) + + sign_in(maintainer) + end + it 'does not show the groups inherited from projects' do project.add_maintainer(maintainer) public_sibbling_group.add_maintainer(maintainer) @@ -183,7 +255,7 @@ RSpec.describe 'Project > Members > Invite group', :js do click_on 'Select a group' wait_for_requests - page.within('[data-testid="group-select-dropdown"]') do + page.within(group_dropdown_selector) do expect_to_have_group(public_membership_group) expect_to_have_group(public_sibbling_group) expect_to_have_group(private_membership_group) @@ -204,7 +276,7 @@ RSpec.describe 'Project > Members > Invite group', :js do click_on 'Select a group' wait_for_requests - page.within('[data-testid="group-select-dropdown"]') do + page.within(group_dropdown_selector) do expect_to_have_group(public_membership_group) expect_to_have_group(public_sibbling_group) expect_to_have_group(private_membership_group) @@ -215,14 +287,10 @@ RSpec.describe 'Project > Members > Invite group', :js do expect_not_to_have_group(project_group) end end - - def expect_to_have_group(group) - expect(page).to have_selector("[entity-id='#{group.id}']") - end - - def expect_not_to_have_group(group) - expect(page).not_to have_selector("[entity-id='#{group.id}']") - end end end + + def invite_group_selector + 'button[data-test-id="invite-group-button"]' + end end diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/manage_members_spec.rb index f2424a4acc3..0f4120e88e0 100644 --- a/spec/features/projects/members/list_spec.rb +++ b/spec/features/projects/members/manage_members_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Project members list', :js do +RSpec.describe 'Projects > Members > Manage members', :js do include Spec::Support::Helpers::Features::MembersHelpers include Spec::Support::Helpers::Features::InviteMembersModalHelper include Spec::Support::Helpers::ModalHelpers @@ -48,24 +48,6 @@ RSpec.describe 'Project members list', :js do end end - it 'add user to project', :snowplow, :aggregate_failures do - visit_members_page - - invite_member(user2.name, role: 'Reporter') - - page.within find_member_row(user2) do - expect(page).to have_button('Reporter') - end - - expect_snowplow_event( - category: 'Members::CreateService', - action: 'create_member', - label: 'project-members-page', - property: 'existing_user', - user: user1 - ) - end - it 'uses ProjectMember access_level_roles for the invite members modal access option', :aggregate_failures do visit_members_page @@ -104,24 +86,41 @@ RSpec.describe 'Project members list', :js do expect(members_table).not_to have_content(other_user.name) end - it 'invite user to project', :snowplow, :aggregate_failures do - visit_members_page + it_behaves_like 'inviting members', 'project-members-page' do + let_it_be(:entity) { project } + let_it_be(:members_page_path) { project_project_members_path(entity) } + let_it_be(:subentity) { project } + let_it_be(:subentity_members_page_path) { project_project_members_path(entity) } + end - invite_member('test@example.com', role: 'Reporter') + describe 'member search results' do + it 'does not show project_bots', :aggregate_failures do + internal_project_bot = create(:user, :project_bot, name: '_internal_project_bot_') + project.add_maintainer(internal_project_bot) - click_link 'Invited' + external_group = create(:group) + external_project_bot = create(:user, :project_bot, name: '_external_project_bot_') + external_project = create(:project, group: external_group) + external_project.add_maintainer(external_project_bot) + external_project.add_maintainer(user1) - page.within find_invited_member_row('test@example.com') do - expect(page).to have_button('Reporter') - end + visit_members_page + + click_on 'Invite members' - expect_snowplow_event( - category: 'Members::InviteService', - action: 'create_member', - label: 'project-members-page', - property: 'net_new_user', - user: user1 - ) + page.within invite_modal_selector do + field = find(member_dropdown_selector) + field.native.send_keys :tab + field.click + + wait_for_requests + + expect(page).to have_content(user1.name) + expect(page).to have_content(user2.name) + expect(page).not_to have_content(internal_project_bot.name) + expect(page).not_to have_content(external_project_bot.name) + end + end end context 'as a signed out visitor viewing a public project' do diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index 653564d1566..8aadd6302d0 100644 --- a/spec/features/projects/members/sorting_spec.rb +++ b/spec/features/projects/members/sorting_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe 'Projects > Members > Sorting', :js do include Spec::Support::Helpers::Features::MembersHelpers - let(:maintainer) { create(:user, name: 'John Doe') } - let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) } + let(:maintainer) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) } + let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) } let(:project) { create(:project, namespace: maintainer.namespace, creator: maintainer) } before do @@ -42,6 +42,42 @@ RSpec.describe 'Projects > Members > Sorting', :js do expect_sort_by('Max role', :desc) end + it 'sorts by user created on ascending' do + visit_members_list(sort: :oldest_created_user) + + expect(first_row.text).to have_content(maintainer.name) + expect(second_row.text).to have_content(developer.name) + + expect_sort_by('Created on', :asc) + end + + it 'sorts by user created on descending' do + visit_members_list(sort: :recent_created_user) + + expect(first_row.text).to have_content(developer.name) + expect(second_row.text).to have_content(maintainer.name) + + expect_sort_by('Created on', :desc) + end + + it 'sorts by last activity ascending' do + visit_members_list(sort: :oldest_last_activity) + + expect(first_row.text).to have_content(developer.name) + expect(second_row.text).to have_content(maintainer.name) + + expect_sort_by('Last activity', :asc) + end + + it 'sorts by last activity descending' do + visit_members_list(sort: :recent_last_activity) + + expect(first_row.text).to have_content(maintainer.name) + expect(second_row.text).to have_content(developer.name) + + expect_sort_by('Last activity', :desc) + end + it 'sorts by access granted ascending' do visit_members_list(sort: :last_joined) diff --git a/spec/features/projects/milestones/milestones_sorting_spec.rb b/spec/features/projects/milestones/milestones_sorting_spec.rb index 565c61cfaa0..2ad820e4a06 100644 --- a/spec/features/projects/milestones/milestones_sorting_spec.rb +++ b/spec/features/projects/milestones/milestones_sorting_spec.rb @@ -5,49 +5,55 @@ require 'spec_helper' RSpec.describe 'Milestones sorting', :js do let(:user) { create(:user) } let(:project) { create(:project, name: 'test', namespace: user.namespace) } + let(:milestones_for_sort_by) do + { + 'Due later' => %w[b c a], + 'Name, ascending' => %w[a b c], + 'Name, descending' => %w[c b a], + 'Start later' => %w[a c b], + 'Start soon' => %w[b c a], + 'Due soon' => %w[a c b] + } + end + + let(:ordered_milestones) do + ['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending'] + end before do - # Milestones - create(:milestone, - due_date: 10.days.from_now, - created_at: 2.hours.ago, - title: "aaa", project: project) - create(:milestone, - due_date: 11.days.from_now, - created_at: 1.hour.ago, - title: "bbb", project: project) + create(:milestone, start_date: 7.days.from_now, due_date: 10.days.from_now, title: "a", project: project) + create(:milestone, start_date: 6.days.from_now, due_date: 11.days.from_now, title: "c", project: project) + create(:milestone, start_date: 5.days.from_now, due_date: 12.days.from_now, title: "b", project: project) sign_in(user) end - it 'visit project milestones and sort by due_date_asc' do + it 'visit project milestones and sort by various orders' do visit project_milestones_path(project) expect(page).to have_button('Due soon') - # assert default sorting + # assert default sorting order within '.milestones' do - expect(page.all('ul.content-list > li').first.text).to include('aaa') - expect(page.all('ul.content-list > li').last.text).to include('bbb') + expect(page.all('ul.content-list > li strong > a').map(&:text)).to eq(%w[a c b]) end - click_button 'Due soon' + # assert milestones listed for given sort order + selected_sort_order = 'Due soon' + milestones_for_sort_by.each do |sort_by, expected_milestones| + within '[data-testid=milestone_sort_by_dropdown]' do + click_button selected_sort_order + milestones = find('.gl-new-dropdown-contents').all('.gl-new-dropdown-item-text-wrapper p').map(&:text) + expect(milestones).to eq(ordered_milestones) - sort_options = find('ul.dropdown-menu-sort li').all('a').collect(&:text) + click_button sort_by + expect(page).to have_button(sort_by) + end - expect(sort_options[0]).to eq('Due soon') - expect(sort_options[1]).to eq('Due later') - expect(sort_options[2]).to eq('Start soon') - expect(sort_options[3]).to eq('Start later') - expect(sort_options[4]).to eq('Name, ascending') - expect(sort_options[5]).to eq('Name, descending') + within '.milestones' do + expect(page.all('ul.content-list > li strong > a').map(&:text)).to eq(expected_milestones) + end - click_link 'Due later' - - expect(page).to have_button('Due later') - - within '.milestones' do - expect(page.all('ul.content-list > li').first.text).to include('bbb') - expect(page.all('ul.content-list > li').last.text).to include('aaa') + selected_sort_order = sort_by end end end diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index c57e39b6508..0046dfe436f 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -191,7 +191,8 @@ RSpec.describe 'New project', :js do click_link 'Create blank project' end - it 'selects the user namespace' do + it 'does not select the user namespace' do + click_on 'Pick a group or namespace' expect(page).to have_button user.username end end @@ -328,6 +329,14 @@ RSpec.describe 'New project', :js do click_on 'Create project' + expect(page).to have_content( + s_('ProjectsNew|Pick a group or namespace where you want to create this project.') + ) + + click_on 'Pick a group or namespace' + click_on user.username + click_on 'Create project' + expect(page).to have_css('#import-project-pane.active') expect(page).not_to have_css('.toggle-import-form.hide') end diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 6b9dfdf3a7b..219c8ec0070 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -15,6 +15,7 @@ RSpec.describe 'Pipeline', :js do before do sign_in(user) project.add_role(user, role) + stub_feature_flags(pipeline_tabs_vue: false) end shared_context 'pipeline builds' do @@ -356,6 +357,7 @@ RSpec.describe 'Pipeline', :js do context 'page tabs' do before do + stub_feature_flags(pipeline_tabs_vue: false) visit_pipeline end @@ -388,6 +390,7 @@ RSpec.describe 'Pipeline', :js do let(:pipeline) { create(:ci_pipeline, :with_test_reports, :with_report_results, project: project) } before do + stub_feature_flags(pipeline_tabs_vue: false) visit_pipeline wait_for_requests end @@ -924,6 +927,7 @@ RSpec.describe 'Pipeline', :js do let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } before do + stub_feature_flags(pipeline_tabs_vue: false) visit builds_project_pipeline_path(project, pipeline) end @@ -944,6 +948,10 @@ RSpec.describe 'Pipeline', :js do end context 'page tabs' do + before do + stub_feature_flags(pipeline_tabs_vue: false) + end + it 'shows Pipeline, Jobs and DAG tabs with link' do expect(page).to have_link('Pipeline') expect(page).to have_link('Jobs') @@ -1014,6 +1022,10 @@ RSpec.describe 'Pipeline', :js do end describe 'GET /:project/-/pipelines/:id/failures' do + before do + stub_feature_flags(pipeline_tabs_vue: false) + end + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: '1234') } let(:pipeline_failures_page) { failures_project_pipeline_path(project, pipeline) } let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline) } @@ -1139,6 +1151,7 @@ RSpec.describe 'Pipeline', :js do let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } before do + stub_feature_flags(pipeline_tabs_vue: false) visit dag_project_pipeline_path(project, pipeline) end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 0e1728858ec..8b1a22ae05a 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -623,6 +623,7 @@ RSpec.describe 'Pipelines', :js do create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3, ref: 'master') + stub_feature_flags(pipeline_tabs_vue: false) visit project_pipeline_path(project, pipeline) wait_for_requests end diff --git a/spec/features/projects/releases/user_views_releases_spec.rb b/spec/features/projects/releases/user_views_releases_spec.rb index 98935fdf872..a7348b62fc0 100644 --- a/spec/features/projects/releases/user_views_releases_spec.rb +++ b/spec/features/projects/releases/user_views_releases_spec.rb @@ -24,129 +24,111 @@ RSpec.describe 'User views releases', :js do stub_default_url_options(host: 'localhost') end - shared_examples 'releases index page' do - context('when the user is a maintainer') do - before do - sign_in(maintainer) + context('when the user is a maintainer') do + before do + sign_in(maintainer) - visit project_releases_path(project) + visit project_releases_path(project) - wait_for_requests - end + wait_for_requests + end - it 'sees the release' do - page.within("##{release_v1.tag}") do - expect(page).to have_content(release_v1.name) - expect(page).to have_content(release_v1.tag) - expect(page).not_to have_content('Upcoming Release') - end + it 'sees the release' do + page.within("##{release_v1.tag}") do + expect(page).to have_content(release_v1.name) + expect(page).to have_content(release_v1.tag) + expect(page).not_to have_content('Upcoming Release') end + end - it 'renders the correct links', :aggregate_failures do - page.within("##{release_v1.tag} .js-assets-list") do - external_link_indicator_selector = '[data-testid="external-link-indicator"]' + it 'renders the correct links', :aggregate_failures do + page.within("##{release_v1.tag} .js-assets-list") do + external_link_indicator_selector = '[data-testid="external-link-indicator"]' - expect(page).to have_link internal_link.name, href: internal_link.url - expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector) + expect(page).to have_link internal_link.name, href: internal_link.url + expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector) - expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}" - expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector) + expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}" + expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector) - expect(page).to have_link external_link.name, href: external_link.url - expect(find_link(external_link.name)).to have_css(external_link_indicator_selector) - end + expect(page).to have_link external_link.name, href: external_link.url + expect(find_link(external_link.name)).to have_css(external_link_indicator_selector) end + end - context 'with an upcoming release' do - it 'sees the upcoming tag' do - page.within("##{release_v3.tag}") do - expect(page).to have_content('Upcoming Release') - end + context 'with an upcoming release' do + it 'sees the upcoming tag' do + page.within("##{release_v3.tag}") do + expect(page).to have_content('Upcoming Release') end end + end - context 'with a tag containing a slash' do - it 'sees the release' do - page.within("##{release_v2.tag.parameterize}") do - expect(page).to have_content(release_v2.name) - expect(page).to have_content(release_v2.tag) - end + context 'with a tag containing a slash' do + it 'sees the release' do + page.within("##{release_v2.tag.parameterize}") do + expect(page).to have_content(release_v2.name) + expect(page).to have_content(release_v2.tag) end end + end - context 'sorting' do - def sort_page(by:, direction:) - within '[data-testid="releases-sort"]' do - find('.dropdown-toggle').click - - click_button(by, class: 'dropdown-item') - - find('.sorting-direction-button').click if direction == :ascending - end - end - - shared_examples 'releases sort order' do - it "sorts the releases #{description}" do - card_titles = page.all('.release-block .card-title', minimum: expected_releases.count) - - card_titles.each_with_index do |title, index| - expect(title).to have_content(expected_releases[index].name) - end - end - end + context 'sorting' do + def sort_page(by:, direction:) + within '[data-testid="releases-sort"]' do + find('.dropdown-toggle').click - context "when the page is sorted by the default sort order" do - let(:expected_releases) { [release_v3, release_v2, release_v1] } + click_button(by, class: 'dropdown-item') - it_behaves_like 'releases sort order' + find('.sorting-direction-button').click if direction == :ascending end + end - context "when the page is sorted by created_at ascending " do - let(:expected_releases) { [release_v2, release_v1, release_v3] } + shared_examples 'releases sort order' do + it "sorts the releases #{description}" do + card_titles = page.all('.release-block .card-title', minimum: expected_releases.count) - before do - sort_page by: 'Created date', direction: :ascending + card_titles.each_with_index do |title, index| + expect(title).to have_content(expected_releases[index].name) end - - it_behaves_like 'releases sort order' end end - end - context('when the user is a guest') do - before do - sign_in(guest) - end + context "when the page is sorted by the default sort order" do + let(:expected_releases) { [release_v3, release_v2, release_v1] } - it 'renders release info except for Git-related data' do - visit project_releases_path(project) + it_behaves_like 'releases sort order' + end - within('.release-block', match: :first) do - expect(page).to have_content(release_v3.description) - expect(page).to have_content(release_v3.tag) - expect(page).to have_content(release_v3.name) + context "when the page is sorted by created_at ascending " do + let(:expected_releases) { [release_v2, release_v1, release_v3] } - # The following properties (sometimes) include Git info, - # so they are not rendered for Guest users - expect(page).not_to have_content(release_v3.commit.short_id) + before do + sort_page by: 'Created date', direction: :ascending end + + it_behaves_like 'releases sort order' end end end - context 'when the releases_index_apollo_client feature flag is enabled' do + context('when the user is a guest') do before do - stub_feature_flags(releases_index_apollo_client: true) + sign_in(guest) end - it_behaves_like 'releases index page' - end + it 'renders release info except for Git-related data' do + visit project_releases_path(project) - context 'when the releases_index_apollo_client feature flag is disabled' do - before do - stub_feature_flags(releases_index_apollo_client: false) - end + within('.release-block', match: :first) do + expect(page).to have_content(release_v3.description) + expect(page).to have_content(release_v3.tag) + expect(page).to have_content(release_v3.name) - it_behaves_like 'releases index page' + # The following properties (sometimes) include Git info, + # so they are not rendered for Guest users + expect(page).not_to have_content(release_v3.commit.short_id) + end + end end end diff --git a/spec/features/projects/terraform_spec.rb b/spec/features/projects/terraform_spec.rb index 2c63f2bfc02..d9e45b5e78e 100644 --- a/spec/features/projects/terraform_spec.rb +++ b/spec/features/projects/terraform_spec.rb @@ -22,7 +22,7 @@ RSpec.describe 'Terraform', :js do end it 'sees an empty state' do - expect(page).to have_content('Get started with Terraform') + expect(page).to have_content("Your project doesn't have any Terraform state files") end end diff --git a/spec/features/projects/user_creates_project_spec.rb b/spec/features/projects/user_creates_project_spec.rb index 6491a7425f7..b07f2d12660 100644 --- a/spec/features/projects/user_creates_project_spec.rb +++ b/spec/features/projects/user_creates_project_spec.rb @@ -33,29 +33,6 @@ RSpec.describe 'User creates a project', :js do end it 'creates a new project that is not blank' do - stub_experiments(new_project_sast_enabled: 'candidate') - - visit(new_project_path) - - click_link 'Create blank project' - fill_in(:project_name, with: 'With initial commits') - - expect(page).to have_checked_field 'Initialize repository with a README' - expect(page).to have_checked_field 'Enable Static Application Security Testing (SAST)' - - click_button('Create project') - - project = Project.last - - expect(page).to have_current_path(project_path(project), ignore_query: true) - expect(page).to have_content('With initial commits') - expect(page).to have_content('Configure SAST in `.gitlab-ci.yml`, creating this file if it does not already exist') - expect(page).to have_content('README.md Initial commit') - end - - it 'allows creating a new project when the new_project_sast_enabled is assigned the unchecked candidate' do - stub_experiments(new_project_sast_enabled: 'unchecked_candidate') - visit(new_project_path) click_link 'Create blank project' @@ -93,7 +70,7 @@ RSpec.describe 'User creates a project', :js do fill_in :project_name, with: 'A Subgroup Project' fill_in :project_path, with: 'a-subgroup-project' - click_button user.username + click_on 'Pick a group or namespace' click_button subgroup.full_path click_button('Create project') @@ -120,9 +97,6 @@ RSpec.describe 'User creates a project', :js do fill_in :project_name, with: 'a-new-project' fill_in :project_path, with: 'a-new-project' - click_button user.username - click_button group.full_path - page.within('#content-body') do click_button('Create project') end diff --git a/spec/features/projects/user_sorts_projects_spec.rb b/spec/features/projects/user_sorts_projects_spec.rb index 71e43467a39..7c970f7ee3d 100644 --- a/spec/features/projects/user_sorts_projects_spec.rb +++ b/spec/features/projects/user_sorts_projects_spec.rb @@ -14,25 +14,29 @@ RSpec.describe 'User sorts projects and order persists' do it "is set on the dashboard_projects_path" do visit(dashboard_projects_path) - expect(find('.dropdown-menu a.is-active', text: project_paths_label)).to have_content(project_paths_label) + expect(find('#sort-projects-dropdown')).to have_content(project_paths_label) end it "is set on the explore_projects_path" do visit(explore_projects_path) - expect(find('.dropdown-menu a.is-active', text: project_paths_label)).to have_content(project_paths_label) + expect(find('#sort-projects-dropdown')).to have_content(project_paths_label) end it "is set on the group_canonical_path" do visit(group_canonical_path(group)) - expect(find('.dropdown-menu a.is-active', text: group_paths_label)).to have_content(group_paths_label) + within '[data-testid=group_sort_by_dropdown]' do + expect(find('.gl-dropdown-toggle')).to have_content(group_paths_label) + end end it "is set on the details_group_path" do visit(details_group_path(group)) - expect(find('.dropdown-menu a.is-active', text: group_paths_label)).to have_content(group_paths_label) + within '[data-testid=group_sort_by_dropdown]' do + expect(find('.gl-dropdown-toggle')).to have_content(group_paths_label) + end end end @@ -58,23 +62,27 @@ RSpec.describe 'User sorts projects and order persists' do it_behaves_like "sort order persists across all views", "Name", "Name" end - context 'from group homepage' do + context 'from group homepage', :js do before do sign_in(user) visit(group_canonical_path(group)) - find('button.dropdown-menu-toggle').click - first(:link, 'Last created').click + within '[data-testid=group_sort_by_dropdown]' do + find('button.gl-dropdown-toggle').click + first(:button, 'Last created').click + end end it_behaves_like "sort order persists across all views", "Created date", "Last created" end - context 'from group details' do + context 'from group details', :js do before do sign_in(user) visit(details_group_path(group)) - find('button.dropdown-menu-toggle').click - first(:link, 'Most stars').click + within '[data-testid=group_sort_by_dropdown]' do + find('button.gl-dropdown-toggle').click + first(:button, 'Most stars').click + end end it_behaves_like "sort order persists across all views", "Stars", "Most stars" diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 1049f8bc18f..db64f84aa76 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -15,6 +15,12 @@ RSpec.describe 'Project' do end shared_examples 'creates from template' do |template, sub_template_tab = nil| + let(:selected_template) { page.find('.project-fields-form .selected-template') } + + choose_template_selector = '.choose-template' + template_option_selector = '.template-option' + template_name_selector = '.description strong' + it "is created from template", :js do click_link 'Create from template' find(".project-template #{sub_template_tab}").click if sub_template_tab @@ -27,6 +33,39 @@ RSpec.describe 'Project' do expect(page).to have_content template.name end + + it 'is created using keyboard navigation', :js do + click_link 'Create from template' + + first_template = first(template_option_selector) + first_template_name = first_template.find(template_name_selector).text + first_template.find(choose_template_selector).click + + expect(selected_template).to have_text(first_template_name) + + click_button "Change template" + find("#built-in").click + + # Jumps down 1 template, skipping the `preview` buttons + 2.times do + page.send_keys :tab + end + + # Ensure the template with focus is selected + project_name = "project from template" + focused_template = page.find(':focus').ancestor(template_option_selector) + focused_template_name = focused_template.find(template_name_selector).text + focused_template.find(choose_template_selector).send_keys :enter + fill_in "project_name", with: project_name + + expect(selected_template).to have_text(focused_template_name) + + page.within '#content-body' do + click_button "Create project" + end + + expect(page).to have_content project_name + end end context 'create with project template' do diff --git a/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb b/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb deleted file mode 100644 index 3638e98a08a..00000000000 --- a/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Balsamiq file blob', :js do - let(:project) { create(:project, :public, :repository) } - - before do - stub_feature_flags(refactor_blob_viewer: false) - visit project_blob_path(project, 'add-balsamiq-file/files/images/balsamiq.bmpr') - - wait_for_requests - end - - it 'displays Balsamiq file content' do - expect(page).to have_content("Mobile examples") - end -end diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb index 49c468976b9..2dddcd62a6c 100644 --- a/spec/features/runners_spec.rb +++ b/spec/features/runners_spec.rb @@ -352,6 +352,7 @@ RSpec.describe 'Runners' do before do group.add_owner(user) + stub_feature_flags(runner_list_group_view_vue_ui: false) end context 'group with no runners' do diff --git a/spec/features/search/user_searches_for_projects_spec.rb b/spec/features/search/user_searches_for_projects_spec.rb index c38ad077cd0..562da56275c 100644 --- a/spec/features/search/user_searches_for_projects_spec.rb +++ b/spec/features/search/user_searches_for_projects_spec.rb @@ -8,6 +8,8 @@ RSpec.describe 'User searches for projects', :js do context 'when signed out' do context 'when block_anonymous_global_searches is disabled' do before do + allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit).and_return(1000) + allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit_unauthenticated).and_return(1000) stub_feature_flags(block_anonymous_global_searches: false) end diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb index 8736f16b991..7350a54e8df 100644 --- a/spec/features/search/user_uses_header_search_field_spec.rb +++ b/spec/features/search/user_uses_header_search_field_spec.rb @@ -17,12 +17,15 @@ RSpec.describe 'User uses header search field', :js do end before do + allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit).and_return(1000) + allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit_unauthenticated).and_return(1000) sign_in(user) end shared_examples 'search field examples' do before do visit(url) + wait_for_all_requests end it 'starts searching by pressing the enter key' do @@ -37,7 +40,6 @@ RSpec.describe 'User uses header search field', :js do before do find('#search') find('body').native.send_keys('s') - wait_for_all_requests end @@ -49,6 +51,7 @@ RSpec.describe 'User uses header search field', :js do context 'when clicking the search field' do before do page.find('#search').click + wait_for_all_requests end it 'shows category search dropdown', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/250285' do @@ -59,7 +62,7 @@ RSpec.describe 'User uses header search field', :js do let!(:issue) { create(:issue, project: project, author: user, assignees: [user]) } it 'shows assigned issues' do - find('.search-input-container .dropdown-menu').click_link('Issues assigned to me') + find('[data-testid="header-search-dropdown-menu"]').click_link('Issues assigned to me') expect(page).to have_selector('.issues-list .issue') expect_tokens([assignee_token(user.name)]) @@ -67,7 +70,7 @@ RSpec.describe 'User uses header search field', :js do end it 'shows created issues' do - find('.search-input-container .dropdown-menu').click_link("Issues I've created") + find('[data-testid="header-search-dropdown-menu"]').click_link("Issues I've created") expect(page).to have_selector('.issues-list .issue') expect_tokens([author_token(user.name)]) @@ -79,7 +82,7 @@ RSpec.describe 'User uses header search field', :js do let!(:merge_request) { create(:merge_request, source_project: project, author: user, assignees: [user]) } it 'shows assigned merge requests' do - find('.search-input-container .dropdown-menu').click_link('Merge requests assigned to me') + find('[data-testid="header-search-dropdown-menu"]').click_link('Merge requests assigned to me') expect(page).to have_selector('.mr-list .merge-request') expect_tokens([assignee_token(user.name)]) @@ -87,7 +90,7 @@ RSpec.describe 'User uses header search field', :js do end it 'shows created merge requests' do - find('.search-input-container .dropdown-menu').click_link("Merge requests I've created") + find('[data-testid="header-search-dropdown-menu"]').click_link("Merge requests I've created") expect(page).to have_selector('.mr-list .merge-request') expect_tokens([author_token(user.name)]) @@ -150,10 +153,9 @@ RSpec.describe 'User uses header search field', :js do it 'displays search options' do fill_in_search('test') - - expect(page).to have_selector(scoped_search_link('test')) - expect(page).to have_selector(scoped_search_link('test', group_id: group.id)) - expect(page).to have_selector(scoped_search_link('test', project_id: project.id, group_id: group.id)) + expect(page).to have_selector(scoped_search_link('test', search_code: true)) + expect(page).to have_selector(scoped_search_link('test', group_id: group.id, search_code: true)) + expect(page).to have_selector(scoped_search_link('test', project_id: project.id, group_id: group.id, search_code: true)) end end @@ -165,10 +167,9 @@ RSpec.describe 'User uses header search field', :js do it 'displays search options' do fill_in_search('test') - - expect(page).to have_selector(scoped_search_link('test')) - expect(page).not_to have_selector(scoped_search_link('test', group_id: project.namespace_id)) - expect(page).to have_selector(scoped_search_link('test', project_id: project.id)) + expect(page).to have_selector(scoped_search_link('test', search_code: true, repository_ref: 'master')) + expect(page).not_to have_selector(scoped_search_link('test', search_code: true, group_id: project.namespace_id, repository_ref: 'master')) + expect(page).to have_selector(scoped_search_link('test', search_code: true, project_id: project.id, repository_ref: 'master')) end it 'displays a link to project merge requests' do @@ -217,7 +218,6 @@ RSpec.describe 'User uses header search field', :js do it 'displays search options' do fill_in_search('test') - expect(page).to have_selector(scoped_search_link('test')) expect(page).to have_selector(scoped_search_link('test', group_id: group.id)) expect(page).not_to have_selector(scoped_search_link('test', project_id: project.id)) @@ -248,18 +248,20 @@ RSpec.describe 'User uses header search field', :js do end end - def scoped_search_link(term, project_id: nil, group_id: nil) + def scoped_search_link(term, project_id: nil, group_id: nil, search_code: nil, repository_ref: nil) # search_path will accept group_id and project_id but the order does not match # what is expected in the href, so the variable must be built manually href = search_path(search: term) + href.concat("&nav_source=navbar") href.concat("&project_id=#{project_id}") if project_id href.concat("&group_id=#{group_id}") if group_id - href.concat("&nav_source=navbar") + href.concat("&search_code=true") if search_code + href.concat("&repository_ref=#{repository_ref}") if repository_ref - ".dropdown a[href='#{href}']" + "[data-testid='header-search-dropdown-menu'] a[href='#{href}']" end def dashboard_search_options_popup_menu - "div[data-testid='dashboard-search-options']" + "[data-testid='header-search-dropdown-menu'] .header-search-dropdown-content" end end diff --git a/spec/features/static_site_editor_spec.rb b/spec/features/static_site_editor_spec.rb deleted file mode 100644 index 98313905a33..00000000000 --- a/spec/features/static_site_editor_spec.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Static Site Editor' do - include ContentSecurityPolicyHelpers - - let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project, :public, :repository) } - - let(:sse_path) { project_show_sse_path(project, 'master/README.md') } - - before_all do - project.add_developer(user) - end - - before do - sign_in(user) - end - - context "when no config file is present" do - before do - visit sse_path - end - - it 'renders SSE page with all generated config values and default config file values' do - node = page.find('#static-site-editor') - - # assert generated config values are present - expect(node['data-base-url']).to eq("/#{project.full_path}/-/sse/master%2FREADME.md") - expect(node['data-branch']).to eq('master') - expect(node['data-commit-id']).to match(/\A[0-9a-f]{40}\z/) - expect(node['data-is-supported-content']).to eq('true') - expect(node['data-merge-requests-illustration-path']) - .to match(%r{/assets/illustrations/merge_requests-.*\.svg}) - expect(node['data-namespace']).to eq(project.namespace.full_path) - expect(node['data-project']).to eq(project.path) - expect(node['data-project-id']).to eq(project.id.to_s) - - # assert default config file values are present - expect(node['data-image-upload-path']).to eq('source/images') - expect(node['data-mounts']).to eq('[{"source":"source","target":""}]') - expect(node['data-static-site-generator']).to eq('middleman') - end - end - - context "when a config file is present" do - let(:config_file_yml) do - <<~YAML - image_upload_path: custom-image-upload-path - mounts: - - source: source1 - target: "" - - source: source2 - target: target2 - static_site_generator: middleman - YAML - end - - before do - allow_next_instance_of(Repository) do |repository| - allow(repository).to receive(:blob_data_at).and_return(config_file_yml) - end - - visit sse_path - end - - it 'renders Static Site Editor page values read from config file' do - node = page.find('#static-site-editor') - - # assert user-specified config file values are present - expected_mounts = '[{"source":"source1","target":""},{"source":"source2","target":"target2"}]' - expect(node['data-image-upload-path']).to eq('custom-image-upload-path') - expect(node['data-mounts']).to eq(expected_mounts) - expect(node['data-static-site-generator']).to eq('middleman') - end - end - - describe 'Static Site Editor Content Security Policy' do - subject { response_headers['Content-Security-Policy'] } - - context 'when no global CSP config exists' do - before do - setup_csp_for_controller(Projects::StaticSiteEditorController) - end - - it 'does not add CSP directives' do - visit sse_path - - is_expected.to be_blank - end - end - - context 'when a global CSP config exists' do - let_it_be(:cdn_url) { 'https://some-cdn.test' } - let_it_be(:youtube_url) { 'https://www.youtube.com' } - - before do - csp = ActionDispatch::ContentSecurityPolicy.new do |p| - p.frame_src :self, cdn_url - end - - setup_existing_csp_for_controller(Projects::StaticSiteEditorController, csp) - end - - it 'appends youtube to the CSP frame-src policy' do - visit sse_path - - is_expected.to eql("frame-src 'self' #{cdn_url} #{youtube_url}") - end - end - end -end diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index 0f8daaf8e15..6907701de9c 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -22,7 +22,7 @@ RSpec.describe 'Task Lists', :js do MARKDOWN end - let(:singleIncompleteMarkdown) do + let(:single_incomplete_markdown) do <<-MARKDOWN.strip_heredoc This is a task list: @@ -30,7 +30,7 @@ RSpec.describe 'Task Lists', :js do MARKDOWN end - let(:singleCompleteMarkdown) do + let(:single_complete_markdown) do <<-MARKDOWN.strip_heredoc This is a task list: @@ -94,7 +94,7 @@ RSpec.describe 'Task Lists', :js do end describe 'single incomplete task' do - let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) } + let!(:issue) { create(:issue, description: single_incomplete_markdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) @@ -113,7 +113,7 @@ RSpec.describe 'Task Lists', :js do end describe 'single complete task' do - let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) } + let!(:issue) { create(:issue, description: single_complete_markdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) @@ -171,7 +171,7 @@ RSpec.describe 'Task Lists', :js do describe 'single incomplete task' do let!(:note) do - create(:note, note: singleIncompleteMarkdown, noteable: issue, + create(:note, note: single_incomplete_markdown, noteable: issue, project: project, author: user) end @@ -186,7 +186,7 @@ RSpec.describe 'Task Lists', :js do describe 'single complete task' do let!(:note) do - create(:note, note: singleCompleteMarkdown, noteable: issue, + create(:note, note: single_complete_markdown, noteable: issue, project: project, author: user) end @@ -264,7 +264,7 @@ RSpec.describe 'Task Lists', :js do end describe 'single incomplete task' do - let!(:merge) { create(:merge_request, :simple, description: singleIncompleteMarkdown, author: user, source_project: project) } + let!(:merge) { create(:merge_request, :simple, description: single_incomplete_markdown, author: user, source_project: project) } it 'renders for description' do visit_merge_request(project, merge) @@ -283,7 +283,7 @@ RSpec.describe 'Task Lists', :js do end describe 'single complete task' do - let!(:merge) { create(:merge_request, :simple, description: singleCompleteMarkdown, author: user, source_project: project) } + let!(:merge) { create(:merge_request, :simple, description: single_complete_markdown, author: user, source_project: project) } it 'renders for description' do visit_merge_request(project, merge) diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index 8610cae58a4..822bf898034 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -818,7 +818,6 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do context 'when 2FA is required for the user' do before do - stub_feature_flags(mr_attention_requests: false) group = create(:group, require_two_factor_authentication: true) group.add_developer(user) end @@ -840,7 +839,15 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do expect(page).to have_current_path(profile_two_factor_auth_path, ignore_query: true) - fill_in 'pin_code', with: user.reload.current_otp + # Use the secret shown on the page to generate the OTP that will be entered. + # This detects issues wherein a new secret gets generated after the + # page is shown. + wait_for_requests + + otp_secret = page.find('.two-factor-secret').text.gsub('Key:', '').delete(' ') + current_otp = ROTP::TOTP.new(otp_secret).now + + fill_in 'pin_code', with: current_otp fill_in 'current_password', with: user.password click_button 'Register with two-factor app' diff --git a/spec/finders/bulk_imports/entities_finder_spec.rb b/spec/finders/bulk_imports/entities_finder_spec.rb index e053011b60d..54c792cb4d8 100644 --- a/spec/finders/bulk_imports/entities_finder_spec.rb +++ b/spec/finders/bulk_imports/entities_finder_spec.rb @@ -51,7 +51,7 @@ RSpec.describe BulkImports::EntitiesFinder do end context 'when status is specified' do - subject { described_class.new(user: user, status: 'failed') } + subject { described_class.new(user: user, params: { status: 'failed' }) } it 'returns a list of import entities filtered by status' do expect(subject.execute) @@ -61,7 +61,7 @@ RSpec.describe BulkImports::EntitiesFinder do end context 'when invalid status is specified' do - subject { described_class.new(user: user, status: 'invalid') } + subject { described_class.new(user: user, params: { status: 'invalid' }) } it 'does not filter entities by status' do expect(subject.execute) @@ -74,11 +74,37 @@ RSpec.describe BulkImports::EntitiesFinder do end context 'when bulk import and status are specified' do - subject { described_class.new(user: user, bulk_import: user_import_2, status: 'finished') } + subject { described_class.new(user: user, bulk_import: user_import_2, params: { status: 'finished' }) } it 'returns matched import entities' do expect(subject.execute).to contain_exactly(finished_entity_2) end end + + context 'when order is specifed' do + subject { described_class.new(user: user, params: { sort: order }) } + + context 'when order is specified as asc' do + let(:order) { :asc } + + it 'returns entities sorted ascending' do + expect(subject.execute).to eq([ + started_entity_1, finished_entity_1, failed_entity_1, + started_entity_2, finished_entity_2, failed_entity_2 + ]) + end + end + + context 'when order is specified as desc' do + let(:order) { :desc } + + it 'returns entities sorted descending' do + expect(subject.execute).to eq([ + failed_entity_2, finished_entity_2, started_entity_2, + failed_entity_1, finished_entity_1, started_entity_1 + ]) + end + end + end end end diff --git a/spec/finders/bulk_imports/imports_finder_spec.rb b/spec/finders/bulk_imports/imports_finder_spec.rb index aac83c86c84..2f550514a33 100644 --- a/spec/finders/bulk_imports/imports_finder_spec.rb +++ b/spec/finders/bulk_imports/imports_finder_spec.rb @@ -16,19 +16,39 @@ RSpec.describe BulkImports::ImportsFinder do end context 'when status is specified' do - subject { described_class.new(user: user, status: 'started') } + subject { described_class.new(user: user, params: { status: 'started' }) } it 'returns a list of import entities filtered by status' do expect(subject.execute).to contain_exactly(started_import) end context 'when invalid status is specified' do - subject { described_class.new(user: user, status: 'invalid') } + subject { described_class.new(user: user, params: { status: 'invalid' }) } it 'does not filter entities by status' do expect(subject.execute).to contain_exactly(started_import, finished_import) end end end + + context 'when order is specifed' do + subject { described_class.new(user: user, params: { sort: order }) } + + context 'when order is specified as asc' do + let(:order) { :asc } + + it 'returns entities sorted ascending' do + expect(subject.execute).to eq([started_import, finished_import]) + end + end + + context 'when order is specified as desc' do + let(:order) { :desc } + + it 'returns entities sorted descending' do + expect(subject.execute).to eq([finished_import, started_import]) + end + end + end end end diff --git a/spec/finders/ci/jobs_finder_spec.rb b/spec/finders/ci/jobs_finder_spec.rb index 959716b1fd3..45e8cf5a582 100644 --- a/spec/finders/ci/jobs_finder_spec.rb +++ b/spec/finders/ci/jobs_finder_spec.rb @@ -7,9 +7,9 @@ RSpec.describe Ci::JobsFinder, '#execute' do let_it_be(:admin) { create(:user, :admin) } let_it_be(:project) { create(:project, :private, public_builds: false) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) } - let_it_be(:job_1) { create(:ci_build) } - let_it_be(:job_2) { create(:ci_build, :running) } - let_it_be(:job_3) { create(:ci_build, :success, pipeline: pipeline, name: 'build') } + let_it_be(:pending_job) { create(:ci_build, :pending) } + let_it_be(:running_job) { create(:ci_build, :running) } + let_it_be(:successful_job) { create(:ci_build, :success, pipeline: pipeline, name: 'build') } let(:params) { {} } @@ -17,7 +17,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do subject { described_class.new(current_user: admin, params: params).execute } it 'returns all jobs' do - expect(subject).to match_array([job_1, job_2, job_3]) + expect(subject).to match_array([pending_job, running_job, successful_job]) end context 'non admin user' do @@ -37,7 +37,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do end context 'scope is present' do - let(:jobs) { [job_1, job_2, job_3] } + let(:jobs) { [pending_job, running_job, successful_job] } where(:scope, :index) do [ @@ -55,11 +55,11 @@ RSpec.describe Ci::JobsFinder, '#execute' do end context 'scope is an array' do - let(:jobs) { [job_1, job_2, job_3] } - let(:params) {{ scope: ['running'] }} + let(:jobs) { [pending_job, running_job, successful_job, canceled_job] } + let(:params) {{ scope: %w'running success' }} it 'filters by the job statuses in the scope' do - expect(subject).to match_array([job_2]) + expect(subject).to contain_exactly(running_job, successful_job) end end end @@ -73,7 +73,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do end it 'returns jobs for the specified project' do - expect(subject).to match_array([job_3]) + expect(subject).to match_array([successful_job]) end end @@ -99,7 +99,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do context 'when pipeline is present' do before_all do project.add_maintainer(user) - job_3.update!(retried: true) + successful_job.update!(retried: true) end let_it_be(:job_4) { create(:ci_build, :success, pipeline: pipeline, name: 'build') } @@ -122,7 +122,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do let(:params) { { include_retried: true } } it 'returns retried jobs' do - expect(subject).to match_array([job_3, job_4]) + expect(subject).to match_array([successful_job, job_4]) end end end diff --git a/spec/finders/concerns/finder_methods_spec.rb b/spec/finders/concerns/finder_methods_spec.rb index 195449d70c3..09ec8110129 100644 --- a/spec/finders/concerns/finder_methods_spec.rb +++ b/spec/finders/concerns/finder_methods_spec.rb @@ -12,7 +12,7 @@ RSpec.describe FinderMethods do end def execute - Project.all.order(id: :desc) + Project.where.not(name: 'foo').order(id: :desc) end private @@ -21,22 +21,30 @@ RSpec.describe FinderMethods do end end - let(:user) { create(:user) } - let(:finder) { finder_class.new(user) } - let(:authorized_project) { create(:project) } - let(:unauthorized_project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:authorized_project) { create(:project) } + let_it_be(:unmatched_project) { create(:project, name: 'foo') } + let_it_be(:unauthorized_project) { create(:project) } - before do + subject(:finder) { finder_class.new(user) } + + before_all do authorized_project.add_developer(user) + unmatched_project.add_developer(user) end + # rubocop:disable Rails/FindById describe '#find_by!' do it 'returns the project if the user has access' do expect(finder.find_by!(id: authorized_project.id)).to eq(authorized_project) end - it 'raises not found when the project is not found' do - expect { finder.find_by!(id: 0) }.to raise_error(ActiveRecord::RecordNotFound) + it 'raises not found when the project is not found by id' do + expect { finder.find_by!(id: non_existing_record_id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'raises not found when the project is not found by filter' do + expect { finder.find_by!(id: unmatched_project.id) }.to raise_error(ActiveRecord::RecordNotFound) end it 'raises not found the user does not have access' do @@ -53,19 +61,34 @@ RSpec.describe FinderMethods do finder.find_by!(id: authorized_project.id) end end + # rubocop:enable Rails/FindById describe '#find' do it 'returns the project if the user has access' do expect(finder.find(authorized_project.id)).to eq(authorized_project) end - it 'raises not found when the project is not found' do - expect { finder.find(0) }.to raise_error(ActiveRecord::RecordNotFound) + it 'raises not found when the project is not found by id' do + expect { finder.find(non_existing_record_id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'raises not found when the project is not found by filter' do + expect { finder.find(unmatched_project.id) }.to raise_error(ActiveRecord::RecordNotFound) end it 'raises not found the user does not have access' do expect { finder.find(unauthorized_project.id) }.to raise_error(ActiveRecord::RecordNotFound) end + + it 'ignores ordering' do + # Memoise the finder result so we can add message expectations to it + relation = finder.execute + allow(finder).to receive(:execute).and_return(relation) + + expect(relation).to receive(:reorder).with(nil).and_call_original + + finder.find(authorized_project.id) + end end describe '#find_by' do @@ -73,8 +96,12 @@ RSpec.describe FinderMethods do expect(finder.find_by(id: authorized_project.id)).to eq(authorized_project) end - it 'returns nil when the project is not found' do - expect(finder.find_by(id: 0)).to be_nil + it 'returns nil when the project is not found by id' do + expect(finder.find_by(id: non_existing_record_id)).to be_nil + end + + it 'returns nil when the project is not found by filter' do + expect(finder.find_by(id: unmatched_project.id)).to be_nil end it 'returns nil when the user does not have access' do diff --git a/spec/finders/concerns/finder_with_cross_project_access_spec.rb b/spec/finders/concerns/finder_with_cross_project_access_spec.rb index 116b523bd99..0798528c200 100644 --- a/spec/finders/concerns/finder_with_cross_project_access_spec.rb +++ b/spec/finders/concerns/finder_with_cross_project_access_spec.rb @@ -93,11 +93,11 @@ RSpec.describe FinderWithCrossProjectAccess do it 'checks the accessibility of the subject directly' do expect_access_check_on_result - finder.find_by!(id: result.id) + finder.find(result.id) end it 're-enables the check after the find failed' do - finder.find_by!(id: non_existing_record_id) rescue ActiveRecord::RecordNotFound + finder.find(non_existing_record_id) rescue ActiveRecord::RecordNotFound expect(finder.instance_variable_get(:@should_skip_cross_project_check)) .to eq(false) diff --git a/spec/finders/keys_finder_spec.rb b/spec/finders/keys_finder_spec.rb index 277c852c953..332aa7afde1 100644 --- a/spec/finders/keys_finder_spec.rb +++ b/spec/finders/keys_finder_spec.rb @@ -5,23 +5,22 @@ require 'spec_helper' RSpec.describe KeysFinder do subject { described_class.new(params).execute } - let(:user) { create(:user) } - let(:params) { {} } - - let!(:key_1) do - create(:personal_key, + let_it_be(:user) { create(:user) } + let_it_be(:key_1) do + create(:rsa_key_4096, last_used_at: 7.days.ago, user: user, - key: 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1016k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=', - fingerprint: 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1', - fingerprint_sha256: 'nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg') + fingerprint: 'df:73:db:29:3c:a5:32:cf:09:17:7e:8e:9d:de:d7:f7', + fingerprint_sha256: 'ByDU7hQ1JB95l6p53rHrffc4eXvEtqGUtQhS+Dhyy7g') end - let!(:key_2) { create(:personal_key, last_used_at: nil, user: user) } - let!(:key_3) { create(:personal_key, last_used_at: 2.days.ago) } + let_it_be(:key_2) { create(:personal_key_4096, last_used_at: nil, user: user) } + let_it_be(:key_3) { create(:personal_key_4096, last_used_at: 2.days.ago) } + + let(:params) { {} } context 'key_type' do - let!(:deploy_key) { create(:deploy_key) } + let_it_be(:deploy_key) { create(:deploy_key) } context 'when `key_type` is `ssh`' do before do @@ -64,35 +63,41 @@ RSpec.describe KeysFinder do end context 'with valid fingerprints' do - let!(:deploy_key) do - create(:deploy_key, - user: user, - key: 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1017k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=', - fingerprint: '8a:4a:12:92:0b:50:47:02:d4:5a:8e:a9:44:4e:08:b4', - fingerprint_sha256: '4DPHOVNh53i9dHb5PpY2vjfyf5qniTx1/pBFPoZLDdk') - end + let_it_be(:deploy_key) { create(:rsa_deploy_key_5120, user: user) } context 'personal key with valid MD5 params' do context 'with an existent fingerprint' do before do - params[:fingerprint] = 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1' + params[:fingerprint] = 'df:73:db:29:3c:a5:32:cf:09:17:7e:8e:9d:de:d7:f7' end it 'returns the key' do expect(subject).to eq(key_1) expect(subject.user).to eq(user) end + + context 'with FIPS mode', :fips_mode do + it 'raises InvalidFingerprint' do + expect { subject }.to raise_error(KeysFinder::InvalidFingerprint) + end + end end context 'deploy key with an existent fingerprint' do before do - params[:fingerprint] = '8a:4a:12:92:0b:50:47:02:d4:5a:8e:a9:44:4e:08:b4' + params[:fingerprint] = 'fe:fa:3a:4d:7d:51:ec:bf:c7:64:0c:96:d0:17:8a:d0' end it 'returns the key' do expect(subject).to eq(deploy_key) expect(subject.user).to eq(user) end + + context 'with FIPS mode', :fips_mode do + it 'raises InvalidFingerprint' do + expect { subject }.to raise_error(KeysFinder::InvalidFingerprint) + end + end end context 'with a non-existent fingerprint' do @@ -103,13 +108,19 @@ RSpec.describe KeysFinder do it 'returns nil' do expect(subject).to be_nil end + + context 'with FIPS mode', :fips_mode do + it 'raises InvalidFingerprint' do + expect { subject }.to raise_error(KeysFinder::InvalidFingerprint) + end + end end end context 'personal key with valid SHA256 params' do context 'with an existent fingerprint' do before do - params[:fingerprint] = 'SHA256:nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg' + params[:fingerprint] = 'SHA256:ByDU7hQ1JB95l6p53rHrffc4eXvEtqGUtQhS+Dhyy7g' end it 'returns key' do @@ -120,7 +131,7 @@ RSpec.describe KeysFinder do context 'deploy key with an existent fingerprint' do before do - params[:fingerprint] = 'SHA256:4DPHOVNh53i9dHb5PpY2vjfyf5qniTx1/pBFPoZLDdk' + params[:fingerprint] = 'SHA256:PCCupLbFHScm4AbEufbGDvhBU27IM0MVAor715qKQK8' end it 'returns key' do diff --git a/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb b/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb new file mode 100644 index 00000000000..f3c79d0c825 --- /dev/null +++ b/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::BuildInfosForManyPackagesFinder do + using RSpec::Parameterized::TableSyntax + + let_it_be(:package) { create(:package) } + let_it_be(:build_infos) { create_list(:package_build_info, 5, :with_pipeline, package: package) } + let_it_be(:build_info_with_empty_pipeline) { create(:package_build_info, package: package) } + + let_it_be(:other_package) { create(:package) } + let_it_be(:other_build_infos) { create_list(:package_build_info, 5, :with_pipeline, package: other_package) } + let_it_be(:other_build_info_with_empty_pipeline) { create(:package_build_info, package: other_package) } + + let_it_be(:all_build_infos) { build_infos + other_build_infos } + + let(:finder) { described_class.new(packages, params) } + let(:packages) { nil } + let(:first) { nil } + let(:last) { nil } + let(:after) { nil } + let(:before) { nil } + let(:max_page_size) { nil } + let(:support_next_page) { false } + let(:params) do + { + first: first, + last: last, + after: after, + before: before, + max_page_size: max_page_size, + support_next_page: support_next_page + } + end + + describe '#execute' do + subject { finder.execute } + + shared_examples 'returning the expected build infos' do + let(:expected_build_infos) do + expected_build_infos_indexes.map do |idx| + all_build_infos[idx] + end + end + + let(:after) do + all_build_infos[after_index].pipeline_id if after_index + end + + let(:before) do + all_build_infos[before_index].pipeline_id if before_index + end + + it { is_expected.to eq(expected_build_infos) } + end + + context 'with nil packages' do + let(:packages) { nil } + + it { is_expected.to be_empty } + end + + context 'with [] packages' do + let(:packages) { [] } + + it { is_expected.to be_empty } + end + + context 'with empy scope packages' do + let(:packages) { Packages::Package.none } + + it { is_expected.to be_empty } + end + + context 'with a single package' do + let(:packages) { package.id } + + # rubocop: disable Layout/LineLength + where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do + # F L AI BI MPS SNP + nil | nil | nil | nil | nil | false | [4, 3, 2, 1, 0] + nil | nil | nil | nil | 10 | false | [4, 3, 2, 1, 0] + nil | nil | nil | nil | 2 | false | [4, 3] + 2 | nil | nil | nil | nil | false | [4, 3] + 2 | nil | nil | nil | nil | true | [4, 3, 2] + 2 | nil | 3 | nil | nil | false | [2, 1] + 2 | nil | 3 | nil | nil | true | [2, 1, 0] + 3 | nil | 4 | nil | 2 | false | [3, 2] + 3 | nil | 4 | nil | 2 | true | [3, 2, 1] + nil | 2 | nil | nil | nil | false | [1, 0] + nil | 2 | nil | nil | nil | true | [2, 1, 0] + nil | 2 | nil | 1 | nil | false | [3, 2] + nil | 2 | nil | 1 | nil | true | [4, 3, 2] + nil | 3 | nil | 0 | 2 | false | [2, 1] + nil | 3 | nil | 0 | 2 | true | [3, 2, 1] + end + # rubocop: enable Layout/LineLength + + with_them do + it_behaves_like 'returning the expected build infos' + end + end + + context 'with many packages' do + let(:packages) { [package.id, other_package.id] } + + # using after_index/before_index when receiving multiple packages doesn't + # make sense but we still verify here that the behavior is coherent. + # rubocop: disable Layout/LineLength + where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do + # F L AI BI MPS SNP + nil | nil | nil | nil | nil | false | [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] + nil | nil | nil | nil | 10 | false | [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] + nil | nil | nil | nil | 2 | false | [9, 8, 4, 3] + 2 | nil | nil | nil | nil | false | [9, 8, 4, 3] + 2 | nil | nil | nil | nil | true | [9, 8, 7, 4, 3, 2] + 2 | nil | 3 | nil | nil | false | [2, 1] + 2 | nil | 3 | nil | nil | true | [2, 1, 0] + 3 | nil | 4 | nil | 2 | false | [3, 2] + 3 | nil | 4 | nil | 2 | true | [3, 2, 1] + nil | 2 | nil | nil | nil | false | [6, 5, 1, 0] + nil | 2 | nil | nil | nil | true | [7, 6, 5, 2, 1, 0] + nil | 2 | nil | 1 | nil | false | [6, 5, 3, 2] + nil | 2 | nil | 1 | nil | true | [7, 6, 5, 4, 3, 2] + nil | 3 | nil | 0 | 2 | false | [6, 5, 2, 1] + nil | 3 | nil | 0 | 2 | true | [7, 6, 5, 3, 2, 1] + end + + with_them do + it_behaves_like 'returning the expected build infos' + end + # rubocop: enable Layout/LineLength + end + end +end diff --git a/spec/finders/packages/group_packages_finder_spec.rb b/spec/finders/packages/group_packages_finder_spec.rb index c2dbfb59eb2..954db6481cd 100644 --- a/spec/finders/packages/group_packages_finder_spec.rb +++ b/spec/finders/packages/group_packages_finder_spec.rb @@ -149,6 +149,22 @@ RSpec.describe Packages::GroupPackagesFinder do it { is_expected.to match_array([package1, package2]) } end + context 'preload_pipelines' do + it 'preloads pipelines by default' do + expect(Packages::Package).to receive(:preload_pipelines).and_call_original + expect(subject).to match_array([package1, package2]) + end + + context 'set to false' do + let(:params) { { preload_pipelines: false } } + + it 'does not preload pipelines' do + expect(Packages::Package).not_to receive(:preload_pipelines) + expect(subject).to match_array([package1, package2]) + end + end + end + context 'with package_name' do let_it_be(:named_package) { create(:maven_package, project: project, name: 'maven') } diff --git a/spec/finders/packages/packages_finder_spec.rb b/spec/finders/packages/packages_finder_spec.rb index b72f4aab3ec..6cea0a44541 100644 --- a/spec/finders/packages/packages_finder_spec.rb +++ b/spec/finders/packages/packages_finder_spec.rb @@ -81,6 +81,22 @@ RSpec.describe ::Packages::PackagesFinder do it { is_expected.to match_array([conan_package, maven_package]) } end + context 'preload_pipelines' do + it 'preloads pipelines by default' do + expect(Packages::Package).to receive(:preload_pipelines).and_call_original + expect(subject).to match_array([maven_package, conan_package]) + end + + context 'set to false' do + let(:params) { { preload_pipelines: false } } + + it 'does not preload pipelines' do + expect(Packages::Package).not_to receive(:preload_pipelines) + expect(subject).to match_array([maven_package, conan_package]) + end + end + end + it_behaves_like 'concerning versionless param' it_behaves_like 'concerning package statuses' end diff --git a/spec/finders/releases/group_releases_finder_spec.rb b/spec/finders/releases/group_releases_finder_spec.rb index b8899a8ee40..5eac6f4fbdc 100644 --- a/spec/finders/releases/group_releases_finder_spec.rb +++ b/spec/finders/releases/group_releases_finder_spec.rb @@ -95,8 +95,6 @@ RSpec.describe Releases::GroupReleasesFinder do end describe 'with subgroups' do - let(:params) { { include_subgroups: true } } - subject(:releases) { described_class.new(group, user, params).execute(**args) } context 'with a single-level subgroup' do @@ -164,22 +162,12 @@ RSpec.describe Releases::GroupReleasesFinder do end end - context 'when the user a guest on the group' do - before do - group.add_guest(user) - end - - it 'returns all releases' do - expect(releases).to match_array([v1_1_1, v1_1_0, v6, v1_0_0, p3]) - end - end - context 'performance testing' do shared_examples 'avoids N+1 queries' do |query_params = {}| context 'with subgroups' do let(:params) { query_params } - it 'include_subgroups avoids N+1 queries' do + it 'subgroups avoids N+1 queries' do control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do releases end.count @@ -196,7 +184,6 @@ RSpec.describe Releases::GroupReleasesFinder do end it_behaves_like 'avoids N+1 queries' - it_behaves_like 'avoids N+1 queries', { simple: true } end end end diff --git a/spec/finders/user_recent_events_finder_spec.rb b/spec/finders/user_recent_events_finder_spec.rb index 6019d22059d..d7f7bb9cebe 100644 --- a/spec/finders/user_recent_events_finder_spec.rb +++ b/spec/finders/user_recent_events_finder_spec.rb @@ -8,9 +8,9 @@ RSpec.describe UserRecentEventsFinder do let_it_be(:private_project) { create(:project, :private, creator: project_owner) } let_it_be(:internal_project) { create(:project, :internal, creator: project_owner) } let_it_be(:public_project) { create(:project, :public, creator: project_owner) } - let!(:private_event) { create(:event, project: private_project, author: project_owner) } - let!(:internal_event) { create(:event, project: internal_project, author: project_owner) } - let!(:public_event) { create(:event, project: public_project, author: project_owner) } + let_it_be(:private_event) { create(:event, project: private_project, author: project_owner) } + let_it_be(:internal_event) { create(:event, project: internal_project, author: project_owner) } + let_it_be(:public_event) { create(:event, project: public_project, author: project_owner) } let_it_be(:issue) { create(:issue, project: public_project) } let(:limit) { nil } @@ -18,210 +18,266 @@ RSpec.describe UserRecentEventsFinder do subject(:finder) { described_class.new(current_user, project_owner, nil, params) } - describe '#execute' do - context 'when profile is public' do - it 'returns all the events' do - expect(finder.execute).to include(private_event, internal_event, public_event) + shared_examples 'UserRecentEventsFinder examples' do + describe '#execute' do + context 'when profile is public' do + it 'returns all the events' do + expect(finder.execute).to include(private_event, internal_event, public_event) + end end - end - context 'when profile is private' do - it 'returns no event' do - allow(Ability).to receive(:allowed?).and_call_original - allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false) + context 'when profile is private' do + it 'returns no event' do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false) - expect(finder.execute).to be_empty + expect(finder.execute).to be_empty + end end - end - it 'does not include the events if the user cannot read cross project' do - allow(Ability).to receive(:allowed?).and_call_original - expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false } + it 'does not include the events if the user cannot read cross project' do + allow(Ability).to receive(:allowed?).and_call_original + expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false } - expect(finder.execute).to be_empty - end + expect(finder.execute).to be_empty + end - context 'events from multiple users' do - let_it_be(:second_user, reload: true) { create(:user) } - let_it_be(:private_project_second_user) { create(:project, :private, creator: second_user) } + context 'events from multiple users' do + let_it_be(:second_user, reload: true) { create(:user) } + let_it_be(:private_project_second_user) { create(:project, :private, creator: second_user) } - let(:internal_project_second_user) { create(:project, :internal, creator: second_user) } - let(:public_project_second_user) { create(:project, :public, creator: second_user) } - let!(:private_event_second_user) { create(:event, project: private_project_second_user, author: second_user) } - let!(:internal_event_second_user) { create(:event, project: internal_project_second_user, author: second_user) } - let!(:public_event_second_user) { create(:event, project: public_project_second_user, author: second_user) } + let_it_be(:internal_project_second_user) { create(:project, :internal, creator: second_user) } + let_it_be(:public_project_second_user) { create(:project, :public, creator: second_user) } + let_it_be(:private_event_second_user) { create(:event, project: private_project_second_user, author: second_user) } + let_it_be(:internal_event_second_user) { create(:event, project: internal_project_second_user, author: second_user) } + let_it_be(:public_event_second_user) { create(:event, project: public_project_second_user, author: second_user) } - it 'includes events from all users', :aggregate_failures do - events = described_class.new(current_user, [project_owner, second_user], nil, params).execute + it 'includes events from all users', :aggregate_failures do + events = described_class.new(current_user, [project_owner, second_user], nil, params).execute - expect(events).to include(private_event, internal_event, public_event) - expect(events).to include(private_event_second_user, internal_event_second_user, public_event_second_user) - expect(events.size).to eq(6) - end + expect(events).to include(private_event, internal_event, public_event) + expect(events).to include(private_event_second_user, internal_event_second_user, public_event_second_user) + expect(events.size).to eq(6) + end - context 'selected events' do - let!(:push_event) { create(:push_event, project: public_project, author: project_owner) } - let!(:push_event_second_user) { create(:push_event, project: public_project_second_user, author: second_user) } + context 'selected events' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:push_event1) { create(:push_event, project: public_project, author: project_owner) } + let_it_be(:push_event2) { create(:push_event, project: public_project_second_user, author: second_user) } + let_it_be(:merge_event1) { create(:event, :merged, target_type: MergeRequest.to_s, project: public_project, author: project_owner) } + let_it_be(:merge_event2) { create(:event, :merged, target_type: MergeRequest.to_s, project: public_project_second_user, author: second_user) } + let_it_be(:comment_event1) { create(:event, :commented, target_type: Note.to_s, project: public_project, author: project_owner) } + let_it_be(:comment_event2) { create(:event, :commented, target_type: DiffNote.to_s, project: public_project, author: project_owner) } + let_it_be(:comment_event3) { create(:event, :commented, target_type: DiscussionNote.to_s, project: public_project_second_user, author: second_user) } + let_it_be(:issue_event1) { create(:event, :created, project: public_project, target: issue, author: project_owner) } + let_it_be(:issue_event2) { create(:event, :updated, project: public_project, target: issue, author: project_owner) } + let_it_be(:issue_event3) { create(:event, :closed, project: public_project_second_user, target: issue, author: second_user) } + let_it_be(:wiki_event1) { create(:wiki_page_event, project: public_project, author: project_owner) } + let_it_be(:wiki_event2) { create(:wiki_page_event, project: public_project_second_user, author: second_user) } + let_it_be(:design_event1) { create(:design_event, project: public_project, author: project_owner) } + let_it_be(:design_event2) { create(:design_updated_event, project: public_project_second_user, author: second_user) } + + where(:event_filter, :ordered_expected_events) do + EventFilter.new(EventFilter::PUSH) | lazy { [push_event1, push_event2] } + EventFilter.new(EventFilter::MERGED) | lazy { [merge_event1, merge_event2] } + EventFilter.new(EventFilter::COMMENTS) | lazy { [comment_event1, comment_event2, comment_event3] } + EventFilter.new(EventFilter::TEAM) | lazy { [private_event, internal_event, public_event, private_event_second_user, internal_event_second_user, public_event_second_user] } + EventFilter.new(EventFilter::ISSUE) | lazy { [issue_event1, issue_event2, issue_event3] } + EventFilter.new(EventFilter::WIKI) | lazy { [wiki_event1, wiki_event2] } + EventFilter.new(EventFilter::DESIGNS) | lazy { [design_event1, design_event2] } + end - it 'only includes selected events (PUSH) from all users', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::PUSH) - events = described_class.new(current_user, [project_owner, second_user], event_filter, params).execute + with_them do + it 'only returns selected events from all users (id DESC)' do + events = described_class.new(current_user, [project_owner, second_user], event_filter, params).execute - expect(events).to contain_exactly(push_event, push_event_second_user) + expect(events).to eq(ordered_expected_events.reverse) + end + end end - end - it 'does not include events from users with private profile', :aggregate_failures do - allow(Ability).to receive(:allowed?).and_call_original - allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, second_user).and_return(false) + it 'does not include events from users with private profile', :aggregate_failures do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, second_user).and_return(false) - events = described_class.new(current_user, [project_owner, second_user], nil, params).execute + events = described_class.new(current_user, [project_owner, second_user], nil, params).execute - expect(events).to contain_exactly(private_event, internal_event, public_event) - end + expect(events).to contain_exactly(private_event, internal_event, public_event) + end - context 'with pagination params' do - using RSpec::Parameterized::TableSyntax + context 'with pagination params' do + using RSpec::Parameterized::TableSyntax - where(:limit, :offset, :ordered_expected_events) do - nil | nil | lazy { [public_event_second_user, internal_event_second_user, private_event_second_user, public_event, internal_event, private_event] } - 2 | nil | lazy { [public_event_second_user, internal_event_second_user] } - nil | 4 | lazy { [internal_event, private_event] } - 2 | 2 | lazy { [private_event_second_user, public_event] } - end + where(:limit, :offset, :ordered_expected_events) do + nil | nil | lazy { [public_event_second_user, internal_event_second_user, private_event_second_user, public_event, internal_event, private_event] } + 2 | nil | lazy { [public_event_second_user, internal_event_second_user] } + nil | 4 | lazy { [internal_event, private_event] } + 2 | 2 | lazy { [private_event_second_user, public_event] } + end - with_them do - let(:params) { { limit: limit, offset: offset }.compact } + with_them do + let(:params) { { limit: limit, offset: offset }.compact } - it 'returns paginated events sorted by id (DESC)' do - events = described_class.new(current_user, [project_owner, second_user], nil, params).execute + it 'returns paginated events sorted by id (DESC)' do + events = described_class.new(current_user, [project_owner, second_user], nil, params).execute - expect(events).to eq(ordered_expected_events) + expect(events).to eq(ordered_expected_events) + end end end end - end - context 'filter activity events' do - let!(:push_event) { create(:push_event, project: public_project, author: project_owner) } - let!(:merge_event) { create(:event, :merged, project: public_project, author: project_owner) } - let!(:issue_event) { create(:event, :closed, project: public_project, target: issue, author: project_owner) } - let!(:comment_event) { create(:event, :commented, project: public_project, author: project_owner) } - let!(:wiki_event) { create(:wiki_page_event, project: public_project, author: project_owner) } - let!(:design_event) { create(:design_event, project: public_project, author: project_owner) } - let!(:team_event) { create(:event, :joined, project: public_project, author: project_owner) } - - it 'includes all events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::ALL) - events = described_class.new(current_user, project_owner, event_filter, params).execute - - expect(events).to include(private_event, internal_event, public_event) - expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event) - expect(events.size).to eq(10) - end + context 'filter activity events' do + let_it_be(:push_event) { create(:push_event, project: public_project, author: project_owner) } + let_it_be(:merge_event) { create(:event, :merged, project: public_project, author: project_owner) } + let_it_be(:issue_event) { create(:event, :closed, project: public_project, target: issue, author: project_owner) } + let_it_be(:comment_event) { create(:event, :commented, project: public_project, author: project_owner) } + let_it_be(:wiki_event) { create(:wiki_page_event, project: public_project, author: project_owner) } + let_it_be(:design_event) { create(:design_event, project: public_project, author: project_owner) } + let_it_be(:team_event) { create(:event, :joined, project: public_project, author: project_owner) } + + it 'includes all events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::ALL) + events = described_class.new(current_user, project_owner, event_filter, params).execute + + expect(events).to include(private_event, internal_event, public_event) + expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event) + expect(events.size).to eq(10) + end - it 'only includes push events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::PUSH) - events = described_class.new(current_user, project_owner, event_filter, params).execute + context 'when unknown filter is given' do + it 'includes returns all events', :aggregate_failures do + event_filter = EventFilter.new('unknown') + allow(event_filter).to receive(:filter).and_return('unknown') - expect(events).to include(push_event) - expect(events.size).to eq(1) - end + events = described_class.new(current_user, [project_owner], event_filter, params).execute - it 'only includes merge events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::MERGED) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(private_event, internal_event, public_event) + expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event) + expect(events.size).to eq(10) + end + end - expect(events).to include(merge_event) - expect(events.size).to eq(1) - end + it 'only includes push events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::PUSH) + events = described_class.new(current_user, project_owner, event_filter, params).execute - it 'only includes issue events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::ISSUE) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(push_event) + expect(events.size).to eq(1) + end - expect(events).to include(issue_event) - expect(events.size).to eq(1) - end + it 'only includes merge events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::MERGED) + events = described_class.new(current_user, project_owner, event_filter, params).execute - it 'only includes comments events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::COMMENTS) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(merge_event) + expect(events.size).to eq(1) + end - expect(events).to include(comment_event) - expect(events.size).to eq(1) - end + it 'only includes issue events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::ISSUE) + events = described_class.new(current_user, project_owner, event_filter, params).execute - it 'only includes wiki events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::WIKI) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(issue_event) + expect(events.size).to eq(1) + end - expect(events).to include(wiki_event) - expect(events.size).to eq(1) - end + it 'only includes comments events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::COMMENTS) + events = described_class.new(current_user, project_owner, event_filter, params).execute - it 'only includes design events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::DESIGNS) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(comment_event) + expect(events.size).to eq(1) + end - expect(events).to include(design_event) - expect(events.size).to eq(1) - end + it 'only includes wiki events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::WIKI) + events = described_class.new(current_user, project_owner, event_filter, params).execute - it 'only includes team events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::TEAM) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(wiki_event) + expect(events.size).to eq(1) + end - expect(events).to include(private_event, internal_event, public_event, team_event) - expect(events.size).to eq(4) - end - end + it 'only includes design events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::DESIGNS) + events = described_class.new(current_user, project_owner, event_filter, params).execute - describe 'issue activity events' do - let(:issue) { create(:issue, project: public_project) } - let(:note) { create(:note_on_issue, noteable: issue, project: public_project) } - let!(:event_a) { create(:event, :commented, target: note, author: project_owner) } - let!(:event_b) { create(:event, :closed, target: issue, author: project_owner) } + expect(events).to include(design_event) + expect(events.size).to eq(1) + end - it 'includes all issue related events', :aggregate_failures do - events = finder.execute + it 'only includes team events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::TEAM) + events = described_class.new(current_user, project_owner, event_filter, params).execute - expect(events).to include(event_a) - expect(events).to include(event_b) + expect(events).to include(private_event, internal_event, public_event, team_event) + expect(events.size).to eq(4) + end end - end - context 'limits' do - before do - stub_const("#{described_class}::DEFAULT_LIMIT", 1) - stub_const("#{described_class}::MAX_LIMIT", 3) - end + describe 'issue activity events' do + let(:issue) { create(:issue, project: public_project) } + let(:note) { create(:note_on_issue, noteable: issue, project: public_project) } + let!(:event_a) { create(:event, :commented, target: note, author: project_owner) } + let!(:event_b) { create(:event, :closed, target: issue, author: project_owner) } - context 'when limit is not set' do - it 'returns events limited to DEFAULT_LIMIT' do - expect(finder.execute.size).to eq(described_class::DEFAULT_LIMIT) + it 'includes all issue related events', :aggregate_failures do + events = finder.execute + + expect(events).to include(event_a) + expect(events).to include(event_b) end end - context 'when limit is set' do - let(:limit) { 2 } + context 'limits' do + before do + stub_const("#{described_class}::DEFAULT_LIMIT", 1) + stub_const("#{described_class}::MAX_LIMIT", 3) + end - it 'returns events limited to specified limit' do - expect(finder.execute.size).to eq(limit) + context 'when limit is not set' do + it 'returns events limited to DEFAULT_LIMIT' do + expect(finder.execute.size).to eq(described_class::DEFAULT_LIMIT) + end end - end - context 'when limit is set to a number that exceeds maximum limit' do - let(:limit) { 4 } + context 'when limit is set' do + let(:limit) { 2 } - before do - create(:event, project: public_project, author: project_owner) + it 'returns events limited to specified limit' do + expect(finder.execute.size).to eq(limit) + end end - it 'returns events limited to MAX_LIMIT' do - expect(finder.execute.size).to eq(described_class::MAX_LIMIT) + context 'when limit is set to a number that exceeds maximum limit' do + let(:limit) { 4 } + + before do + create(:event, project: public_project, author: project_owner) + end + + it 'returns events limited to MAX_LIMIT' do + expect(finder.execute.size).to eq(described_class::MAX_LIMIT) + end end end end end + + context 'when the optimized_followed_users_queries FF is on' do + before do + stub_feature_flags(optimized_followed_users_queries: true) + end + + it_behaves_like 'UserRecentEventsFinder examples' + end + + context 'when the optimized_followed_users_queries FF is off' do + before do + stub_feature_flags(optimized_followed_users_queries: false) + end + + it_behaves_like 'UserRecentEventsFinder examples' + end end diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb index fab48cf3178..271dce44db7 100644 --- a/spec/finders/users_finder_spec.rb +++ b/spec/finders/users_finder_spec.rb @@ -6,13 +6,15 @@ RSpec.describe UsersFinder do describe '#execute' do include_context 'UsersFinder#execute filter by project context' + let_it_be(:project_bot) { create(:user, :project_bot) } + context 'with a normal user' do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } - it 'returns all users' do + it 'returns searchable users' do users = described_class.new(user).execute - expect(users).to contain_exactly(user, normal_user, blocked_user, external_user, omniauth_user, internal_user, admin_user) + expect(users).to contain_exactly(user, normal_user, external_user, unconfirmed_user, omniauth_user, internal_user, admin_user, project_bot) end it 'filters by username' do @@ -34,9 +36,9 @@ RSpec.describe UsersFinder do end it 'filters by search' do - users = described_class.new(user, search: 'orando').execute + users = described_class.new(user, search: 'ohndo').execute - expect(users).to contain_exactly(blocked_user) + expect(users).to contain_exactly(normal_user) end it 'does not filter by private emails search' do @@ -45,18 +47,6 @@ RSpec.describe UsersFinder do expect(users).to be_empty end - it 'filters by blocked users' do - users = described_class.new(user, blocked: true).execute - - expect(users).to contain_exactly(blocked_user) - end - - it 'filters by active users' do - users = described_class.new(user, active: true).execute - - expect(users).to contain_exactly(user, normal_user, external_user, omniauth_user, admin_user) - end - it 'filters by external users' do users = described_class.new(user, external: true).execute @@ -66,7 +56,7 @@ RSpec.describe UsersFinder do it 'filters by non external users' do users = described_class.new(user, non_external: true).execute - expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user, internal_user, admin_user) + expect(users).to contain_exactly(user, normal_user, unconfirmed_user, omniauth_user, internal_user, admin_user, project_bot) end it 'filters by created_at' do @@ -83,7 +73,7 @@ RSpec.describe UsersFinder do it 'filters by non internal users' do users = described_class.new(user, non_internal: true).execute - expect(users).to contain_exactly(user, normal_user, external_user, blocked_user, omniauth_user, admin_user) + expect(users).to contain_exactly(user, normal_user, unconfirmed_user, external_user, omniauth_user, admin_user, project_bot) end it 'does not filter by custom attributes' do @@ -92,23 +82,23 @@ RSpec.describe UsersFinder do custom_attributes: { foo: 'bar' } ).execute - expect(users).to contain_exactly(user, normal_user, blocked_user, external_user, omniauth_user, internal_user, admin_user) + expect(users).to contain_exactly(user, normal_user, external_user, unconfirmed_user, omniauth_user, internal_user, admin_user, project_bot) end it 'orders returned results' do users = described_class.new(user, sort: 'id_asc').execute - expect(users).to eq([normal_user, admin_user, blocked_user, external_user, omniauth_user, internal_user, user]) + expect(users).to eq([normal_user, admin_user, external_user, unconfirmed_user, omniauth_user, internal_user, project_bot, user]) end it 'does not filter by admins' do users = described_class.new(user, admins: true).execute - expect(users).to contain_exactly(user, normal_user, external_user, admin_user, blocked_user, omniauth_user, internal_user) + expect(users).to contain_exactly(user, normal_user, external_user, admin_user, unconfirmed_user, omniauth_user, internal_user, project_bot) end end context 'with an admin user', :enable_admin_mode do - let(:admin) { create(:admin) } + let_it_be(:admin) { create(:admin) } it 'filters by external users' do users = described_class.new(admin, external: true).execute @@ -119,7 +109,19 @@ RSpec.describe UsersFinder do it 'returns all users' do users = described_class.new(admin).execute - expect(users).to contain_exactly(admin, normal_user, blocked_user, external_user, omniauth_user, internal_user, admin_user) + expect(users).to contain_exactly(admin, normal_user, blocked_user, unconfirmed_user, banned_user, external_user, omniauth_user, internal_user, admin_user, project_bot) + end + + it 'filters by blocked users' do + users = described_class.new(admin, blocked: true).execute + + expect(users).to contain_exactly(blocked_user) + end + + it 'filters by active users' do + users = described_class.new(admin, active: true).execute + + expect(users).to contain_exactly(admin, normal_user, unconfirmed_user, external_user, omniauth_user, admin_user, project_bot) end it 'returns only admins' do diff --git a/spec/fixtures/api/schemas/entities/member_user.json b/spec/fixtures/api/schemas/entities/member_user.json index d42c686bb65..0750e81e115 100644 --- a/spec/fixtures/api/schemas/entities/member_user.json +++ b/spec/fixtures/api/schemas/entities/member_user.json @@ -1,15 +1,28 @@ { "type": "object", - "required": ["id", "name", "username", "avatar_url", "web_url", "blocked", "two_factor_enabled", "show_status"], + "required": [ + "id", + "name", + "username", + "created_at", + "last_activity_on", + "avatar_url", + "web_url", + "blocked", + "two_factor_enabled", + "show_status" + ], "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "username": { "type": "string" }, + "created_at": { "type": ["string"] }, "avatar_url": { "type": ["string", "null"] }, "web_url": { "type": "string" }, "blocked": { "type": "boolean" }, "two_factor_enabled": { "type": "boolean" }, "availability": { "type": ["string", "null"] }, + "last_activity_on": { "type": ["string", "null"] }, "status": { "type": "object", "required": ["emoji"], diff --git a/spec/fixtures/api/schemas/group_link/group_group_link.json b/spec/fixtures/api/schemas/group_link/group_group_link.json index bfca5c885e3..689679cbc0f 100644 --- a/spec/fixtures/api/schemas/group_link/group_group_link.json +++ b/spec/fixtures/api/schemas/group_link/group_group_link.json @@ -4,12 +4,19 @@ { "$ref": "group_link.json" }, { "required": [ - "can_update", - "can_remove" + "source" ], "properties": { - "can_update": { "type": "boolean" }, - "can_remove": { "type": "boolean" } + "source": { + "type": "object", + "required": ["id", "full_name", "web_url"], + "properties": { + "id": { "type": "integer" }, + "full_name": { "type": "string" }, + "web_url": { "type": "string" } + }, + "additionalProperties": false + } } } ] diff --git a/spec/fixtures/api/schemas/group_link/group_link.json b/spec/fixtures/api/schemas/group_link/group_link.json index 300790728a8..3c2195df11e 100644 --- a/spec/fixtures/api/schemas/group_link/group_link.json +++ b/spec/fixtures/api/schemas/group_link/group_link.json @@ -5,7 +5,10 @@ "created_at", "expires_at", "access_level", - "valid_roles" + "valid_roles", + "can_update", + "can_remove", + "is_direct_member" ], "properties": { "id": { "type": "integer" }, @@ -33,6 +36,9 @@ "web_url": { "type": "string" } }, "additionalProperties": false - } + }, + "can_update": { "type": "boolean" }, + "can_remove": { "type": "boolean" }, + "is_direct_member": { "type": "boolean" } } } diff --git a/spec/fixtures/api/schemas/group_link/project_group_link.json b/spec/fixtures/api/schemas/group_link/project_group_link.json index bfca5c885e3..615c808e5aa 100644 --- a/spec/fixtures/api/schemas/group_link/project_group_link.json +++ b/spec/fixtures/api/schemas/group_link/project_group_link.json @@ -4,12 +4,18 @@ { "$ref": "group_link.json" }, { "required": [ - "can_update", - "can_remove" + "source" ], "properties": { - "can_update": { "type": "boolean" }, - "can_remove": { "type": "boolean" } + "source": { + "type": "object", + "required": ["id", "full_name"], + "properties": { + "id": { "type": "integer" }, + "full_name": { "type": "string" } + }, + "additionalProperties": false + } } } ] diff --git a/spec/fixtures/api/schemas/public_api/v4/agent.json b/spec/fixtures/api/schemas/public_api/v4/agent.json new file mode 100644 index 00000000000..4821d5e0b04 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/agent.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "required": [ + "id", + "name", + "config_project", + "created_at", + "created_by_user_id" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "config_project": { "$ref": "project_identity.json" }, + "created_at": { "type": "string", "format": "date-time" }, + "created_by_user_id": { "type": "integer" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/agents.json b/spec/fixtures/api/schemas/public_api/v4/agents.json new file mode 100644 index 00000000000..5fe3d7f9481 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/agents.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "agent.json" } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/issue.json b/spec/fixtures/api/schemas/public_api/v4/issue.json index 3173a8ebfb5..90b368b5226 100644 --- a/spec/fixtures/api/schemas/public_api/v4/issue.json +++ b/spec/fixtures/api/schemas/public_api/v4/issue.json @@ -86,6 +86,7 @@ "due_date": { "type": ["string", "null"] }, "confidential": { "type": "boolean" }, "web_url": { "type": "uri" }, + "severity": { "type": "string", "enum": ["UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL"] }, "time_stats": { "time_estimate": { "type": "integer" }, "total_time_spent": { "type": "integer" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/issue_links.json b/spec/fixtures/api/schemas/public_api/v4/issue_links.json deleted file mode 100644 index d254615dd58..00000000000 --- a/spec/fixtures/api/schemas/public_api/v4/issue_links.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "type": "array", - "items": { - "type": "object", - "properties" : { - "$ref": "./issue_link.json" - } - } -} diff --git a/spec/fixtures/api/schemas/public_api/v4/project_identity.json b/spec/fixtures/api/schemas/public_api/v4/project_identity.json new file mode 100644 index 00000000000..6471dd560c5 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/project_identity.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "required": [ + "id", + "description", + "name", + "name_with_namespace", + "path", + "path_with_namespace", + "created_at" + ], + "properties": { + "id": { "type": "integer" }, + "description": { "type": ["string", "null"] }, + "name": { "type": "string" }, + "name_with_namespace": { "type": "string" }, + "path": { "type": "string" }, + "path_with_namespace": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/related_issues.json b/spec/fixtures/api/schemas/public_api/v4/related_issues.json new file mode 100644 index 00000000000..83095ab44c1 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/related_issues.json @@ -0,0 +1,26 @@ +{ + "type": "array", + "items": { + "type": "object", + "allOf": [ + { "$ref": "../../../../../../spec/fixtures/api/schemas/public_api/v4/issue.json" }, + { + "required" : [ + "link_type", + "issue_link_id", + "link_created_at", + "link_updated_at" + ], + "properties" : { + "link_type": { + "type": "string", + "enum": ["relates_to", "blocks", "is_blocked_by"] + }, + "issue_link_id": { "type": "integer" }, + "link_created_at": { "type": "string" }, + "link_updated_at": { "type": "string" } + } + } + ] + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json b/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json index 465e1193a64..0f9a5ccfa7d 100644 --- a/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json +++ b/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json @@ -5,6 +5,7 @@ "name": { "type": "string" }, "description": { "type": "string" }, "description_html": { "type": "string" }, + "tag_name": { "type": "string"}, "created_at": { "type": "string", "format": "date-time" }, "released_at": { "type": "string", "format": "date-time" }, "upcoming_release": { "type": "boolean" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json b/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json new file mode 100644 index 00000000000..3636c970e83 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "required": [ + "id", + "name", + "user_id", + "active", + "created_at", + "expires_at", + "revoked", + "access_level", + "scopes", + "last_used_at" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "user_id": { "type": "integer" }, + "active": { "type": "boolean" }, + "created_at": { "type": "string", "format": "date-time" }, + "expires_at": { "type": ["string", "null"], "format": "date" }, + "revoked": { "type": "boolean" }, + "access_level": { "type": "integer" }, + "scopes": { + "type": "array", + "items": { "type": "string" } + }, + "last_used_at": { "type": ["string", "null"], "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/resource_access_tokens.json b/spec/fixtures/api/schemas/public_api/v4/resource_access_tokens.json new file mode 100644 index 00000000000..1bf013b8bca --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/resource_access_tokens.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "resource_access_token.json" } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/user/admin.json b/spec/fixtures/api/schemas/public_api/v4/user/admin.json index f733914fbf8..8d06e16848f 100644 --- a/spec/fixtures/api/schemas/public_api/v4/user/admin.json +++ b/spec/fixtures/api/schemas/public_api/v4/user/admin.json @@ -26,7 +26,8 @@ "can_create_group", "can_create_project", "two_factor_enabled", - "external" + "external", + "namespace_id" ], "properties": { "$ref": "full.json" diff --git a/spec/fixtures/avatars/avatar1.png b/spec/fixtures/avatars/avatar1.png Binary files differnew file mode 100644 index 00000000000..7e8afb39f17 --- /dev/null +++ b/spec/fixtures/avatars/avatar1.png diff --git a/spec/fixtures/avatars/avatar2.png b/spec/fixtures/avatars/avatar2.png Binary files differnew file mode 100644 index 00000000000..462678b1871 --- /dev/null +++ b/spec/fixtures/avatars/avatar2.png diff --git a/spec/fixtures/avatars/avatar3.png b/spec/fixtures/avatars/avatar3.png Binary files differnew file mode 100644 index 00000000000..e065f681817 --- /dev/null +++ b/spec/fixtures/avatars/avatar3.png diff --git a/spec/fixtures/avatars/avatar4.png b/spec/fixtures/avatars/avatar4.png Binary files differnew file mode 100644 index 00000000000..647ee193cbd --- /dev/null +++ b/spec/fixtures/avatars/avatar4.png diff --git a/spec/fixtures/avatars/avatar5.png b/spec/fixtures/avatars/avatar5.png Binary files differnew file mode 100644 index 00000000000..27e973dc5e3 --- /dev/null +++ b/spec/fixtures/avatars/avatar5.png diff --git a/spec/fixtures/emails/service_desk_reply_to_and_from.eml b/spec/fixtures/emails/service_desk_reply_to_and_from.eml deleted file mode 100644 index 2545e0d30f8..00000000000 --- a/spec/fixtures/emails/service_desk_reply_to_and_from.eml +++ /dev/null @@ -1,28 +0,0 @@ -Delivered-To: incoming+email-test-project_id-issue-@appmail.adventuretime.ooo -Return-Path: <jake@adventuretime.ooo> -Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 -Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400 -Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <incoming+email-test-project_id-issue-@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700 -Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 -Date: Thu, 13 Jun 2013 17:03:48 -0400 -Reply-To: Marceline <marceline@adventuretime.ooo> -From: Finn the Human <finn@adventuretime.ooo> -Sender: Jake the Dog <jake@adventuretime.ooo> -To: support@adventuretime.ooo -Delivered-To: support@adventuretime.ooo -Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com> -Subject: The message subject! @all -Mime-Version: 1.0 -Content-Type: text/plain; - charset=ISO-8859-1 -Content-Transfer-Encoding: 7bit -X-Sieve: CMU Sieve 2.2 -X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu, - 13 Jun 2013 14:03:48 -0700 (PDT) -X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1 - -Service desk stuff! - -``` -a = b -``` diff --git a/spec/fixtures/markdown/markdown_golden_master_examples.yml b/spec/fixtures/markdown/markdown_golden_master_examples.yml index 8556811974d..bdd7c13c1a3 100644 --- a/spec/fixtures/markdown/markdown_golden_master_examples.yml +++ b/spec/fixtures/markdown/markdown_golden_master_examples.yml @@ -377,6 +377,34 @@ </ol> </details> +- name: diagram_kroki_nomnoml + markdown: |- + ```nomnoml + #stroke: #a86128 + [<frame>Decorator pattern| + [<abstract>Component||+ operation()] + [Client] depends --> [Component] + [Decorator|- next: Component] + [Decorator] decorates -- [ConcreteComponent] + [Component] <:- [Decorator] + [Component] <:- [ConcreteComponent] + ] + ``` + html: |- + <a class="no-attachment-icon" href="http://localhost:8000/nomnoml/svg/eNp1jbsOwjAMRfd-haUuIJQBBlRFVZb2L1CGkBqpgtpR6oEhH0_CW6hsts-9xwD1LJHPqKF2zX67ayqAQ3uKbkLTo-fohCMEJ4KRUoYFu2MuOS-m4ykwIUlKG-CAOT0yrdb2EewuY2YWBgxIwwxKmXx8dZ6h95ekgPAqGv4miuk-YnEVFfmIgr-Fzw6tVt-CZb7osdUNUAReJA==" target="_blank" rel="noopener noreferrer" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,ICAjc3Ryb2tlOiAjYTg2MTI4CiAgWzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybnwKICAgIFs8YWJzdHJhY3Q+Q29tcG9uZW50fHwrIG9wZXJhdGlvbigpXQogICAgW0NsaWVudF0gZGVwZW5kcyAtLT4gW0NvbXBvbmVudF0KICAgIFtEZWNvcmF0b3J8LSBuZXh0OiBDb21wb25lbnRdCiAgICBbRGVjb3JhdG9yXSBkZWNvcmF0ZXMgLS0gW0NvbmNyZXRlQ29tcG9uZW50XQogICAgW0NvbXBvbmVudF0gPDotIFtEZWNvcmF0b3JdCiAgICBbQ29tcG9uZW50XSA8Oi0gW0NvbmNyZXRlQ29tcG9uZW50XQogIF0K"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" class="js-render-kroki lazy" data-src="http://localhost:8000/nomnoml/svg/eNp1jbsOwjAMRfd-haUuIJQBBlRFVZb2L1CGkBqpgtpR6oEhH0_CW6hsts-9xwD1LJHPqKF2zX67ayqAQ3uKbkLTo-fohCMEJ4KRUoYFu2MuOS-m4ykwIUlKG-CAOT0yrdb2EewuY2YWBgxIwwxKmXx8dZ6h95ekgPAqGv4miuk-YnEVFfmIgr-Fzw6tVt-CZb7osdUNUAReJA=="></a> + +- name: diagram_plantuml + markdown: |- + ```plantuml + Alice -> Bob: Authentication Request + Bob --> Alice: Authentication Response + + Alice -> Bob: Another authentication Request + Alice <-- Bob: Another authentication Response + ``` + html: |- + <a class="no-attachment-icon" href="http://localhost:8080/png/U9nJK73CoKnELT2rKt3AJx9IS2mjoKZDAybCJYp9pCzJ24ejB4qjBk5I0Cagw09LWPLZKLTSa9zNdCe5L8bcO5u-K6MHGY8kWo7ARNHr2QY7MW00AeWxTG00" target="_blank" rel="noopener noreferrer" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,ICBBbGljZSAtPiBCb2I6IEF1dGhlbnRpY2F0aW9uIFJlcXVlc3QKICBCb2IgLS0+IEFsaWNlOiBBdXRoZW50aWNhdGlvbiBSZXNwb25zZQoKICBBbGljZSAtPiBCb2I6IEFub3RoZXIgYXV0aGVudGljYXRpb24gUmVxdWVzdAogIEFsaWNlIDwtLSBCb2I6IEFub3RoZXIgYXV0aGVudGljYXRpb24gUmVzcG9uc2UK"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" class="lazy" data-src="http://localhost:8080/png/U9nJK73CoKnELT2rKt3AJx9IS2mjoKZDAybCJYp9pCzJ24ejB4qjBk5I0Cagw09LWPLZKLTSa9zNdCe5L8bcO5u-K6MHGY8kWo7ARNHr2QY7MW00AeWxTG00"></a> + - name: div markdown: |- <div>plain text</div> diff --git a/spec/fixtures/security_reports/master/gl-sast-report-bandit.json b/spec/fixtures/security_reports/master/gl-sast-report-bandit.json new file mode 100644 index 00000000000..a80833354ed --- /dev/null +++ b/spec/fixtures/security_reports/master/gl-sast-report-bandit.json @@ -0,0 +1,43 @@ +{ + "version": "14.0.4", + "vulnerabilities": [ + { + "id": "985a5666dcae22adef5ac12f8a8a2dacf9b9b481ae5d87cd0ac1712b0fd64864", + "category": "sast", + "message": "Deserialization of Untrusted Data", + "description": "Avoid using `load()`. `PyYAML.load` can create arbitrary Python\nobjects. A malicious actor could exploit this to run arbitrary\ncode. Use `safe_load()` instead.\n", + "cve": "", + "severity": "Critical", + "scanner": { + "id": "bandit", + "name": "Bandit" + }, + "location": { + "file": "app/app.py", + "start_line": 39 + }, + "identifiers": [ + { + "type": "bandit_test_id", + "name": "Bandit Test ID B506", + "value": "B506" + } + ] + } + ], + "scan": { + "scanner": { + "id": "bandit", + "name": "Bandit", + "url": "https://github.com/PyCQA/bandit", + "vendor": { + "name": "GitLab" + }, + "version": "1.7.1" + }, + "type": "sast", + "start_time": "2022-03-11T00:21:49", + "end_time": "2022-03-11T00:21:50", + "status": "success" + } +} diff --git a/spec/fixtures/security_reports/master/gl-sast-report-gosec.json b/spec/fixtures/security_reports/master/gl-sast-report-gosec.json new file mode 100644 index 00000000000..42986ea1045 --- /dev/null +++ b/spec/fixtures/security_reports/master/gl-sast-report-gosec.json @@ -0,0 +1,68 @@ +{ + "version": "14.0.4", + "vulnerabilities": [ + { + "id": "2e5656ff30e2e7cc93c36b4845c8a689ddc47fdbccf45d834c67442fbaa89be0", + "category": "sast", + "name": "Key Exchange without Entity Authentication", + "message": "Use of ssh InsecureIgnoreHostKey should be audited", + "description": "The software performs a key exchange with an actor without verifying the identity of that actor.", + "cve": "og.go:8:7: func foo() {\n8: \t_ = ssh.InsecureIgnoreHostKey()\n9: }\n:CWE-322", + "severity": "Medium", + "confidence": "High", + "raw_source_code_extract": "7: func foo() {\n8: \t_ = ssh.InsecureIgnoreHostKey()\n9: }\n", + "scanner": { + "id": "gosec", + "name": "Gosec" + }, + "location": { + "file": "og.go", + "start_line": 8 + }, + "identifiers": [ + { + "type": "gosec_rule_id", + "name": "Gosec Rule ID G106", + "value": "G106" + }, + { + "type": "CWE", + "name": "CWE-322", + "value": "322", + "url": "https://cwe.mitre.org/data/definitions/322.html" + } + ], + "tracking": { + "type": "source", + "items": [ + { + "file": "og.go", + "line_start": 8, + "line_end": 8, + "signatures": [ + { + "algorithm": "scope_offset", + "value": "og.go|foo[0]:1" + } + ] + } + ] + } + } + ], + "scan": { + "scanner": { + "id": "gosec", + "name": "Gosec", + "url": "https://github.com/securego/gosec", + "vendor": { + "name": "GitLab" + }, + "version": "2.10.0" + }, + "type": "sast", + "start_time": "2022-03-15T20:33:12", + "end_time": "2022-03-15T20:33:17", + "status": "success" + } +} diff --git a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json new file mode 100644 index 00000000000..2a60a75366e --- /dev/null +++ b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json @@ -0,0 +1,71 @@ +{ + "version": "14.0.4", + "vulnerabilities": [ + { + "id": "985a5666dcae22adef5ac12f8a8a2dacf9b9b481ae5d87cd0ac1712b0fd64864", + "category": "sast", + "message": "Deserialization of Untrusted Data", + "description": "Avoid using `load()`. `PyYAML.load` can create arbitrary Python\nobjects. A malicious actor could exploit this to run arbitrary\ncode. Use `safe_load()` instead.\n", + "cve": "", + "severity": "Critical", + "scanner": { + "id": "semgrep", + "name": "Semgrep" + }, + "location": { + "file": "app/app.py", + "start_line": 39 + }, + "identifiers": [ + { + "type": "semgrep_id", + "name": "bandit.B506", + "value": "bandit.B506", + "url": "https://semgrep.dev/r/gitlab.bandit.B506" + }, + { + "type": "cwe", + "name": "CWE-502", + "value": "502", + "url": "https://cwe.mitre.org/data/definitions/502.html" + }, + { + "type": "bandit_test_id", + "name": "Bandit Test ID B506", + "value": "B506" + } + ], + "tracking": { + "type": "source", + "items": [ + { + "file": "app/app.py", + "line_start": 39, + "line_end": 39, + "signatures": [ + { + "algorithm": "scope_offset", + "value": "app/app.py|yaml_hammer[0]:13" + } + ] + } + ] + } + } + ], + "scan": { + "scanner": { + "id": "semgrep", + "name": "Semgrep", + "url": "https://github.com/returntocorp/semgrep", + "vendor": { + "name": "GitLab" + }, + "version": "0.82.0" + }, + "type": "sast", + "start_time": "2022-03-11T18:48:16", + "end_time": "2022-03-11T18:48:22", + "status": "success" + } +} diff --git a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json new file mode 100644 index 00000000000..3d8c65d5823 --- /dev/null +++ b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json @@ -0,0 +1,70 @@ +{ + "version": "14.0.4", + "vulnerabilities": [ + { + "id": "79f6537b7ec83c7717f5bd1a4f12645916caafefe2e4359148d889855505aa67", + "category": "sast", + "message": "Key Exchange without Entity Authentication", + "description": "Audit the use of ssh.InsecureIgnoreHostKey\n", + "cve": "", + "severity": "Medium", + "scanner": { + "id": "semgrep", + "name": "Semgrep" + }, + "location": { + "file": "og.go", + "start_line": 8 + }, + "identifiers": [ + { + "type": "semgrep_id", + "name": "gosec.G106-1", + "value": "gosec.G106-1" + }, + { + "type": "cwe", + "name": "CWE-322", + "value": "322", + "url": "https://cwe.mitre.org/data/definitions/322.html" + }, + { + "type": "gosec_rule_id", + "name": "Gosec Rule ID G106", + "value": "G106" + } + ], + "tracking": { + "type": "source", + "items": [ + { + "file": "og.go", + "line_start": 8, + "line_end": 8, + "signatures": [ + { + "algorithm": "scope_offset", + "value": "og.go|foo[0]:1" + } + ] + } + ] + } + } + ], + "scan": { + "scanner": { + "id": "semgrep", + "name": "Semgrep", + "url": "https://github.com/returntocorp/semgrep", + "vendor": { + "name": "GitLab" + }, + "version": "0.82.0" + }, + "type": "sast", + "start_time": "2022-03-15T20:36:58", + "end_time": "2022-03-15T20:37:05", + "status": "success" + } +} diff --git a/spec/frontend/__helpers__/matchers/index.js b/spec/frontend/__helpers__/matchers/index.js index 76571bafb06..9b83ced10e1 100644 --- a/spec/frontend/__helpers__/matchers/index.js +++ b/spec/frontend/__helpers__/matchers/index.js @@ -1,3 +1,4 @@ export * from './to_have_sprite_icon'; export * from './to_have_tracking_attributes'; export * from './to_match_interpolated_text'; +export * from './to_validate_json_schema'; diff --git a/spec/frontend/__helpers__/matchers/to_validate_json_schema.js b/spec/frontend/__helpers__/matchers/to_validate_json_schema.js new file mode 100644 index 00000000000..ff391f08c55 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_validate_json_schema.js @@ -0,0 +1,34 @@ +// NOTE: Make sure to initialize ajv when using this helper + +const getAjvErrorMessage = ({ errors }) => { + return (errors || []).map((error) => { + return `Error with item ${error.instancePath}: ${error.message}`; + }); +}; + +export function toValidateJsonSchema(testData, validator) { + if (!(validator instanceof Function && validator.schema)) { + return { + validator, + message: () => + 'Validator must be a validating function with property "schema", created with `ajv.compile`. See https://ajv.js.org/api.html#ajv-compile-schema-object-data-any-boolean-promise-any.', + pass: false, + }; + } + + const isValid = validator(testData); + + return { + actual: testData, + message: () => { + if (isValid) { + // We can match, but still fail because we're in a `expect...not.` context + return 'Expected the given data not to pass the schema validation, but found that it was considered valid.'; + } + + const errorMessages = getAjvErrorMessage(validator).join('\n'); + return `Expected the given data to pass the schema validation, but found that it was considered invalid. Errors:\n${errorMessages}`; + }, + pass: isValid, + }; +} diff --git a/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js b/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js new file mode 100644 index 00000000000..fd42c710c65 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js @@ -0,0 +1,65 @@ +import Ajv from 'ajv'; +import AjvFormats from 'ajv-formats'; + +const JSON_SCHEMA = { + type: 'object', + properties: { + fruit: { + type: 'string', + minLength: 3, + }, + }, +}; + +const ajv = new Ajv({ + strictTypes: false, + strictTuples: false, + allowMatchingProperties: true, +}); + +AjvFormats(ajv); +const schema = ajv.compile(JSON_SCHEMA); + +describe('custom matcher toValidateJsonSchema', () => { + it('throws error if validator is not compiled correctly', () => { + expect(() => { + expect({}).toValidateJsonSchema({}); + }).toThrow( + 'Validator must be a validating function with property "schema", created with `ajv.compile`. See https://ajv.js.org/api.html#ajv-compile-schema-object-data-any-boolean-promise-any.', + ); + }); + + describe('positive assertions', () => { + it.each` + description | input + ${'valid input'} | ${{ fruit: 'apple' }} + `('schema validation passes for $description', ({ input }) => { + expect(input).toValidateJsonSchema(schema); + }); + + it('throws if not matching', () => { + expect(() => expect(null).toValidateJsonSchema(schema)).toThrowError( + `Expected the given data to pass the schema validation, but found that it was considered invalid. Errors: +Error with item : must be object`, + ); + }); + }); + + describe('negative assertions', () => { + it.each` + description | input + ${'no input'} | ${null} + ${'input with invalid type'} | ${'banana'} + ${'input with invalid length'} | ${{ fruit: 'aa' }} + ${'input with invalid type'} | ${{ fruit: 12345 }} + `('schema validation fails for $description', ({ input }) => { + expect(input).not.toValidateJsonSchema(schema); + }); + + it('throws if matching', () => { + expect(() => expect({ fruit: 'apple' }).not.toValidateJsonSchema(schema)).toThrowError( + 'Expected the given data not to pass the schema validation, but found that it was considered valid.', + ); + }); + }); +}); diff --git a/spec/frontend/__helpers__/mock_apollo_helper.js b/spec/frontend/__helpers__/mock_apollo_helper.js index c07a6d8ef85..bae9f33be87 100644 --- a/spec/frontend/__helpers__/mock_apollo_helper.js +++ b/spec/frontend/__helpers__/mock_apollo_helper.js @@ -1,7 +1,7 @@ import { InMemoryCache } from '@apollo/client/core'; import { createMockClient as createMockApolloClient } from 'mock-apollo-client'; import VueApollo from 'vue-apollo'; -import possibleTypes from '~/graphql_shared/possibleTypes.json'; +import possibleTypes from '~/graphql_shared/possible_types.json'; import { typePolicies } from '~/lib/graphql'; export function createMockClient(handlers = [], resolvers = {}, cacheOptions = {}) { diff --git a/spec/frontend/__helpers__/mock_dom_observer.js b/spec/frontend/__helpers__/mock_dom_observer.js index dd26b594ad9..bc2646be4c2 100644 --- a/spec/frontend/__helpers__/mock_dom_observer.js +++ b/spec/frontend/__helpers__/mock_dom_observer.js @@ -22,14 +22,14 @@ class MockObserver { takeRecords() {} - // eslint-disable-next-line babel/camelcase + // eslint-disable-next-line camelcase $_triggerObserve(node, { entry = {}, options = {} } = {}) { if (this.$_hasObserver(node, options)) { this.$_cb([{ target: node, ...entry }]); } } - // eslint-disable-next-line babel/camelcase + // eslint-disable-next-line camelcase $_hasObserver(node, options = {}) { return this.$_observers.some( ([obvNode, obvOptions]) => node === obvNode && isMatch(options, obvOptions), diff --git a/spec/frontend/__helpers__/vuex_action_helper.js b/spec/frontend/__helpers__/vuex_action_helper.js index 68203b544ef..95a811d0385 100644 --- a/spec/frontend/__helpers__/vuex_action_helper.js +++ b/spec/frontend/__helpers__/vuex_action_helper.js @@ -49,6 +49,7 @@ const noop = () => {}; * expectedActions: [], * }) */ + export default ( actionArg, payloadArg, diff --git a/spec/frontend/__helpers__/yaml_transformer.js b/spec/frontend/__helpers__/yaml_transformer.js new file mode 100644 index 00000000000..a23f9b1f715 --- /dev/null +++ b/spec/frontend/__helpers__/yaml_transformer.js @@ -0,0 +1,11 @@ +/* eslint-disable import/no-commonjs */ +const JsYaml = require('js-yaml'); + +// This will transform YAML files to JSON strings +module.exports = { + process: (sourceContent) => { + const jsonContent = JsYaml.load(sourceContent); + const json = JSON.stringify(jsonContent); + return `module.exports = ${json}`; + }, +}; diff --git a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap index dd742419d32..36003154b58 100644 --- a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap +++ b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap @@ -8,7 +8,7 @@ exports[`~/access_tokens/components/expires_at_field should render datepicker wi optionaltext="(optional)" > <gl-datepicker-stub - ariallabel="" + arialabel="" autocomplete="" container="" displayfield="true" diff --git a/spec/frontend/add_context_commits_modal/store/actions_spec.js b/spec/frontend/add_context_commits_modal/store/actions_spec.js index fa4d52cbfbb..4b58a69c2b8 100644 --- a/spec/frontend/add_context_commits_modal/store/actions_spec.js +++ b/spec/frontend/add_context_commits_modal/store/actions_spec.js @@ -42,9 +42,9 @@ describe('AddContextCommitsModalStoreActions', () => { }); describe('setBaseConfig', () => { - it('commits SET_BASE_CONFIG', (done) => { + it('commits SET_BASE_CONFIG', () => { const options = { contextCommitsPath, mergeRequestIid, projectId }; - testAction( + return testAction( setBaseConfig, options, { @@ -59,62 +59,54 @@ describe('AddContextCommitsModalStoreActions', () => { }, ], [], - done, ); }); }); describe('setTabIndex', () => { - it('commits SET_TABINDEX', (done) => { - testAction( + it('commits SET_TABINDEX', () => { + return testAction( setTabIndex, { tabIndex: 1 }, { tabIndex: 0 }, [{ type: types.SET_TABINDEX, payload: { tabIndex: 1 } }], [], - done, ); }); }); describe('setCommits', () => { - it('commits SET_COMMITS', (done) => { - testAction( + it('commits SET_COMMITS', () => { + return testAction( setCommits, { commits: [], silentAddition: false }, { isLoadingCommits: false, commits: [] }, [{ type: types.SET_COMMITS, payload: [] }], [], - done, ); }); - it('commits SET_COMMITS_SILENT', (done) => { - testAction( + it('commits SET_COMMITS_SILENT', () => { + return testAction( setCommits, { commits: [], silentAddition: true }, { isLoadingCommits: true, commits: [] }, [{ type: types.SET_COMMITS_SILENT, payload: [] }], [], - done, ); }); }); describe('createContextCommits', () => { - it('calls API to create context commits', (done) => { + it('calls API to create context commits', async () => { mock.onPost(contextCommitEndpoint).reply(200, {}); - testAction(createContextCommits, { commits: [] }, {}, [], [], done); + await testAction(createContextCommits, { commits: [] }, {}, [], []); - createContextCommits( + await createContextCommits( { state: { projectId, mergeRequestIid }, commit: () => null }, { commits: [] }, - ) - .then(() => { - done(); - }) - .catch(done.fail); + ); }); }); @@ -126,9 +118,9 @@ describe('AddContextCommitsModalStoreActions', () => { ) .reply(200, [dummyCommit]); }); - it('commits FETCH_CONTEXT_COMMITS', (done) => { + it('commits FETCH_CONTEXT_COMMITS', () => { const contextCommit = { ...dummyCommit, isSelected: true }; - testAction( + return testAction( fetchContextCommits, null, { @@ -144,20 +136,18 @@ describe('AddContextCommitsModalStoreActions', () => { { type: 'setCommits', payload: { commits: [contextCommit], silentAddition: true } }, { type: 'setSelectedCommits', payload: [contextCommit] }, ], - done, ); }); }); describe('setContextCommits', () => { - it('commits SET_CONTEXT_COMMITS', (done) => { - testAction( + it('commits SET_CONTEXT_COMMITS', () => { + return testAction( setContextCommits, { data: [] }, { contextCommits: [], isLoadingContextCommits: false }, [{ type: types.SET_CONTEXT_COMMITS, payload: { data: [] } }], [], - done, ); }); }); @@ -168,71 +158,66 @@ describe('AddContextCommitsModalStoreActions', () => { .onDelete('/api/v4/projects/gitlab-org%2Fgitlab/merge_requests/1/context_commits') .reply(204); }); - it('calls API to remove context commits', (done) => { - testAction( + it('calls API to remove context commits', () => { + return testAction( removeContextCommits, { forceReload: false }, { mergeRequestIid, projectId, toRemoveCommits: [] }, [], [], - done, ); }); }); describe('setSelectedCommits', () => { - it('commits SET_SELECTED_COMMITS', (done) => { - testAction( + it('commits SET_SELECTED_COMMITS', () => { + return testAction( setSelectedCommits, [dummyCommit], { selectedCommits: [] }, [{ type: types.SET_SELECTED_COMMITS, payload: [dummyCommit] }], [], - done, ); }); }); describe('setSearchText', () => { - it('commits SET_SEARCH_TEXT', (done) => { + it('commits SET_SEARCH_TEXT', () => { const searchText = 'Dummy Text'; - testAction( + return testAction( setSearchText, searchText, { searchText: '' }, [{ type: types.SET_SEARCH_TEXT, payload: searchText }], [], - done, ); }); }); describe('setToRemoveCommits', () => { - it('commits SET_TO_REMOVE_COMMITS', (done) => { + it('commits SET_TO_REMOVE_COMMITS', () => { const commitId = 'abcde'; - testAction( + return testAction( setToRemoveCommits, [commitId], { toRemoveCommits: [] }, [{ type: types.SET_TO_REMOVE_COMMITS, payload: [commitId] }], [], - done, ); }); }); describe('resetModalState', () => { - it('commits RESET_MODAL_STATE', (done) => { + it('commits RESET_MODAL_STATE', () => { const commitId = 'abcde'; - testAction( + return testAction( resetModalState, null, { toRemoveCommits: [commitId] }, [{ type: types.RESET_MODAL_STATE }], [], - done, ); }); }); diff --git a/spec/frontend/admin/statistics_panel/store/actions_spec.js b/spec/frontend/admin/statistics_panel/store/actions_spec.js index c7481b664b3..e7cdb5feb6a 100644 --- a/spec/frontend/admin/statistics_panel/store/actions_spec.js +++ b/spec/frontend/admin/statistics_panel/store/actions_spec.js @@ -22,8 +22,8 @@ describe('Admin statistics panel actions', () => { mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(200, mockStatistics); }); - it('dispatches success with received data', (done) => - testAction( + it('dispatches success with received data', () => { + return testAction( actions.fetchStatistics, null, state, @@ -37,8 +37,8 @@ describe('Admin statistics panel actions', () => { ), }, ], - done, - )); + ); + }); }); describe('error', () => { @@ -46,8 +46,8 @@ describe('Admin statistics panel actions', () => { mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(500); }); - it('dispatches error', (done) => - testAction( + it('dispatches error', () => { + return testAction( actions.fetchStatistics, null, state, @@ -61,26 +61,26 @@ describe('Admin statistics panel actions', () => { payload: new Error('Request failed with status code 500'), }, ], - done, - )); + ); + }); }); }); describe('requestStatistic', () => { - it('should commit the request mutation', (done) => - testAction( + it('should commit the request mutation', () => { + return testAction( actions.requestStatistics, null, state, [{ type: types.REQUEST_STATISTICS }], [], - done, - )); + ); + }); }); describe('receiveStatisticsSuccess', () => { - it('should commit received data', (done) => - testAction( + it('should commit received data', () => { + return testAction( actions.receiveStatisticsSuccess, mockStatistics, state, @@ -91,13 +91,13 @@ describe('Admin statistics panel actions', () => { }, ], [], - done, - )); + ); + }); }); describe('receiveStatisticsError', () => { - it('should commit error', (done) => { - testAction( + it('should commit error', () => { + return testAction( actions.receiveStatisticsError, 500, state, @@ -108,7 +108,6 @@ describe('Admin statistics panel actions', () => { }, ], [], - done, ); }); }); diff --git a/spec/frontend/admin/topics/components/remove_avatar_spec.js b/spec/frontend/admin/topics/components/remove_avatar_spec.js index d4656f0a199..97d257c682c 100644 --- a/spec/frontend/admin/topics/components/remove_avatar_spec.js +++ b/spec/frontend/admin/topics/components/remove_avatar_spec.js @@ -1,10 +1,11 @@ -import { GlButton, GlModal } from '@gitlab/ui'; +import { GlButton, GlModal, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import RemoveAvatar from '~/admin/topics/components/remove_avatar.vue'; const modalID = 'fake-id'; const path = 'topic/path/1'; +const name = 'Topic 1'; jest.mock('lodash/uniqueId', () => () => 'fake-id'); jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -16,10 +17,14 @@ describe('RemoveAvatar', () => { wrapper = shallowMount(RemoveAvatar, { provide: { path, + name, }, directives: { GlModal: createMockDirective(), }, + stubs: { + GlSprintf, + }, }); }; @@ -55,8 +60,8 @@ describe('RemoveAvatar', () => { const modal = findModal(); expect(modal.exists()).toBe(true); - expect(modal.props('title')).toBe('Confirm remove avatar'); - expect(modal.text()).toBe('Avatar will be removed. Are you sure?'); + expect(modal.props('title')).toBe('Remove topic avatar'); + expect(modal.text()).toBe(`Topic avatar for ${name} will be removed. This cannot be undone.`); }); it('contains the correct modal ID', () => { diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index fa485e73999..b758c15a91a 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -1,9 +1,9 @@ import { GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { kebabCase } from 'lodash'; import Actions from '~/admin/users/components/actions'; -import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue'; +import eventHub, { + EVENT_OPEN_DELETE_USER_MODAL, +} from '~/admin/users/components/modals/delete_user_modal_event_hub'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants'; @@ -14,12 +14,11 @@ describe('Action components', () => { const findDropdownItem = () => wrapper.find(GlDropdownItem); - const initComponent = ({ component, props, stubs = {} } = {}) => { + const initComponent = ({ component, props } = {}) => { wrapper = shallowMount(component, { propsData: { ...props, }, - stubs, }); }; @@ -29,7 +28,7 @@ describe('Action components', () => { }); describe('CONFIRMATION_ACTIONS', () => { - it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', async (action) => { + it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', (action) => { initComponent({ component: Actions[capitalizeFirstCharacter(action)], props: { @@ -38,20 +37,23 @@ describe('Action components', () => { }, }); - await nextTick(); expect(findDropdownItem().exists()).toBe(true); }); }); describe('DELETE_ACTION_COMPONENTS', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + }); + const userDeletionObstacles = [ { name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules }, { name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies }, ]; - it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))( - 'renders a dropdown item for "%s"', - async (action, expectedPath) => { + it.each(DELETE_ACTIONS)( + 'renders a dropdown item that opens the delete user modal when clicked for "%s"', + async (action) => { initComponent({ component: Actions[capitalizeFirstCharacter(action)], props: { @@ -59,21 +61,19 @@ describe('Action components', () => { paths, userDeletionObstacles, }, - stubs: { SharedDeleteAction }, }); - await nextTick(); - const sharedAction = wrapper.find(SharedDeleteAction); + await findDropdownItem().vm.$emit('click'); - expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block); - expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath); - expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action)); - expect(sharedAction.attributes('data-username')).toBe('John Doe'); - expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe( - JSON.stringify(userDeletionObstacles), + expect(eventHub.$emit).toHaveBeenCalledWith( + EVENT_OPEN_DELETE_USER_MODAL, + expect.objectContaining({ + username: 'John Doe', + blockPath: paths.block, + deletePath: paths[action], + userDeletionObstacles, + }), ); - - expect(findDropdownItem().exists()).toBe(true); }, ); }); diff --git a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap index 7a17ef2cc6c..265569ac0e3 100644 --- a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap @@ -1,160 +1,28 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`User Operation confirmation modal renders modal with form included 1`] = ` -<div> - <p> - <gl-sprintf-stub - message="content" - /> - </p> - - <user-deletion-obstacles-list-stub - obstacles="schedule1,policy1" - username="username" +exports[`Delete user modal renders modal with form included 1`] = ` +<form + action="" + method="post" +> + <input + name="_method" + type="hidden" + value="delete" /> - <p> - <gl-sprintf-stub - message="To confirm, type %{username}" - /> - </p> - - <form - action="delete-url" - method="post" - > - <input - name="_method" - type="hidden" - value="delete" - /> - - <input - name="authenticity_token" - type="hidden" - value="csrf" - /> - - <gl-form-input-stub - autocomplete="off" - autofocus="" - name="username" - type="text" - value="" - /> - </form> - <gl-button-stub - buttontextclasses="" - category="primary" - icon="" - size="medium" - variant="default" - > - Cancel - </gl-button-stub> - - <gl-button-stub - buttontextclasses="" - category="secondary" - disabled="true" - icon="" - size="medium" - variant="danger" - > - - secondaryAction - - </gl-button-stub> - - <gl-button-stub - buttontextclasses="" - category="primary" - disabled="true" - icon="" - size="medium" - variant="danger" - > - action - </gl-button-stub> -</div> -`; - -exports[`User Operation confirmation modal when user's name has leading and trailing whitespace displays user's name without whitespace 1`] = ` -<div> - <p> - content - </p> - - <user-deletion-obstacles-list-stub - obstacles="schedule1,policy1" - username="John Smith" + <input + name="authenticity_token" + type="hidden" + value="csrf" /> - <p> - To confirm, type - <code - class="gl-white-space-pre-wrap" - > - John Smith - </code> - </p> - - <form - action="delete-url" - method="post" - > - <input - name="_method" - type="hidden" - value="delete" - /> - - <input - name="authenticity_token" - type="hidden" - value="csrf" - /> - - <gl-form-input-stub - autocomplete="off" - autofocus="" - name="username" - type="text" - value="" - /> - </form> - <gl-button-stub - buttontextclasses="" - category="primary" - icon="" - size="medium" - variant="default" - > - Cancel - </gl-button-stub> - - <gl-button-stub - buttontextclasses="" - category="secondary" - disabled="true" - icon="" - size="medium" - variant="danger" - > - - secondaryAction - - </gl-button-stub> - - <gl-button-stub - buttontextclasses="" - category="primary" - disabled="true" - icon="" - size="medium" - variant="danger" - > - action - </gl-button-stub> -</div> + <gl-form-input-stub + autocomplete="off" + autofocus="" + name="username" + type="text" + value="" + /> +</form> `; diff --git a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js index f875cd24ee1..09a345ac826 100644 --- a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js +++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js @@ -1,6 +1,8 @@ import { GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import eventHub, { + EVENT_OPEN_DELETE_USER_MODAL, +} from '~/admin/users/components/modals/delete_user_modal_event_hub'; import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; import ModalStub from './stubs/modal_stub'; @@ -9,7 +11,7 @@ const TEST_DELETE_USER_URL = 'delete-url'; const TEST_BLOCK_USER_URL = 'block-url'; const TEST_CSRF = 'csrf'; -describe('User Operation confirmation modal', () => { +describe('Delete user modal', () => { let wrapper; let formSubmitSpy; @@ -27,28 +29,36 @@ describe('User Operation confirmation modal', () => { const getMethodParam = () => new FormData(findForm().element).get('_method'); const getFormAction = () => findForm().attributes('action'); const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList); + const findMessageUsername = () => wrapper.findByTestId('message-username'); + const findConfirmUsername = () => wrapper.findByTestId('confirm-username'); + const emitOpenModalEvent = (modalData) => { + return eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, modalData); + }; const setUsername = (username) => { - findUsernameInput().vm.$emit('input', username); + return findUsernameInput().vm.$emit('input', username); }; const username = 'username'; const badUsername = 'bad_username'; - const userDeletionObstacles = '["schedule1", "policy1"]'; + const userDeletionObstacles = ['schedule1', 'policy1']; + + const mockModalData = { + username, + blockPath: TEST_BLOCK_USER_URL, + deletePath: TEST_DELETE_USER_URL, + userDeletionObstacles, + i18n: { + title: 'Modal for %{username}', + primaryButtonLabel: 'Delete user', + messageBody: 'Delete %{username} or rather %{strongStart}block user%{strongEnd}?', + }, + }; - const createComponent = (props = {}, stubs = {}) => { - wrapper = shallowMount(DeleteUserModal, { + const createComponent = (stubs = {}) => { + wrapper = shallowMountExtended(DeleteUserModal, { propsData: { - username, - title: 'title', - content: 'content', - action: 'action', - secondaryAction: 'secondaryAction', - deleteUserUrl: TEST_DELETE_USER_URL, - blockUserUrl: TEST_BLOCK_USER_URL, csrfToken: TEST_CSRF, - userDeletionObstacles, - ...props, }, stubs: { GlModal: ModalStub, @@ -68,7 +78,7 @@ describe('User Operation confirmation modal', () => { it('renders modal with form included', () => { createComponent(); - expect(wrapper.element).toMatchSnapshot(); + expect(findForm().element).toMatchSnapshot(); }); describe('on created', () => { @@ -83,11 +93,11 @@ describe('User Operation confirmation modal', () => { }); describe('with incorrect username', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); - setUsername(badUsername); + emitOpenModalEvent(mockModalData); - await nextTick(); + return setUsername(badUsername); }); it('shows incorrect username', () => { @@ -101,11 +111,11 @@ describe('User Operation confirmation modal', () => { }); describe('with correct username', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); - setUsername(username); + emitOpenModalEvent(mockModalData); - await nextTick(); + return setUsername(username); }); it('shows correct username', () => { @@ -117,11 +127,9 @@ describe('User Operation confirmation modal', () => { expect(findSecondaryButton().attributes('disabled')).toBeFalsy(); }); - describe('when primary action is submitted', () => { - beforeEach(async () => { - findPrimaryButton().vm.$emit('click'); - - await nextTick(); + describe('when primary action is clicked', () => { + beforeEach(() => { + return findPrimaryButton().vm.$emit('click'); }); it('clears the input', () => { @@ -136,11 +144,9 @@ describe('User Operation confirmation modal', () => { }); }); - describe('when secondary action is submitted', () => { - beforeEach(async () => { - findSecondaryButton().vm.$emit('click'); - - await nextTick(); + describe('when secondary action is clicked', () => { + beforeEach(() => { + return findSecondaryButton().vm.$emit('click'); }); it('has correct form attributes and calls submit', () => { @@ -154,22 +160,23 @@ describe('User Operation confirmation modal', () => { describe("when user's name has leading and trailing whitespace", () => { beforeEach(() => { - createComponent( - { - username: ' John Smith ', - }, - { GlSprintf }, - ); + createComponent({ GlSprintf }); + return emitOpenModalEvent({ ...mockModalData, username: ' John Smith ' }); }); it("displays user's name without whitespace", () => { - expect(wrapper.element).toMatchSnapshot(); + expect(findMessageUsername().text()).toBe('John Smith'); + expect(findConfirmUsername().text()).toBe('John Smith'); }); - it("shows enabled buttons when user's name is entered without whitespace", async () => { - setUsername('John Smith'); + it('passes user name without whitespace to the obstacles', () => { + expect(findUserDeletionObstaclesList().props()).toMatchObject({ + userName: 'John Smith', + }); + }); - await nextTick(); + it("shows enabled buttons when user's name is entered without whitespace", async () => { + await setUsername('John Smith'); expect(findPrimaryButton().attributes('disabled')).toBeUndefined(); expect(findSecondaryButton().attributes('disabled')).toBeUndefined(); @@ -177,17 +184,20 @@ describe('User Operation confirmation modal', () => { }); describe('Related user-deletion-obstacles list', () => { - it('does NOT render the list when user has no related obstacles', () => { - createComponent({ userDeletionObstacles: '[]' }); + it('does NOT render the list when user has no related obstacles', async () => { + createComponent(); + await emitOpenModalEvent({ ...mockModalData, userDeletionObstacles: [] }); + expect(findUserDeletionObstaclesList().exists()).toBe(false); }); - it('renders the list when user has related obstalces', () => { + it('renders the list when user has related obstalces', async () => { createComponent(); + await emitOpenModalEvent(mockModalData); const obstacles = findUserDeletionObstaclesList(); expect(obstacles.exists()).toBe(true); - expect(obstacles.props('obstacles')).toEqual(JSON.parse(userDeletionObstacles)); + expect(obstacles.props('obstacles')).toEqual(userDeletionObstacles); }); }); }); diff --git a/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js b/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js deleted file mode 100644 index 4786357faa1..00000000000 --- a/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js +++ /dev/null @@ -1,126 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import UserModalManager from '~/admin/users/components/modals/user_modal_manager.vue'; -import ModalStub from './stubs/modal_stub'; - -describe('Users admin page Modal Manager', () => { - let wrapper; - - const modalConfiguration = { - action1: { - title: 'action1', - content: 'Action Modal 1', - }, - action2: { - title: 'action2', - content: 'Action Modal 2', - }, - }; - - const findModal = () => wrapper.find({ ref: 'modal' }); - - const createComponent = (props = {}) => { - wrapper = mount(UserModalManager, { - propsData: { - selector: '.js-delete-user-modal-button', - modalConfiguration, - csrfToken: 'dummyCSRF', - ...props, - }, - stubs: { - DeleteUserModal: ModalStub, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('render behavior', () => { - it('does not renders modal when initialized', () => { - createComponent(); - expect(findModal().exists()).toBeFalsy(); - }); - - it('throws if action has no proper configuration', () => { - createComponent({ - modalConfiguration: {}, - }); - expect(() => wrapper.vm.show({ glModalAction: 'action1' })).toThrow(); - }); - - it('renders modal with expected props when valid configuration is passed', async () => { - createComponent(); - wrapper.vm.show({ - glModalAction: 'action1', - extraProp: 'extraPropValue', - }); - - await nextTick(); - const modal = findModal(); - expect(modal.exists()).toBeTruthy(); - expect(modal.vm.$attrs.csrfToken).toEqual('dummyCSRF'); - expect(modal.vm.$attrs.extraProp).toEqual('extraPropValue'); - expect(modal.vm.showWasCalled).toBeTruthy(); - }); - }); - - describe('click handling', () => { - let button; - let button2; - - const createButtons = () => { - button = document.createElement('button'); - button2 = document.createElement('button'); - button.setAttribute('class', 'js-delete-user-modal-button'); - button.setAttribute('data-username', 'foo'); - button.setAttribute('data-gl-modal-action', 'action1'); - button.setAttribute('data-block-user-url', '/block'); - button.setAttribute('data-delete-user-url', '/delete'); - document.body.appendChild(button); - document.body.appendChild(button2); - }; - const removeButtons = () => { - button.remove(); - button = null; - button2.remove(); - button2 = null; - }; - - beforeEach(() => { - createButtons(); - createComponent(); - }); - - afterEach(() => { - removeButtons(); - }); - - it('renders the modal when the button is clicked', async () => { - button.click(); - - await nextTick(); - - expect(findModal().exists()).toBe(true); - }); - - it('does not render the modal when a misconfigured button is clicked', async () => { - button.removeAttribute('data-gl-modal-action'); - button.click(); - - await nextTick(); - - expect(findModal().exists()).toBe(false); - }); - - it('does not render the modal when a button without the selector class is clicked', async () => { - button2.click(); - - await nextTick(); - - expect(findModal().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 6193233881d..ed185c11732 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -476,9 +476,6 @@ describe('AlertsSettingsWrapper', () => { destroyHttpIntegration(wrapper); expect(destroyIntegrationHandler).toHaveBeenCalled(); - await waitForPromises(); - - expect(findIntegrations()).toHaveLength(3); }); it('displays flash if mutation had a recoverable error', async () => { diff --git a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js index 694dff56632..170af1b5e0c 100644 --- a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js +++ b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js @@ -102,7 +102,7 @@ export const destroyIntegrationResponse = { httpIntegrationDestroy: { errors: [], integration: { - __typename: 'AlertManagementIntegration', + __typename: 'AlertManagementHttpIntegration', id: '37', type: 'HTTP', active: true, diff --git a/spec/frontend/api/alert_management_alerts_api_spec.js b/spec/frontend/api/alert_management_alerts_api_spec.js new file mode 100644 index 00000000000..aac14e64286 --- /dev/null +++ b/spec/frontend/api/alert_management_alerts_api_spec.js @@ -0,0 +1,140 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as alertManagementAlertsApi from '~/api/alert_management_alerts_api'; +import axios from '~/lib/utils/axios_utils'; + +describe('~/api/alert_management_alerts_api.js', () => { + let mock; + let originalGon; + + const projectId = 1; + const alertIid = 2; + + const imageData = { filePath: 'test', filename: 'hello', id: 5, url: null }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + originalGon = window.gon; + window.gon = { api_version: 'v4' }; + }); + + afterEach(() => { + mock.restore(); + window.gon = originalGon; + }); + + describe('fetchAlertMetricImages', () => { + beforeEach(() => { + jest.spyOn(axios, 'get'); + }); + + it('retrieves metric images from the correct URL and returns them in the response data', () => { + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images`; + const expectedData = [imageData]; + const options = { alertIid, id: projectId }; + + mock.onGet(expectedUrl).reply(200, { data: expectedData }); + + return alertManagementAlertsApi.fetchAlertMetricImages(options).then(({ data }) => { + expect(axios.get).toHaveBeenCalledWith(expectedUrl); + expect(data.data).toEqual(expectedData); + }); + }); + }); + + describe('uploadAlertMetricImage', () => { + beforeEach(() => { + jest.spyOn(axios, 'post'); + }); + + it('uploads a metric image to the correct URL and returns it in the response data', () => { + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images`; + const expectedData = [imageData]; + + const file = new File(['zip contents'], 'hello'); + const url = 'https://www.example.com'; + const urlText = 'Example website'; + + const expectedFormData = new FormData(); + expectedFormData.append('file', file); + expectedFormData.append('url', url); + expectedFormData.append('url_text', urlText); + + mock.onPost(expectedUrl).reply(201, { data: expectedData }); + + return alertManagementAlertsApi + .uploadAlertMetricImage({ + alertIid, + id: projectId, + file, + url, + urlText, + }) + .then(({ data }) => { + expect(data).toEqual({ data: expectedData }); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, expectedFormData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }); + }); + }); + + describe('updateAlertMetricImage', () => { + beforeEach(() => { + jest.spyOn(axios, 'put'); + }); + + it('updates a metric image to the correct URL and returns it in the response data', () => { + const imageIid = 3; + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images/${imageIid}`; + const expectedData = [imageData]; + + const url = 'https://www.example.com'; + const urlText = 'Example website'; + + const expectedFormData = new FormData(); + expectedFormData.append('url', url); + expectedFormData.append('url_text', urlText); + + mock.onPut(expectedUrl).reply(200, { data: expectedData }); + + return alertManagementAlertsApi + .updateAlertMetricImage({ + alertIid, + id: projectId, + imageId: imageIid, + url, + urlText, + }) + .then(({ data }) => { + expect(data).toEqual({ data: expectedData }); + expect(axios.put).toHaveBeenCalledWith(expectedUrl, expectedFormData); + }); + }); + }); + + describe('deleteAlertMetricImage', () => { + beforeEach(() => { + jest.spyOn(axios, 'delete'); + }); + + it('deletes a metric image to the correct URL and returns it in the response data', () => { + const imageIid = 3; + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images/${imageIid}`; + const expectedData = [imageData]; + + mock.onDelete(expectedUrl).reply(204, { data: expectedData }); + + return alertManagementAlertsApi + .deleteAlertMetricImage({ + alertIid, + id: projectId, + imageId: imageIid, + }) + .then(({ data }) => { + expect(data).toEqual({ data: expectedData }); + expect(axios.delete).toHaveBeenCalledWith(expectedUrl); + }); + }); + }); +}); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index bc3e12d3fc4..85332bf21d8 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -2,6 +2,9 @@ import MockAdapter from 'axios-mock-adapter'; import Api, { DEFAULT_PER_PAGE } from '~/api'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); describe('Api', () => { const dummyApiVersion = 'v3000'; @@ -155,66 +158,44 @@ describe('Api', () => { }); describe('group', () => { - it('fetches a group', (done) => { + it('fetches a group', () => { const groupId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}`; mock.onGet(expectedUrl).reply(httpStatus.OK, { name: 'test', }); - Api.group(groupId, (response) => { - expect(response.name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.group(groupId, (response) => { + expect(response.name).toBe('test'); + resolve(); + }); }); }); }); describe('groupMembers', () => { - it('fetches group members', (done) => { + it('fetches group members', () => { const groupId = '54321'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/members`; const expectedData = [{ id: 7 }]; mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); - Api.groupMembers(groupId) - .then(({ data }) => { - expect(data).toEqual(expectedData); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('addGroupMembersByUserId', () => { - it('adds an existing User as a new Group Member by User ID', () => { - const groupId = 1; - const expectedUserId = 2; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/members`; - const params = { - user_id: expectedUserId, - access_level: 10, - expires_at: undefined, - }; - - mock.onPost(expectedUrl).reply(200, { - id: expectedUserId, - state: 'active', - }); - - return Api.addGroupMembersByUserId(groupId, params).then(({ data }) => { - expect(data.id).toBe(expectedUserId); - expect(data.state).toBe('active'); + return Api.groupMembers(groupId).then(({ data }) => { + expect(data).toEqual(expectedData); }); }); }); - describe('inviteGroupMembersByEmail', () => { + describe('inviteGroupMembers', () => { it('invites a new email address to create a new User and become a Group Member', () => { const groupId = 1; const email = 'email@example.com'; + const userId = '1'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/invitations`; const params = { email, + userId, access_level: 10, expires_at: undefined, }; @@ -223,14 +204,14 @@ describe('Api', () => { status: 'success', }); - return Api.inviteGroupMembersByEmail(groupId, params).then(({ data }) => { + return Api.inviteGroupMembers(groupId, params).then(({ data }) => { expect(data.status).toBe('success'); }); }); }); describe('groupMilestones', () => { - it('fetches group milestones', (done) => { + it('fetches group milestones', () => { const groupId = '16'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/milestones`; const expectedData = [ @@ -250,17 +231,14 @@ describe('Api', () => { ]; mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); - Api.groupMilestones(groupId) - .then(({ data }) => { - expect(data).toEqual(expectedData); - }) - .then(done) - .catch(done.fail); + return Api.groupMilestones(groupId).then(({ data }) => { + expect(data).toEqual(expectedData); + }); }); }); describe('groups', () => { - it('fetches groups', (done) => { + it('fetches groups', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups.json`; @@ -270,16 +248,18 @@ describe('Api', () => { }, ]); - Api.groups(query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.groups(query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('groupLabels', () => { - it('fetches group labels', (done) => { + it('fetches group labels', () => { const options = { params: { search: 'foo' } }; const expectedGroup = 'gitlab-org'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${expectedGroup}/labels`; @@ -290,18 +270,15 @@ describe('Api', () => { }, ]); - Api.groupLabels(expectedGroup, options) - .then((res) => { - expect(res.length).toBe(1); - expect(res[0].name).toBe('Foo Label'); - }) - .then(done) - .catch(done.fail); + return Api.groupLabels(expectedGroup, options).then((res) => { + expect(res.length).toBe(1); + expect(res[0].name).toBe('Foo Label'); + }); }); }); describe('namespaces', () => { - it('fetches namespaces', (done) => { + it('fetches namespaces', () => { const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/namespaces.json`; mock.onGet(expectedUrl).reply(httpStatus.OK, [ @@ -310,16 +287,18 @@ describe('Api', () => { }, ]); - Api.namespaces(query, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.namespaces(query, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('projects', () => { - it('fetches projects with membership when logged in', (done) => { + it('fetches projects with membership when logged in', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; @@ -330,14 +309,16 @@ describe('Api', () => { }, ]); - Api.projects(query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projects(query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); - it('fetches projects without membership when not logged in', (done) => { + it('fetches projects without membership when not logged in', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; @@ -347,31 +328,30 @@ describe('Api', () => { }, ]); - Api.projects(query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projects(query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('updateProject', () => { - it('update a project with the given payload', (done) => { + it('update a project with the given payload', () => { const projectPath = 'foo'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}`; mock.onPut(expectedUrl).reply(httpStatus.OK, { foo: 'bar' }); - Api.updateProject(projectPath, { foo: 'bar' }) - .then(({ data }) => { - expect(data.foo).toBe('bar'); - done(); - }) - .catch(done.fail); + return Api.updateProject(projectPath, { foo: 'bar' }).then(({ data }) => { + expect(data.foo).toBe('bar'); + }); }); }); describe('projectUsers', () => { - it('fetches all users of a particular project', (done) => { + it('fetches all users of a particular project', () => { const query = 'dummy query'; const options = { unused: 'option' }; const projectPath = 'gitlab-org%2Fgitlab-ce'; @@ -382,13 +362,10 @@ describe('Api', () => { }, ]); - Api.projectUsers('gitlab-org/gitlab-ce', query, options) - .then((response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.projectUsers('gitlab-org/gitlab-ce', query, options).then((response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + }); }); }); @@ -396,38 +373,32 @@ describe('Api', () => { const projectPath = 'abc'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests`; - it('fetches all merge requests for a project', (done) => { + it('fetches all merge requests for a project', () => { const mockData = [{ source_branch: 'foo' }, { source_branch: 'bar' }]; mock.onGet(expectedUrl).reply(httpStatus.OK, mockData); - Api.projectMergeRequests(projectPath) - .then(({ data }) => { - expect(data.length).toEqual(2); - expect(data[0].source_branch).toBe('foo'); - expect(data[1].source_branch).toBe('bar'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequests(projectPath).then(({ data }) => { + expect(data.length).toEqual(2); + expect(data[0].source_branch).toBe('foo'); + expect(data[1].source_branch).toBe('bar'); + }); }); - it('fetches merge requests filtered with passed params', (done) => { + it('fetches merge requests filtered with passed params', () => { const params = { source_branch: 'bar', }; const mockData = [{ source_branch: 'bar' }]; mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData); - Api.projectMergeRequests(projectPath, params) - .then(({ data }) => { - expect(data.length).toEqual(1); - expect(data[0].source_branch).toBe('bar'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequests(projectPath, params).then(({ data }) => { + expect(data.length).toEqual(1); + expect(data[0].source_branch).toBe('bar'); + }); }); }); describe('projectMergeRequest', () => { - it('fetches a merge request', (done) => { + it('fetches a merge request', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}`; @@ -435,17 +406,14 @@ describe('Api', () => { title: 'test', }); - Api.projectMergeRequest(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data.title).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequest(projectPath, mergeRequestId).then(({ data }) => { + expect(data.title).toBe('test'); + }); }); }); describe('projectMergeRequestChanges', () => { - it('fetches the changes of a merge request', (done) => { + it('fetches the changes of a merge request', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/changes`; @@ -453,17 +421,14 @@ describe('Api', () => { title: 'test', }); - Api.projectMergeRequestChanges(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data.title).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequestChanges(projectPath, mergeRequestId).then(({ data }) => { + expect(data.title).toBe('test'); + }); }); }); describe('projectMergeRequestVersions', () => { - it('fetches the versions of a merge request', (done) => { + it('fetches the versions of a merge request', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/versions`; @@ -473,30 +438,24 @@ describe('Api', () => { }, ]); - Api.projectMergeRequestVersions(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].id).toBe(123); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequestVersions(projectPath, mergeRequestId).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].id).toBe(123); + }); }); }); describe('projectRunners', () => { - it('fetches the runners of a project', (done) => { + it('fetches the runners of a project', () => { const projectPath = 7; const params = { scope: 'active' }; const mockData = [{ id: 4 }]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/runners`; mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData); - Api.projectRunners(projectPath, { params }) - .then(({ data }) => { - expect(data).toEqual(mockData); - }) - .then(done) - .catch(done.fail); + return Api.projectRunners(projectPath, { params }).then(({ data }) => { + expect(data).toEqual(mockData); + }); }); }); @@ -525,7 +484,7 @@ describe('Api', () => { }); describe('projectMilestones', () => { - it('fetches project milestones', (done) => { + it('fetches project milestones', () => { const projectId = 1; const options = { state: 'active' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/milestones`; @@ -537,13 +496,10 @@ describe('Api', () => { }, ]); - Api.projectMilestones(projectId, options) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].title).toBe('milestone1'); - }) - .then(done) - .catch(done.fail); + return Api.projectMilestones(projectId, options).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].title).toBe('milestone1'); + }); }); }); @@ -566,36 +522,15 @@ describe('Api', () => { }); }); - describe('addProjectMembersByUserId', () => { - it('adds an existing User as a new Project Member by User ID', () => { - const projectId = 1; - const expectedUserId = 2; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/members`; - const params = { - user_id: expectedUserId, - access_level: 10, - expires_at: undefined, - }; - - mock.onPost(expectedUrl).reply(200, { - id: expectedUserId, - state: 'active', - }); - - return Api.addProjectMembersByUserId(projectId, params).then(({ data }) => { - expect(data.id).toBe(expectedUserId); - expect(data.state).toBe('active'); - }); - }); - }); - - describe('inviteProjectMembersByEmail', () => { + describe('inviteProjectMembers', () => { it('invites a new email address to create a new User and become a Project Member', () => { const projectId = 1; - const expectedEmail = 'email@example.com'; + const email = 'email@example.com'; + const userId = '1'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/invitations`; const params = { - email: expectedEmail, + email, + userId, access_level: 10, expires_at: undefined, }; @@ -604,14 +539,14 @@ describe('Api', () => { status: 'success', }); - return Api.inviteProjectMembersByEmail(projectId, params).then(({ data }) => { + return Api.inviteProjectMembers(projectId, params).then(({ data }) => { expect(data.status).toBe('success'); }); }); }); describe('newLabel', () => { - it('creates a new project label', (done) => { + it('creates a new project label', () => { const namespace = 'some namespace'; const project = 'some project'; const labelData = { some: 'data' }; @@ -630,13 +565,15 @@ describe('Api', () => { ]; }); - Api.newLabel(namespace, project, labelData, (response) => { - expect(response.name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.newLabel(namespace, project, labelData, (response) => { + expect(response.name).toBe('test'); + resolve(); + }); }); }); - it('creates a new group label', (done) => { + it('creates a new group label', () => { const namespace = 'group/subgroup'; const labelData = { name: 'Foo', color: '#000000' }; const expectedUrl = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace); @@ -651,15 +588,17 @@ describe('Api', () => { ]; }); - Api.newLabel(namespace, undefined, labelData, (response) => { - expect(response.name).toBe('Foo'); - done(); + return new Promise((resolve) => { + Api.newLabel(namespace, undefined, labelData, (response) => { + expect(response.name).toBe('Foo'); + resolve(); + }); }); }); }); describe('groupProjects', () => { - it('fetches group projects', (done) => { + it('fetches group projects', () => { const groupId = '123456'; const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; @@ -669,11 +608,40 @@ describe('Api', () => { }, ]); - Api.groupProjects(groupId, query, {}, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.groupProjects(groupId, query, {}, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); + }); + }); + + it('uses flesh on error by default', async () => { + const groupId = '123456'; + const query = 'dummy query'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; + const flashCallback = (callCount) => { + expect(createFlash).toHaveBeenCalledTimes(callCount); + createFlash.mockClear(); + }; + + mock.onGet(expectedUrl).reply(500, null); + + const response = await Api.groupProjects(groupId, query, {}, () => {}).then(() => { + flashCallback(1); }); + expect(response).toBeUndefined(); + }); + + it('NOT uses flesh on error with param useCustomErrorHandler', async () => { + const groupId = '123456'; + const query = 'dummy query'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; + + mock.onGet(expectedUrl).reply(500, null); + const apiCall = Api.groupProjects(groupId, query, {}, () => {}, true); + await expect(apiCall).rejects.toThrow(); }); }); @@ -734,12 +702,14 @@ describe('Api', () => { templateKey, )}`; - it('fetches an issue template', (done) => { + it('fetches an issue template', () => { mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); - Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => { - expect(response).toBe('test'); - done(); + return new Promise((resolve) => { + Api.issueTemplate(namespace, project, templateKey, templateType, (_, response) => { + expect(response).toBe('test'); + resolve(); + }); }); }); @@ -747,8 +717,11 @@ describe('Api', () => { it('rejects the Promise', () => { mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); - Api.issueTemplate(namespace, project, templateKey, templateType, () => { - expect(mock.history.get).toHaveLength(1); + return new Promise((resolve) => { + Api.issueTemplate(namespace, project, templateKey, templateType, () => { + expect(mock.history.get).toHaveLength(1); + resolve(); + }); }); }); }); @@ -760,19 +733,21 @@ describe('Api', () => { const templateType = 'template type'; const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}`; - it('fetches all templates by type', (done) => { + it('fetches all templates by type', () => { const expectedData = [ { key: 'Template1', name: 'Template 1', content: 'This is template 1!' }, ]; mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); - Api.issueTemplates(namespace, project, templateType, (error, response) => { - expect(response.length).toBe(1); - const { key, name, content } = response[0]; - expect(key).toBe('Template1'); - expect(name).toBe('Template 1'); - expect(content).toBe('This is template 1!'); - done(); + return new Promise((resolve) => { + Api.issueTemplates(namespace, project, templateType, (_, response) => { + expect(response.length).toBe(1); + const { key, name, content } = response[0]; + expect(key).toBe('Template1'); + expect(name).toBe('Template 1'); + expect(content).toBe('This is template 1!'); + resolve(); + }); }); }); @@ -788,34 +763,44 @@ describe('Api', () => { }); describe('projectTemplates', () => { - it('fetches a list of templates', (done) => { + it('fetches a list of templates', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses`; mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); - Api.projectTemplates('gitlab-org/gitlab-ce', 'licenses', {}, (response) => { - expect(response).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projectTemplates('gitlab-org/gitlab-ce', 'licenses', {}, (response) => { + expect(response).toBe('test'); + resolve(); + }); }); }); }); describe('projectTemplate', () => { - it('fetches a single template', (done) => { + it('fetches a single template', () => { const data = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses/test%20license`; mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); - Api.projectTemplate('gitlab-org/gitlab-ce', 'licenses', 'test license', data, (response) => { - expect(response).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projectTemplate( + 'gitlab-org/gitlab-ce', + 'licenses', + 'test license', + data, + (response) => { + expect(response).toBe('test'); + resolve(); + }, + ); }); }); }); describe('users', () => { - it('fetches users', (done) => { + it('fetches users', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users.json`; @@ -825,68 +810,56 @@ describe('Api', () => { }, ]); - Api.users(query, options) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.users(query, options).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }); }); }); describe('user', () => { - it('fetches single user', (done) => { + it('fetches single user', () => { const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`; mock.onGet(expectedUrl).reply(httpStatus.OK, { name: 'testuser', }); - Api.user(userId) - .then(({ data }) => { - expect(data.name).toBe('testuser'); - }) - .then(done) - .catch(done.fail); + return Api.user(userId).then(({ data }) => { + expect(data.name).toBe('testuser'); + }); }); }); describe('user counts', () => { - it('fetches single user counts', (done) => { + it('fetches single user counts', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/user_counts`; mock.onGet(expectedUrl).reply(httpStatus.OK, { merge_requests: 4, }); - Api.userCounts() - .then(({ data }) => { - expect(data.merge_requests).toBe(4); - }) - .then(done) - .catch(done.fail); + return Api.userCounts().then(({ data }) => { + expect(data.merge_requests).toBe(4); + }); }); }); describe('user status', () => { - it('fetches single user status', (done) => { + it('fetches single user status', () => { const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`; mock.onGet(expectedUrl).reply(httpStatus.OK, { message: 'testmessage', }); - Api.userStatus(userId) - .then(({ data }) => { - expect(data.message).toBe('testmessage'); - }) - .then(done) - .catch(done.fail); + return Api.userStatus(userId).then(({ data }) => { + expect(data.message).toBe('testmessage'); + }); }); }); describe('user projects', () => { - it('fetches all projects that belong to a particular user', (done) => { + it('fetches all projects that belong to a particular user', () => { const query = 'dummy query'; const options = { unused: 'option' }; const userId = '123456'; @@ -897,16 +870,18 @@ describe('Api', () => { }, ]); - Api.userProjects(userId, query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.userProjects(userId, query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('commitPipelines', () => { - it('fetches pipelines for a given commit', (done) => { + it('fetches pipelines for a given commit', () => { const projectId = 'example/foobar'; const commitSha = 'abc123def'; const expectedUrl = `${dummyUrlRoot}/${projectId}/commit/${commitSha}/pipelines`; @@ -916,13 +891,10 @@ describe('Api', () => { }, ]); - Api.commitPipelines(projectId, commitSha) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.commitPipelines(projectId, commitSha).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }); }); }); @@ -947,7 +919,7 @@ describe('Api', () => { }); describe('createBranch', () => { - it('creates new branch', (done) => { + it('creates new branch', () => { const ref = 'main'; const branch = 'new-branch-name'; const dummyProjectPath = 'gitlab-org/gitlab-ce'; @@ -961,18 +933,15 @@ describe('Api', () => { name: branch, }); - Api.createBranch(dummyProjectPath, { ref, branch }) - .then(({ data }) => { - expect(data.name).toBe(branch); - expect(axios.post).toHaveBeenCalledWith(expectedUrl, { ref, branch }); - }) - .then(done) - .catch(done.fail); + return Api.createBranch(dummyProjectPath, { ref, branch }).then(({ data }) => { + expect(data.name).toBe(branch); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, { ref, branch }); + }); }); }); describe('projectForks', () => { - it('gets forked projects', (done) => { + it('gets forked projects', () => { const dummyProjectPath = 'gitlab-org/gitlab-ce'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent( dummyProjectPath, @@ -982,20 +951,17 @@ describe('Api', () => { mock.onGet(expectedUrl).replyOnce(httpStatus.OK, ['fork']); - Api.projectForks(dummyProjectPath, { visibility: 'private' }) - .then(({ data }) => { - expect(data).toEqual(['fork']); - expect(axios.get).toHaveBeenCalledWith(expectedUrl, { - params: { visibility: 'private' }, - }); - }) - .then(done) - .catch(done.fail); + return Api.projectForks(dummyProjectPath, { visibility: 'private' }).then(({ data }) => { + expect(data).toEqual(['fork']); + expect(axios.get).toHaveBeenCalledWith(expectedUrl, { + params: { visibility: 'private' }, + }); + }); }); }); describe('createContextCommits', () => { - it('creates a new context commit', (done) => { + it('creates a new context commit', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const commitsData = ['abcdefg']; @@ -1014,17 +980,16 @@ describe('Api', () => { }, ]); - Api.createContextCommits(projectPath, mergeRequestId, expectedData) - .then(({ data }) => { + return Api.createContextCommits(projectPath, mergeRequestId, expectedData).then( + ({ data }) => { expect(data[0].title).toBe('Dummy commit'); - }) - .then(done) - .catch(done.fail); + }, + ); }); }); describe('allContextCommits', () => { - it('gets all context commits', (done) => { + it('gets all context commits', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/context_commits`; @@ -1035,17 +1000,14 @@ describe('Api', () => { .onGet(expectedUrl) .replyOnce(200, [{ id: 'abcdef', short_id: 'abcdefghi', title: 'Dummy commit title' }]); - Api.allContextCommits(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data[0].title).toBe('Dummy commit title'); - }) - .then(done) - .catch(done.fail); + return Api.allContextCommits(projectPath, mergeRequestId).then(({ data }) => { + expect(data[0].title).toBe('Dummy commit title'); + }); }); }); describe('removeContextCommits', () => { - it('removes context commits', (done) => { + it('removes context commits', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const commitsData = ['abcdefg']; @@ -1058,12 +1020,9 @@ describe('Api', () => { mock.onDelete(expectedUrl).replyOnce(204); - Api.removeContextCommits(projectPath, mergeRequestId, expectedData) - .then(() => { - expect(axios.delete).toHaveBeenCalledWith(expectedUrl, { data: expectedData }); - }) - .then(done) - .catch(done.fail); + return Api.removeContextCommits(projectPath, mergeRequestId, expectedData).then(() => { + expect(axios.delete).toHaveBeenCalledWith(expectedUrl, { data: expectedData }); + }); }); }); @@ -1306,41 +1265,37 @@ describe('Api', () => { }); describe('updateIssue', () => { - it('update an issue with the given payload', (done) => { + it('update an issue with the given payload', () => { const projectId = 8; const issue = 1; const expectedArray = [1, 2, 3]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/issues/${issue}`; mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray }); - Api.updateIssue(projectId, issue, { assigneeIds: expectedArray }) - .then(({ data }) => { - expect(data.assigneeIds).toEqual(expectedArray); - done(); - }) - .catch(done.fail); + return Api.updateIssue(projectId, issue, { assigneeIds: expectedArray }).then(({ data }) => { + expect(data.assigneeIds).toEqual(expectedArray); + }); }); }); describe('updateMergeRequest', () => { - it('update an issue with the given payload', (done) => { + it('update an issue with the given payload', () => { const projectId = 8; const mergeRequest = 1; const expectedArray = [1, 2, 3]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/merge_requests/${mergeRequest}`; mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray }); - Api.updateMergeRequest(projectId, mergeRequest, { assigneeIds: expectedArray }) - .then(({ data }) => { + return Api.updateMergeRequest(projectId, mergeRequest, { assigneeIds: expectedArray }).then( + ({ data }) => { expect(data.assigneeIds).toEqual(expectedArray); - done(); - }) - .catch(done.fail); + }, + ); }); }); describe('tags', () => { - it('fetches all tags of a particular project', (done) => { + it('fetches all tags of a particular project', () => { const query = 'dummy query'; const options = { unused: 'option' }; const projectId = 8; @@ -1351,13 +1306,10 @@ describe('Api', () => { }, ]); - Api.tags(projectId, query, options) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.tags(projectId, query, options).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }); }); }); @@ -1641,6 +1593,18 @@ describe('Api', () => { }); }); + describe('dependency proxy cache', () => { + it('schedules the cache list for deletion', async () => { + const groupId = 1; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/dependency_proxy/cache`; + + mock.onDelete(expectedUrl).reply(httpStatus.ACCEPTED); + const { status } = await Api.deleteDependencyProxyCacheList(groupId, {}); + + expect(status).toBe(httpStatus.ACCEPTED); + }); + }); + describe('Feature Flag User List', () => { let expectedUrl; let projectId; @@ -1727,4 +1691,36 @@ describe('Api', () => { }); }); }); + + describe('projectProtectedBranch', () => { + const branchName = 'new-branch-name'; + const dummyProjectId = 5; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${dummyProjectId}/protected_branches/${branchName}`; + + it('returns 404 for non-existing branch', () => { + jest.spyOn(axios, 'get'); + + mock.onGet(expectedUrl).replyOnce(httpStatus.NOT_FOUND, { + message: '404 Not found', + }); + + return Api.projectProtectedBranch(dummyProjectId, branchName).catch((error) => { + expect(error.response.status).toBe(httpStatus.NOT_FOUND); + expect(axios.get).toHaveBeenCalledWith(expectedUrl); + }); + }); + + it('returns 200 with branch information', () => { + const expectedObj = { name: branchName }; + + jest.spyOn(axios, 'get'); + + mock.onGet(expectedUrl).replyOnce(httpStatus.OK, expectedObj); + + return Api.projectProtectedBranch(dummyProjectId, branchName).then((data) => { + expect(data).toEqual(expectedObj); + expect(axios.get).toHaveBeenCalledWith(expectedUrl); + }); + }); + }); }); diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js index 153d4be56af..31782899ce4 100644 --- a/spec/frontend/authentication/u2f/authenticate_spec.js +++ b/spec/frontend/authentication/u2f/authenticate_spec.js @@ -36,24 +36,19 @@ describe('U2FAuthenticate', () => { window.u2f = oldu2f; }); - it('falls back to normal 2fa', (done) => { - component - .start() - .then(() => { - expect(component.switchToFallbackUI).toHaveBeenCalled(); - done(); - }) - .catch(done.fail); + it('falls back to normal 2fa', async () => { + await component.start(); + expect(component.switchToFallbackUI).toHaveBeenCalled(); }); }); describe('with u2f available', () => { - beforeEach((done) => { + beforeEach(() => { // bypass automatic form submission within renderAuthenticated jest.spyOn(component, 'renderAuthenticated').mockReturnValue(true); u2fDevice = new MockU2FDevice(); - component.start().then(done).catch(done.fail); + return component.start(); }); it('allows authenticating via a U2F device', () => { diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js index a814144ac7a..810396aa9fd 100644 --- a/spec/frontend/authentication/u2f/register_spec.js +++ b/spec/frontend/authentication/u2f/register_spec.js @@ -8,12 +8,12 @@ describe('U2FRegister', () => { let container; let component; - beforeEach((done) => { + beforeEach(() => { loadFixtures('u2f/register.html'); u2fDevice = new MockU2FDevice(); container = $('#js-register-token-2fa'); component = new U2FRegister(container, {}); - component.start().then(done).catch(done.fail); + return component.start(); }); it('allows registering a U2F device', () => { diff --git a/spec/frontend/badges/components/badge_spec.js b/spec/frontend/badges/components/badge_spec.js index 2310fb8bd8e..fe4cf8ce8eb 100644 --- a/spec/frontend/badges/components/badge_spec.js +++ b/spec/frontend/badges/components/badge_spec.js @@ -89,11 +89,9 @@ describe('Badge component', () => { }); describe('behavior', () => { - beforeEach((done) => { + beforeEach(() => { setFixtures('<div id="dummy-element"></div>'); - createComponent({ ...dummyProps }, '#dummy-element') - .then(done) - .catch(done.fail); + return createComponent({ ...dummyProps }, '#dummy-element'); }); it('shows a badge image after loading', () => { diff --git a/spec/frontend/badges/store/actions_spec.js b/spec/frontend/badges/store/actions_spec.js index 75699f24463..02e1b8e65e4 100644 --- a/spec/frontend/badges/store/actions_spec.js +++ b/spec/frontend/badges/store/actions_spec.js @@ -33,41 +33,38 @@ describe('Badges store actions', () => { }); describe('requestNewBadge', () => { - it('commits REQUEST_NEW_BADGE', (done) => { - testAction( + it('commits REQUEST_NEW_BADGE', () => { + return testAction( actions.requestNewBadge, null, state, [{ type: mutationTypes.REQUEST_NEW_BADGE }], [], - done, ); }); }); describe('receiveNewBadge', () => { - it('commits RECEIVE_NEW_BADGE', (done) => { + it('commits RECEIVE_NEW_BADGE', () => { const newBadge = createDummyBadge(); - testAction( + return testAction( actions.receiveNewBadge, newBadge, state, [{ type: mutationTypes.RECEIVE_NEW_BADGE, payload: newBadge }], [], - done, ); }); }); describe('receiveNewBadgeError', () => { - it('commits RECEIVE_NEW_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_NEW_BADGE_ERROR', () => { + return testAction( actions.receiveNewBadgeError, null, state, [{ type: mutationTypes.RECEIVE_NEW_BADGE_ERROR }], [], - done, ); }); }); @@ -87,7 +84,7 @@ describe('Badges store actions', () => { }; }); - it('dispatches requestNewBadge and receiveNewBadge for successful response', (done) => { + it('dispatches requestNewBadge and receiveNewBadge for successful response', async () => { const dummyResponse = createDummyBadgeResponse(); endpointMock.replyOnce((req) => { @@ -105,16 +102,12 @@ describe('Badges store actions', () => { }); const dummyBadge = transformBackendBadge(dummyResponse); - actions - .addBadge({ state, dispatch }) - .then(() => { - expect(dispatch.mock.calls).toEqual([['receiveNewBadge', dummyBadge]]); - }) - .then(done) - .catch(done.fail); + + await actions.addBadge({ state, dispatch }); + expect(dispatch.mock.calls).toEqual([['receiveNewBadge', dummyBadge]]); }); - it('dispatches requestNewBadge and receiveNewBadgeError for error response', (done) => { + it('dispatches requestNewBadge and receiveNewBadgeError for error response', async () => { endpointMock.replyOnce((req) => { expect(req.data).toBe( JSON.stringify({ @@ -129,52 +122,43 @@ describe('Badges store actions', () => { return [500, '']; }); - actions - .addBadge({ state, dispatch }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveNewBadgeError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.addBadge({ state, dispatch })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveNewBadgeError']]); }); }); describe('requestDeleteBadge', () => { - it('commits REQUEST_DELETE_BADGE', (done) => { - testAction( + it('commits REQUEST_DELETE_BADGE', () => { + return testAction( actions.requestDeleteBadge, badgeId, state, [{ type: mutationTypes.REQUEST_DELETE_BADGE, payload: badgeId }], [], - done, ); }); }); describe('receiveDeleteBadge', () => { - it('commits RECEIVE_DELETE_BADGE', (done) => { - testAction( + it('commits RECEIVE_DELETE_BADGE', () => { + return testAction( actions.receiveDeleteBadge, badgeId, state, [{ type: mutationTypes.RECEIVE_DELETE_BADGE, payload: badgeId }], [], - done, ); }); }); describe('receiveDeleteBadgeError', () => { - it('commits RECEIVE_DELETE_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_DELETE_BADGE_ERROR', () => { + return testAction( actions.receiveDeleteBadgeError, badgeId, state, [{ type: mutationTypes.RECEIVE_DELETE_BADGE_ERROR, payload: badgeId }], [], - done, ); }); }); @@ -188,91 +172,76 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', (done) => { + it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', async () => { endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]); dispatch.mockClear(); return [200, '']; }); - actions - .deleteBadge({ state, dispatch }, { id: badgeId }) - .then(() => { - expect(dispatch.mock.calls).toEqual([['receiveDeleteBadge', badgeId]]); - }) - .then(done) - .catch(done.fail); + await actions.deleteBadge({ state, dispatch }, { id: badgeId }); + expect(dispatch.mock.calls).toEqual([['receiveDeleteBadge', badgeId]]); }); - it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', (done) => { + it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', async () => { endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]); dispatch.mockClear(); return [500, '']; }); - actions - .deleteBadge({ state, dispatch }, { id: badgeId }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveDeleteBadgeError', badgeId]]); - }) - .then(done) - .catch(done.fail); + await expect(actions.deleteBadge({ state, dispatch }, { id: badgeId })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveDeleteBadgeError', badgeId]]); }); }); describe('editBadge', () => { - it('commits START_EDITING', (done) => { + it('commits START_EDITING', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.editBadge, dummyBadge, state, [{ type: mutationTypes.START_EDITING, payload: dummyBadge }], [], - done, ); }); }); describe('requestLoadBadges', () => { - it('commits REQUEST_LOAD_BADGES', (done) => { + it('commits REQUEST_LOAD_BADGES', () => { const dummyData = 'this is not real data'; - testAction( + return testAction( actions.requestLoadBadges, dummyData, state, [{ type: mutationTypes.REQUEST_LOAD_BADGES, payload: dummyData }], [], - done, ); }); }); describe('receiveLoadBadges', () => { - it('commits RECEIVE_LOAD_BADGES', (done) => { + it('commits RECEIVE_LOAD_BADGES', () => { const badges = dummyBadges; - testAction( + return testAction( actions.receiveLoadBadges, badges, state, [{ type: mutationTypes.RECEIVE_LOAD_BADGES, payload: badges }], [], - done, ); }); }); describe('receiveLoadBadgesError', () => { - it('commits RECEIVE_LOAD_BADGES_ERROR', (done) => { - testAction( + it('commits RECEIVE_LOAD_BADGES_ERROR', () => { + return testAction( actions.receiveLoadBadgesError, null, state, [{ type: mutationTypes.RECEIVE_LOAD_BADGES_ERROR }], [], - done, ); }); }); @@ -286,7 +255,7 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('dispatches requestLoadBadges and receiveLoadBadges for successful response', (done) => { + it('dispatches requestLoadBadges and receiveLoadBadges for successful response', async () => { const dummyData = 'this is just some data'; const dummyReponse = [ createDummyBadgeResponse(), @@ -299,18 +268,13 @@ describe('Badges store actions', () => { return [200, dummyReponse]; }); - actions - .loadBadges({ state, dispatch }, dummyData) - .then(() => { - const badges = dummyReponse.map(transformBackendBadge); + await actions.loadBadges({ state, dispatch }, dummyData); + const badges = dummyReponse.map(transformBackendBadge); - expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]); - }) - .then(done) - .catch(done.fail); + expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]); }); - it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', (done) => { + it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', async () => { const dummyData = 'this is just some data'; endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestLoadBadges', dummyData]]); @@ -318,53 +282,44 @@ describe('Badges store actions', () => { return [500, '']; }); - actions - .loadBadges({ state, dispatch }, dummyData) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveLoadBadgesError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.loadBadges({ state, dispatch }, dummyData)).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveLoadBadgesError']]); }); }); describe('requestRenderedBadge', () => { - it('commits REQUEST_RENDERED_BADGE', (done) => { - testAction( + it('commits REQUEST_RENDERED_BADGE', () => { + return testAction( actions.requestRenderedBadge, null, state, [{ type: mutationTypes.REQUEST_RENDERED_BADGE }], [], - done, ); }); }); describe('receiveRenderedBadge', () => { - it('commits RECEIVE_RENDERED_BADGE', (done) => { + it('commits RECEIVE_RENDERED_BADGE', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.receiveRenderedBadge, dummyBadge, state, [{ type: mutationTypes.RECEIVE_RENDERED_BADGE, payload: dummyBadge }], [], - done, ); }); }); describe('receiveRenderedBadgeError', () => { - it('commits RECEIVE_RENDERED_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_RENDERED_BADGE_ERROR', () => { + return testAction( actions.receiveRenderedBadgeError, null, state, [{ type: mutationTypes.RECEIVE_RENDERED_BADGE_ERROR }], [], - done, ); }); }); @@ -388,56 +343,41 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('returns immediately if imageUrl is empty', (done) => { + it('returns immediately if imageUrl is empty', async () => { jest.spyOn(axios, 'get').mockImplementation(() => {}); badgeInForm.imageUrl = ''; - actions - .renderBadge({ state, dispatch }) - .then(() => { - expect(axios.get).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await actions.renderBadge({ state, dispatch }); + expect(axios.get).not.toHaveBeenCalled(); }); - it('returns immediately if linkUrl is empty', (done) => { + it('returns immediately if linkUrl is empty', async () => { jest.spyOn(axios, 'get').mockImplementation(() => {}); badgeInForm.linkUrl = ''; - actions - .renderBadge({ state, dispatch }) - .then(() => { - expect(axios.get).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await actions.renderBadge({ state, dispatch }); + expect(axios.get).not.toHaveBeenCalled(); }); - it('escapes user input', (done) => { + it('escapes user input', async () => { jest .spyOn(axios, 'get') .mockImplementation(() => Promise.resolve({ data: createDummyBadgeResponse() })); badgeInForm.imageUrl = '&make-sandwich=true'; badgeInForm.linkUrl = '<script>I am dangerous!</script>'; - actions - .renderBadge({ state, dispatch }) - .then(() => { - expect(axios.get.mock.calls.length).toBe(1); - const url = axios.get.mock.calls[0][0]; + await actions.renderBadge({ state, dispatch }); + expect(axios.get.mock.calls.length).toBe(1); + const url = axios.get.mock.calls[0][0]; - expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`)); - expect(url).toMatch( - new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'), - ); - expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$')); - }) - .then(done) - .catch(done.fail); + expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`)); + expect(url).toMatch( + new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'), + ); + expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$')); }); - it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', (done) => { + it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', async () => { const dummyReponse = createDummyBadgeResponse(); endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]); @@ -445,71 +385,57 @@ describe('Badges store actions', () => { return [200, dummyReponse]; }); - actions - .renderBadge({ state, dispatch }) - .then(() => { - const renderedBadge = transformBackendBadge(dummyReponse); + await actions.renderBadge({ state, dispatch }); + const renderedBadge = transformBackendBadge(dummyReponse); - expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]); - }) - .then(done) - .catch(done.fail); + expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]); }); - it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', (done) => { + it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', async () => { endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]); dispatch.mockClear(); return [500, '']; }); - actions - .renderBadge({ state, dispatch }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveRenderedBadgeError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.renderBadge({ state, dispatch })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveRenderedBadgeError']]); }); }); describe('requestUpdatedBadge', () => { - it('commits REQUEST_UPDATED_BADGE', (done) => { - testAction( + it('commits REQUEST_UPDATED_BADGE', () => { + return testAction( actions.requestUpdatedBadge, null, state, [{ type: mutationTypes.REQUEST_UPDATED_BADGE }], [], - done, ); }); }); describe('receiveUpdatedBadge', () => { - it('commits RECEIVE_UPDATED_BADGE', (done) => { + it('commits RECEIVE_UPDATED_BADGE', () => { const updatedBadge = createDummyBadge(); - testAction( + return testAction( actions.receiveUpdatedBadge, updatedBadge, state, [{ type: mutationTypes.RECEIVE_UPDATED_BADGE, payload: updatedBadge }], [], - done, ); }); }); describe('receiveUpdatedBadgeError', () => { - it('commits RECEIVE_UPDATED_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_UPDATED_BADGE_ERROR', () => { + return testAction( actions.receiveUpdatedBadgeError, null, state, [{ type: mutationTypes.RECEIVE_UPDATED_BADGE_ERROR }], [], - done, ); }); }); @@ -529,7 +455,7 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', (done) => { + it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', async () => { const dummyResponse = createDummyBadgeResponse(); endpointMock.replyOnce((req) => { @@ -547,16 +473,11 @@ describe('Badges store actions', () => { }); const updatedBadge = transformBackendBadge(dummyResponse); - actions - .saveBadge({ state, dispatch }) - .then(() => { - expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadge', updatedBadge]]); - }) - .then(done) - .catch(done.fail); + await actions.saveBadge({ state, dispatch }); + expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadge', updatedBadge]]); }); - it('dispatches requestUpdatedBadge and receiveUpdatedBadgeError for error response', (done) => { + it('dispatches requestUpdatedBadge and receiveUpdatedBadgeError for error response', async () => { endpointMock.replyOnce((req) => { expect(req.data).toBe( JSON.stringify({ @@ -571,53 +492,44 @@ describe('Badges store actions', () => { return [500, '']; }); - actions - .saveBadge({ state, dispatch }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadgeError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.saveBadge({ state, dispatch })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadgeError']]); }); }); describe('stopEditing', () => { - it('commits STOP_EDITING', (done) => { - testAction( + it('commits STOP_EDITING', () => { + return testAction( actions.stopEditing, null, state, [{ type: mutationTypes.STOP_EDITING }], [], - done, ); }); }); describe('updateBadgeInForm', () => { - it('commits UPDATE_BADGE_IN_FORM', (done) => { + it('commits UPDATE_BADGE_IN_FORM', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.updateBadgeInForm, dummyBadge, state, [{ type: mutationTypes.UPDATE_BADGE_IN_FORM, payload: dummyBadge }], [], - done, ); }); describe('updateBadgeInModal', () => { - it('commits UPDATE_BADGE_IN_MODAL', (done) => { + it('commits UPDATE_BADGE_IN_MODAL', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.updateBadgeInModal, dummyBadge, state, [{ type: mutationTypes.UPDATE_BADGE_IN_MODAL, payload: dummyBadge }], [], - done, ); }); }); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index b0e9e5dd00b..e9535d8cc12 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -29,53 +29,56 @@ describe('Batch comments store actions', () => { }); describe('addDraftToDiscussion', () => { - it('commits ADD_NEW_DRAFT if no errors returned', (done) => { + it('commits ADD_NEW_DRAFT if no errors returned', () => { res = { id: 1 }; mock.onAny().reply(200, res); - testAction( + return testAction( actions.addDraftToDiscussion, { endpoint: TEST_HOST, data: 'test' }, null, [{ type: 'ADD_NEW_DRAFT', payload: res }], [], - done, ); }); - it('does not commit ADD_NEW_DRAFT if errors returned', (done) => { + it('does not commit ADD_NEW_DRAFT if errors returned', () => { mock.onAny().reply(500); - testAction( + return testAction( actions.addDraftToDiscussion, { endpoint: TEST_HOST, data: 'test' }, null, [], [], - done, ); }); }); describe('createNewDraft', () => { - it('commits ADD_NEW_DRAFT if no errors returned', (done) => { + it('commits ADD_NEW_DRAFT if no errors returned', () => { res = { id: 1 }; mock.onAny().reply(200, res); - testAction( + return testAction( actions.createNewDraft, { endpoint: TEST_HOST, data: 'test' }, null, [{ type: 'ADD_NEW_DRAFT', payload: res }], [], - done, ); }); - it('does not commit ADD_NEW_DRAFT if errors returned', (done) => { + it('does not commit ADD_NEW_DRAFT if errors returned', () => { mock.onAny().reply(500); - testAction(actions.createNewDraft, { endpoint: TEST_HOST, data: 'test' }, null, [], [], done); + return testAction( + actions.createNewDraft, + { endpoint: TEST_HOST, data: 'test' }, + null, + [], + [], + ); }); }); @@ -90,7 +93,7 @@ describe('Batch comments store actions', () => { }; }); - it('commits DELETE_DRAFT if no errors returned', (done) => { + it('commits DELETE_DRAFT if no errors returned', () => { const commit = jest.fn(); const context = { getters, @@ -99,16 +102,12 @@ describe('Batch comments store actions', () => { res = { id: 1 }; mock.onAny().reply(200); - actions - .deleteDraft(context, { id: 1 }) - .then(() => { - expect(commit).toHaveBeenCalledWith('DELETE_DRAFT', 1); - }) - .then(done) - .catch(done.fail); + return actions.deleteDraft(context, { id: 1 }).then(() => { + expect(commit).toHaveBeenCalledWith('DELETE_DRAFT', 1); + }); }); - it('does not commit DELETE_DRAFT if errors returned', (done) => { + it('does not commit DELETE_DRAFT if errors returned', () => { const commit = jest.fn(); const context = { getters, @@ -116,13 +115,9 @@ describe('Batch comments store actions', () => { }; mock.onAny().reply(500); - actions - .deleteDraft(context, { id: 1 }) - .then(() => { - expect(commit).not.toHaveBeenCalledWith('DELETE_DRAFT', 1); - }) - .then(done) - .catch(done.fail); + return actions.deleteDraft(context, { id: 1 }).then(() => { + expect(commit).not.toHaveBeenCalledWith('DELETE_DRAFT', 1); + }); }); }); @@ -137,7 +132,7 @@ describe('Batch comments store actions', () => { }; }); - it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', (done) => { + it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', () => { const commit = jest.fn(); const dispatch = jest.fn(); const context = { @@ -151,14 +146,10 @@ describe('Batch comments store actions', () => { res = { id: 1 }; mock.onAny().reply(200, res); - actions - .fetchDrafts(context) - .then(() => { - expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 }); - expect(dispatch).toHaveBeenCalledWith('convertToDiscussion', '1', { root: true }); - }) - .then(done) - .catch(done.fail); + return actions.fetchDrafts(context).then(() => { + expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 }); + expect(dispatch).toHaveBeenCalledWith('convertToDiscussion', '1', { root: true }); + }); }); }); @@ -177,32 +168,24 @@ describe('Batch comments store actions', () => { rootGetters = { discussionsStructuredByLineCode: 'discussions' }; }); - it('dispatches actions & commits', (done) => { + it('dispatches actions & commits', () => { mock.onAny().reply(200); - actions - .publishReview({ dispatch, commit, getters, rootGetters }) - .then(() => { - expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); - expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_SUCCESS']); + return actions.publishReview({ dispatch, commit, getters, rootGetters }).then(() => { + expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); + expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_SUCCESS']); - expect(dispatch.mock.calls[0]).toEqual(['updateDiscussionsAfterPublish']); - }) - .then(done) - .catch(done.fail); + expect(dispatch.mock.calls[0]).toEqual(['updateDiscussionsAfterPublish']); + }); }); - it('dispatches error commits', (done) => { + it('dispatches error commits', () => { mock.onAny().reply(500); - actions - .publishReview({ dispatch, commit, getters, rootGetters }) - .then(() => { - expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); - expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_ERROR']); - }) - .then(done) - .catch(done.fail); + return actions.publishReview({ dispatch, commit, getters, rootGetters }).then(() => { + expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); + expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_ERROR']); + }); }); }); @@ -262,7 +245,7 @@ describe('Batch comments store actions', () => { }); describe('expandAllDiscussions', () => { - it('dispatches expandDiscussion for all drafts', (done) => { + it('dispatches expandDiscussion for all drafts', () => { const state = { drafts: [ { @@ -271,7 +254,7 @@ describe('Batch comments store actions', () => { ], }; - testAction( + return testAction( actions.expandAllDiscussions, null, state, @@ -282,7 +265,6 @@ describe('Batch comments store actions', () => { payload: { discussionId: '1' }, }, ], - done, ); }); }); diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js index cac1ea67cf5..8842ad636ec 100644 --- a/spec/frontend/behaviors/gl_emoji_spec.js +++ b/spec/frontend/behaviors/gl_emoji_spec.js @@ -77,6 +77,12 @@ describe('gl_emoji', () => { '<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament">❔</gl-emoji>', `<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" width="20" height="20" align="absmiddle"></gl-emoji>`, ], + [ + 'custom emoji with image fallback', + '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"></gl-emoji>', + '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>', + '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>', + ], ])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => { it(`renders correctly with emoji support`, async () => { jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true); diff --git a/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js b/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js deleted file mode 100644 index d7531d15b9a..00000000000 --- a/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js +++ /dev/null @@ -1,363 +0,0 @@ -import sqljs from 'sql.js'; -import ClassSpecHelper from 'helpers/class_spec_helper'; -import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer'; -import axios from '~/lib/utils/axios_utils'; - -jest.mock('sql.js'); - -describe('BalsamiqViewer', () => { - const mockArrayBuffer = new ArrayBuffer(10); - let balsamiqViewer; - let viewer; - - describe('class constructor', () => { - beforeEach(() => { - viewer = {}; - - balsamiqViewer = new BalsamiqViewer(viewer); - }); - - it('should set .viewer', () => { - expect(balsamiqViewer.viewer).toBe(viewer); - }); - }); - - describe('loadFile', () => { - let bv; - const endpoint = 'endpoint'; - const requestSuccess = Promise.resolve({ - data: mockArrayBuffer, - status: 200, - }); - - beforeEach(() => { - viewer = {}; - bv = new BalsamiqViewer(viewer); - }); - - it('should call `axios.get` on `endpoint` param with responseType set to `arraybuffer', () => { - jest.spyOn(axios, 'get').mockReturnValue(requestSuccess); - jest.spyOn(bv, 'renderFile').mockReturnValue(); - - bv.loadFile(endpoint); - - expect(axios.get).toHaveBeenCalledWith( - endpoint, - expect.objectContaining({ - responseType: 'arraybuffer', - }), - ); - }); - - it('should call `renderFile` on request success', (done) => { - jest.spyOn(axios, 'get').mockReturnValue(requestSuccess); - jest.spyOn(bv, 'renderFile').mockImplementation(() => {}); - - bv.loadFile(endpoint) - .then(() => { - expect(bv.renderFile).toHaveBeenCalledWith(mockArrayBuffer); - }) - .then(done) - .catch(done.fail); - }); - - it('should not call `renderFile` on request failure', (done) => { - jest.spyOn(axios, 'get').mockReturnValue(Promise.reject()); - jest.spyOn(bv, 'renderFile').mockImplementation(() => {}); - - bv.loadFile(endpoint) - .then(() => { - done.fail('Expected loadFile to throw error!'); - }) - .catch(() => { - expect(bv.renderFile).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('renderFile', () => { - let container; - let previews; - - beforeEach(() => { - viewer = { - appendChild: jest.fn(), - }; - previews = [document.createElement('ul'), document.createElement('ul')]; - - balsamiqViewer = { - initDatabase: jest.fn(), - getPreviews: jest.fn(), - renderPreview: jest.fn(), - }; - balsamiqViewer.viewer = viewer; - - balsamiqViewer.getPreviews.mockReturnValue(previews); - balsamiqViewer.renderPreview.mockImplementation((preview) => preview); - viewer.appendChild.mockImplementation((containerElement) => { - container = containerElement; - }); - - BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, mockArrayBuffer); - }); - - it('should call .initDatabase', () => { - expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(mockArrayBuffer); - }); - - it('should call .getPreviews', () => { - expect(balsamiqViewer.getPreviews).toHaveBeenCalled(); - }); - - it('should call .renderPreview for each preview', () => { - const allArgs = balsamiqViewer.renderPreview.mock.calls; - - expect(allArgs.length).toBe(2); - - previews.forEach((preview, i) => { - expect(allArgs[i][0]).toBe(preview); - }); - }); - - it('should set the container HTML', () => { - expect(container.innerHTML).toBe('<ul></ul><ul></ul>'); - }); - - it('should add inline preview classes', () => { - expect(container.classList[0]).toBe('list-inline'); - expect(container.classList[1]).toBe('previews'); - }); - - it('should call viewer.appendChild', () => { - expect(viewer.appendChild).toHaveBeenCalledWith(container); - }); - }); - - describe('initDatabase', () => { - let uint8Array; - let data; - - beforeEach(() => { - uint8Array = {}; - data = 'data'; - balsamiqViewer = {}; - window.Uint8Array = jest.fn(); - window.Uint8Array.mockReturnValue(uint8Array); - - BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data); - }); - - it('should instantiate Uint8Array', () => { - expect(window.Uint8Array).toHaveBeenCalledWith(data); - }); - - it('should call sqljs.Database', () => { - expect(sqljs.Database).toHaveBeenCalledWith(uint8Array); - }); - - it('should set .database', () => { - expect(balsamiqViewer.database).not.toBe(null); - }); - }); - - describe('getPreviews', () => { - let database; - let thumbnails; - let getPreviews; - - beforeEach(() => { - database = { - exec: jest.fn(), - }; - thumbnails = [{ values: [0, 1, 2] }]; - - balsamiqViewer = { - database, - }; - - jest - .spyOn(BalsamiqViewer, 'parsePreview') - .mockImplementation((preview) => preview.toString()); - database.exec.mockReturnValue(thumbnails); - - getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer); - }); - - it('should call database.exec', () => { - expect(database.exec).toHaveBeenCalledWith('SELECT * FROM thumbnails'); - }); - - it('should call .parsePreview for each value', () => { - const allArgs = BalsamiqViewer.parsePreview.mock.calls; - - expect(allArgs.length).toBe(3); - - thumbnails[0].values.forEach((value, i) => { - expect(allArgs[i][0]).toBe(value); - }); - }); - - it('should return an array of parsed values', () => { - expect(getPreviews).toEqual(['0', '1', '2']); - }); - }); - - describe('getResource', () => { - let database; - let resourceID; - let resource; - let getResource; - - beforeEach(() => { - database = { - exec: jest.fn(), - }; - resourceID = 4; - resource = ['resource']; - - balsamiqViewer = { - database, - }; - - database.exec.mockReturnValue(resource); - - getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID); - }); - - it('should call database.exec', () => { - expect(database.exec).toHaveBeenCalledWith( - `SELECT * FROM resources WHERE id = '${resourceID}'`, - ); - }); - - it('should return the selected resource', () => { - expect(getResource).toBe(resource[0]); - }); - }); - - describe('renderPreview', () => { - let previewElement; - let innerHTML; - let preview; - let renderPreview; - - beforeEach(() => { - innerHTML = '<a>innerHTML</a>'; - previewElement = { - outerHTML: '<p>outerHTML</p>', - classList: { - add: jest.fn(), - }, - }; - preview = {}; - - balsamiqViewer = { - renderTemplate: jest.fn(), - }; - - jest.spyOn(document, 'createElement').mockReturnValue(previewElement); - balsamiqViewer.renderTemplate.mockReturnValue(innerHTML); - - renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview); - }); - - it('should call classList.add', () => { - expect(previewElement.classList.add).toHaveBeenCalledWith('preview'); - }); - - it('should call .renderTemplate', () => { - expect(balsamiqViewer.renderTemplate).toHaveBeenCalledWith(preview); - }); - - it('should set .innerHTML', () => { - expect(previewElement.innerHTML).toBe(innerHTML); - }); - - it('should return element', () => { - expect(renderPreview).toBe(previewElement); - }); - }); - - describe('renderTemplate', () => { - let preview; - let name; - let resource; - let template; - let renderTemplate; - - beforeEach(() => { - preview = { resourceID: 1, image: 'image' }; - name = 'name'; - resource = 'resource'; - template = ` - <div class="card"> - <div class="card-header">name</div> - <div class="card-body"> - <img class="img-thumbnail" src="data:image/png;base64,image"/> - </div> - </div> - `; - - balsamiqViewer = { - getResource: jest.fn(), - }; - - jest.spyOn(BalsamiqViewer, 'parseTitle').mockReturnValue(name); - balsamiqViewer.getResource.mockReturnValue(resource); - - renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview); - }); - - it('should call .getResource', () => { - expect(balsamiqViewer.getResource).toHaveBeenCalledWith(preview.resourceID); - }); - - it('should call .parseTitle', () => { - expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource); - }); - - it('should return the template string', () => { - expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, '')); - }); - }); - - describe('parsePreview', () => { - let preview; - let parsePreview; - - beforeEach(() => { - preview = ['{}', '{ "id": 1 }']; - - jest.spyOn(JSON, 'parse'); - - parsePreview = BalsamiqViewer.parsePreview(preview); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); - - it('should return the parsed JSON', () => { - expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }')); - }); - }); - - describe('parseTitle', () => { - let title; - let parseTitle; - - beforeEach(() => { - title = { values: [['{}', '{}', '{"name":"name"}']] }; - - jest.spyOn(JSON, 'parse'); - - parseTitle = BalsamiqViewer.parseTitle(title); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); - - it('should return the name value', () => { - expect(parseTitle).toBe('name'); - }); - }); -}); diff --git a/spec/frontend/boards/boards_util_spec.js b/spec/frontend/boards/boards_util_spec.js index d45b6e35a45..ab3cf072357 100644 --- a/spec/frontend/boards/boards_util_spec.js +++ b/spec/frontend/boards/boards_util_spec.js @@ -1,6 +1,12 @@ import { formatIssueInput, filterVariables } from '~/boards/boards_util'; describe('formatIssueInput', () => { + const issueInput = { + labelIds: ['gid://gitlab/GroupLabel/5'], + projectPath: 'gitlab-org/gitlab-test', + id: 'gid://gitlab/Issue/11', + }; + it('correctly merges boardConfig into the issue', () => { const boardConfig = { labels: [ @@ -14,12 +20,6 @@ describe('formatIssueInput', () => { weight: 1, }; - const issueInput = { - labelIds: ['gid://gitlab/GroupLabel/5'], - projectPath: 'gitlab-org/gitlab-test', - id: 'gid://gitlab/Issue/11', - }; - const result = formatIssueInput(issueInput, boardConfig); expect(result).toEqual({ projectPath: 'gitlab-org/gitlab-test', @@ -27,8 +27,26 @@ describe('formatIssueInput', () => { labelIds: ['gid://gitlab/GroupLabel/5', 'gid://gitlab/GroupLabel/44'], assigneeIds: ['gid://gitlab/User/55'], milestoneId: 'gid://gitlab/Milestone/66', + weight: 1, }); }); + + it('does not add weight to input if weight is NONE', () => { + const boardConfig = { + weight: -2, // NO_WEIGHT + }; + + const result = formatIssueInput(issueInput, boardConfig); + const expected = { + projectPath: 'gitlab-org/gitlab-test', + id: 'gid://gitlab/Issue/11', + labelIds: ['gid://gitlab/GroupLabel/5'], + assigneeIds: [], + milestoneId: undefined, + }; + + expect(result).toEqual(expected); + }); }); describe('filterVariables', () => { diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index 85ba703a6ee..731578e15a3 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -124,7 +124,7 @@ describe('BoardFilteredSearch', () => { { type: 'milestone', value: { data: 'New Milestone', operator: '=' } }, { type: 'type', value: { data: 'INCIDENT', operator: '=' } }, { type: 'weight', value: { data: '2', operator: '=' } }, - { type: 'iteration', value: { data: '3341', operator: '=' } }, + { type: 'iteration', value: { data: 'Any&3', operator: '=' } }, { type: 'release', value: { data: 'v1.0.0', operator: '=' } }, ]; jest.spyOn(urlUtility, 'updateHistory'); @@ -134,7 +134,7 @@ describe('BoardFilteredSearch', () => { title: '', replace: true, url: - 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=3341&types=INCIDENT&weight=2&release_tag=v1.0.0', + 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=Any&iteration_cadence_id=3&types=INCIDENT&weight=2&release_tag=v1.0.0', }); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index c976ba7525b..6a659623b53 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -62,7 +62,7 @@ describe('BoardForm', () => { }; }, provide: { - rootPath: 'root', + boardBaseUrl: 'root', }, mocks: { $apollo: { diff --git a/spec/frontend/boards/components/board_top_bar_spec.js b/spec/frontend/boards/components/board_top_bar_spec.js new file mode 100644 index 00000000000..997768a0cc7 --- /dev/null +++ b/spec/frontend/boards/components/board_top_bar_spec.js @@ -0,0 +1,88 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; + +import BoardTopBar from '~/boards/components/board_top_bar.vue'; +import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; +import BoardsSelector from '~/boards/components/boards_selector.vue'; +import ConfigToggle from '~/boards/components/config_toggle.vue'; +import IssueBoardFilteredSearch from '~/boards/components/issue_board_filtered_search.vue'; +import NewBoardButton from '~/boards/components/new_board_button.vue'; +import ToggleFocus from '~/boards/components/toggle_focus.vue'; + +describe('BoardTopBar', () => { + let wrapper; + + Vue.use(Vuex); + + const createStore = ({ mockGetters = {} } = {}) => { + return new Vuex.Store({ + state: {}, + getters: { + isEpicBoard: () => false, + ...mockGetters, + }, + }); + }; + + const createComponent = ({ provide = {}, mockGetters = {} } = {}) => { + const store = createStore({ mockGetters }); + wrapper = shallowMount(BoardTopBar, { + store, + provide: { + swimlanesFeatureAvailable: false, + canAdminList: false, + isSignedIn: false, + fullPath: 'gitlab-org', + boardType: 'group', + releasesFetchPath: '/releases', + ...provide, + }, + stubs: { IssueBoardFilteredSearch }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('base template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders BoardsSelector component', () => { + expect(wrapper.findComponent(BoardsSelector).exists()).toBe(true); + }); + + it('renders IssueBoardFilteredSearch component', () => { + expect(wrapper.findComponent(IssueBoardFilteredSearch).exists()).toBe(true); + }); + + it('renders NewBoardButton component', () => { + expect(wrapper.findComponent(NewBoardButton).exists()).toBe(true); + }); + + it('renders ConfigToggle component', () => { + expect(wrapper.findComponent(ConfigToggle).exists()).toBe(true); + }); + + it('renders ToggleFocus component', () => { + expect(wrapper.findComponent(ToggleFocus).exists()).toBe(true); + }); + + it('does not render BoardAddNewColumnTrigger component', () => { + expect(wrapper.findComponent(BoardAddNewColumnTrigger).exists()).toBe(false); + }); + }); + + describe('when user can admin list', () => { + beforeEach(() => { + createComponent({ provide: { canAdminList: true } }); + }); + + it('renders BoardAddNewColumnTrigger component', () => { + expect(wrapper.findComponent(BoardAddNewColumnTrigger).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index 0c044deb78c..f60d04af4fc 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -1,5 +1,4 @@ import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; @@ -14,6 +13,7 @@ import groupRecentBoardsQuery from '~/boards/graphql/group_recent_boards.query.g import projectRecentBoardsQuery from '~/boards/graphql/project_recent_boards.query.graphql'; import defaultStore from '~/boards/stores'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mockGroupBoardResponse, mockProjectBoardResponse, @@ -60,7 +60,7 @@ describe('BoardsSelector', () => { searchBoxInput.trigger('input'); }; - const getDropdownItems = () => wrapper.findAll('.js-dropdown-item'); + const getDropdownItems = () => wrapper.findAllByTestId('dropdown-item'); const getDropdownHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader); const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findDropdown = () => wrapper.findComponent(GlDropdown); @@ -100,11 +100,15 @@ describe('BoardsSelector', () => { [groupRecentBoardsQuery, groupRecentBoardsQueryHandlerSuccess], ]); - wrapper = mount(BoardsSelector, { + wrapper = mountExtended(BoardsSelector, { store, apolloProvider: fakeApollo, propsData: { throttleDuration, + }, + attachTo: document.body, + provide: { + fullPath: '', boardBaseUrl: `${TEST_HOST}/board/base/url`, hasMissingBoards: false, canAdminBoard: true, @@ -112,10 +116,6 @@ describe('BoardsSelector', () => { scopedIssueBoardFeatureEnabled: true, weights: [], }, - attachTo: document.body, - provide: { - fullPath: '', - }, }); }; diff --git a/spec/frontend/boards/components/issuable_title_spec.js b/spec/frontend/boards/components/issuable_title_spec.js deleted file mode 100644 index 4b7f491b998..00000000000 --- a/spec/frontend/boards/components/issuable_title_spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import IssuableTitle from '~/boards/components/issuable_title.vue'; - -describe('IssuableTitle', () => { - let wrapper; - const defaultProps = { - title: 'One', - refPath: 'path', - }; - const createComponent = () => { - wrapper = shallowMount(IssuableTitle, { - propsData: { ...defaultProps }, - }); - }; - const findIssueContent = () => wrapper.find('[data-testid="issue-title"]'); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('renders a title of an issue in the sidebar', () => { - expect(findIssueContent().text()).toContain('One'); - }); - - it('renders a referencePath of an issue in the sidebar', () => { - expect(findIssueContent().text()).toContain('path'); - }); -}); diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js index 76e8b84d8ef..e4a6a2b8b76 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -14,10 +14,11 @@ describe('IssueBoardFilter', () => { const createComponent = ({ isSignedIn = false } = {}) => { wrapper = shallowMount(IssueBoardFilteredSpec, { - propsData: { fullPath: 'gitlab-org', boardType: 'group' }, provide: { isSignedIn, releasesFetchPath: '/releases', + fullPath: 'gitlab-org', + boardType: 'group', }, }); }; diff --git a/spec/frontend/boards/components/issue_time_estimate_spec.js b/spec/frontend/boards/components/issue_time_estimate_spec.js index 635964b6b4a..948a7a20f7f 100644 --- a/spec/frontend/boards/components/issue_time_estimate_spec.js +++ b/spec/frontend/boards/components/issue_time_estimate_spec.js @@ -5,6 +5,8 @@ import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue'; describe('Issue Time Estimate component', () => { let wrapper; + const findIssueTimeEstimate = () => wrapper.find('[data-testid="issue-time-estimate"]'); + afterEach(() => { wrapper.destroy(); }); @@ -26,7 +28,7 @@ describe('Issue Time Estimate component', () => { }); it('renders expanded time estimate in tooltip', () => { - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute'); + expect(findIssueTimeEstimate().text()).toContain('2 weeks 3 days 1 minute'); }); it('prevents tooltip xss', async () => { @@ -42,7 +44,7 @@ describe('Issue Time Estimate component', () => { expect(alertSpy).not.toHaveBeenCalled(); expect(wrapper.find('time').text().trim()).toEqual('0m'); - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m'); + expect(findIssueTimeEstimate().text()).toContain('0m'); }); }); @@ -63,7 +65,7 @@ describe('Issue Time Estimate component', () => { }); it('renders expanded time estimate in tooltip', () => { - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute'); + expect(findIssueTimeEstimate().text()).toContain('104 hours 1 minute'); }); }); }); diff --git a/spec/frontend/boards/components/item_count_spec.js b/spec/frontend/boards/components/item_count_spec.js index 45980c36f1c..06cd3910fc0 100644 --- a/spec/frontend/boards/components/item_count_spec.js +++ b/spec/frontend/boards/components/item_count_spec.js @@ -29,7 +29,7 @@ describe('IssueCount', () => { }); it('does not contains maxIssueCount in the template', () => { - expect(vm.find('.js-max-issue-size').exists()).toBe(false); + expect(vm.find('.max-issue-size').exists()).toBe(false); }); }); @@ -50,7 +50,7 @@ describe('IssueCount', () => { }); it('contains maxIssueCount in the template', () => { - expect(vm.find('.js-max-issue-size').text()).toEqual(String(maxIssueCount)); + expect(vm.find('.max-issue-size').text()).toEqual(String(maxIssueCount)); }); it('does not have text-danger class when issueSize is less than maxIssueCount', () => { @@ -75,7 +75,7 @@ describe('IssueCount', () => { }); it('contains maxIssueCount in the template', () => { - expect(vm.find('.js-max-issue-size').text()).toEqual(String(maxIssueCount)); + expect(vm.find('.max-issue-size').text()).toEqual(String(maxIssueCount)); }); it('has text-danger class', () => { diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index ad661a31556..eacf9db191e 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -166,31 +166,29 @@ describe('setFilters', () => { }); describe('performSearch', () => { - it('should dispatch setFilters, fetchLists and resetIssues action', (done) => { - testAction( + it('should dispatch setFilters, fetchLists and resetIssues action', () => { + return testAction( actions.performSearch, {}, {}, [], [{ type: 'setFilters', payload: {} }, { type: 'fetchLists' }, { type: 'resetIssues' }], - done, ); }); }); describe('setActiveId', () => { - it('should commit mutation SET_ACTIVE_ID', (done) => { + it('should commit mutation SET_ACTIVE_ID', () => { const state = { activeId: inactiveId, }; - testAction( + return testAction( actions.setActiveId, { id: 1, sidebarType: 'something' }, state, [{ type: types.SET_ACTIVE_ID, payload: { id: 1, sidebarType: 'something' } }], [], - done, ); }); }); @@ -219,10 +217,10 @@ describe('fetchLists', () => { const formattedLists = formatBoardLists(queryResponse.data.group.board.lists); - it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', (done) => { + it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchLists, {}, state, @@ -233,14 +231,13 @@ describe('fetchLists', () => { }, ], [], - done, ); }); - it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', (done) => { + it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); - testAction( + return testAction( actions.fetchLists, {}, state, @@ -250,11 +247,10 @@ describe('fetchLists', () => { }, ], [], - done, ); }); - it('dispatch createList action when backlog list does not exist and is not hidden', (done) => { + it('dispatch createList action when backlog list does not exist and is not hidden', () => { queryResponse = { data: { group: { @@ -269,7 +265,7 @@ describe('fetchLists', () => { }; jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchLists, {}, state, @@ -280,7 +276,6 @@ describe('fetchLists', () => { }, ], [{ type: 'createList', payload: { backlog: true } }], - done, ); }); @@ -951,10 +946,10 @@ describe('fetchItemsForList', () => { }); }); - it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', (done) => { + it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchItemsForList, { listId }, state, @@ -973,14 +968,13 @@ describe('fetchItemsForList', () => { }, ], [], - done, ); }); - it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', (done) => { + it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); - testAction( + return testAction( actions.fetchItemsForList, { listId }, state, @@ -996,7 +990,6 @@ describe('fetchItemsForList', () => { { type: types.RECEIVE_ITEMS_FOR_LIST_FAILURE, payload: listId }, ], [], - done, ); }); }); @@ -1398,8 +1391,8 @@ describe('setAssignees', () => { const node = { username: 'name' }; describe('when succeeds', () => { - it('calls the correct mutation with the correct values', (done) => { - testAction( + it('calls the correct mutation with the correct values', () => { + return testAction( actions.setAssignees, { assignees: [node], iid: '1' }, { commit: () => {} }, @@ -1410,7 +1403,6 @@ describe('setAssignees', () => { }, ], [], - done, ); }); }); @@ -1728,7 +1720,7 @@ describe('setActiveItemSubscribed', () => { projectPath: 'gitlab-org/gitlab-test', }; - it('should commit subscribed status', (done) => { + it('should commit subscribed status', () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { updateIssuableSubscription: { @@ -1746,7 +1738,7 @@ describe('setActiveItemSubscribed', () => { value: subscribedState, }; - testAction( + return testAction( actions.setActiveItemSubscribed, input, { ...state, ...getters }, @@ -1757,7 +1749,6 @@ describe('setActiveItemSubscribed', () => { }, ], [], - done, ); }); @@ -1783,7 +1774,7 @@ describe('setActiveItemTitle', () => { projectPath: 'h/b', }; - it('should commit title after setting the issue', (done) => { + it('should commit title after setting the issue', () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { updateIssuableTitle: { @@ -1801,7 +1792,7 @@ describe('setActiveItemTitle', () => { value: testTitle, }; - testAction( + return testAction( actions.setActiveItemTitle, input, { ...state, ...getters }, @@ -1812,7 +1803,6 @@ describe('setActiveItemTitle', () => { }, ], [], - done, ); }); @@ -1829,14 +1819,14 @@ describe('setActiveItemConfidential', () => { const state = { boardItems: { [mockIssue.id]: mockIssue } }; const getters = { activeBoardItem: mockIssue }; - it('set confidential value on board item', (done) => { + it('set confidential value on board item', () => { const payload = { itemId: getters.activeBoardItem.id, prop: 'confidential', value: true, }; - testAction( + return testAction( actions.setActiveItemConfidential, true, { ...state, ...getters }, @@ -1847,7 +1837,6 @@ describe('setActiveItemConfidential', () => { }, ], [], - done, ); }); }); @@ -1876,10 +1865,10 @@ describe('fetchGroupProjects', () => { }, }; - it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_SUCCESS on success', (done) => { + it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_SUCCESS on success', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchGroupProjects, {}, state, @@ -1894,14 +1883,13 @@ describe('fetchGroupProjects', () => { }, ], [], - done, ); }); - it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_FAILURE on failure', (done) => { + it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_FAILURE on failure', () => { jest.spyOn(gqlClient, 'query').mockRejectedValue(); - testAction( + return testAction( actions.fetchGroupProjects, {}, state, @@ -1915,16 +1903,15 @@ describe('fetchGroupProjects', () => { }, ], [], - done, ); }); }); describe('setSelectedProject', () => { - it('should commit mutation SET_SELECTED_PROJECT', (done) => { + it('should commit mutation SET_SELECTED_PROJECT', () => { const project = mockGroupProjects[0]; - testAction( + return testAction( actions.setSelectedProject, project, {}, @@ -1935,7 +1922,6 @@ describe('setSelectedProject', () => { }, ], [], - done, ); }); }); diff --git a/spec/frontend/captcha/apollo_captcha_link_spec.js b/spec/frontend/captcha/apollo_captcha_link_spec.js index eab52344d1f..cd32e63d00c 100644 --- a/spec/frontend/captcha/apollo_captcha_link_spec.js +++ b/spec/frontend/captcha/apollo_captcha_link_spec.js @@ -95,70 +95,82 @@ describe('apolloCaptchaLink', () => { return { operationName: 'operation', variables: {}, setContext: mockContext }; } - it('successful responses are passed through', (done) => { + it('successful responses are passed through', () => { setupLink(SUCCESS_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(SUCCESS_RESPONSE); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); - done(); + + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SUCCESS_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + resolve(); + }); }); }); - it('non-spam related errors are passed through', (done) => { + it('non-spam related errors are passed through', () => { setupLink(NON_CAPTCHA_ERROR_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(NON_CAPTCHA_ERROR_RESPONSE); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - expect(mockContext).not.toHaveBeenCalled(); - expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); - done(); + + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(NON_CAPTCHA_ERROR_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(mockContext).not.toHaveBeenCalled(); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + resolve(); + }); }); }); - it('unresolvable spam errors are passed through', (done) => { + it('unresolvable spam errors are passed through', () => { setupLink(SPAM_ERROR_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(SPAM_ERROR_RESPONSE); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - expect(mockContext).not.toHaveBeenCalled(); - expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); - done(); + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SPAM_ERROR_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(mockContext).not.toHaveBeenCalled(); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + resolve(); + }); }); }); describe('resolvable spam errors', () => { - it('re-submits request with spam headers if the captcha modal was solved correctly', (done) => { + it('re-submits request with spam headers if the captcha modal was solved correctly', () => { waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE); setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(SUCCESS_RESPONSE); - expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); - expect(mockContext).toHaveBeenCalledWith({ - headers: { - 'X-GitLab-Captcha-Response': CAPTCHA_RESPONSE, - 'X-GitLab-Spam-Log-Id': SPAM_LOG_ID, - }, + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SUCCESS_RESPONSE); + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + expect(mockContext).toHaveBeenCalledWith({ + headers: { + 'X-GitLab-Captcha-Response': CAPTCHA_RESPONSE, + 'X-GitLab-Spam-Log-Id': SPAM_LOG_ID, + }, + }); + expect(mockLinkImplementation).toHaveBeenCalledTimes(2); + resolve(); }); - expect(mockLinkImplementation).toHaveBeenCalledTimes(2); - done(); }); }); - it('throws error if the captcha modal was not solved correctly', (done) => { + it('throws error if the captcha modal was not solved correctly', () => { const error = new UnsolvedCaptchaError(); waitForCaptchaToBeSolved.mockRejectedValue(error); setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE); - link.request(mockOperation()).subscribe({ - next: done.catch, - error: (result) => { - expect(result).toEqual(error); - expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); - expect(mockContext).not.toHaveBeenCalled(); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - done(); - }, + return new Promise((resolve, reject) => { + link.request(mockOperation()).subscribe({ + next: reject, + error: (result) => { + expect(result).toEqual(error); + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + expect(mockContext).not.toHaveBeenCalled(); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + resolve(); + }, + }); }); }); }); diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js index 085ab1c0c30..2fedbbecd64 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js @@ -36,7 +36,7 @@ describe('Ci variable modal', () => { const findAddorUpdateButton = () => findModal() .findAll(GlButton) - .wrappers.find((button) => button.props('variant') === 'success'); + .wrappers.find((button) => button.props('variant') === 'confirm'); const deleteVariableButton = () => findModal() .findAll(GlButton) diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js index 426e6cae8fb..eb31fcd3ef4 100644 --- a/spec/frontend/ci_variable_list/store/actions_spec.js +++ b/spec/frontend/ci_variable_list/store/actions_spec.js @@ -86,10 +86,10 @@ describe('CI variable list store actions', () => { }); describe('deleteVariable', () => { - it('dispatch correct actions on successful deleted variable', (done) => { + it('dispatch correct actions on successful deleted variable', () => { mock.onPatch(state.endpoint).reply(200); - testAction( + return testAction( actions.deleteVariable, {}, state, @@ -99,16 +99,13 @@ describe('CI variable list store actions', () => { { type: 'receiveDeleteVariableSuccess' }, { type: 'fetchVariables' }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on delete failure', (done) => { + it('should show flash error and set error in state on delete failure', async () => { mock.onPatch(state.endpoint).reply(500, ''); - testAction( + await testAction( actions.deleteVariable, {}, state, @@ -120,19 +117,16 @@ describe('CI variable list store actions', () => { payload: payloadError, }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); }); describe('updateVariable', () => { - it('dispatch correct actions on successful updated variable', (done) => { + it('dispatch correct actions on successful updated variable', () => { mock.onPatch(state.endpoint).reply(200); - testAction( + return testAction( actions.updateVariable, {}, state, @@ -142,16 +136,13 @@ describe('CI variable list store actions', () => { { type: 'receiveUpdateVariableSuccess' }, { type: 'fetchVariables' }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on update failure', (done) => { + it('should show flash error and set error in state on update failure', async () => { mock.onPatch(state.endpoint).reply(500, ''); - testAction( + await testAction( actions.updateVariable, mockVariable, state, @@ -163,19 +154,16 @@ describe('CI variable list store actions', () => { payload: payloadError, }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); }); describe('addVariable', () => { - it('dispatch correct actions on successful added variable', (done) => { + it('dispatch correct actions on successful added variable', () => { mock.onPatch(state.endpoint).reply(200); - testAction( + return testAction( actions.addVariable, {}, state, @@ -185,16 +173,13 @@ describe('CI variable list store actions', () => { { type: 'receiveAddVariableSuccess' }, { type: 'fetchVariables' }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on add failure', (done) => { + it('should show flash error and set error in state on add failure', async () => { mock.onPatch(state.endpoint).reply(500, ''); - testAction( + await testAction( actions.addVariable, {}, state, @@ -206,19 +191,16 @@ describe('CI variable list store actions', () => { payload: payloadError, }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); }); describe('fetchVariables', () => { - it('dispatch correct actions on fetchVariables', (done) => { + it('dispatch correct actions on fetchVariables', () => { mock.onGet(state.endpoint).reply(200, { variables: mockData.mockVariables }); - testAction( + return testAction( actions.fetchVariables, {}, state, @@ -230,29 +212,24 @@ describe('CI variable list store actions', () => { payload: prepareDataForDisplay(mockData.mockVariables), }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on fetch variables failure', (done) => { + it('should show flash error and set error in state on fetch variables failure', async () => { mock.onGet(state.endpoint).reply(500); - testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }], () => { - expect(createFlash).toHaveBeenCalledWith({ - message: 'There was an error fetching the variables.', - }); - done(); + await testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }]); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error fetching the variables.', }); }); }); describe('fetchEnvironments', () => { - it('dispatch correct actions on fetchEnvironments', (done) => { + it('dispatch correct actions on fetchEnvironments', () => { Api.environments = jest.fn().mockResolvedValue({ data: mockData.mockEnvironments }); - testAction( + return testAction( actions.fetchEnvironments, {}, state, @@ -264,28 +241,17 @@ describe('CI variable list store actions', () => { payload: prepareEnvironments(mockData.mockEnvironments), }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on fetch environments failure', (done) => { + it('should show flash error and set error in state on fetch environments failure', async () => { Api.environments = jest.fn().mockRejectedValue(); - testAction( - actions.fetchEnvironments, - {}, - state, - [], - [{ type: 'requestEnvironments' }], - () => { - expect(createFlash).toHaveBeenCalledWith({ - message: 'There was an error fetching the environments information.', - }); - done(); - }, - ); + await testAction(actions.fetchEnvironments, {}, state, [], [{ type: 'requestEnvironments' }]); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error fetching the environments information.', + }); }); }); diff --git a/spec/frontend/clusters_list/components/agent_empty_state_spec.js b/spec/frontend/clusters_list/components/agent_empty_state_spec.js index ed2a0d0b97b..22775aa6603 100644 --- a/spec/frontend/clusters_list/components/agent_empty_state_spec.js +++ b/spec/frontend/clusters_list/components/agent_empty_state_spec.js @@ -1,8 +1,6 @@ -import { GlEmptyState, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue'; -import { INSTALL_AGENT_MODAL_ID } from '~/clusters_list/constants'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { helpPagePath } from '~/helpers/help_page_helper'; const emptyStateImage = '/path/to/image'; @@ -15,16 +13,12 @@ describe('AgentEmptyStateComponent', () => { }; const findInstallDocsLink = () => wrapper.findComponent(GlLink); - const findIntegrationButton = () => wrapper.findComponent(GlButton); const findEmptyState = () => wrapper.findComponent(GlEmptyState); beforeEach(() => { wrapper = shallowMountExtended(AgentEmptyState, { provide: provideData, - directives: { - GlModalDirective: createMockDirective(), - }, - stubs: { GlEmptyState, GlSprintf }, + stubs: { GlSprintf }, }); }); @@ -38,17 +32,7 @@ describe('AgentEmptyStateComponent', () => { expect(findEmptyState().exists()).toBe(true); }); - it('renders button for the agent registration', () => { - expect(findIntegrationButton().exists()).toBe(true); - }); - it('renders correct href attributes for the docs link', () => { expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl); }); - - it('renders correct modal id for the agent registration modal', () => { - const binding = getBinding(findIntegrationButton().element, 'gl-modal-directive'); - - expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); - }); }); diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js index db723622a51..a466a35428a 100644 --- a/spec/frontend/clusters_list/components/agent_table_spec.js +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -9,7 +9,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago'; import { clusterAgents, connectedTimeNow, connectedTimeInactive } from './mock_data'; const defaultConfigHelpUrl = - '/help/user/clusters/agent/install/index#create-an-agent-without-configuration-file'; + '/help/user/clusters/agent/install/index#create-an-agent-configuration-file'; const provideData = { gitlabVersion: '14.8', diff --git a/spec/frontend/clusters_list/components/agent_token_spec.js b/spec/frontend/clusters_list/components/agent_token_spec.js index a80c8ffaad4..7f6ec2eb3a2 100644 --- a/spec/frontend/clusters_list/components/agent_token_spec.js +++ b/spec/frontend/clusters_list/components/agent_token_spec.js @@ -53,7 +53,7 @@ describe('InstallAgentModal', () => { }); it('shows agent token as an input value', () => { - expect(findInput().props('value')).toBe('agent-token'); + expect(findInput().props('value')).toBe(agentToken); }); it('renders a copy button', () => { @@ -65,12 +65,12 @@ describe('InstallAgentModal', () => { }); it('shows warning alert', () => { - expect(findAlert().props('title')).toBe(I18N_AGENT_TOKEN.tokenSingleUseWarningTitle); + expect(findAlert().text()).toBe(I18N_AGENT_TOKEN.tokenSingleUseWarningTitle); }); it('shows code block with agent installation command', () => { - expect(findCodeBlock().props('code')).toContain('--agent-token=agent-token'); - expect(findCodeBlock().props('code')).toContain('--kas-address=kas.example.com'); + expect(findCodeBlock().props('code')).toContain(`--set config.token=${agentToken}`); + expect(findCodeBlock().props('code')).toContain(`--set config.kasAddress=${kasAddress}`); }); }); }); diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js index 3cfa4b92bc0..92cfff7d490 100644 --- a/spec/frontend/clusters_list/components/agents_spec.js +++ b/spec/frontend/clusters_list/components/agents_spec.js @@ -308,7 +308,7 @@ describe('Agents', () => { }); it('displays an alert message', () => { - expect(findAlert().text()).toBe('An error occurred while loading your Agents'); + expect(findAlert().text()).toBe('An error occurred while loading your agents'); }); }); diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js index eca2b1f5cb1..197735d3c77 100644 --- a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js +++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js @@ -1,5 +1,6 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { ENTER_KEY } from '~/lib/utils/keys'; import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue'; import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '~/clusters_list/constants'; @@ -18,6 +19,7 @@ describe('AvailableAgentsDropdown', () => { propsData, stubs: { GlDropdown }, }); + wrapper.vm.$refs.dropdown.hide = jest.fn(); }; afterEach(() => { @@ -96,6 +98,25 @@ describe('AvailableAgentsDropdown', () => { expect(findDropdown().props('text')).toBe('new-agent'); }); }); + + describe('click enter to register new agent without configuration', () => { + beforeEach(async () => { + await findSearchInput().vm.$emit('input', 'new-agent'); + await findSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + }); + + it('emits agentSelected with the name of the clicked agent', () => { + expect(wrapper.emitted('agentSelected')).toEqual([['new-agent']]); + }); + + it('marks the clicked item as selected', () => { + expect(findDropdown().props('text')).toBe('new-agent'); + }); + + it('closes the dropdown', () => { + expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalledTimes(1); + }); + }); }); describe('registration in progress', () => { diff --git a/spec/frontend/clusters_list/components/clusters_actions_spec.js b/spec/frontend/clusters_list/components/clusters_actions_spec.js index 312df12ab5f..21dcc66c639 100644 --- a/spec/frontend/clusters_list/components/clusters_actions_spec.js +++ b/spec/frontend/clusters_list/components/clusters_actions_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlTooltip } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ClustersActions from '~/clusters_list/components/clusters_actions.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -7,12 +7,14 @@ import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '~/clusters_list/consta describe('ClustersActionsComponent', () => { let wrapper; - const newClusterPath = 'path/to/create/cluster'; + const newClusterPath = 'path/to/add/cluster'; const addClusterPath = 'path/to/connect/existing/cluster'; + const newClusterDocsPath = 'path/to/create/new/cluster'; const defaultProvide = { newClusterPath, addClusterPath, + newClusterDocsPath, canAddCluster: true, displayClusterAgents: true, certificateBasedClustersEnabled: true, @@ -20,12 +22,13 @@ describe('ClustersActionsComponent', () => { const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findTooltip = () => wrapper.findComponent(GlTooltip); const findDropdownItemIds = () => findDropdownItems().wrappers.map((x) => x.attributes('data-testid')); + const findDropdownItemTexts = () => findDropdownItems().wrappers.map((x) => x.text()); const findNewClusterLink = () => wrapper.findByTestId('new-cluster-link'); + const findNewClusterDocsLink = () => wrapper.findByTestId('create-cluster-link'); const findConnectClusterLink = () => wrapper.findByTestId('connect-cluster-link'); - const findConnectNewAgentLink = () => wrapper.findByTestId('connect-new-agent-link'); - const findConnectWithAgentButton = () => wrapper.findComponent(GlButton); const createWrapper = (provideData = {}) => { wrapper = shallowMountExtended(ClustersActions, { @@ -35,7 +38,6 @@ describe('ClustersActionsComponent', () => { }, directives: { GlModalDirective: createMockDirective(), - GlTooltip: createMockDirective(), }, }); }; @@ -49,12 +51,15 @@ describe('ClustersActionsComponent', () => { }); describe('when the certificate based clusters are enabled', () => { it('renders actions menu', () => { - expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.actionsButton); + expect(findDropdown().exists()).toBe(true); }); - it('renders correct href attributes for the links', () => { - expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); - expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath); + it('shows split button in dropdown', () => { + expect(findDropdown().props('split')).toBe(true); + }); + + it("doesn't show the tooltip", () => { + expect(findTooltip().exists()).toBe(false); }); describe('when user cannot add clusters', () => { @@ -67,8 +72,7 @@ describe('ClustersActionsComponent', () => { }); it('shows tooltip explaining why dropdown is disabled', () => { - const tooltip = getBinding(findDropdown().element, 'gl-tooltip'); - expect(tooltip.value).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint); + expect(findTooltip().attributes('title')).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint); }); it('does not bind split dropdown button', () => { @@ -79,33 +83,36 @@ describe('ClustersActionsComponent', () => { }); describe('when on project level', () => { - it('renders a dropdown with 3 actions items', () => { - expect(findDropdownItemIds()).toEqual([ - 'connect-new-agent-link', - 'new-cluster-link', - 'connect-cluster-link', - ]); + it(`displays default action as ${CLUSTERS_ACTIONS.connectWithAgent}`, () => { + expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectWithAgent); }); - it('renders correct modal id for the agent link', () => { - const binding = getBinding(findConnectNewAgentLink().element, 'gl-modal-directive'); + it('renders correct modal id for the default action', () => { + const binding = getBinding(findDropdown().element, 'gl-modal-directive'); expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); }); - it('shows tooltip', () => { - const tooltip = getBinding(findDropdown().element, 'gl-tooltip'); - expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectWithAgent); + it('renders a dropdown with 3 actions items', () => { + expect(findDropdownItemIds()).toEqual([ + 'create-cluster-link', + 'new-cluster-link', + 'connect-cluster-link', + ]); }); - it('shows split button in dropdown', () => { - expect(findDropdown().props('split')).toBe(true); + it('renders correct texts for the dropdown items', () => { + expect(findDropdownItemTexts()).toEqual([ + CLUSTERS_ACTIONS.createCluster, + CLUSTERS_ACTIONS.createClusterCertificate, + CLUSTERS_ACTIONS.connectClusterCertificate, + ]); }); - it('binds split button with modal id', () => { - const binding = getBinding(findDropdown().element, 'gl-modal-directive'); - - expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); + it('renders correct href attributes for the links', () => { + expect(findNewClusterDocsLink().attributes('href')).toBe(newClusterDocsPath); + expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); + expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath); }); }); @@ -114,17 +121,20 @@ describe('ClustersActionsComponent', () => { createWrapper({ displayClusterAgents: false }); }); - it('renders a dropdown with 2 actions items', () => { - expect(findDropdownItemIds()).toEqual(['new-cluster-link', 'connect-cluster-link']); + it(`displays default action as ${CLUSTERS_ACTIONS.connectClusterDeprecated}`, () => { + expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectClusterDeprecated); }); - it('shows tooltip', () => { - const tooltip = getBinding(findDropdown().element, 'gl-tooltip'); - expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectExistingCluster); + it('renders a dropdown with 1 action item', () => { + expect(findDropdownItemIds()).toEqual(['new-cluster-link']); }); - it('does not show split button in dropdown', () => { - expect(findDropdown().props('split')).toBe(false); + it('renders correct text for the dropdown item', () => { + expect(findDropdownItemTexts()).toEqual([CLUSTERS_ACTIONS.createClusterDeprecated]); + }); + + it('renders correct href attributes for the links', () => { + expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); }); it('does not bind dropdown button to modal', () => { @@ -140,17 +150,26 @@ describe('ClustersActionsComponent', () => { createWrapper({ certificateBasedClustersEnabled: false }); }); - it('it does not show the the dropdown', () => { - expect(findDropdown().exists()).toBe(false); + it(`displays default action as ${CLUSTERS_ACTIONS.connectCluster}`, () => { + expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectCluster); }); - it('shows the connect with agent button', () => { - expect(findConnectWithAgentButton().props()).toMatchObject({ - disabled: !defaultProvide.canAddCluster, - category: 'primary', - variant: 'confirm', - }); - expect(findConnectWithAgentButton().text()).toBe(CLUSTERS_ACTIONS.connectWithAgent); + it('renders correct modal id for the default action', () => { + const binding = getBinding(findDropdown().element, 'gl-modal-directive'); + + expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); + }); + + it('renders a dropdown with 1 action item', () => { + expect(findDropdownItemIds()).toEqual(['create-cluster-link']); + }); + + it('renders correct text for the dropdown item', () => { + expect(findDropdownItemTexts()).toEqual([CLUSTERS_ACTIONS.createCluster]); + }); + + it('renders correct href attributes for the links', () => { + expect(findNewClusterDocsLink().attributes('href')).toBe(newClusterDocsPath); }); }); }); diff --git a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js index fe2189296a6..2c3a224f3c8 100644 --- a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js +++ b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js @@ -1,10 +1,8 @@ -import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { GlEmptyState } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ClustersEmptyState from '~/clusters_list/components/clusters_empty_state.vue'; -import ClusterStore from '~/clusters_list/store'; const clustersEmptyStateImage = 'path/to/svg'; -const addClusterPath = '/path/to/connect/cluster'; const emptyStateHelpText = 'empty state text'; describe('ClustersEmptyStateComponent', () => { @@ -12,52 +10,28 @@ describe('ClustersEmptyStateComponent', () => { const defaultProvideData = { clustersEmptyStateImage, - addClusterPath, }; - const findButton = () => wrapper.findComponent(GlButton); const findEmptyStateText = () => wrapper.findByTestId('clusters-empty-state-text'); - const createWrapper = ({ - provideData = { emptyStateHelpText: null }, - isChildComponent = false, - canAddCluster = true, - } = {}) => { + const createWrapper = ({ provideData = { emptyStateHelpText: null } } = {}) => { wrapper = shallowMountExtended(ClustersEmptyState, { - store: ClusterStore({ canAddCluster }), - propsData: { isChildComponent }, provide: { ...defaultProvideData, ...provideData }, stubs: { GlEmptyState }, }); }; - beforeEach(() => { - createWrapper(); - }); - afterEach(() => { wrapper.destroy(); }); - describe('when the component is loaded independently', () => { - it('should render the action button', () => { - expect(findButton().exists()).toBe(true); - }); - }); - describe('when the help text is not provided', () => { - it('should not render the empty state text', () => { - expect(findEmptyStateText().exists()).toBe(false); - }); - }); - - describe('when the component is loaded as a child component', () => { beforeEach(() => { - createWrapper({ isChildComponent: true }); + createWrapper(); }); - it('should not render the action button', () => { - expect(findButton().exists()).toBe(false); + it('should not render the empty state text', () => { + expect(findEmptyStateText().exists()).toBe(false); }); }); @@ -70,13 +44,4 @@ describe('ClustersEmptyStateComponent', () => { expect(findEmptyStateText().text()).toBe(emptyStateHelpText); }); }); - - describe('when the user cannot add clusters', () => { - beforeEach(() => { - createWrapper({ canAddCluster: false }); - }); - it('should disable the button', () => { - expect(findButton().props('disabled')).toBe(true); - }); - }); }); diff --git a/spec/frontend/clusters_list/components/clusters_view_all_spec.js b/spec/frontend/clusters_list/components/clusters_view_all_spec.js index 2c1e3d909cc..b4eb9242003 100644 --- a/spec/frontend/clusters_list/components/clusters_view_all_spec.js +++ b/spec/frontend/clusters_list/components/clusters_view_all_spec.js @@ -1,24 +1,21 @@ -import { GlCard, GlLoadingIcon, GlButton, GlSprintf, GlBadge } from '@gitlab/ui'; +import { GlCard, GlLoadingIcon, GlSprintf, GlBadge } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ClustersViewAll from '~/clusters_list/components/clusters_view_all.vue'; import Agents from '~/clusters_list/components/agents.vue'; import Clusters from '~/clusters_list/components/clusters.vue'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { AGENT, CERTIFICATE_BASED, AGENT_CARD_INFO, CERTIFICATE_BASED_CARD_INFO, MAX_CLUSTERS_LIST, - INSTALL_AGENT_MODAL_ID, } from '~/clusters_list/constants'; import { sprintf } from '~/locale'; Vue.use(Vuex); -const addClusterPath = '/path/to/add/cluster'; const defaultBranchName = 'default-branch'; describe('ClustersViewAllComponent', () => { @@ -32,11 +29,6 @@ describe('ClustersViewAllComponent', () => { defaultBranchName, }; - const defaultProvide = { - addClusterPath, - canAddCluster: true, - }; - const entryData = { loadingClusters: false, totalClusters: 0, @@ -46,37 +38,20 @@ describe('ClustersViewAllComponent', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAgentsComponent = () => wrapper.findComponent(Agents); const findClustersComponent = () => wrapper.findComponent(Clusters); - const findInstallAgentButtonTooltip = () => wrapper.findByTestId('install-agent-button-tooltip'); - const findConnectExistingClusterButtonTooltip = () => - wrapper.findByTestId('connect-existing-cluster-button-tooltip'); const findCardsContainer = () => wrapper.findByTestId('clusters-cards-container'); const findAgentCardTitle = () => wrapper.findByTestId('agent-card-title'); const findRecommendedBadge = () => wrapper.findComponent(GlBadge); const findClustersCardTitle = () => wrapper.findByTestId('clusters-card-title'); - const findFooterButton = (line) => findCards().at(line).findComponent(GlButton); - const getTooltipText = (el) => { - const binding = getBinding(el, 'gl-tooltip'); - - return binding.value; - }; const createStore = (initialState) => new Vuex.Store({ state: initialState, }); - const createWrapper = ({ initialState = entryData, provideData } = {}) => { + const createWrapper = ({ initialState = entryData } = {}) => { wrapper = shallowMountExtended(ClustersViewAll, { store: createStore(initialState), propsData, - provide: { - ...defaultProvide, - ...provideData, - }, - directives: { - GlModalDirective: createMockDirective(), - GlTooltip: createMockDirective(), - }, stubs: { GlCard, GlSprintf }, }); }; @@ -138,25 +113,10 @@ describe('ClustersViewAllComponent', () => { expect(findAgentsComponent().props('defaultBranchName')).toBe(defaultBranchName); }); - it('should show install new Agent button in the footer', () => { - expect(findFooterButton(0).exists()).toBe(true); - expect(findFooterButton(0).props('disabled')).toBe(false); - }); - - it('does not show tooltip for install new Agent button', () => { - expect(getTooltipText(findInstallAgentButtonTooltip().element)).toBe(''); - }); - describe('when there are no agents', () => { it('should show the empty title', () => { expect(findAgentCardTitle().text()).toBe(AGENT_CARD_INFO.emptyTitle); }); - - it('should render correct modal id for the agent link', () => { - const binding = getBinding(findFooterButton(0).element, 'gl-modal-directive'); - - expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); - }); }); describe('when the agents are present', () => { @@ -191,22 +151,6 @@ describe('ClustersViewAllComponent', () => { }); }); }); - - describe('when the user cannot add clusters', () => { - beforeEach(() => { - createWrapper({ provideData: { canAddCluster: false } }); - }); - - it('should disable the button', () => { - expect(findFooterButton(0).props('disabled')).toBe(true); - }); - - it('should show a tooltip explaining why the button is disabled', () => { - expect(getTooltipText(findInstallAgentButtonTooltip().element)).toBe( - AGENT_CARD_INFO.installAgentDisabledHint, - ); - }); - }); }); describe('clusters tab', () => { @@ -214,43 +158,10 @@ describe('ClustersViewAllComponent', () => { expect(findClustersComponent().props('limit')).toBe(MAX_CLUSTERS_LIST); }); - it('should pass the is-child-component prop', () => { - expect(findClustersComponent().props('isChildComponent')).toBe(true); - }); - describe('when there are no clusters', () => { it('should show the empty title', () => { expect(findClustersCardTitle().text()).toBe(CERTIFICATE_BASED_CARD_INFO.emptyTitle); }); - - it('should show install new cluster button in the footer', () => { - expect(findFooterButton(1).exists()).toBe(true); - expect(findFooterButton(1).props('disabled')).toBe(false); - }); - - it('should render correct href for the button in the footer', () => { - expect(findFooterButton(1).attributes('href')).toBe(addClusterPath); - }); - - it('does not show tooltip for install new cluster button', () => { - expect(getTooltipText(findConnectExistingClusterButtonTooltip().element)).toBe(''); - }); - }); - - describe('when the user cannot add clusters', () => { - beforeEach(() => { - createWrapper({ provideData: { canAddCluster: false } }); - }); - - it('should disable the button', () => { - expect(findFooterButton(1).props('disabled')).toBe(true); - }); - - it('should show a tooltip explaining why the button is disabled', () => { - expect(getTooltipText(findConnectExistingClusterButtonTooltip().element)).toBe( - CERTIFICATE_BASED_CARD_INFO.connectExistingClusterDisabledHint, - ); - }); }); describe('when the clusters are present', () => { diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js index b0f2978a230..3467b4c665c 100644 --- a/spec/frontend/clusters_list/mocks/apollo.js +++ b/spec/frontend/clusters_list/mocks/apollo.js @@ -1,4 +1,5 @@ const agent = { + __typename: 'ClusterAgent', id: 'agent-id', name: 'agent-name', webPath: 'agent-webPath', diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index f4b69053e14..7663f329b3f 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -24,14 +24,12 @@ describe('Clusters store actions', () => { captureException.mockRestore(); }); - it('should report sentry error', (done) => { + it('should report sentry error', async () => { const sentryError = new Error('New Sentry Error'); const tag = 'sentryErrorTag'; - testAction(actions.reportSentryError, { error: sentryError, tag }, {}, [], [], () => { - expect(captureException).toHaveBeenCalledWith(sentryError); - done(); - }); + await testAction(actions.reportSentryError, { error: sentryError, tag }, {}, [], []); + expect(captureException).toHaveBeenCalledWith(sentryError); }); }); @@ -62,10 +60,10 @@ describe('Clusters store actions', () => { afterEach(() => mock.restore()); - it('should commit SET_CLUSTERS_DATA with received response', (done) => { + it('should commit SET_CLUSTERS_DATA with received response', () => { mock.onGet().reply(200, apiData, headers); - testAction( + return testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -75,14 +73,13 @@ describe('Clusters store actions', () => { { type: types.SET_LOADING_CLUSTERS, payload: false }, ], [], - () => done(), ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mock.onGet().reply(400, 'Not Found'); - testAction( + await testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -100,13 +97,10 @@ describe('Clusters store actions', () => { }, }, ], - () => { - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringMatching('error'), - }); - done(); - }, ); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('error'), + }); }); describe('multiple api requests', () => { @@ -128,8 +122,8 @@ describe('Clusters store actions', () => { pollStop.mockRestore(); }); - it('should stop polling after MAX Requests', (done) => { - testAction( + it('should stop polling after MAX Requests', async () => { + await testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -139,47 +133,43 @@ describe('Clusters store actions', () => { { type: types.SET_LOADING_CLUSTERS, payload: false }, ], [], - () => { - expect(pollRequest).toHaveBeenCalledTimes(1); + ); + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + + return waitForPromises() + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(2); expect(pollStop).toHaveBeenCalledTimes(0); jest.advanceTimersByTime(pollInterval); - - waitForPromises() - .then(() => { - expect(pollRequest).toHaveBeenCalledTimes(2); - expect(pollStop).toHaveBeenCalledTimes(0); - jest.advanceTimersByTime(pollInterval); - }) - .then(() => waitForPromises()) - .then(() => { - expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS); - expect(pollStop).toHaveBeenCalledTimes(0); - jest.advanceTimersByTime(pollInterval); - }) - .then(() => waitForPromises()) - .then(() => { - expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); - // Stops poll once it exceeds the MAX_REQUESTS limit - expect(pollStop).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(pollInterval); - }) - .then(() => waitForPromises()) - .then(() => { - // Additional poll requests are not made once pollStop is called - expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); - expect(pollStop).toHaveBeenCalledTimes(1); - }) - .then(done) - .catch(done.fail); - }, - ); + }) + .then(() => waitForPromises()) + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); + // Stops poll once it exceeds the MAX_REQUESTS limit + expect(pollStop).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + // Additional poll requests are not made once pollStop is called + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); }); - it('should stop polling and report to Sentry when data is invalid', (done) => { + it('should stop polling and report to Sentry when data is invalid', async () => { const badApiResponse = { clusters: {} }; mock.onGet().reply(200, badApiResponse, pollHeaders); - testAction( + await testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -202,12 +192,9 @@ describe('Clusters store actions', () => { }, }, ], - () => { - expect(pollRequest).toHaveBeenCalledTimes(1); - expect(pollStop).toHaveBeenCalledTimes(1); - done(); - }, ); + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js index 0d7c0360e9b..f2f97092c5a 100644 --- a/spec/frontend/code_navigation/components/app_spec.js +++ b/spec/frontend/code_navigation/components/app_spec.js @@ -38,12 +38,17 @@ describe('Code navigation app component', () => { const codeNavigationPath = 'code/nav/path.js'; const path = 'blob/path.js'; const definitionPathPrefix = 'path/prefix'; + const wrapTextNodes = true; - factory({}, { codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix }); + factory( + {}, + { codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix, wrapTextNodes }, + ); expect(setInitialData).toHaveBeenCalledWith(expect.anything(), { blobs: [{ codeNavigationPath, path }], definitionPathPrefix, + wrapTextNodes, }); }); diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js index 73f935deeca..c26416aca94 100644 --- a/spec/frontend/code_navigation/store/actions_spec.js +++ b/spec/frontend/code_navigation/store/actions_spec.js @@ -7,15 +7,16 @@ import axios from '~/lib/utils/axios_utils'; jest.mock('~/code_navigation/utils'); describe('Code navigation actions', () => { + const wrapTextNodes = true; + describe('setInitialData', () => { - it('commits SET_INITIAL_DATA', (done) => { - testAction( + it('commits SET_INITIAL_DATA', () => { + return testAction( actions.setInitialData, - { projectPath: 'test' }, + { projectPath: 'test', wrapTextNodes }, {}, - [{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test' } }], + [{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test', wrapTextNodes } }], [], - done, ); }); }); @@ -30,7 +31,7 @@ describe('Code navigation actions', () => { const codeNavigationPath = 'gitlab-org/gitlab-shell/-/jobs/1114/artifacts/raw/lsif/cmd/check/main.go.json'; - const state = { blobs: [{ path: 'index.js', codeNavigationPath }] }; + const state = { blobs: [{ path: 'index.js', codeNavigationPath }], wrapTextNodes }; beforeEach(() => { window.gon = { api_version: '1' }; @@ -57,8 +58,8 @@ describe('Code navigation actions', () => { ]); }); - it('commits REQUEST_DATA_SUCCESS with normalized data', (done) => { - testAction( + it('commits REQUEST_DATA_SUCCESS with normalized data', () => { + return testAction( actions.fetchData, null, state, @@ -80,12 +81,11 @@ describe('Code navigation actions', () => { }, ], [], - done, ); }); - it('calls addInteractionClass with data', (done) => { - testAction( + it('calls addInteractionClass with data', () => { + return testAction( actions.fetchData, null, state, @@ -107,16 +107,17 @@ describe('Code navigation actions', () => { }, ], [], - ) - .then(() => { - expect(addInteractionClass).toHaveBeenCalledWith('index.js', { + ).then(() => { + expect(addInteractionClass).toHaveBeenCalledWith({ + path: 'index.js', + d: { start_line: 0, start_char: 0, hover: { value: '123' }, - }); - }) - .then(done) - .catch(done.fail); + }, + wrapTextNodes, + }); + }); }); }); @@ -125,14 +126,13 @@ describe('Code navigation actions', () => { mock.onGet(codeNavigationPath).replyOnce(500); }); - it('dispatches requestDataError', (done) => { - testAction( + it('dispatches requestDataError', () => { + return testAction( actions.fetchData, null, state, [{ type: 'REQUEST_DATA' }], [{ type: 'requestDataError' }], - done, ); }); }); @@ -144,14 +144,19 @@ describe('Code navigation actions', () => { data: { 'index.js': { '0:0': 'test', '1:1': 'console.log' }, }, + wrapTextNodes, }; actions.showBlobInteractionZones({ state }, 'index.js'); expect(addInteractionClass).toHaveBeenCalled(); expect(addInteractionClass.mock.calls.length).toBe(2); - expect(addInteractionClass.mock.calls[0]).toEqual(['index.js', 'test']); - expect(addInteractionClass.mock.calls[1]).toEqual(['index.js', 'console.log']); + expect(addInteractionClass.mock.calls[0]).toEqual([ + { path: 'index.js', d: 'test', wrapTextNodes }, + ]); + expect(addInteractionClass.mock.calls[1]).toEqual([ + { path: 'index.js', d: 'console.log', wrapTextNodes }, + ]); }); it('does not call addInteractionClass when no data exists', () => { @@ -175,20 +180,20 @@ describe('Code navigation actions', () => { target = document.querySelector('.js-test'); }); - it('returns early when no data exists', (done) => { - testAction(actions.showDefinition, { target }, {}, [], [], done); + it('returns early when no data exists', () => { + return testAction(actions.showDefinition, { target }, {}, [], []); }); - it('commits SET_CURRENT_DEFINITION when target is not code navitation element', (done) => { - testAction(actions.showDefinition, { target }, { data: {} }, [], [], done); + it('commits SET_CURRENT_DEFINITION when target is not code navitation element', () => { + return testAction(actions.showDefinition, { target }, { data: {} }, [], []); }); - it('commits SET_CURRENT_DEFINITION with LSIF data', (done) => { + it('commits SET_CURRENT_DEFINITION with LSIF data', () => { target.classList.add('js-code-navigation'); target.setAttribute('data-line-index', '0'); target.setAttribute('data-char-index', '0'); - testAction( + return testAction( actions.showDefinition, { target }, { data: { 'index.js': { '0:0': { hover: 'test' } } } }, @@ -203,7 +208,6 @@ describe('Code navigation actions', () => { }, ], [], - done, ); }); diff --git a/spec/frontend/code_navigation/store/mutations_spec.js b/spec/frontend/code_navigation/store/mutations_spec.js index cb10729f4b6..b2f1b3bddfd 100644 --- a/spec/frontend/code_navigation/store/mutations_spec.js +++ b/spec/frontend/code_navigation/store/mutations_spec.js @@ -13,10 +13,12 @@ describe('Code navigation mutations', () => { mutations.SET_INITIAL_DATA(state, { blobs: ['test'], definitionPathPrefix: 'https://test.com/blob/main', + wrapTextNodes: true, }); expect(state.blobs).toEqual(['test']); expect(state.definitionPathPrefix).toBe('https://test.com/blob/main'); + expect(state.wrapTextNodes).toBe(true); }); }); diff --git a/spec/frontend/code_navigation/utils/index_spec.js b/spec/frontend/code_navigation/utils/index_spec.js index 6a01249d2a3..682c8bce8c5 100644 --- a/spec/frontend/code_navigation/utils/index_spec.js +++ b/spec/frontend/code_navigation/utils/index_spec.js @@ -45,14 +45,42 @@ describe('addInteractionClass', () => { ${0} | ${0} | ${0} ${0} | ${8} | ${2} ${1} | ${0} | ${0} + ${1} | ${0} | ${0} `( 'it sets code navigation attributes for line $line and character $char', ({ line, char, index }) => { - addInteractionClass('index.js', { start_line: line, start_char: char }); + addInteractionClass({ path: 'index.js', d: { start_line: line, start_char: char } }); expect(document.querySelectorAll(`#LC${line + 1} span`)[index].classList).toContain( 'js-code-navigation', ); }, ); + + describe('wrapTextNodes', () => { + beforeEach(() => { + setFixtures( + '<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"> Text </div></div></div>', + ); + }); + + const params = { path: 'index.js', d: { start_line: 0, start_char: 0 } }; + const findAllSpans = () => document.querySelectorAll('#LC1 span'); + + it('does not wrap text nodes by default', () => { + addInteractionClass(params); + const spans = findAllSpans(); + expect(spans.length).toBe(0); + }); + + it('wraps text nodes if wrapTextNodes is true', () => { + addInteractionClass({ ...params, wrapTextNodes: true }); + const spans = findAllSpans(); + + expect(spans.length).toBe(3); + expect(spans[0].textContent).toBe(' '); + expect(spans[1].textContent).toBe('Text'); + expect(spans[2].textContent).toBe(' '); + }); + }); }); diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js index 1a2e188e7ae..b1c8ba48475 100644 --- a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js +++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js @@ -1,7 +1,18 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue'; -import { mockStages } from './mock_data'; +import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '~/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql'; +import { mockPipelineStagesQueryResponse, mockStages } from './mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); describe('Commit box pipeline mini graph', () => { let wrapper; @@ -10,34 +21,36 @@ describe('Commit box pipeline mini graph', () => { const findUpstream = () => wrapper.findByTestId('commit-box-mini-graph-upstream'); const findDownstream = () => wrapper.findByTestId('commit-box-mini-graph-downstream'); - const createComponent = () => { + const stagesHandler = jest.fn().mockResolvedValue(mockPipelineStagesQueryResponse); + + const createComponent = ({ props = {} } = {}) => { + const handlers = [ + [getLinkedPipelinesQuery, {}], + [getPipelineStagesQuery, stagesHandler], + ]; + wrapper = extendedWrapper( shallowMount(CommitBoxPipelineMiniGraph, { propsData: { stages: mockStages, + ...props, }, - mocks: { - $apollo: { - queries: { - pipeline: { - loading: false, - }, - }, - }, - }, + apolloProvider: createMockApollo(handlers), }), ); - }; - beforeEach(() => { - createComponent(); - }); + return waitForPromises(); + }; afterEach(() => { wrapper.destroy(); }); describe('linked pipelines', () => { + beforeEach(async () => { + await createComponent(); + }); + it('should display the mini pipeine graph', () => { expect(findMiniGraph().exists()).toBe(true); }); @@ -47,4 +60,18 @@ describe('Commit box pipeline mini graph', () => { expect(findDownstream().exists()).toBe(false); }); }); + + describe('when data is mismatched', () => { + beforeEach(async () => { + await createComponent({ props: { stages: [] } }); + }); + + it('calls create flash with expected arguments', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem handling the pipeline data.', + captureError: true, + error: new Error('Rest stages and graphQl stages must be the same length'), + }); + }); + }); }); diff --git a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js new file mode 100644 index 00000000000..db7b7b45397 --- /dev/null +++ b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js @@ -0,0 +1,150 @@ +import { GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CommitBoxPipelineStatus from '~/projects/commit_box/info/components/commit_box_pipeline_status.vue'; +import { + COMMIT_BOX_POLL_INTERVAL, + PIPELINE_STATUS_FETCH_ERROR, +} from '~/projects/commit_box/info/constants'; +import getLatestPipelineStatusQuery from '~/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql'; +import * as graphQlUtils from '~/pipelines/components/graph/utils'; +import { mockPipelineStatusResponse } from '../mock_data'; + +const mockProvide = { + fullPath: 'root/ci-project', + iid: '46', + graphqlResourceEtag: '/api/graphql:pipelines/id/320', +}; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +describe('Commit box pipeline status', () => { + let wrapper; + + const statusSuccessHandler = jest.fn().mockResolvedValue(mockPipelineStatusResponse); + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findStatusIcon = () => wrapper.findComponent(CiIcon); + const findPipelineLink = () => wrapper.findComponent(GlLink); + + const advanceToNextFetch = () => { + jest.advanceTimersByTime(COMMIT_BOX_POLL_INTERVAL); + }; + + const createMockApolloProvider = (handler) => { + const requestHandlers = [[getLatestPipelineStatusQuery, handler]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (handler = statusSuccessHandler) => { + wrapper = shallowMount(CommitBoxPipelineStatus, { + provide: { + ...mockProvide, + }, + apolloProvider: createMockApolloProvider(handler), + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('loading state', () => { + it('should display loading state when loading', () => { + createComponent(); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findStatusIcon().exists()).toBe(false); + }); + }); + + describe('loaded state', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('should display pipeline status after the query is resolved successfully', async () => { + expect(findStatusIcon().exists()).toBe(true); + + expect(findLoadingIcon().exists()).toBe(false); + expect(createFlash).toHaveBeenCalledTimes(0); + }); + + it('should link to the latest pipeline', () => { + const { + data: { + project: { + pipeline: { + detailedStatus: { detailsPath }, + }, + }, + }, + } = mockPipelineStatusResponse; + + expect(findPipelineLink().attributes('href')).toBe(detailsPath); + }); + }); + + describe('error state', () => { + it('createFlash should show if there is an error fetching the pipeline status', async () => { + createComponent(failedHandler); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: PIPELINE_STATUS_FETCH_ERROR, + }); + }); + }); + + describe('polling', () => { + it('polling interval is set for pipeline stages', () => { + createComponent(); + + const expectedInterval = wrapper.vm.$apollo.queries.pipelineStatus.options.pollInterval; + + expect(expectedInterval).toBe(COMMIT_BOX_POLL_INTERVAL); + }); + + it('polls for pipeline status', async () => { + createComponent(); + + await waitForPromises(); + + expect(statusSuccessHandler).toHaveBeenCalledTimes(1); + + advanceToNextFetch(); + await waitForPromises(); + + expect(statusSuccessHandler).toHaveBeenCalledTimes(2); + + advanceToNextFetch(); + await waitForPromises(); + + expect(statusSuccessHandler).toHaveBeenCalledTimes(3); + }); + + it('toggles pipelineStatus polling with visibility check', async () => { + jest.spyOn(graphQlUtils, 'toggleQueryPollingByVisibility'); + + createComponent(); + + await waitForPromises(); + + expect(graphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledWith( + wrapper.vm.$apollo.queries.pipelineStatus, + ); + }); + }); +}); diff --git a/spec/frontend/commit/mock_data.js b/spec/frontend/commit/mock_data.js index ef018a4fbd7..8db162c07c2 100644 --- a/spec/frontend/commit/mock_data.js +++ b/spec/frontend/commit/mock_data.js @@ -115,3 +115,49 @@ export const mockStages = [ dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=qa', }, ]; + +export const mockPipelineStagesQueryResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + stages: { + nodes: [ + { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/409', + name: 'build', + detailedStatus: { + id: 'success-409-409', + group: 'success', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + }, + ], + }, + }, + }, + }, +}; + +export const mockPipelineStatusResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + detailedStatus: { + id: 'pending-320-320', + detailsPath: '/root/ci-project/-/pipelines/320', + icon: 'status_pending', + group: 'pending', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', + }, + __typename: 'Project', + }, + }, +}; diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js index 203a4d23160..9b01af1e585 100644 --- a/spec/frontend/commit/pipelines/pipelines_table_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js @@ -120,18 +120,20 @@ describe('Pipelines table in Commits and Merge requests', () => { }); describe('pipeline badge counts', () => { - it('should receive update-pipelines-count event', (done) => { + it('should receive update-pipelines-count event', () => { const element = document.createElement('div'); document.body.appendChild(element); - element.addEventListener('update-pipelines-count', (event) => { - expect(event.detail.pipelineCount).toEqual(10); - done(); - }); + return new Promise((resolve) => { + element.addEventListener('update-pipelines-count', (event) => { + expect(event.detail.pipelineCount).toEqual(10); + resolve(); + }); - createComponent(); + createComponent(); - element.appendChild(wrapper.vm.$el); + element.appendChild(wrapper.vm.$el); + }); }); }); }); diff --git a/spec/frontend/commit/pipelines/utils_spec.js b/spec/frontend/commit/pipelines/utils_spec.js new file mode 100644 index 00000000000..472e35a6eb3 --- /dev/null +++ b/spec/frontend/commit/pipelines/utils_spec.js @@ -0,0 +1,59 @@ +import { formatStages } from '~/projects/commit_box/info/utils'; + +const graphqlStage = [ + { + __typename: 'CiStage', + name: 'deploy', + detailedStatus: { + __typename: 'DetailedStatus', + icon: 'status_success', + group: 'success', + id: 'success-409-409', + }, + }, +]; + +const restStage = [ + { + name: 'deploy', + title: 'deploy: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/ci-project/-/pipelines/318#deploy', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/ci-project/-/pipelines/318#deploy', + dropdown_path: '/root/ci-project/-/pipelines/318/stage.json?stage=deploy', + }, +]; + +describe('Utils', () => { + it('combines REST and GraphQL stages correctly for component', () => { + expect(formatStages(graphqlStage, restStage)).toEqual([ + { + dropdown_path: '/root/ci-project/-/pipelines/318/stage.json?stage=deploy', + name: 'deploy', + status: { + __typename: 'DetailedStatus', + group: 'success', + icon: 'status_success', + id: 'success-409-409', + }, + title: 'deploy: passed', + }, + ]); + }); + + it('throws an error if arrays are not the same length', () => { + expect(() => { + formatStages(graphqlStage, []); + }).toThrow('Rest stages and graphQl stages must be the same length'); + }); +}); diff --git a/spec/frontend/commits_spec.js b/spec/frontend/commits_spec.js index 8189ebe6e55..a049a6997f0 100644 --- a/spec/frontend/commits_spec.js +++ b/spec/frontend/commits_spec.js @@ -70,29 +70,17 @@ describe('Commits List', () => { mock.restore(); }); - it('should save the last search string', (done) => { + it('should save the last search string', async () => { commitsList.searchField.val('GitLab'); - commitsList - .filterResults() - .then(() => { - expect(ajaxSpy).toHaveBeenCalled(); - expect(commitsList.lastSearch).toEqual('GitLab'); - - done(); - }) - .catch(done.fail); + await commitsList.filterResults(); + expect(ajaxSpy).toHaveBeenCalled(); + expect(commitsList.lastSearch).toEqual('GitLab'); }); - it('should not make ajax call if the input does not change', (done) => { - commitsList - .filterResults() - .then(() => { - expect(ajaxSpy).not.toHaveBeenCalled(); - expect(commitsList.lastSearch).toEqual(''); - - done(); - }) - .catch(done.fail); + it('should not make ajax call if the input does not change', async () => { + await commitsList.filterResults(); + expect(ajaxSpy).not.toHaveBeenCalled(); + expect(commitsList.lastSearch).toEqual(''); }); }); }); 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 c2fa6556847..d9f161b47b1 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 @@ -12,7 +12,7 @@ exports[`Confidential merge request project form group component renders empty s <!----> <p - class="text-muted mt-1 mb-0" + class="gl-text-gray-600 gl-mt-1 gl-mb-0" > No forks are available to you. @@ -27,7 +27,7 @@ exports[`Confidential merge request project form group component renders empty s </a> and set the fork's visibility to private. <gl-link-stub - class="w-auto p-0 d-inline-block text-primary bg-transparent" + class="gl-w-auto gl-p-0 gl-display-inline-block gl-bg-transparent" href="/help" target="_blank" > @@ -62,13 +62,13 @@ exports[`Confidential merge request project form group component renders fork dr /> <p - class="text-muted mt-1 mb-0" + class="gl-text-gray-600 gl-mt-1 gl-mb-0" > 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" + class="gl-w-auto gl-p-0 gl-display-inline-block gl-bg-transparent" href="/help" target="_blank" > diff --git a/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js new file mode 100644 index 00000000000..074c311495f --- /dev/null +++ b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js @@ -0,0 +1,142 @@ +import { BubbleMenu } from '@tiptap/vue-2'; +import { GlButton, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import Vue from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import CodeBlockBubbleMenu from '~/content_editor/components/code_block_bubble_menu.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; +import { createTestEditor, emitEditorEvent } from '../test_utils'; + +describe('content_editor/components/code_block_bubble_menu', () => { + let wrapper; + let tiptapEditor; + let bubbleMenu; + let eventHub; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); + eventHub = eventHubFactory(); + }; + + const buildWrapper = () => { + wrapper = mountExtended(CodeBlockBubbleMenu, { + provide: { + tiptapEditor, + eventHub, + }, + }); + }; + + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemsData = () => + findDropdownItems().wrappers.map((x) => ({ + text: x.text(), + visible: x.isVisible(), + checked: x.props('isChecked'), + })); + + beforeEach(() => { + buildEditor(); + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders bubble menu component', async () => { + tiptapEditor.commands.insertContent('<pre>test</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(bubbleMenu.props('editor')).toBe(tiptapEditor); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']); + }); + + it('selects plaintext language by default', async () => { + tiptapEditor.commands.insertContent('<pre>test</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Plain text'); + }); + + it('selects appropriate language based on the code block', async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Javascript'); + }); + + it("selects Custom (syntax) if the language doesn't exist in the list", async () => { + tiptapEditor.commands.insertContent('<pre lang="nomnoml">test</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (nomnoml)'); + }); + + it('delete button deletes the code block', async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + await wrapper.findComponent(GlButton).vm.$emit('click'); + + expect(tiptapEditor.getText()).toBe(''); + }); + + describe('when opened and search is changed', () => { + beforeEach(async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'js'); + + await Vue.nextTick(); + }); + + it('shows dropdown items', () => { + expect(findDropdownItemsData()).toEqual([ + { text: 'Javascript', visible: true, checked: true }, + { text: 'Java', visible: true, checked: false }, + { text: 'Javascript', visible: false, checked: false }, + { text: 'JSON', visible: true, checked: false }, + ]); + }); + + describe('when dropdown item is clicked', () => { + beforeEach(async () => { + jest.spyOn(codeBlockLanguageLoader, 'loadLanguages').mockResolvedValue(); + + findDropdownItems().at(1).vm.$emit('click'); + + await Vue.nextTick(); + }); + + it('loads language', () => { + expect(codeBlockLanguageLoader.loadLanguages).toHaveBeenCalledWith(['java']); + }); + + it('sets code block', () => { + expect(tiptapEditor.getJSON()).toMatchObject({ + content: [ + { + type: 'codeBlock', + attrs: { + language: 'java', + }, + }, + ], + }); + }); + + it('updates selected dropdown', () => { + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Java'); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js index e44a7fa4ddb..192ddee78c6 100644 --- a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js @@ -9,7 +9,7 @@ import { } from '~/content_editor/constants'; import { createTestEditor } from '../test_utils'; -describe('content_editor/components/top_toolbar', () => { +describe('content_editor/components/formatting_bubble_menu', () => { let wrapper; let trackingSpy; let tiptapEditor; diff --git a/spec/frontend/content_editor/components/wrappers/image_spec.js b/spec/frontend/content_editor/components/wrappers/media_spec.js index 7b057f9cabc..3e95e2f3914 100644 --- a/spec/frontend/content_editor/components/wrappers/image_spec.js +++ b/spec/frontend/content_editor/components/wrappers/media_spec.js @@ -1,21 +1,24 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { NodeViewWrapper } from '@tiptap/vue-2'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ImageWrapper from '~/content_editor/components/wrappers/image.vue'; +import MediaWrapper from '~/content_editor/components/wrappers/media.vue'; -describe('content/components/wrappers/image', () => { +describe('content/components/wrappers/media', () => { let wrapper; const createWrapper = async (nodeAttrs = {}) => { - wrapper = shallowMountExtended(ImageWrapper, { + wrapper = shallowMountExtended(MediaWrapper, { propsData: { node: { attrs: nodeAttrs, + type: { + name: 'image', + }, }, }, }); }; - const findImage = () => wrapper.findByTestId('image'); + const findMedia = () => wrapper.findByTestId('media'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); afterEach(() => { @@ -33,7 +36,7 @@ describe('content/components/wrappers/image', () => { createWrapper({ src }); - expect(findImage().attributes().src).toBe(src); + expect(findMedia().attributes().src).toBe(src); }); describe('when uploading', () => { @@ -45,8 +48,8 @@ describe('content/components/wrappers/image', () => { expect(findLoadingIcon().exists()).toBe(true); }); - it('adds gl-opacity-5 class selector to image', () => { - expect(findImage().classes()).toContain('gl-opacity-5'); + it('adds gl-opacity-5 class selector to the media tag', () => { + expect(findMedia().classes()).toContain('gl-opacity-5'); }); }); @@ -59,8 +62,8 @@ describe('content/components/wrappers/image', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('does not add gl-opacity-5 class selector to image', () => { - expect(findImage().classes()).not.toContain('gl-opacity-5'); + it('does not add gl-opacity-5 class selector to the media tag', () => { + expect(findMedia().classes()).not.toContain('gl-opacity-5'); }); }); }); diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index ec67545cf17..d3c42104e47 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -1,7 +1,10 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; import Attachment from '~/content_editor/extensions/attachment'; import Image from '~/content_editor/extensions/image'; +import Audio from '~/content_editor/extensions/audio'; +import Video from '~/content_editor/extensions/video'; import Link from '~/content_editor/extensions/link'; import Loading from '~/content_editor/extensions/loading'; import { VARIANT_DANGER } from '~/flash'; @@ -14,6 +17,23 @@ const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="au <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_VIDEO_HTML = `<p data-sourcepos="1:1-1:132" dir="auto"> + <span class="media-container video-container"> + <video src="/group1/project1/-/wikis/test-file.mp4" controls="true" data-setup="{}" data-title="test-file" width="400" preload="metadata" data-canonical-src="test-file.mp4"> + </video> + <a href="/himkp/test/-/wikis/test-file.mp4" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp4">test-file</a> + </span> +</p>`; + +const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `<p data-sourcepos="3:1-3:74" dir="auto"> + <span class="media-container audio-container"> + <audio src="/himkp/test/-/wikis/test-file.mp3" controls="true" data-setup="{}" data-title="test-file" data-canonical-src="test-file.mp3"> + </audio> + <a href="/himkp/test/-/wikis/test-file.mp3" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp3">test-file</a> + </span> +</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>`; @@ -23,6 +43,8 @@ describe('content_editor/extensions/attachment', () => { let doc; let p; let image; + let audio; + let video; let loading; let link; let renderMarkdown; @@ -31,15 +53,18 @@ describe('content_editor/extensions/attachment', () => { const uploadsPath = '/uploads/'; const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); + const audioFile = new File(['foo'], 'test-file.mp3', { type: 'audio/mpeg' }); + const videoFile = new File(['foo'], 'test-file.mp4', { type: 'video/mp4' }); 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 = () => { + const handleTransaction = async () => { if (counter === number) { expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); tiptapEditor.off('update', handleTransaction); + await waitForPromises(); resolve(); } @@ -60,18 +85,22 @@ describe('content_editor/extensions/attachment', () => { Loading, Link, Image, + Audio, + Video, Attachment.configure({ renderMarkdown, uploadsPath, eventHub }), ], }); ({ - builders: { doc, p, image, loading, link }, + builders: { doc, p, image, audio, video, loading, link }, } = createDocBuilder({ tiptapEditor, names: { loading: { markType: Loading.name }, image: { nodeType: Image.name }, link: { nodeType: Link.name }, + audio: { nodeType: Audio.name }, + video: { nodeType: Video.name }, }, })); @@ -103,17 +132,22 @@ describe('content_editor/extensions/attachment', () => { tiptapEditor.commands.setContent(initialDoc.toJSON()); }); - describe('when the file has image mime type', () => { - const base64EncodedFile = 'data:image/png;base64,Zm9v'; + describe.each` + nodeType | mimeType | html | file | mediaType + ${'image'} | ${'image/png'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${imageFile} | ${(attrs) => image(attrs)} + ${'audio'} | ${'audio/mpeg'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${audioFile} | ${(attrs) => audio(attrs)} + ${'video'} | ${'video/mp4'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${videoFile} | ${(attrs) => video(attrs)} + `('when the file has $nodeType mime type', ({ mimeType, html, file, mediaType }) => { + const base64EncodedFile = `data:${mimeType};base64,Zm9v`; beforeEach(() => { - renderMarkdown.mockResolvedValue(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML); + renderMarkdown.mockResolvedValue(html); }); describe('when uploading succeeds', () => { const successResponse = { link: { - markdown: '![test-file](test-file.png)', + markdown: `![test-file](${file.name})`, }, }; @@ -121,21 +155,21 @@ 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', async () => { - const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile }))); + it('inserts a media content with src set to the encoded content and uploading true', async () => { + const expectedDoc = doc(p(mediaType({ uploading: true, src: base64EncodedFile }))); await expectDocumentAfterTransaction({ number: 1, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); - it('updates the inserted image with canonicalSrc when upload is successful', async () => { + it('updates the inserted content with canonicalSrc when upload is successful', async () => { const expectedDoc = doc( p( - image({ - canonicalSrc: 'test-file.png', + mediaType({ + canonicalSrc: file.name, src: base64EncodedFile, alt: 'test-file', uploading: false, @@ -146,7 +180,7 @@ describe('content_editor/extensions/attachment', () => { await expectDocumentAfterTransaction({ number: 2, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); }); @@ -162,17 +196,19 @@ describe('content_editor/extensions/attachment', () => { await expectDocumentAfterTransaction({ number: 2, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); - it('emits an alert event that includes an error message', (done) => { - tiptapEditor.commands.uploadAttachment({ file: imageFile }); + it('emits an alert event that includes an error message', () => { + tiptapEditor.commands.uploadAttachment({ file }); - eventHub.$on('alert', ({ message, variant }) => { - expect(variant).toBe(VARIANT_DANGER); - expect(message).toBe('An error occurred while uploading the image. Please try again.'); - done(); + return new Promise((resolve) => { + eventHub.$on('alert', ({ message, variant }) => { + expect(variant).toBe(VARIANT_DANGER); + expect(message).toBe('An error occurred while uploading the file. Please try again.'); + resolve(); + }); }); }); }); @@ -243,13 +279,12 @@ describe('content_editor/extensions/attachment', () => { }); }); - it('emits an alert event that includes an error message', (done) => { + it('emits an alert event that includes an error message', () => { tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); eventHub.$on('alert', ({ message, variant }) => { expect(variant).toBe(VARIANT_DANGER); expect(message).toBe('An error occurred while uploading the file. Please try again.'); - done(); }); }); }); 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 05fa0f79ef0..02e5b1dc271 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -1,5 +1,5 @@ import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; -import { createTestEditor } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"> <code> @@ -12,34 +12,78 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language describe('content_editor/extensions/code_block_highlight', () => { let parsedCodeBlockHtmlFixture; let tiptapEditor; + let doc; + let codeBlock; + let languageLoader; const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); beforeEach(() => { - tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); - parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); + languageLoader = { loadLanguages: jest.fn() }; + tiptapEditor = createTestEditor({ + extensions: [CodeBlockHighlight.configure({ languageLoader })], + }); - tiptapEditor.commands.setContent(CODE_BLOCK_HTML); + ({ + builders: { doc, codeBlock }, + } = createDocBuilder({ + tiptapEditor, + names: { + codeBlock: { nodeType: CodeBlockHighlight.name }, + }, + })); }); - it('extracts language and params attributes from Markdown API output', () => { - const language = preElement().getAttribute('lang'); + describe('when parsing HTML', () => { + beforeEach(() => { + parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); - expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ - language, + tiptapEditor.commands.setContent(CODE_BLOCK_HTML); + }); + it('extracts language and params attributes from Markdown API output', () => { + const language = preElement().getAttribute('lang'); + + expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ + language, + }); + }); + + it('adds code, highlight, and js-syntax-highlight to code block element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + + expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); }); - }); - it('adds code, highlight, and js-syntax-highlight to code block element', () => { - const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + it('adds content-editor-code-block class to the pre element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); - expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); + expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); + }); }); - it('adds content-editor-code-block class to the pre element', () => { - const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + describe.each` + inputRule + ${'```'} + ${'~~~'} + `('when typing $inputRule input rule', ({ inputRule }) => { + const language = 'javascript'; + + beforeEach(() => { + triggerNodeInputRule({ + tiptapEditor, + inputRuleText: `${inputRule}${language} `, + }); + }); + + it('creates a new code block and loads related language', () => { + const expectedDoc = doc(codeBlock({ language })); - expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + + it('loads language when language loader is available', () => { + expect(languageLoader.loadLanguages).toHaveBeenCalledWith([language]); + }); }); }); diff --git a/spec/frontend/content_editor/extensions/frontmatter_spec.js b/spec/frontend/content_editor/extensions/frontmatter_spec.js index a8cbad6ef81..4f80c2cb81a 100644 --- a/spec/frontend/content_editor/extensions/frontmatter_spec.js +++ b/spec/frontend/content_editor/extensions/frontmatter_spec.js @@ -23,7 +23,7 @@ describe('content_editor/extensions/frontmatter', () => { }); it('does not insert a frontmatter block when executing code block input rule', () => { - const expectedDoc = doc(codeBlock('')); + const expectedDoc = doc(codeBlock({ language: 'plaintext' }, '')); const inputRuleText = '``` '; triggerNodeInputRule({ tiptapEditor, inputRuleText }); diff --git a/spec/frontend/content_editor/services/code_block_language_loader_spec.js b/spec/frontend/content_editor/services/code_block_language_loader_spec.js new file mode 100644 index 00000000000..905c1685b94 --- /dev/null +++ b/spec/frontend/content_editor/services/code_block_language_loader_spec.js @@ -0,0 +1,120 @@ +import codeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader'; +import waitForPromises from 'helpers/wait_for_promises'; +import { backtickInputRegex } from '~/content_editor/extensions/code_block_highlight'; + +describe('content_editor/services/code_block_language_loader', () => { + let languageLoader; + let lowlight; + + beforeEach(() => { + lowlight = { + languages: [], + registerLanguage: jest + .fn() + .mockImplementation((language) => lowlight.languages.push(language)), + registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(language)), + }; + languageLoader = codeBlockLanguageBlocker; + languageLoader.lowlight = lowlight; + }); + + describe('findLanguageBySyntax', () => { + it.each` + syntax | language + ${'javascript'} | ${{ syntax: 'javascript', label: 'Javascript' }} + ${'js'} | ${{ syntax: 'javascript', label: 'Javascript' }} + ${'jsx'} | ${{ syntax: 'javascript', label: 'Javascript' }} + `('returns a language by syntax and its variants', ({ syntax, language }) => { + expect(languageLoader.findLanguageBySyntax(syntax)).toMatchObject(language); + }); + + it('returns Custom (syntax) if the language does not exist', () => { + expect(languageLoader.findLanguageBySyntax('foobar')).toMatchObject({ + syntax: 'foobar', + label: 'Custom (foobar)', + }); + }); + + it('returns plaintext if no syntax is passed', () => { + expect(languageLoader.findLanguageBySyntax('')).toMatchObject({ + syntax: 'plaintext', + label: 'Plain text', + }); + }); + }); + + describe('filterLanguages', () => { + it('filters languages by the given search term', () => { + expect(languageLoader.filterLanguages('ts')).toEqual([ + { label: 'Device Tree', syntax: 'dts' }, + { label: 'Kotlin', syntax: 'kotlin', variants: 'kt, kts' }, + { label: 'TypeScript', syntax: 'typescript', variants: 'ts, tsx' }, + ]); + }); + }); + + describe('loadLanguages', () => { + it('loads highlight.js language packages identified by a list of languages', async () => { + const languages = ['javascript', 'ruby']; + + await languageLoader.loadLanguages(languages); + + languages.forEach((language) => { + expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function)); + }); + }); + + describe('when language is already registered', () => { + it('does not load the language again', async () => { + const languages = ['javascript']; + + await languageLoader.loadLanguages(languages); + await languageLoader.loadLanguages(languages); + + expect(lowlight.registerLanguage).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('loadLanguagesFromDOM', () => { + it('loads highlight.js language packages identified by pre tags in a DOM fragment', async () => { + const parser = new DOMParser(); + const { body } = parser.parseFromString( + ` + <pre lang="javascript"></pre> + <pre lang="ruby"></pre> + `, + 'text/html', + ); + + await languageLoader.loadLanguagesFromDOM(body); + + expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function)); + expect(lowlight.registerLanguage).toHaveBeenCalledWith('ruby', expect.any(Function)); + }); + }); + + describe('loadLanguageFromInputRule', () => { + it('loads highlight.js language packages identified from the input rule', async () => { + const match = new RegExp(backtickInputRegex).exec('```js '); + const attrs = languageLoader.loadLanguageFromInputRule(match); + + await waitForPromises(); + + expect(attrs).toEqual({ language: 'javascript' }); + expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function)); + }); + }); + + describe('isLanguageLoaded', () => { + it('returns true when a language is registered', async () => { + const language = 'javascript'; + + expect(languageLoader.isLanguageLoaded(language)).toBe(false); + + await languageLoader.loadLanguages([language]); + + expect(languageLoader.isLanguageLoaded(language)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index 3bc72b13302..5b7a27b501d 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -11,6 +11,7 @@ describe('content_editor/services/content_editor', () => { let contentEditor; let serializer; let deserializer; + let languageLoader; let eventHub; let doc; let p; @@ -27,8 +28,15 @@ describe('content_editor/services/content_editor', () => { serializer = { deserialize: jest.fn() }; deserializer = { deserialize: jest.fn() }; + languageLoader = { loadLanguagesFromDOM: jest.fn() }; eventHub = eventHubFactory(); - contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub }); + contentEditor = new ContentEditor({ + tiptapEditor, + serializer, + deserializer, + eventHub, + languageLoader, + }); }); describe('.dispose', () => { @@ -43,10 +51,12 @@ describe('content_editor/services/content_editor', () => { describe('when setSerializedContent succeeds', () => { let document; + const dom = {}; + const testMarkdown = '**bold text**'; beforeEach(() => { document = doc(p('document')); - deserializer.deserialize.mockResolvedValueOnce({ document }); + deserializer.deserialize.mockResolvedValueOnce({ document, dom }); }); it('emits loadingContent and loadingSuccess event in the eventHub', () => { @@ -59,14 +69,20 @@ describe('content_editor/services/content_editor', () => { expect(loadingContentEmitted).toBe(true); }); - contentEditor.setSerializedContent('**bold text**'); + contentEditor.setSerializedContent(testMarkdown); }); it('sets the deserialized document in the tiptap editor object', async () => { - await contentEditor.setSerializedContent('**bold text**'); + await contentEditor.setSerializedContent(testMarkdown); expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON()); }); + + it('passes deserialized DOM document to language loader', async () => { + await contentEditor.setSerializedContent(testMarkdown); + + expect(languageLoader.loadLanguagesFromDOM).toHaveBeenCalledWith(dom); + }); }); describe('when setSerializedContent fails', () => { diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js index a4054ab1fc8..ef0ff8ca208 100644 --- a/spec/frontend/contributors/store/actions_spec.js +++ b/spec/frontend/contributors/store/actions_spec.js @@ -17,10 +17,14 @@ describe('Contributors store actions', () => { mock = new MockAdapter(axios); }); - it('should commit SET_CHART_DATA with received response', (done) => { + afterEach(() => { + mock.restore(); + }); + + it('should commit SET_CHART_DATA with received response', () => { mock.onGet().reply(200, chartData); - testAction( + return testAction( actions.fetchChartData, { endpoint }, {}, @@ -30,30 +34,22 @@ describe('Contributors store actions', () => { { type: types.SET_LOADING_STATE, payload: false }, ], [], - () => { - mock.restore(); - done(); - }, ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mock.onGet().reply(400, 'Not Found'); - testAction( + await testAction( actions.fetchChartData, { endpoint }, {}, [{ type: types.SET_LOADING_STATE, payload: true }], [], - () => { - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringMatching('error'), - }); - mock.restore(); - done(); - }, ); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('error'), + }); }); }); }); diff --git a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js b/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js index 55c502b96bb..c365cb6a9f4 100644 --- a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js @@ -14,53 +14,49 @@ import { describe('GCP Cluster Dropdown Store Actions', () => { describe('setProject', () => { - it('should set project', (done) => { - testAction( + it('should set project', () => { + return testAction( actions.setProject, selectedProjectMock, { selectedProject: {} }, [{ type: 'SET_PROJECT', payload: selectedProjectMock }], [], - done, ); }); }); describe('setZone', () => { - it('should set zone', (done) => { - testAction( + it('should set zone', () => { + return testAction( actions.setZone, selectedZoneMock, { selectedZone: '' }, [{ type: 'SET_ZONE', payload: selectedZoneMock }], [], - done, ); }); }); describe('setMachineType', () => { - it('should set machine type', (done) => { - testAction( + it('should set machine type', () => { + return testAction( actions.setMachineType, selectedMachineTypeMock, { selectedMachineType: '' }, [{ type: 'SET_MACHINE_TYPE', payload: selectedMachineTypeMock }], [], - done, ); }); }); describe('setIsValidatingProjectBilling', () => { - it('should set machine type', (done) => { - testAction( + it('should set machine type', () => { + return testAction( actions.setIsValidatingProjectBilling, true, { isValidatingProjectBilling: null }, [{ type: 'SET_IS_VALIDATING_PROJECT_BILLING', payload: true }], [], - done, ); }); }); @@ -94,8 +90,8 @@ describe('GCP Cluster Dropdown Store Actions', () => { }); describe('validateProjectBilling', () => { - it('checks project billing status from Google API', (done) => { - testAction( + it('checks project billing status from Google API', () => { + return testAction( actions.validateProjectBilling, true, { @@ -110,7 +106,6 @@ describe('GCP Cluster Dropdown Store Actions', () => { { type: 'SET_PROJECT_BILLING_STATUS', payload: true }, ], [{ type: 'setIsValidatingProjectBilling', payload: false }], - done, ); }); }); diff --git a/spec/frontend/crm/contact_form_spec.js b/spec/frontend/crm/contact_form_spec.js deleted file mode 100644 index 0edab4f5ec5..00000000000 --- a/spec/frontend/crm/contact_form_spec.js +++ /dev/null @@ -1,157 +0,0 @@ -import { GlAlert } from '@gitlab/ui'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import ContactForm from '~/crm/components/contact_form.vue'; -import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql'; -import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql'; -import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; -import { - createContactMutationErrorResponse, - createContactMutationResponse, - getGroupContactsQueryResponse, - updateContactMutationErrorResponse, - updateContactMutationResponse, -} from './mock_data'; - -describe('Customer relations contact form component', () => { - Vue.use(VueApollo); - let wrapper; - let fakeApollo; - let mutation; - let queryHandler; - - const findSaveContactButton = () => wrapper.findByTestId('save-contact-button'); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findForm = () => wrapper.find('form'); - const findError = () => wrapper.findComponent(GlAlert); - - const mountComponent = ({ mountFunction = shallowMountExtended, editForm = false } = {}) => { - fakeApollo = createMockApollo([[mutation, queryHandler]]); - fakeApollo.clients.defaultClient.cache.writeQuery({ - query: getGroupContactsQuery, - variables: { groupFullPath: 'flightjs' }, - data: getGroupContactsQueryResponse.data, - }); - const propsData = { drawerOpen: true }; - if (editForm) - propsData.contact = { firstName: 'First', lastName: 'Last', email: 'email@example.com' }; - wrapper = mountFunction(ContactForm, { - provide: { groupId: 26, groupFullPath: 'flightjs' }, - apolloProvider: fakeApollo, - propsData, - }); - }; - - beforeEach(() => { - mutation = createContactMutation; - queryHandler = jest.fn().mockResolvedValue(createContactMutationResponse); - }); - - afterEach(() => { - wrapper.destroy(); - fakeApollo = null; - }); - - describe('Save contact button', () => { - it('should be disabled when required fields are empty', () => { - mountComponent(); - - expect(findSaveContactButton().props('disabled')).toBe(true); - }); - - it('should not be disabled when required fields have values', async () => { - mountComponent(); - - wrapper.find('#contact-first-name').vm.$emit('input', 'A'); - wrapper.find('#contact-last-name').vm.$emit('input', 'B'); - wrapper.find('#contact-email').vm.$emit('input', 'C'); - await waitForPromises(); - - expect(findSaveContactButton().props('disabled')).toBe(false); - }); - }); - - it("should emit 'close' when cancel button is clicked", () => { - mountComponent(); - - findCancelButton().vm.$emit('click'); - - expect(wrapper.emitted().close).toBeTruthy(); - }); - - describe('when create mutation is successful', () => { - it("should emit 'close'", async () => { - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(wrapper.emitted().close).toBeTruthy(); - }); - }); - - describe('when create mutation fails', () => { - it('should show error on reject', async () => { - queryHandler = jest.fn().mockRejectedValue('ERROR'); - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - }); - - it('should show error on error response', async () => { - queryHandler = jest.fn().mockResolvedValue(createContactMutationErrorResponse); - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('create contact is invalid.'); - }); - }); - - describe('when update mutation is successful', () => { - it("should emit 'close'", async () => { - mutation = updateContactMutation; - queryHandler = jest.fn().mockResolvedValue(updateContactMutationResponse); - mountComponent({ editForm: true }); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(wrapper.emitted().close).toBeTruthy(); - }); - }); - - describe('when update mutation fails', () => { - beforeEach(() => { - mutation = updateContactMutation; - }); - - it('should show error on reject', async () => { - queryHandler = jest.fn().mockRejectedValue('ERROR'); - mountComponent({ editForm: true }); - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - }); - - it('should show error on error response', async () => { - queryHandler = jest.fn().mockResolvedValue(updateContactMutationErrorResponse); - mountComponent({ editForm: true }); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('update contact is invalid.'); - }); - }); -}); diff --git a/spec/frontend/crm/contact_form_wrapper_spec.js b/spec/frontend/crm/contact_form_wrapper_spec.js new file mode 100644 index 00000000000..6307889a7aa --- /dev/null +++ b/spec/frontend/crm/contact_form_wrapper_spec.js @@ -0,0 +1,88 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContactFormWrapper from '~/crm/contacts/components/contact_form_wrapper.vue'; +import ContactForm from '~/crm/components/form.vue'; +import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql'; +import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql'; +import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql'; + +describe('Customer relations contact form wrapper', () => { + let wrapper; + + const findContactForm = () => wrapper.findComponent(ContactForm); + + const $apollo = { + queries: { + contacts: { + loading: false, + }, + }, + }; + const $route = { + params: { + id: 7, + }, + }; + const contacts = [{ id: 'gid://gitlab/CustomerRelations::Contact/7' }]; + + const mountComponent = ({ isEditMode = false } = {}) => { + wrapper = shallowMountExtended(ContactFormWrapper, { + propsData: { + isEditMode, + }, + provide: { + groupFullPath: 'flightjs', + groupId: 26, + }, + mocks: { + $apollo, + $route, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('in edit mode', () => { + it('should render contact form with correct props', () => { + mountComponent({ isEditMode: true }); + + const contactForm = findContactForm(); + expect(contactForm.props('fields')).toHaveLength(5); + expect(contactForm.props('title')).toBe('Edit contact'); + expect(contactForm.props('successMessage')).toBe('Contact has been updated.'); + expect(contactForm.props('mutation')).toBe(updateContactMutation); + expect(contactForm.props('getQuery')).toMatchObject({ + query: getGroupContactsQuery, + variables: { groupFullPath: 'flightjs' }, + }); + expect(contactForm.props('getQueryNodePath')).toBe('group.contacts'); + expect(contactForm.props('existingId')).toBe(contacts[0].id); + expect(contactForm.props('additionalCreateParams')).toMatchObject({ + groupId: 'gid://gitlab/Group/26', + }); + }); + }); + + describe('in create mode', () => { + it('should render contact form with correct props', () => { + mountComponent(); + + const contactForm = findContactForm(); + expect(contactForm.props('fields')).toHaveLength(5); + expect(contactForm.props('title')).toBe('New contact'); + expect(contactForm.props('successMessage')).toBe('Contact has been added.'); + expect(contactForm.props('mutation')).toBe(createContactMutation); + expect(contactForm.props('getQuery')).toMatchObject({ + query: getGroupContactsQuery, + variables: { groupFullPath: 'flightjs' }, + }); + expect(contactForm.props('getQueryNodePath')).toBe('group.contacts'); + expect(contactForm.props('existingId')).toBeNull(); + expect(contactForm.props('additionalCreateParams')).toMatchObject({ + groupId: 'gid://gitlab/Group/26', + }); + }); + }); +}); diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js index b30349305a3..b02d94e9cb1 100644 --- a/spec/frontend/crm/contacts_root_spec.js +++ b/spec/frontend/crm/contacts_root_spec.js @@ -5,11 +5,9 @@ import VueRouter from 'vue-router'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import ContactsRoot from '~/crm/components/contacts_root.vue'; -import ContactForm from '~/crm/components/contact_form.vue'; -import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; -import { NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '~/crm/constants'; -import routes from '~/crm/routes'; +import ContactsRoot from '~/crm/contacts/components/contacts_root.vue'; +import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql'; +import routes from '~/crm/contacts/routes'; import { getGroupContactsQueryResponse } from './mock_data'; describe('Customer relations contacts root app', () => { @@ -23,8 +21,6 @@ describe('Customer relations contacts root app', () => { const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); const findIssuesLinks = () => wrapper.findAllByTestId('issues-link'); const findNewContactButton = () => wrapper.findByTestId('new-contact-button'); - const findEditContactButton = () => wrapper.findByTestId('edit-contact-button'); - const findContactForm = () => wrapper.findComponent(ContactForm); const findError = () => wrapper.findComponent(GlAlert); const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse); @@ -40,8 +36,8 @@ describe('Customer relations contacts root app', () => { router, provide: { groupFullPath: 'flightjs', - groupIssuesPath: '/issues', groupId: 26, + groupIssuesPath: '/issues', canAdminCrmContact, }, apolloProvider: fakeApollo, @@ -82,71 +78,6 @@ describe('Customer relations contacts root app', () => { }); }); - describe('contact form', () => { - it('should not exist by default', async () => { - mountComponent(); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(false); - }); - - it('should exist when user clicks new contact button', async () => { - mountComponent(); - - findNewContactButton().vm.$emit('click'); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(true); - }); - - it('should exist when user navigates directly to `new` route', async () => { - router.replace({ name: NEW_ROUTE_NAME }); - mountComponent(); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(true); - }); - - it('should exist when user clicks edit contact button', async () => { - mountComponent({ mountFunction: mountExtended }); - await waitForPromises(); - - findEditContactButton().vm.$emit('click'); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(true); - }); - - it('should exist when user navigates directly to `edit` route', async () => { - router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } }); - mountComponent(); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(true); - }); - - it('should not exist when new form emits close', async () => { - router.replace({ name: NEW_ROUTE_NAME }); - mountComponent(); - - findContactForm().vm.$emit('close'); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(false); - }); - - it('should not exist when edit form emits close', async () => { - router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } }); - mountComponent(); - await waitForPromises(); - - findContactForm().vm.$emit('close'); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(false); - }); - }); - describe('error', () => { it('should exist on reject', async () => { mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); diff --git a/spec/frontend/crm/form_spec.js b/spec/frontend/crm/form_spec.js index 0e3abc05c37..5c349b24ea1 100644 --- a/spec/frontend/crm/form_spec.js +++ b/spec/frontend/crm/form_spec.js @@ -6,12 +6,12 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Form from '~/crm/components/form.vue'; -import routes from '~/crm/routes'; -import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql'; -import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql'; -import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; -import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql'; -import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; +import routes from '~/crm/contacts/routes'; +import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql'; +import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql'; +import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql'; +import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql'; +import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql'; import { createContactMutationErrorResponse, createContactMutationResponse, @@ -101,6 +101,11 @@ describe('Reusable form component', () => { { name: 'phone', label: 'Phone' }, { name: 'description', label: 'Description' }, ], + getQuery: { + query: getGroupContactsQuery, + variables: { groupFullPath: 'flightjs' }, + }, + getQueryNodePath: 'group.contacts', ...propsData, }); }; @@ -108,13 +113,8 @@ describe('Reusable form component', () => { const mountContactCreate = () => { const propsData = { title: 'New contact', - successMessage: 'Contact has been added', + successMessage: 'Contact has been added.', buttonLabel: 'Create contact', - getQuery: { - query: getGroupContactsQuery, - variables: { groupFullPath: 'flightjs' }, - }, - getQueryNodePath: 'group.contacts', mutation: createContactMutation, additionalCreateParams: { groupId: 'gid://gitlab/Group/26' }, }; @@ -124,14 +124,9 @@ describe('Reusable form component', () => { const mountContactUpdate = () => { const propsData = { title: 'Edit contact', - successMessage: 'Contact has been updated', + successMessage: 'Contact has been updated.', mutation: updateContactMutation, - existingModel: { - id: 'gid://gitlab/CustomerRelations::Contact/12', - firstName: 'First', - lastName: 'Last', - email: 'email@example.com', - }, + existingId: 'gid://gitlab/CustomerRelations::Contact/12', }; mountContact({ propsData }); }; @@ -143,6 +138,11 @@ describe('Reusable form component', () => { { name: 'defaultRate', label: 'Default rate', input: { type: 'number', step: '0.01' } }, { name: 'description', label: 'Description' }, ], + getQuery: { + query: getGroupOrganizationsQuery, + variables: { groupFullPath: 'flightjs' }, + }, + getQueryNodePath: 'group.organizations', ...propsData, }); }; @@ -150,13 +150,8 @@ describe('Reusable form component', () => { const mountOrganizationCreate = () => { const propsData = { title: 'New organization', - successMessage: 'Organization has been added', + successMessage: 'Organization has been added.', buttonLabel: 'Create organization', - getQuery: { - query: getGroupOrganizationsQuery, - variables: { groupFullPath: 'flightjs' }, - }, - getQueryNodePath: 'group.organizations', mutation: createOrganizationMutation, additionalCreateParams: { groupId: 'gid://gitlab/Group/26' }, }; @@ -167,17 +162,17 @@ describe('Reusable form component', () => { [FORM_CREATE_CONTACT]: { mountFunction: mountContactCreate, mutationErrorResponse: createContactMutationErrorResponse, - toastMessage: 'Contact has been added', + toastMessage: 'Contact has been added.', }, [FORM_UPDATE_CONTACT]: { mountFunction: mountContactUpdate, mutationErrorResponse: updateContactMutationErrorResponse, - toastMessage: 'Contact has been updated', + toastMessage: 'Contact has been updated.', }, [FORM_CREATE_ORG]: { mountFunction: mountOrganizationCreate, mutationErrorResponse: createOrganizationMutationErrorResponse, - toastMessage: 'Organization has been added', + toastMessage: 'Organization has been added.', }, }; const asTestParams = (...keys) => keys.map((name) => [name, forms[name]]); diff --git a/spec/frontend/crm/mock_data.js b/spec/frontend/crm/mock_data.js index e351e101b29..35bc7fb69b4 100644 --- a/spec/frontend/crm/mock_data.js +++ b/spec/frontend/crm/mock_data.js @@ -157,3 +157,28 @@ export const createOrganizationMutationErrorResponse = { }, }, }; + +export const updateOrganizationMutationResponse = { + data: { + customerRelationsOrganizationUpdate: { + __typeName: 'CustomerRelationsOrganizationUpdatePayload', + organization: { + __typename: 'CustomerRelationsOrganization', + id: 'gid://gitlab/CustomerRelations::Organization/2', + name: 'A', + defaultRate: null, + description: null, + }, + errors: [], + }, + }, +}; + +export const updateOrganizationMutationErrorResponse = { + data: { + customerRelationsOrganizationUpdate: { + organization: null, + errors: ['Description is invalid.'], + }, + }, +}; diff --git a/spec/frontend/crm/new_organization_form_spec.js b/spec/frontend/crm/new_organization_form_spec.js deleted file mode 100644 index 0a7909774c9..00000000000 --- a/spec/frontend/crm/new_organization_form_spec.js +++ /dev/null @@ -1,109 +0,0 @@ -import { GlAlert } from '@gitlab/ui'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import NewOrganizationForm from '~/crm/components/new_organization_form.vue'; -import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql'; -import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; -import { - createOrganizationMutationErrorResponse, - createOrganizationMutationResponse, - getGroupOrganizationsQueryResponse, -} from './mock_data'; - -describe('Customer relations organizations root app', () => { - Vue.use(VueApollo); - let wrapper; - let fakeApollo; - let queryHandler; - - const findCreateNewOrganizationButton = () => - wrapper.findByTestId('create-new-organization-button'); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findForm = () => wrapper.find('form'); - const findError = () => wrapper.findComponent(GlAlert); - - const mountComponent = () => { - fakeApollo = createMockApollo([[createOrganizationMutation, queryHandler]]); - fakeApollo.clients.defaultClient.cache.writeQuery({ - query: getGroupOrganizationsQuery, - variables: { groupFullPath: 'flightjs' }, - data: getGroupOrganizationsQueryResponse.data, - }); - wrapper = shallowMountExtended(NewOrganizationForm, { - provide: { groupId: 26, groupFullPath: 'flightjs' }, - apolloProvider: fakeApollo, - propsData: { drawerOpen: true }, - }); - }; - - beforeEach(() => { - queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationResponse); - }); - - afterEach(() => { - wrapper.destroy(); - fakeApollo = null; - }); - - describe('Create new organization button', () => { - it('should be disabled by default', () => { - mountComponent(); - - expect(findCreateNewOrganizationButton().attributes('disabled')).toBeTruthy(); - }); - - it('should not be disabled when first, last and email have values', async () => { - mountComponent(); - - wrapper.find('#organization-name').vm.$emit('input', 'A'); - await waitForPromises(); - - expect(findCreateNewOrganizationButton().attributes('disabled')).toBeFalsy(); - }); - }); - - it("should emit 'close' when cancel button is clicked", () => { - mountComponent(); - - findCancelButton().vm.$emit('click'); - - expect(wrapper.emitted().close).toBeTruthy(); - }); - - describe('when query is successful', () => { - it("should emit 'close'", async () => { - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(wrapper.emitted().close).toBeTruthy(); - }); - }); - - describe('when query fails', () => { - it('should show error on reject', async () => { - queryHandler = jest.fn().mockRejectedValue('ERROR'); - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - }); - - it('should show error on error response', async () => { - queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationErrorResponse); - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('create organization is invalid.'); - }); - }); -}); diff --git a/spec/frontend/crm/organization_form_wrapper_spec.js b/spec/frontend/crm/organization_form_wrapper_spec.js new file mode 100644 index 00000000000..1a5a7c6ca5d --- /dev/null +++ b/spec/frontend/crm/organization_form_wrapper_spec.js @@ -0,0 +1,88 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import OrganizationFormWrapper from '~/crm/organizations/components/organization_form_wrapper.vue'; +import OrganizationForm from '~/crm/components/form.vue'; +import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql'; +import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql'; +import updateOrganizationMutation from '~/crm/organizations/components/graphql/update_organization.mutation.graphql'; + +describe('Customer relations organization form wrapper', () => { + let wrapper; + + const findOrganizationForm = () => wrapper.findComponent(OrganizationForm); + + const $apollo = { + queries: { + organizations: { + loading: false, + }, + }, + }; + const $route = { + params: { + id: 7, + }, + }; + const organizations = [{ id: 'gid://gitlab/CustomerRelations::Organization/7' }]; + + const mountComponent = ({ isEditMode = false } = {}) => { + wrapper = shallowMountExtended(OrganizationFormWrapper, { + propsData: { + isEditMode, + }, + provide: { + groupFullPath: 'flightjs', + groupId: 26, + }, + mocks: { + $apollo, + $route, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('in edit mode', () => { + it('should render organization form with correct props', () => { + mountComponent({ isEditMode: true }); + + const organizationForm = findOrganizationForm(); + expect(organizationForm.props('fields')).toHaveLength(3); + expect(organizationForm.props('title')).toBe('Edit organization'); + expect(organizationForm.props('successMessage')).toBe('Organization has been updated.'); + expect(organizationForm.props('mutation')).toBe(updateOrganizationMutation); + expect(organizationForm.props('getQuery')).toMatchObject({ + query: getGroupOrganizationsQuery, + variables: { groupFullPath: 'flightjs' }, + }); + expect(organizationForm.props('getQueryNodePath')).toBe('group.organizations'); + expect(organizationForm.props('existingId')).toBe(organizations[0].id); + expect(organizationForm.props('additionalCreateParams')).toMatchObject({ + groupId: 'gid://gitlab/Group/26', + }); + }); + }); + + describe('in create mode', () => { + it('should render organization form with correct props', () => { + mountComponent(); + + const organizationForm = findOrganizationForm(); + expect(organizationForm.props('fields')).toHaveLength(3); + expect(organizationForm.props('title')).toBe('New organization'); + expect(organizationForm.props('successMessage')).toBe('Organization has been added.'); + expect(organizationForm.props('mutation')).toBe(createOrganizationMutation); + expect(organizationForm.props('getQuery')).toMatchObject({ + query: getGroupOrganizationsQuery, + variables: { groupFullPath: 'flightjs' }, + }); + expect(organizationForm.props('getQueryNodePath')).toBe('group.organizations'); + expect(organizationForm.props('existingId')).toBeNull(); + expect(organizationForm.props('additionalCreateParams')).toMatchObject({ + groupId: 'gid://gitlab/Group/26', + }); + }); + }); +}); diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js index aef417964f4..231208d938e 100644 --- a/spec/frontend/crm/organizations_root_spec.js +++ b/spec/frontend/crm/organizations_root_spec.js @@ -5,11 +5,9 @@ import VueRouter from 'vue-router'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import OrganizationsRoot from '~/crm/components/organizations_root.vue'; -import NewOrganizationForm from '~/crm/components/new_organization_form.vue'; -import { NEW_ROUTE_NAME } from '~/crm/constants'; -import routes from '~/crm/routes'; -import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; +import OrganizationsRoot from '~/crm/organizations/components/organizations_root.vue'; +import routes from '~/crm/organizations/routes'; +import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql'; import { getGroupOrganizationsQueryResponse } from './mock_data'; describe('Customer relations organizations root app', () => { @@ -23,7 +21,6 @@ describe('Customer relations organizations root app', () => { const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); const findIssuesLinks = () => wrapper.findAllByTestId('issues-link'); const findNewOrganizationButton = () => wrapper.findByTestId('new-organization-button'); - const findNewOrganizationForm = () => wrapper.findComponent(NewOrganizationForm); const findError = () => wrapper.findComponent(GlAlert); const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse); @@ -37,7 +34,11 @@ describe('Customer relations organizations root app', () => { fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]); wrapper = mountFunction(OrganizationsRoot, { router, - provide: { canAdminCrmOrganization, groupFullPath: 'flightjs', groupIssuesPath: '/issues' }, + provide: { + canAdminCrmOrganization, + groupFullPath: 'flightjs', + groupIssuesPath: '/issues', + }, apolloProvider: fakeApollo, }); }; @@ -76,42 +77,6 @@ describe('Customer relations organizations root app', () => { }); }); - describe('new organization form', () => { - it('should not exist by default', async () => { - mountComponent(); - await waitForPromises(); - - expect(findNewOrganizationForm().exists()).toBe(false); - }); - - it('should exist when user clicks new contact button', async () => { - mountComponent(); - - findNewOrganizationButton().vm.$emit('click'); - await waitForPromises(); - - expect(findNewOrganizationForm().exists()).toBe(true); - }); - - it('should exist when user navigates directly to /new', async () => { - router.replace({ name: NEW_ROUTE_NAME }); - mountComponent(); - await waitForPromises(); - - expect(findNewOrganizationForm().exists()).toBe(true); - }); - - it('should not exist when form emits close', async () => { - router.replace({ name: NEW_ROUTE_NAME }); - mountComponent(); - - findNewOrganizationForm().vm.$emit('close'); - await waitForPromises(); - - expect(findNewOrganizationForm().exists()).toBe(false); - }); - }); - it('should render error message on reject', async () => { mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); await waitForPromises(); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap index 4ecf82a4714..402e55347af 100644 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap @@ -5,16 +5,19 @@ exports[`Design note component should match the snapshot 1`] = ` class="design-note note-form" id="note_123" > - <user-avatar-link-stub - imgalt="foo-bar" - imgcssclasses="" - imgsize="40" - imgsrc="" - linkhref="" - tooltipplacement="top" - tooltiptext="" - username="" - /> + <gl-avatar-link-stub + class="gl-float-left gl-mr-3" + href="https://gitlab.com/user" + > + <gl-avatar-stub + alt="avatar" + entityid="0" + entityname="foo-bar" + shape="circle" + size="32" + src="https://gitlab.com/avatar" + /> + </gl-avatar-link-stub> <div class="gl-display-flex gl-justify-content-space-between" @@ -22,8 +25,10 @@ exports[`Design note component should match the snapshot 1`] = ` <div> <gl-link-stub class="js-user-link" + data-testid="user-link" data-user-id="1" data-username="foo-bar" + href="https://gitlab.com/user" > <span class="note-header-author-name gl-font-weight-bold" @@ -69,8 +74,9 @@ exports[`Design note component should match the snapshot 1`] = ` </div> <div - class="note-text js-note-text md" + class="note-text md" data-qa-selector="note_content" + data-testid="note-text" /> </timeline-entry-item-stub> diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index bbf2190ad47..77935fbde11 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -31,7 +31,6 @@ describe('Design discussions component', () => { const findReplyForm = () => wrapper.find(DesignReplyForm); const findRepliesWidget = () => wrapper.find(ToggleRepliesWidget); const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]'); - const findResolveIcon = () => wrapper.find('[data-testid="resolve-icon"]'); const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]'); const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon); const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]'); @@ -117,7 +116,7 @@ describe('Design discussions component', () => { }); it('does not render an icon to resolve a thread', () => { - expect(findResolveIcon().exists()).toBe(false); + expect(findResolveButton().exists()).toBe(false); }); it('does not render a checkbox in reply form', async () => { @@ -147,7 +146,7 @@ describe('Design discussions component', () => { }); it('renders a correct icon to resolve a thread', () => { - expect(findResolveIcon().props('name')).toBe('check-circle'); + expect(findResolveButton().props('icon')).toBe('check-circle'); }); it('renders a checkbox with Resolve thread text in reply form', async () => { @@ -203,7 +202,7 @@ describe('Design discussions component', () => { }); it('renders a correct icon to resolve a thread', () => { - expect(findResolveIcon().props('name')).toBe('check-circle-filled'); + expect(findResolveButton().props('icon')).toBe('check-circle-filled'); }); it('emit todo:toggle when discussion is resolved', async () => { diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js index 35fd1273270..1f84fde9f7f 100644 --- a/spec/frontend/design_management/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -1,10 +1,10 @@ -import { shallowMount } from '@vue/test-utils'; import { ApolloMutation } from 'vue-apollo'; import { nextTick } from 'vue'; +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import DesignNote from '~/design_management/components/design_notes/design_note.vue'; import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; const scrollIntoViewMock = jest.fn(); const note = { @@ -12,6 +12,8 @@ const note = { author: { id: 'gid://gitlab/User/1', username: 'foo-bar', + avatarUrl: 'https://gitlab.com/avatar', + webUrl: 'https://gitlab.com/user', }, body: 'test', userPermissions: { @@ -30,14 +32,15 @@ const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } }); describe('Design note component', () => { let wrapper; - const findUserAvatar = () => wrapper.find(UserAvatarLink); - const findUserLink = () => wrapper.find('.js-user-link'); - const findReplyForm = () => wrapper.find(DesignReplyForm); - const findEditButton = () => wrapper.find('.js-note-edit'); - const findNoteContent = () => wrapper.find('.js-note-text'); + const findUserAvatar = () => wrapper.findComponent(GlAvatar); + const findUserAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findUserLink = () => wrapper.findByTestId('user-link'); + const findReplyForm = () => wrapper.findComponent(DesignReplyForm); + const findEditButton = () => wrapper.findByTestId('note-edit'); + const findNoteContent = () => wrapper.findByTestId('note-text'); function createComponent(props = {}, data = { isEditing: false }) { - wrapper = shallowMount(DesignNote, { + wrapper = shallowMountExtended(DesignNote, { propsData: { note: {}, ...props, @@ -71,12 +74,24 @@ describe('Design note component', () => { expect(wrapper.element).toMatchSnapshot(); }); - it('should render an author', () => { + it('should render avatar with correct props', () => { + createComponent({ + note, + }); + + expect(findUserAvatar().props()).toMatchObject({ + src: note.author.avatarUrl, + entityName: note.author.username, + }); + + expect(findUserAvatarLink().attributes('href')).toBe(note.author.webUrl); + }); + + it('should render author details', () => { createComponent({ note, }); - expect(findUserAvatar().exists()).toBe(true); expect(findUserLink().exists()).toBe(true); }); @@ -107,7 +122,7 @@ describe('Design note component', () => { }, }); - findEditButton().trigger('click'); + findEditButton().vm.$emit('click'); await nextTick(); expect(findReplyForm().exists()).toBe(true); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index a240a41959f..87531e8b645 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -183,7 +183,7 @@ describe('Design management index page', () => { [moveDesignMutation, moveDesignHandler], ]; - fakeApollo = createMockApollo(requestHandlers); + fakeApollo = createMockApollo(requestHandlers, {}, { addTypename: true }); wrapper = shallowMount(Index, { apolloProvider: fakeApollo, router, diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index d887029124f..eee17e118a0 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -11,7 +11,9 @@ jest.mock('~/user_popovers'); const TEST_AUTHOR_NAME = 'test'; const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com'; const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`; -const TEST_SIGNATURE_HTML = '<a>Legit commit</a>'; +const TEST_SIGNATURE_HTML = `<a class="btn gpg-status-box valid" data-content="signature-content" data-html="true" data-placement="top" data-title="signature-title" data-toggle="popover" role="button" tabindex="0"> + Verified +</a>`; const TEST_PIPELINE_STATUS_PATH = `${TEST_HOST}/pipeline/status`; describe('diffs/components/commit_item', () => { @@ -82,7 +84,7 @@ describe('diffs/components/commit_item', () => { const imgElement = avatarElement.find('img'); expect(avatarElement.attributes('href')).toBe(commit.author.web_url); - expect(imgElement.classes()).toContain('s40'); + expect(imgElement.classes()).toContain('s32'); expect(imgElement.attributes('alt')).toBe(commit.author.name); expect(imgElement.attributes('src')).toBe(commit.author.avatar_url); }); @@ -156,8 +158,9 @@ describe('diffs/components/commit_item', () => { it('renders signature html', () => { const actionsElement = getCommitActionsElement(); + const signatureElement = actionsElement.find('.gpg-status-box'); - expect(actionsElement.html()).toContain(TEST_SIGNATURE_HTML); + expect(signatureElement.html()).toBe(TEST_SIGNATURE_HTML); }); }); diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js index 0ccf996e220..fb9dc22ce25 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -4,7 +4,7 @@ import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue'; import { createStore } from '~/mr_notes/stores'; import NoteForm from '~/notes/components/note_form.vue'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import { noteableDataMock } from '../../notes/mock_data'; +import { noteableDataMock } from 'jest/notes/mock_data'; import diffFileMockData from '../mock_data/diff_file'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => { @@ -98,7 +98,7 @@ describe('DiffLineNoteForm', () => { }); describe('saveNoteForm', () => { - it('should call saveNote action with proper params', (done) => { + it('should call saveNote action with proper params', async () => { const saveDiffDiscussionSpy = jest .spyOn(wrapper.vm, 'saveDiffDiscussion') .mockReturnValue(Promise.resolve()); @@ -123,16 +123,11 @@ describe('DiffLineNoteForm', () => { lineRange, }; - wrapper.vm - .handleSaveNote('note body') - .then(() => { - expect(saveDiffDiscussionSpy).toHaveBeenCalledWith({ - note: 'note body', - formData, - }); - }) - .then(done) - .catch(done.fail); + await wrapper.vm.handleSaveNote('note body'); + expect(saveDiffDiscussionSpy).toHaveBeenCalledWith({ + note: 'note body', + formData, + }); }); }); }); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index d6a2aa104cd..3b567fbc704 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -9,46 +9,7 @@ import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE, } from '~/diffs/constants'; -import { - setBaseConfig, - fetchDiffFilesBatch, - fetchDiffFilesMeta, - fetchCoverageFiles, - assignDiscussionsToDiff, - removeDiscussionsFromDiff, - startRenderDiffsQueue, - setInlineDiffViewType, - setParallelDiffViewType, - showCommentForm, - cancelCommentForm, - loadMoreLines, - scrollToLineIfNeededInline, - scrollToLineIfNeededParallel, - loadCollapsedDiff, - toggleFileDiscussions, - saveDiffDiscussion, - setHighlightedRow, - toggleTreeOpen, - scrollToFile, - setShowTreeList, - renderFileForDiscussionId, - setRenderTreeList, - setShowWhitespace, - setRenderIt, - receiveFullDiffError, - fetchFullDiff, - toggleFullDiff, - switchToFullDiffFromRenamedFile, - setFileCollapsedByUser, - setExpandedDiffLines, - setSuggestPopoverDismissed, - changeCurrentCommit, - moveToNeighboringCommit, - setCurrentDiffFileIdFromNote, - navigateToDiffFileIndex, - setFileByFile, - reviewFile, -} from '~/diffs/store/actions'; +import * as diffActions from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import * as utils from '~/diffs/store/utils'; import * as treeWorkerUtils from '~/diffs/utils/tree_worker_utils'; @@ -62,6 +23,8 @@ import { diffMetadata } from '../mock_data/diff_metadata'; jest.mock('~/flash'); describe('DiffsStoreActions', () => { + let mock; + useLocalStorageSpy(); const originalMethods = { @@ -83,15 +46,20 @@ describe('DiffsStoreActions', () => { }); }); + beforeEach(() => { + mock = new MockAdapter(axios); + }); + afterEach(() => { ['requestAnimationFrame', 'requestIdleCallback'].forEach((method) => { global[method] = originalMethods[method]; }); createFlash.mockClear(); + mock.restore(); }); describe('setBaseConfig', () => { - it('should set given endpoint and project path', (done) => { + it('should set given endpoint and project path', () => { const endpoint = '/diffs/set/endpoint'; const endpointMetadata = '/diffs/set/endpoint/metadata'; const endpointBatch = '/diffs/set/endpoint/batch'; @@ -104,8 +72,8 @@ describe('DiffsStoreActions', () => { b: ['y', 'hash:a'], }; - testAction( - setBaseConfig, + return testAction( + diffActions.setBaseConfig, { endpoint, endpointBatch, @@ -153,23 +121,12 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); }); describe('fetchDiffFilesBatch', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - it('should fetch batch diff files', (done) => { + it('should fetch batch diff files', () => { const endpointBatch = '/fetch/diffs_batch'; const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { total_pages: 7 } }; const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: { total_pages: 7 } }; @@ -199,8 +156,8 @@ describe('DiffsStoreActions', () => { ) .reply(200, res2); - testAction( - fetchDiffFilesBatch, + return testAction( + diffActions.fetchDiffFilesBatch, {}, { endpointBatch, diffViewType: 'inline', diffFiles: [] }, [ @@ -216,7 +173,6 @@ describe('DiffsStoreActions', () => { { type: types.SET_BATCH_LOADING_STATE, payload: 'error' }, ], [{ type: 'startRenderDiffsQueue' }, { type: 'startRenderDiffsQueue' }], - done, ); }); @@ -229,13 +185,14 @@ describe('DiffsStoreActions', () => { ({ viewStyle, otherView }) => { const endpointBatch = '/fetch/diffs_batch'; - fetchDiffFilesBatch({ - commit: () => {}, - state: { - endpointBatch: `${endpointBatch}?view=${otherView}`, - diffViewType: viewStyle, - }, - }) + diffActions + .fetchDiffFilesBatch({ + commit: () => {}, + state: { + endpointBatch: `${endpointBatch}?view=${otherView}`, + diffViewType: viewStyle, + }, + }) .then(() => { expect(mock.history.get[0].url).toContain(`view=${viewStyle}`); expect(mock.history.get[0].url).not.toContain(`view=${otherView}`); @@ -248,19 +205,16 @@ describe('DiffsStoreActions', () => { describe('fetchDiffFilesMeta', () => { const endpointMetadata = '/fetch/diffs_metadata.json?view=inline'; const noFilesData = { ...diffMetadata }; - let mock; beforeEach(() => { - mock = new MockAdapter(axios); - delete noFilesData.diff_files; mock.onGet(endpointMetadata).reply(200, diffMetadata); }); - it('should fetch diff meta information', (done) => { - testAction( - fetchDiffFilesMeta, + it('should fetch diff meta information', () => { + return testAction( + diffActions.fetchDiffFilesMeta, {}, { endpointMetadata, diffViewType: 'inline' }, [ @@ -275,55 +229,41 @@ describe('DiffsStoreActions', () => { }, ], [], - () => { - mock.restore(); - done(); - }, ); }); }); describe('fetchCoverageFiles', () => { - let mock; const endpointCoverage = '/fetch'; - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => mock.restore()); - - it('should commit SET_COVERAGE_DATA with received response', (done) => { + it('should commit SET_COVERAGE_DATA with received response', () => { const data = { files: { 'app.js': { 1: 0, 2: 1 } } }; mock.onGet(endpointCoverage).reply(200, { data }); - testAction( - fetchCoverageFiles, + return testAction( + diffActions.fetchCoverageFiles, {}, { endpointCoverage }, [{ type: types.SET_COVERAGE_DATA, payload: { data } }], [], - done, ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mock.onGet(endpointCoverage).reply(400); - testAction(fetchCoverageFiles, {}, { endpointCoverage }, [], [], () => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringMatching('Something went wrong'), - }); - done(); + await testAction(diffActions.fetchCoverageFiles, {}, { endpointCoverage }, [], []); + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('Something went wrong'), }); }); }); describe('setHighlightedRow', () => { it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => { - testAction(setHighlightedRow, 'ABC_123', {}, [ + return testAction(diffActions.setHighlightedRow, 'ABC_123', {}, [ { type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' }, { type: types.SET_CURRENT_DIFF_FILE, payload: 'ABC' }, ]); @@ -335,7 +275,7 @@ describe('DiffsStoreActions', () => { window.location.hash = ''; }); - it('should merge discussions into diffs', (done) => { + it('should merge discussions into diffs', () => { window.location.hash = 'ABC_123'; const state = { @@ -397,8 +337,8 @@ describe('DiffsStoreActions', () => { const discussions = [singleDiscussion]; - testAction( - assignDiscussionsToDiff, + return testAction( + diffActions.assignDiscussionsToDiff, discussions, state, [ @@ -425,26 +365,24 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); - it('dispatches setCurrentDiffFileIdFromNote with note ID', (done) => { + it('dispatches setCurrentDiffFileIdFromNote with note ID', () => { window.location.hash = 'note_123'; - testAction( - assignDiscussionsToDiff, + return testAction( + diffActions.assignDiscussionsToDiff, [], { diffFiles: [] }, [], [{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }], - done, ); }); }); describe('removeDiscussionsFromDiff', () => { - it('should remove discussions from diffs', (done) => { + it('should remove discussions from diffs', () => { const state = { diffFiles: [ { @@ -480,8 +418,8 @@ describe('DiffsStoreActions', () => { line_code: 'ABC_1_1', }; - testAction( - removeDiscussionsFromDiff, + return testAction( + diffActions.removeDiscussionsFromDiff, singleDiscussion, state, [ @@ -495,7 +433,6 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); }); @@ -528,7 +465,7 @@ describe('DiffsStoreActions', () => { }); }; - startRenderDiffsQueue({ state, commit: pseudoCommit }); + diffActions.startRenderDiffsQueue({ state, commit: pseudoCommit }); expect(state.diffFiles[0].renderIt).toBe(true); expect(state.diffFiles[1].renderIt).toBe(true); @@ -536,69 +473,61 @@ describe('DiffsStoreActions', () => { }); describe('setInlineDiffViewType', () => { - it('should set diff view type to inline and also set the cookie properly', (done) => { - testAction( - setInlineDiffViewType, + it('should set diff view type to inline and also set the cookie properly', async () => { + await testAction( + diffActions.setInlineDiffViewType, null, {}, [{ type: types.SET_DIFF_VIEW_TYPE, payload: INLINE_DIFF_VIEW_TYPE }], [], - () => { - expect(Cookies.get('diff_view')).toEqual(INLINE_DIFF_VIEW_TYPE); - done(); - }, ); + expect(Cookies.get('diff_view')).toEqual(INLINE_DIFF_VIEW_TYPE); }); }); describe('setParallelDiffViewType', () => { - it('should set diff view type to parallel and also set the cookie properly', (done) => { - testAction( - setParallelDiffViewType, + it('should set diff view type to parallel and also set the cookie properly', async () => { + await testAction( + diffActions.setParallelDiffViewType, null, {}, [{ type: types.SET_DIFF_VIEW_TYPE, payload: PARALLEL_DIFF_VIEW_TYPE }], [], - () => { - expect(Cookies.get(DIFF_VIEW_COOKIE_NAME)).toEqual(PARALLEL_DIFF_VIEW_TYPE); - done(); - }, ); + expect(Cookies.get(DIFF_VIEW_COOKIE_NAME)).toEqual(PARALLEL_DIFF_VIEW_TYPE); }); }); describe('showCommentForm', () => { - it('should call mutation to show comment form', (done) => { + it('should call mutation to show comment form', () => { const payload = { lineCode: 'lineCode', fileHash: 'hash' }; - testAction( - showCommentForm, + return testAction( + diffActions.showCommentForm, payload, {}, [{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: true } }], [], - done, ); }); }); describe('cancelCommentForm', () => { - it('should call mutation to cancel comment form', (done) => { + it('should call mutation to cancel comment form', () => { const payload = { lineCode: 'lineCode', fileHash: 'hash' }; - testAction( - cancelCommentForm, + return testAction( + diffActions.cancelCommentForm, payload, {}, [{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: false } }], [], - done, ); }); }); describe('loadMoreLines', () => { - it('should call mutation to show comment form', (done) => { + it('should call mutation to show comment form', () => { const endpoint = '/diffs/load/more/lines'; const params = { since: 6, to: 26 }; const lineNumbers = { oldLineNumber: 3, newLineNumber: 5 }; @@ -606,12 +535,11 @@ describe('DiffsStoreActions', () => { const isExpandDown = false; const nextLineNumbers = {}; const options = { endpoint, params, lineNumbers, fileHash, isExpandDown, nextLineNumbers }; - const mock = new MockAdapter(axios); const contextLines = { contextLines: [{ lineCode: 6 }] }; mock.onGet(endpoint).reply(200, contextLines); - testAction( - loadMoreLines, + return testAction( + diffActions.loadMoreLines, options, {}, [ @@ -621,31 +549,23 @@ describe('DiffsStoreActions', () => { }, ], [], - () => { - mock.restore(); - done(); - }, ); }); }); describe('loadCollapsedDiff', () => { const state = { showWhitespace: true }; - it('should fetch data and call mutation with response and the give parameter', (done) => { + it('should fetch data and call mutation with response and the give parameter', () => { const file = { hash: 123, load_collapsed_diff_url: '/load/collapsed/diff/url' }; const data = { hash: 123, parallelDiffLines: [{ lineCode: 1 }] }; - const mock = new MockAdapter(axios); const commit = jest.fn(); mock.onGet(file.loadCollapsedDiffUrl).reply(200, data); - loadCollapsedDiff({ commit, getters: { commitId: null }, state }, file) + return diffActions + .loadCollapsedDiff({ commit, getters: { commitId: null }, state }, file) .then(() => { expect(commit).toHaveBeenCalledWith(types.ADD_COLLAPSED_DIFFS, { file, data }); - - mock.restore(); - done(); - }) - .catch(done.fail); + }); }); it('should fetch data without commit ID', () => { @@ -656,7 +576,7 @@ describe('DiffsStoreActions', () => { jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} })); - loadCollapsedDiff({ commit() {}, getters, state }, file); + diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file); expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { params: { commit_id: null, w: '0' }, @@ -671,7 +591,7 @@ describe('DiffsStoreActions', () => { jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} })); - loadCollapsedDiff({ commit() {}, getters, state }, file); + diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file); expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { params: { commit_id: '123', w: '0' }, @@ -689,7 +609,7 @@ describe('DiffsStoreActions', () => { const dispatch = jest.fn(); - toggleFileDiscussions({ getters, dispatch }); + diffActions.toggleFileDiscussions({ getters, dispatch }); expect(dispatch).toHaveBeenCalledWith( 'collapseDiscussion', @@ -707,7 +627,7 @@ describe('DiffsStoreActions', () => { const dispatch = jest.fn(); - toggleFileDiscussions({ getters, dispatch }); + diffActions.toggleFileDiscussions({ getters, dispatch }); expect(dispatch).toHaveBeenCalledWith( 'expandDiscussion', @@ -725,7 +645,7 @@ describe('DiffsStoreActions', () => { const dispatch = jest.fn(); - toggleFileDiscussions({ getters, dispatch }); + diffActions.toggleFileDiscussions({ getters, dispatch }); expect(dispatch).toHaveBeenCalledWith( 'expandDiscussion', @@ -743,7 +663,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when there is not hash', () => { window.location.hash = ''; - scrollToLineIfNeededInline({}, lineMock); + diffActions.scrollToLineIfNeededInline({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -751,7 +671,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when the hash does not match any line', () => { window.location.hash = 'XYZ_456'; - scrollToLineIfNeededInline({}, lineMock); + diffActions.scrollToLineIfNeededInline({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -759,14 +679,14 @@ describe('DiffsStoreActions', () => { it('should call handleLocationHash only when the hash matches a line', () => { window.location.hash = 'ABC_123'; - scrollToLineIfNeededInline( + diffActions.scrollToLineIfNeededInline( {}, { lineCode: 'ABC_456', }, ); - scrollToLineIfNeededInline({}, lineMock); - scrollToLineIfNeededInline( + diffActions.scrollToLineIfNeededInline({}, lineMock); + diffActions.scrollToLineIfNeededInline( {}, { lineCode: 'XYZ_456', @@ -789,7 +709,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when there is not hash', () => { window.location.hash = ''; - scrollToLineIfNeededParallel({}, lineMock); + diffActions.scrollToLineIfNeededParallel({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -797,7 +717,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when the hash does not match any line', () => { window.location.hash = 'XYZ_456'; - scrollToLineIfNeededParallel({}, lineMock); + diffActions.scrollToLineIfNeededParallel({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -805,7 +725,7 @@ describe('DiffsStoreActions', () => { it('should call handleLocationHash only when the hash matches a line', () => { window.location.hash = 'ABC_123'; - scrollToLineIfNeededParallel( + diffActions.scrollToLineIfNeededParallel( {}, { left: null, @@ -814,8 +734,8 @@ describe('DiffsStoreActions', () => { }, }, ); - scrollToLineIfNeededParallel({}, lineMock); - scrollToLineIfNeededParallel( + diffActions.scrollToLineIfNeededParallel({}, lineMock); + diffActions.scrollToLineIfNeededParallel( {}, { left: null, @@ -831,7 +751,7 @@ describe('DiffsStoreActions', () => { }); describe('saveDiffDiscussion', () => { - it('dispatches actions', (done) => { + it('dispatches actions', () => { const commitId = 'something'; const formData = { diffFile: { ...mockDiffFile }, @@ -856,33 +776,29 @@ describe('DiffsStoreActions', () => { } }); - saveDiffDiscussion({ state, dispatch }, { note, formData }) - .then(() => { - expect(dispatch).toHaveBeenCalledTimes(5); - expect(dispatch).toHaveBeenNthCalledWith(1, 'saveNote', expect.any(Object), { - root: true, - }); + return diffActions.saveDiffDiscussion({ state, dispatch }, { note, formData }).then(() => { + expect(dispatch).toHaveBeenCalledTimes(5); + expect(dispatch).toHaveBeenNthCalledWith(1, 'saveNote', expect.any(Object), { + root: true, + }); - const postData = dispatch.mock.calls[0][1]; - expect(postData.data.note.commit_id).toBe(commitId); + const postData = dispatch.mock.calls[0][1]; + expect(postData.data.note.commit_id).toBe(commitId); - expect(dispatch).toHaveBeenNthCalledWith(2, 'updateDiscussion', 'test', { root: true }); - expect(dispatch).toHaveBeenNthCalledWith(3, 'assignDiscussionsToDiff', ['discussion']); - }) - .then(done) - .catch(done.fail); + expect(dispatch).toHaveBeenNthCalledWith(2, 'updateDiscussion', 'test', { root: true }); + expect(dispatch).toHaveBeenNthCalledWith(3, 'assignDiscussionsToDiff', ['discussion']); + }); }); }); describe('toggleTreeOpen', () => { - it('commits TOGGLE_FOLDER_OPEN', (done) => { - testAction( - toggleTreeOpen, + it('commits TOGGLE_FOLDER_OPEN', () => { + return testAction( + diffActions.toggleTreeOpen, 'path', {}, [{ type: types.TOGGLE_FOLDER_OPEN, payload: 'path' }], [], - done, ); }); }); @@ -904,7 +820,7 @@ describe('DiffsStoreActions', () => { }, }; - scrollToFile({ state, commit, getters }, { path: 'path' }); + diffActions.scrollToFile({ state, commit, getters }, { path: 'path' }); expect(document.location.hash).toBe('#test'); }); @@ -918,28 +834,27 @@ describe('DiffsStoreActions', () => { }, }; - scrollToFile({ state, commit, getters }, { path: 'path' }); + diffActions.scrollToFile({ state, commit, getters }, { path: 'path' }); expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, 'test'); }); }); describe('setShowTreeList', () => { - it('commits toggle', (done) => { - testAction( - setShowTreeList, + it('commits toggle', () => { + return testAction( + diffActions.setShowTreeList, { showTreeList: true }, {}, [{ type: types.SET_SHOW_TREE_LIST, payload: true }], [], - done, ); }); it('updates localStorage', () => { jest.spyOn(localStorage, 'setItem').mockImplementation(() => {}); - setShowTreeList({ commit() {} }, { showTreeList: true }); + diffActions.setShowTreeList({ commit() {} }, { showTreeList: true }); expect(localStorage.setItem).toHaveBeenCalledWith('mr_tree_show', true); }); @@ -947,7 +862,7 @@ describe('DiffsStoreActions', () => { it('does not update localStorage', () => { jest.spyOn(localStorage, 'setItem').mockImplementation(() => {}); - setShowTreeList({ commit() {} }, { showTreeList: true, saving: false }); + diffActions.setShowTreeList({ commit() {} }, { showTreeList: true, saving: false }); expect(localStorage.setItem).not.toHaveBeenCalled(); }); @@ -994,7 +909,7 @@ describe('DiffsStoreActions', () => { it('renders and expands file for the given discussion id', () => { const localState = state({ collapsed: true, renderIt: false }); - renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); + diffActions.renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); expect(commit).toHaveBeenCalledWith('RENDER_FILE', localState.diffFiles[0]); expect($emit).toHaveBeenCalledTimes(1); @@ -1004,7 +919,7 @@ describe('DiffsStoreActions', () => { it('jumps to discussion on already rendered and expanded file', () => { const localState = state({ collapsed: false, renderIt: true }); - renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); + diffActions.renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); expect(commit).not.toHaveBeenCalled(); expect($emit).toHaveBeenCalledTimes(1); @@ -1013,19 +928,18 @@ describe('DiffsStoreActions', () => { }); describe('setRenderTreeList', () => { - it('commits SET_RENDER_TREE_LIST', (done) => { - testAction( - setRenderTreeList, + it('commits SET_RENDER_TREE_LIST', () => { + return testAction( + diffActions.setRenderTreeList, { renderTreeList: true }, {}, [{ type: types.SET_RENDER_TREE_LIST, payload: true }], [], - done, ); }); it('sets localStorage', () => { - setRenderTreeList({ commit() {} }, { renderTreeList: true }); + diffActions.setRenderTreeList({ commit() {} }, { renderTreeList: true }); expect(localStorage.setItem).toHaveBeenCalledWith('mr_diff_tree_list', true); }); @@ -1034,11 +948,9 @@ describe('DiffsStoreActions', () => { describe('setShowWhitespace', () => { const endpointUpdateUser = 'user/prefs'; let putSpy; - let mock; let gon; beforeEach(() => { - mock = new MockAdapter(axios); putSpy = jest.spyOn(axios, 'put'); gon = window.gon; @@ -1047,25 +959,23 @@ describe('DiffsStoreActions', () => { }); afterEach(() => { - mock.restore(); window.gon = gon; }); - it('commits SET_SHOW_WHITESPACE', (done) => { - testAction( - setShowWhitespace, + it('commits SET_SHOW_WHITESPACE', () => { + return testAction( + diffActions.setShowWhitespace, { showWhitespace: true, updateDatabase: false }, {}, [{ type: types.SET_SHOW_WHITESPACE, payload: true }], [], - done, ); }); it('saves to the database when the user is logged in', async () => { window.gon = { current_user_id: 12345 }; - await setShowWhitespace( + await diffActions.setShowWhitespace( { state: { endpointUpdateUser }, commit() {} }, { showWhitespace: true, updateDatabase: true }, ); @@ -1076,7 +986,7 @@ describe('DiffsStoreActions', () => { it('does not try to save to the API if the user is not logged in', async () => { window.gon = {}; - await setShowWhitespace( + await diffActions.setShowWhitespace( { state: { endpointUpdateUser }, commit() {} }, { showWhitespace: true, updateDatabase: true }, ); @@ -1085,7 +995,7 @@ describe('DiffsStoreActions', () => { }); it('emits eventHub event', async () => { - await setShowWhitespace( + await diffActions.setShowWhitespace( { state: {}, commit() {} }, { showWhitespace: true, updateDatabase: false }, ); @@ -1095,53 +1005,47 @@ describe('DiffsStoreActions', () => { }); describe('setRenderIt', () => { - it('commits RENDER_FILE', (done) => { - testAction(setRenderIt, 'file', {}, [{ type: types.RENDER_FILE, payload: 'file' }], [], done); + it('commits RENDER_FILE', () => { + return testAction( + diffActions.setRenderIt, + 'file', + {}, + [{ type: types.RENDER_FILE, payload: 'file' }], + [], + ); }); }); describe('receiveFullDiffError', () => { - it('updates state with the file that did not load', (done) => { - testAction( - receiveFullDiffError, + it('updates state with the file that did not load', () => { + return testAction( + diffActions.receiveFullDiffError, 'file', {}, [{ type: types.RECEIVE_FULL_DIFF_ERROR, payload: 'file' }], [], - done, ); }); }); describe('fetchFullDiff', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - describe('success', () => { beforeEach(() => { mock.onGet(`${TEST_HOST}/context`).replyOnce(200, ['test']); }); - it('commits the success and dispatches an action to expand the new lines', (done) => { + it('commits the success and dispatches an action to expand the new lines', () => { const file = { context_lines_path: `${TEST_HOST}/context`, file_path: 'test', file_hash: 'test', }; - testAction( - fetchFullDiff, + return testAction( + diffActions.fetchFullDiff, file, null, [{ type: types.RECEIVE_FULL_DIFF_SUCCESS, payload: { filePath: 'test' } }], [{ type: 'setExpandedDiffLines', payload: { file, data: ['test'] } }], - done, ); }); }); @@ -1151,14 +1055,13 @@ describe('DiffsStoreActions', () => { mock.onGet(`${TEST_HOST}/context`).replyOnce(500); }); - it('dispatches receiveFullDiffError', (done) => { - testAction( - fetchFullDiff, + it('dispatches receiveFullDiffError', () => { + return testAction( + diffActions.fetchFullDiff, { context_lines_path: `${TEST_HOST}/context`, file_path: 'test', file_hash: 'test' }, null, [], [{ type: 'receiveFullDiffError', payload: 'test' }], - done, ); }); }); @@ -1173,14 +1076,13 @@ describe('DiffsStoreActions', () => { }; }); - it('dispatches fetchFullDiff when file is not expanded', (done) => { - testAction( - toggleFullDiff, + it('dispatches fetchFullDiff when file is not expanded', () => { + return testAction( + diffActions.toggleFullDiff, 'test', state, [{ type: types.REQUEST_FULL_DIFF, payload: 'test' }], [{ type: 'fetchFullDiff', payload: state.diffFiles[0] }], - done, ); }); }); @@ -1202,16 +1104,13 @@ describe('DiffsStoreActions', () => { }; const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }]; let renamedFile; - let mock; beforeEach(() => { - mock = new MockAdapter(axios); jest.spyOn(utils, 'prepareLineForRenamedFile').mockImplementation(() => preparedLine); }); afterEach(() => { renamedFile = null; - mock.restore(); }); describe('success', () => { @@ -1228,7 +1127,7 @@ describe('DiffsStoreActions', () => { 'performs the correct mutations and starts a render queue for view type $diffViewType', ({ diffViewType }) => { return testAction( - switchToFullDiffFromRenamedFile, + diffActions.switchToFullDiffFromRenamedFile, { diffFile: renamedFile }, { diffViewType }, [ @@ -1249,9 +1148,9 @@ describe('DiffsStoreActions', () => { }); describe('setFileUserCollapsed', () => { - it('commits SET_FILE_COLLAPSED', (done) => { - testAction( - setFileCollapsedByUser, + it('commits SET_FILE_COLLAPSED', () => { + return testAction( + diffActions.setFileCollapsedByUser, { filePath: 'test', collapsed: true }, null, [ @@ -1261,7 +1160,6 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); }); @@ -1273,11 +1171,11 @@ describe('DiffsStoreActions', () => { }); }); - it('commits SET_CURRENT_VIEW_DIFF_FILE_LINES when lines less than MAX_RENDERING_DIFF_LINES', (done) => { + it('commits SET_CURRENT_VIEW_DIFF_FILE_LINES when lines less than MAX_RENDERING_DIFF_LINES', () => { utils.convertExpandLines.mockImplementation(() => ['test']); - testAction( - setExpandedDiffLines, + return testAction( + diffActions.setExpandedDiffLines, { file: { file_path: 'path' }, data: [] }, { diffViewType: 'inline' }, [ @@ -1287,16 +1185,15 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); - it('commits ADD_CURRENT_VIEW_DIFF_FILE_LINES when lines more than MAX_RENDERING_DIFF_LINES', (done) => { + it('commits ADD_CURRENT_VIEW_DIFF_FILE_LINES when lines more than MAX_RENDERING_DIFF_LINES', () => { const lines = new Array(501).fill().map((_, i) => `line-${i}`); utils.convertExpandLines.mockReturnValue(lines); - testAction( - setExpandedDiffLines, + return testAction( + diffActions.setExpandedDiffLines, { file: { file_path: 'path' }, data: [] }, { diffViewType: 'inline' }, [ @@ -1312,41 +1209,34 @@ describe('DiffsStoreActions', () => { { type: 'TOGGLE_DIFF_FILE_RENDERING_MORE', payload: 'path' }, ], [], - done, ); }); }); describe('setSuggestPopoverDismissed', () => { - it('commits SET_SHOW_SUGGEST_POPOVER', (done) => { + it('commits SET_SHOW_SUGGEST_POPOVER', async () => { const state = { dismissEndpoint: `${TEST_HOST}/-/user_callouts` }; - const mock = new MockAdapter(axios); mock.onPost(state.dismissEndpoint).reply(200, {}); jest.spyOn(axios, 'post'); - testAction( - setSuggestPopoverDismissed, + await testAction( + diffActions.setSuggestPopoverDismissed, null, state, [{ type: types.SET_SHOW_SUGGEST_POPOVER }], [], - () => { - expect(axios.post).toHaveBeenCalledWith(state.dismissEndpoint, { - feature_name: 'suggest_popover_dismissed', - }); - - mock.restore(); - done(); - }, ); + expect(axios.post).toHaveBeenCalledWith(state.dismissEndpoint, { + feature_name: 'suggest_popover_dismissed', + }); }); }); describe('changeCurrentCommit', () => { it('commits the new commit information and re-requests the diff metadata for the commit', () => { return testAction( - changeCurrentCommit, + diffActions.changeCurrentCommit, { commitId: 'NEW' }, { commit: { @@ -1384,7 +1274,7 @@ describe('DiffsStoreActions', () => { ({ commitId, commit, msg }) => { const err = new Error(msg); const actionReturn = testAction( - changeCurrentCommit, + diffActions.changeCurrentCommit, { commitId }, { endpoint: 'URL/OLD', @@ -1410,7 +1300,7 @@ describe('DiffsStoreActions', () => { 'for the direction "$direction", dispatches the action to move to the SHA "$expected"', ({ direction, expected, currentCommit }) => { return testAction( - moveToNeighboringCommit, + diffActions.moveToNeighboringCommit, { direction }, { commit: currentCommit }, [], @@ -1431,7 +1321,7 @@ describe('DiffsStoreActions', () => { 'given `{ "isloading": $diffsAreLoading, "commit": $currentCommit }` in state, no actions are dispatched', ({ direction, diffsAreLoading, currentCommit }) => { return testAction( - moveToNeighboringCommit, + diffActions.moveToNeighboringCommit, { direction }, { commit: currentCommit, isLoading: diffsAreLoading }, [], @@ -1450,7 +1340,7 @@ describe('DiffsStoreActions', () => { notesById: { 1: { discussion_id: '2' } }, }; - setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); + diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, '123'); }); @@ -1463,7 +1353,7 @@ describe('DiffsStoreActions', () => { notesById: { 1: { discussion_id: '2' } }, }; - setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); + diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); expect(commit).not.toHaveBeenCalled(); }); @@ -1476,21 +1366,20 @@ describe('DiffsStoreActions', () => { notesById: { 1: { discussion_id: '2' } }, }; - setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); + diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); expect(commit).not.toHaveBeenCalled(); }); }); describe('navigateToDiffFileIndex', () => { - it('commits SET_CURRENT_DIFF_FILE', (done) => { - testAction( - navigateToDiffFileIndex, + it('commits SET_CURRENT_DIFF_FILE', () => { + return testAction( + diffActions.navigateToDiffFileIndex, 0, { diffFiles: [{ file_hash: '123' }] }, [{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }], [], - done, ); }); }); @@ -1498,19 +1387,13 @@ describe('DiffsStoreActions', () => { describe('setFileByFile', () => { const updateUserEndpoint = 'user/prefs'; let putSpy; - let mock; beforeEach(() => { - mock = new MockAdapter(axios); putSpy = jest.spyOn(axios, 'put'); mock.onPut(updateUserEndpoint).reply(200, {}); }); - afterEach(() => { - mock.restore(); - }); - it.each` value ${true} @@ -1519,7 +1402,7 @@ describe('DiffsStoreActions', () => { 'commits SET_FILE_BY_FILE and persists the File-by-File user preference with the new value $value', async ({ value }) => { await testAction( - setFileByFile, + diffActions.setFileByFile, { fileByFile: value }, { viewDiffsFileByFile: null, @@ -1551,7 +1434,7 @@ describe('DiffsStoreActions', () => { const commitSpy = jest.fn(); const getterSpy = jest.fn().mockReturnValue([]); - reviewFile( + diffActions.reviewFile( { commit: commitSpy, getters: { diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 55c0141552d..03bcaab0d2b 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -13,7 +13,7 @@ import { } from '~/diffs/constants'; import * as utils from '~/diffs/store/utils'; import { MERGE_REQUEST_NOTEABLE_TYPE } from '~/notes/constants'; -import { noteableDataMock } from '../../notes/mock_data'; +import { noteableDataMock } from 'jest/notes/mock_data'; import diffFileMockData from '../mock_data/diff_file'; import { diffMetadata } from '../mock_data/diff_metadata'; diff --git a/spec/frontend/editor/components/helpers.js b/spec/frontend/editor/components/helpers.js new file mode 100644 index 00000000000..3e6cd2a236d --- /dev/null +++ b/spec/frontend/editor/components/helpers.js @@ -0,0 +1,12 @@ +import { EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants'; + +export const buildButton = (id = 'foo-bar-btn', options = {}) => { + return { + __typename: 'Item', + id, + label: options.label || 'Foo Bar Button', + icon: options.icon || 'foo-bar', + selected: options.selected || false, + group: options.group || EDITOR_TOOLBAR_RIGHT_GROUP, + }; +}; diff --git a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js new file mode 100644 index 00000000000..5135091af4a --- /dev/null +++ b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js @@ -0,0 +1,146 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import SourceEditorToolbarButton from '~/editor/components/source_editor_toolbar_button.vue'; +import getToolbarItemQuery from '~/editor/graphql/get_item.query.graphql'; +import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql'; +import { buildButton } from './helpers'; + +Vue.use(VueApollo); + +describe('Source Editor Toolbar button', () => { + let wrapper; + let mockApollo; + const defaultBtn = buildButton(); + + const findButton = () => wrapper.findComponent(GlButton); + + const createComponentWithApollo = ({ propsData } = {}) => { + mockApollo = createMockApollo(); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemQuery, + variables: { id: defaultBtn.id }, + data: { + item: { + ...defaultBtn, + }, + }, + }); + + wrapper = shallowMount(SourceEditorToolbarButton, { + propsData, + apolloProvider: mockApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + mockApollo = null; + }); + + describe('default', () => { + const defaultProps = { + category: 'primary', + variant: 'default', + }; + const customProps = { + category: 'secondary', + variant: 'info', + }; + it('renders a default button without props', async () => { + createComponentWithApollo(); + const btn = findButton(); + expect(btn.exists()).toBe(true); + expect(btn.props()).toMatchObject(defaultProps); + }); + + it('renders a button based on the props passed', async () => { + createComponentWithApollo({ + propsData: { + button: customProps, + }, + }); + const btn = findButton(); + expect(btn.props()).toMatchObject(customProps); + }); + }); + + describe('button updates', () => { + it('it properly updates button on Apollo cache update', async () => { + const { id } = defaultBtn; + + createComponentWithApollo({ + propsData: { + button: { + id, + }, + }, + }); + + expect(findButton().props('selected')).toBe(false); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemQuery, + variables: { id }, + data: { + item: { + ...defaultBtn, + selected: true, + }, + }, + }); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(findButton().props('selected')).toBe(true); + }); + }); + + describe('click handler', () => { + it('fires the click handler on the button when available', () => { + const spy = jest.fn(); + createComponentWithApollo({ + propsData: { + button: { + onClick: spy, + }, + }, + }); + expect(spy).not.toHaveBeenCalled(); + findButton().vm.$emit('click'); + expect(spy).toHaveBeenCalled(); + }); + it('emits the "click" event', () => { + createComponentWithApollo(); + jest.spyOn(wrapper.vm, '$emit'); + expect(wrapper.vm.$emit).not.toHaveBeenCalled(); + findButton().vm.$emit('click'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click'); + }); + it('triggers the mutation exposing the changed "selected" prop', () => { + const { id } = defaultBtn; + createComponentWithApollo({ + propsData: { + button: { + id, + }, + }, + }); + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + expect(wrapper.vm.$apollo.mutate).not.toHaveBeenCalled(); + findButton().vm.$emit('click'); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateToolbarItemMutation, + variables: { + id, + propsToUpdate: { + selected: true, + }, + }, + }); + }); + }); +}); diff --git a/spec/frontend/editor/components/source_editor_toolbar_spec.js b/spec/frontend/editor/components/source_editor_toolbar_spec.js new file mode 100644 index 00000000000..6e99eadbd97 --- /dev/null +++ b/spec/frontend/editor/components/source_editor_toolbar_spec.js @@ -0,0 +1,116 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButtonGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import SourceEditorToolbar from '~/editor/components/source_editor_toolbar.vue'; +import SourceEditorToolbarButton from '~/editor/components/source_editor_toolbar_button.vue'; +import { EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants'; +import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql'; +import { buildButton } from './helpers'; + +Vue.use(VueApollo); + +describe('Source Editor Toolbar', () => { + let wrapper; + let mockApollo; + + const findButtons = () => wrapper.findAllComponents(SourceEditorToolbarButton); + + const createApolloMockWithCache = (items = []) => { + mockApollo = createMockApollo(); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemsQuery, + data: { + items: { + nodes: items, + }, + }, + }); + }; + + const createComponentWithApollo = (items = []) => { + createApolloMockWithCache(items); + wrapper = shallowMount(SourceEditorToolbar, { + apolloProvider: mockApollo, + stubs: { + GlButtonGroup, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + mockApollo = null; + }); + + describe('groups', () => { + it.each` + group | expectedGroup + ${EDITOR_TOOLBAR_LEFT_GROUP} | ${EDITOR_TOOLBAR_LEFT_GROUP} + ${EDITOR_TOOLBAR_RIGHT_GROUP} | ${EDITOR_TOOLBAR_RIGHT_GROUP} + ${undefined} | ${EDITOR_TOOLBAR_RIGHT_GROUP} + ${'non-existing'} | ${EDITOR_TOOLBAR_RIGHT_GROUP} + `('puts item with group="$group" into $expectedGroup group', ({ group, expectedGroup }) => { + const item = buildButton('first', { + group, + }); + createComponentWithApollo([item]); + expect(findButtons()).toHaveLength(1); + [EDITOR_TOOLBAR_RIGHT_GROUP, EDITOR_TOOLBAR_LEFT_GROUP].forEach((g) => { + if (g === expectedGroup) { + expect(wrapper.vm.getGroupItems(g)).toEqual([expect.objectContaining({ id: 'first' })]); + } else { + expect(wrapper.vm.getGroupItems(g)).toHaveLength(0); + } + }); + }); + }); + + describe('buttons update', () => { + it('it properly updates buttons on Apollo cache update', async () => { + const item = buildButton('first', { + group: EDITOR_TOOLBAR_RIGHT_GROUP, + }); + createComponentWithApollo(); + + expect(findButtons()).toHaveLength(0); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemsQuery, + data: { + items: { + nodes: [item], + }, + }, + }); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(findButtons()).toHaveLength(1); + }); + }); + + describe('click handler', () => { + it('emits the "click" event when a button is clicked', () => { + const item1 = buildButton('first', { + group: EDITOR_TOOLBAR_LEFT_GROUP, + }); + const item2 = buildButton('second', { + group: EDITOR_TOOLBAR_RIGHT_GROUP, + }); + createComponentWithApollo([item1, item2]); + jest.spyOn(wrapper.vm, '$emit'); + expect(wrapper.vm.$emit).not.toHaveBeenCalled(); + + findButtons().at(0).vm.$emit('click'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item1); + + findButtons().at(1).vm.$emit('click'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item2); + + expect(wrapper.vm.$emit.mock.calls).toHaveLength(2); + }); + }); +}); diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js new file mode 100644 index 00000000000..628c34a27c1 --- /dev/null +++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js @@ -0,0 +1,90 @@ +import Ajv from 'ajv'; +import AjvFormats from 'ajv-formats'; +import CiSchema from '~/editor/schema/ci.json'; + +// JSON POSITIVE TESTS +import AllowFailureJson from './json_tests/positive_tests/allow_failure.json'; +import EnvironmentJson from './json_tests/positive_tests/environment.json'; +import GitlabCiDependenciesJson from './json_tests/positive_tests/gitlab-ci-dependencies.json'; +import GitlabCiJson from './json_tests/positive_tests/gitlab-ci.json'; +import InheritJson from './json_tests/positive_tests/inherit.json'; +import MultipleCachesJson from './json_tests/positive_tests/multiple-caches.json'; +import RetryJson from './json_tests/positive_tests/retry.json'; +import TerraformReportJson from './json_tests/positive_tests/terraform_report.json'; +import VariablesMixStringAndUserInputJson from './json_tests/positive_tests/variables_mix_string_and_user_input.json'; +import VariablesJson from './json_tests/positive_tests/variables.json'; + +// JSON NEGATIVE TESTS +import DefaultNoAdditionalPropertiesJson from './json_tests/negative_tests/default_no_additional_properties.json'; +import InheritDefaultNoAdditionalPropertiesJson from './json_tests/negative_tests/inherit_default_no_additional_properties.json'; +import JobVariablesMustNotContainObjectsJson from './json_tests/negative_tests/job_variables_must_not_contain_objects.json'; +import ReleaseAssetsLinksEmptyJson from './json_tests/negative_tests/release_assets_links_empty.json'; +import ReleaseAssetsLinksInvalidLinkTypeJson from './json_tests/negative_tests/release_assets_links_invalid_link_type.json'; +import ReleaseAssetsLinksMissingJson from './json_tests/negative_tests/release_assets_links_missing.json'; +import RetryUnknownWhenJson from './json_tests/negative_tests/retry_unknown_when.json'; + +// YAML POSITIVE TEST +import CacheYaml from './yaml_tests/positive_tests/cache.yml'; +import FilterYaml from './yaml_tests/positive_tests/filter.yml'; +import IncludeYaml from './yaml_tests/positive_tests/include.yml'; +import RulesYaml from './yaml_tests/positive_tests/rules.yml'; + +// YAML NEGATIVE TEST +import CacheNegativeYaml from './yaml_tests/negative_tests/cache.yml'; +import IncludeNegativeYaml from './yaml_tests/negative_tests/include.yml'; + +const ajv = new Ajv({ + strictTypes: false, + strictTuples: false, + allowMatchingProperties: true, +}); + +AjvFormats(ajv); +const schema = ajv.compile(CiSchema); + +describe('positive tests', () => { + it.each( + Object.entries({ + // JSON + AllowFailureJson, + EnvironmentJson, + GitlabCiDependenciesJson, + GitlabCiJson, + InheritJson, + MultipleCachesJson, + RetryJson, + TerraformReportJson, + VariablesMixStringAndUserInputJson, + VariablesJson, + + // YAML + CacheYaml, + FilterYaml, + IncludeYaml, + RulesYaml, + }), + )('schema validates %s', (_, input) => { + expect(input).toValidateJsonSchema(schema); + }); +}); + +describe('negative tests', () => { + it.each( + Object.entries({ + // JSON + DefaultNoAdditionalPropertiesJson, + JobVariablesMustNotContainObjectsJson, + InheritDefaultNoAdditionalPropertiesJson, + ReleaseAssetsLinksEmptyJson, + ReleaseAssetsLinksInvalidLinkTypeJson, + ReleaseAssetsLinksMissingJson, + RetryUnknownWhenJson, + + // YAML + CacheNegativeYaml, + IncludeNegativeYaml, + }), + )('schema validates %s', (_, input) => { + expect(input).not.toValidateJsonSchema(schema); + }); +}); diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json new file mode 100644 index 00000000000..955c19ef1ab --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json @@ -0,0 +1,12 @@ +{ + "default": { + "secrets": { + "DATABASE_PASSWORD": { + "vault": "production/db/password" + } + }, + "environment": { + "name": "test" + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json new file mode 100644 index 00000000000..7411e4c2434 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json @@ -0,0 +1,8 @@ +{ + "karma": { + "inherit": { + "default": ["secrets"] + }, + "script": "karma" + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json new file mode 100644 index 00000000000..bfdbf26ee70 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json @@ -0,0 +1,12 @@ +{ + "gitlab-ci-variables-object": { + "stage": "test", + "script": ["true"], + "variables": { + "DEPLOY_ENVIRONMENT": { + "value": "staging", + "description": "The deployment target. Change this variable to 'canary' or 'production' if needed." + } + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json new file mode 100644 index 00000000000..84a1aa14698 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json @@ -0,0 +1,13 @@ +{ + "gitlab-ci-release-assets-links-empty": { + "script": "dostuff", + "stage": "deploy", + "release": { + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "assets": { + "links": [] + } + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json new file mode 100644 index 00000000000..048911aefa3 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json @@ -0,0 +1,24 @@ +{ + "gitlab-ci-release-assets-links-invalid-link-type": { + "script": "dostuff", + "stage": "deploy", + "release": { + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "assets": { + "links": [ + { + "name": "asset1", + "url": "https://example.com/assets/1" + }, + { + "name": "asset2", + "url": "https://example.com/assets/2", + "filepath": "/pretty/url/1", + "link_type": "invalid" + } + ] + } + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json new file mode 100644 index 00000000000..6f0b5a3bff8 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json @@ -0,0 +1,11 @@ +{ + "gitlab-ci-release-assets-links-missing": { + "script": "dostuff", + "stage": "deploy", + "release": { + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "assets": {} + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json new file mode 100644 index 00000000000..433504f52c6 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json @@ -0,0 +1,9 @@ +{ + "gitlab-ci-retry-object-unknown-when": { + "stage": "test", + "script": "rspec", + "retry": { + "when": "gitlab-ci-retry-object-unknown-when" + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json new file mode 100644 index 00000000000..44d42116c1a --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json @@ -0,0 +1,19 @@ +{ + "job1": { + "stage": "test", + "script": ["execute_script_that_will_fail"], + "allow_failure": true + }, + "job2": { + "script": ["exit 1"], + "allow_failure": { + "exit_codes": 137 + } + }, + "job3": { + "script": ["exit 137"], + "allow_failure": { + "exit_codes": [137, 255] + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json new file mode 100644 index 00000000000..0c6f7935063 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json @@ -0,0 +1,75 @@ +{ + "deploy to production 1": { + "stage": "deploy", + "script": "git push production HEAD: master", + "environment": "production" + }, + "deploy to production 2": { + "stage": "deploy", + "script": "git push production HEAD:master", + "environment": { + "name": "production" + } + }, + "deploy to production 3": { + "stage": "deploy", + "script": "git push production HEAD:master", + "environment": { + "name": "production", + "url": "https://prod.example.com" + } + }, + "review_app 1": { + "stage": "deploy", + "script": "make deploy-app", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "url": "https://$CI_ENVIRONMENT_SLUG.example.com", + "on_stop": "stop_review_app" + } + }, + "stop_review_app": { + "stage": "deploy", + "variables": { + "GIT_STRATEGY": "none" + }, + "script": "make delete-app", + "when": "manual", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "action": "stop" + } + }, + "review_app 2": { + "script": "deploy-review-app", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "auto_stop_in": "1 day" + } + }, + "deploy 1": { + "stage": "deploy", + "script": "make deploy-app", + "environment": { + "name": "production", + "kubernetes": { + "namespace": "production" + } + } + }, + "deploy 2": { + "script": "echo", + "environment": { + "name": "customer-portal", + "deployment_tier": "production" + } + }, + "deploy as review app": { + "stage": "deploy", + "script": "make deploy", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "url": "https://$CI_ENVIRONMENT_SLUG.example.com/" + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json new file mode 100644 index 00000000000..5ffa7fa799e --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json @@ -0,0 +1,68 @@ +{ + ".build_config": { + "before_script": ["echo test"] + }, + ".build_script": "echo build script", + "default": { + "image": "ruby:2.5", + "services": ["docker:dind"], + "cache": { + "paths": ["vendor/"] + }, + "before_script": ["bundle install --path vendor/"], + "after_script": ["rm -rf tmp/"] + }, + "stages": ["install", "build", "test", "deploy"], + "image": "foo:latest", + "install task1": { + "image": "node:latest", + "stage": "install", + "script": "npm install", + "artifacts": { + "paths": ["node_modules/"] + } + }, + "build dev": { + "image": "node:latest", + "stage": "build", + "needs": [ + { + "job": "install task1" + } + ], + "script": "npm run build:dev" + }, + "build prod": { + "image": "node:latest", + "stage": "build", + "needs": ["install task1"], + "script": "npm run build:prod" + }, + "test": { + "image": "node:latest", + "stage": "build", + "needs": [ + "install task1", + { + "job": "build dev", + "artifacts": true + } + ], + "script": "npm run test" + }, + "deploy it": { + "image": "node:latest", + "stage": "deploy", + "needs": [ + { + "job": "build dev", + "artifacts": false + }, + { + "job": "build prod", + "artifacts": true + } + ], + "script": "npm run test" + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json new file mode 100644 index 00000000000..89420bbc35f --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json @@ -0,0 +1,350 @@ +{ + ".build_config": { + "before_script": ["echo test"] + }, + ".build_script": "echo build script", + ".example_variables": { + "foo": "hello", + "bar": 42 + }, + ".example_services": [ + "docker:dind", + { + "name": "sql:latest", + "command": ["/usr/bin/super-sql", "run"] + } + ], + "default": { + "image": "ruby:2.5", + "services": ["docker:dind"], + "cache": { + "paths": ["vendor/"] + }, + "before_script": ["bundle install --path vendor/"], + "after_script": ["rm -rf tmp/"], + "tags": ["ruby", "postgres"], + "artifacts": { + "name": "%CI_COMMIT_REF_NAME%", + "expose_as": "artifact 1", + "paths": ["path/to/file.txt", "target/*.war"], + "when": "on_failure" + }, + "retry": 2, + "timeout": "3 hours 30 minutes", + "interruptible": true + }, + "stages": ["build", "test", "deploy", "random"], + "image": "foo:latest", + "services": ["sql:latest"], + "before_script": ["echo test", "echo test2"], + "after_script": [], + "cache": { + "key": "asd", + "paths": ["dist/", ".foo"], + "untracked": false, + "policy": "pull" + }, + "variables": { + "STAGE": "yep", + "PROD": "nope" + }, + "include": [ + "https://gitlab.com/awesome-project/raw/master/.before-script-template.yml", + "/templates/.after-script-template.yml", + { "template": "Auto-DevOps.gitlab-ci.yml" }, + { + "project": "my-group/my-project", + "ref": "master", + "file": "/templates/.gitlab-ci-template.yml" + }, + { + "project": "my-group/my-project", + "ref": "master", + "file": ["/templates/.gitlab-ci-template.yml", "/templates/another-template-to-include.yml"] + } + ], + "build": { + "image": { + "name": "node:latest" + }, + "services": [], + "stage": "build", + "script": "npm run build", + "before_script": ["npm install"], + "rules": [ + { + "if": "moo", + "changes": ["Moofile"], + "exists": ["main.cow"], + "when": "delayed", + "start_in": "3 hours" + } + ], + "retry": { + "max": 1, + "when": "stuck_or_timeout_failure" + }, + "cache": { + "key": "$CI_COMMIT_REF_NAME", + "paths": ["node_modules/"], + "policy": "pull-push" + }, + "artifacts": { + "paths": ["dist/"], + "expose_as": "link_name_in_merge_request", + "name": "bundles", + "when": "on_success", + "expire_in": "1 week", + "reports": { + "junit": "result.xml", + "cobertura": "cobertura-coverage.xml", + "codequality": "codequality.json", + "sast": "sast.json", + "dependency_scanning": "scan.json", + "container_scanning": "scan2.json", + "dast": "dast.json", + "license_management": "license.json", + "performance": "performance.json", + "metrics": "metrics.txt" + } + }, + "variables": { + "FOO_BAR": "..." + }, + "only": { + "kubernetes": "active", + "variables": ["$FOO_BAR == '...'"], + "changes": ["/path/to/file", "/another/file"] + }, + "except": ["master", "tags"], + "tags": ["docker"], + "allow_failure": true, + "when": "manual" + }, + "error-report": { + "when": "on_failure", + "script": "report error", + "stage": "test" + }, + "test": { + "image": { + "name": "node:latest", + "entrypoint": [""] + }, + "stage": "test", + "script": "npm test", + "parallel": 5, + "retry": { + "max": 2, + "when": [ + "runner_system_failure", + "stuck_or_timeout_failure", + "script_failure", + "unknown_failure", + "always" + ] + }, + "artifacts": { + "reports": { + "junit": ["result.xml"], + "cobertura": ["cobertura-coverage.xml"], + "codequality": ["codequality.json"], + "sast": ["sast.json"], + "dependency_scanning": ["scan.json"], + "container_scanning": ["scan2.json"], + "dast": ["dast.json"], + "license_management": ["license.json"], + "performance": ["performance.json"], + "metrics": ["metrics.txt"] + } + }, + "coverage": "/Cycles: \\d+\\.\\d+$/", + "dependencies": [] + }, + "docker": { + "script": "docker build -t foo:latest", + "when": "delayed", + "start_in": "10 min", + "timeout": "1h", + "retry": 1, + "only": { + "changes": ["Dockerfile", "docker/scripts/*"] + } + }, + "deploy": { + "services": [ + { + "name": "sql:latest", + "entrypoint": [""], + "command": ["/usr/bin/super-sql", "run"], + "alias": "super-sql" + }, + "sql:latest", + { + "name": "sql:latest", + "alias": "default-sql" + } + ], + "script": "dostuff", + "stage": "deploy", + "environment": { + "name": "prod", + "url": "http://example.com", + "on_stop": "stop-deploy" + }, + "only": ["master"], + "release": { + "name": "Release $CI_COMMIT_TAG", + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "ref": "$CI_COMMIT_TAG", + "milestones": ["m1", "m2", "m3"], + "released_at": "2020-07-15T08:00:00Z", + "assets": { + "links": [ + { + "name": "asset1", + "url": "https://example.com/assets/1" + }, + { + "name": "asset2", + "url": "https://example.com/assets/2", + "filepath": "/pretty/url/1", + "link_type": "other" + }, + { + "name": "asset3", + "url": "https://example.com/assets/3", + "link_type": "runbook" + }, + { + "name": "asset4", + "url": "https://example.com/assets/4", + "link_type": "package" + }, + { + "name": "asset5", + "url": "https://example.com/assets/5", + "link_type": "image" + } + ] + } + } + }, + ".performance-tmpl": { + "after_script": ["echo after"], + "before_script": ["echo before"], + "variables": { + "SCRIPT_NOT_REQUIRED": "true" + } + }, + "performance-a": { + "extends": ".performance-tmpl", + "script": "echo test" + }, + "performance-b": { + "extends": ".performance-tmpl" + }, + "workflow": { + "rules": [ + { + "if": "$CI_COMMIT_REF_NAME =~ /-wip$/", + "when": "never" + }, + { + "if": "$CI_COMMIT_TAG", + "when": "never" + }, + { + "when": "always" + } + ] + }, + "job": { + "script": "echo Hello, Rules!", + "rules": [ + { + "if": "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == \"master\"", + "when": "manual", + "allow_failure": true + } + ] + }, + "microservice_a": { + "trigger": { + "include": "path/to/microservice_a.yml" + } + }, + "microservice_b": { + "trigger": { + "include": [{ "local": "path/to/microservice_b.yml" }, { "template": "SAST.gitlab-cy.yml" }], + "strategy": "depend" + } + }, + "child-pipeline": { + "stage": "test", + "trigger": { + "include": [ + { + "artifact": "generated-config.yml", + "job": "generate-config" + } + ] + } + }, + "child-pipeline-simple": { + "stage": "test", + "trigger": { + "include": "other/file.yml" + } + }, + "complex": { + "stage": "deploy", + "trigger": { + "project": "my/deployment", + "branch": "stable" + } + }, + "parallel-integer": { + "stage": "test", + "script": ["echo ${CI_NODE_INDEX} ${CI_NODE_TOTAL}"], + "parallel": 5 + }, + "parallel-matrix-simple": { + "stage": "test", + "script": ["echo ${MY_VARIABLE}"], + "parallel": { + "matrix": [ + { + "MY_VARIABLE": 0 + }, + { + "MY_VARIABLE": "sample" + }, + { + "MY_VARIABLE": ["element0", 1, "element2"] + } + ] + } + }, + "parallel-matrix-gitlab-docs": { + "stage": "deploy", + "script": ["bin/deploy"], + "parallel": { + "matrix": [ + { + "PROVIDER": "aws", + "STACK": ["app1", "app2"] + }, + { + "PROVIDER": "ovh", + "STACK": ["monitoring", "backup", "app"] + }, + { + "PROVIDER": ["gcp", "vultr"], + "STACK": ["data", "processing"] + } + ] + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json new file mode 100644 index 00000000000..3f72afa6ceb --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json @@ -0,0 +1,54 @@ +{ + "default": { + "image": "ruby:2.4", + "before_script": ["echo Hello World"] + }, + "variables": { + "DOMAIN": "example.com", + "WEBHOOK_URL": "https://my-webhook.example.com" + }, + "rubocop": { + "inherit": { + "default": false, + "variables": false + }, + "script": "bundle exec rubocop" + }, + "rspec": { + "inherit": { + "default": ["image"], + "variables": ["WEBHOOK_URL"] + }, + "script": "bundle exec rspec" + }, + "capybara": { + "inherit": { + "variables": false + }, + "script": "bundle exec capybara" + }, + "karma": { + "inherit": { + "default": true, + "variables": ["DOMAIN"] + }, + "script": "karma" + }, + "inherit literally all": { + "inherit": { + "default": [ + "after_script", + "artifacts", + "before_script", + "cache", + "image", + "interruptible", + "retry", + "services", + "tags", + "timeout" + ] + }, + "script": "true" + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json new file mode 100644 index 00000000000..360938e5ce7 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json @@ -0,0 +1,24 @@ +{ + "test-job": { + "stage": "build", + "cache": [ + { + "key": { + "files": ["Gemfile.lock"] + }, + "paths": ["vendor/ruby"] + }, + { + "key": { + "files": ["yarn.lock"] + }, + "paths": [".yarn-cache/"] + } + ], + "script": [ + "bundle install --path=vendor", + "yarn install --cache-folder .yarn-cache", + "echo Run tests..." + ] + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json new file mode 100644 index 00000000000..1337e5e7bc8 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json @@ -0,0 +1,60 @@ +{ + "gitlab-ci-retry-int": { + "stage": "test", + "script": "rspec", + "retry": 2 + }, + "gitlab-ci-retry-object-no-max": { + "stage": "test", + "script": "rspec", + "retry": { + "when": "runner_system_failure" + } + }, + "gitlab-ci-retry-object-single-when": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": "runner_system_failure" + } + }, + "gitlab-ci-retry-object-multiple-when": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": ["runner_system_failure", "stuck_or_timeout_failure"] + } + }, + "gitlab-ci-retry-object-multiple-when-dupes": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": ["runner_system_failure", "runner_system_failure"] + } + }, + "gitlab-ci-retry-object-all-when": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": [ + "always", + "unknown_failure", + "script_failure", + "api_failure", + "stuck_or_timeout_failure", + "runner_system_failure", + "runner_unsupported", + "stale_schedule", + "job_execution_timeout", + "archived_failure", + "unmet_prerequisites", + "scheduler_failure", + "data_integrity_failure" + ] + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json new file mode 100644 index 00000000000..0e444a4ba62 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json @@ -0,0 +1,50 @@ +{ + "image": { + "name": "registry.gitlab.com/gitlab-org/gitlab-build-images:terraform", + "entrypoint": [ + "/usr/bin/env", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ] + }, + "variables": { + "PLAN": "plan.tfplan", + "JSON_PLAN_FILE": "tfplan.json" + }, + "cache": { + "paths": [".terraform"] + }, + "before_script": [ + "alias convert_report=\"jq -r '([.resource_changes[]?.change.actions?]|flatten)|{\"create\":(map(select(.==\"create\"))|length),\"update\":(map(select(.==\"update\"))|length),\"delete\":(map(select(.==\"delete\"))|length)}'\"", + "terraform --version", + "terraform init" + ], + "stages": ["validate", "build", "test", "deploy"], + "validate": { + "stage": "validate", + "script": ["terraform validate"] + }, + "plan": { + "stage": "build", + "script": [ + "terraform plan -out=$PLAN", + "terraform show --json $PLAN | convert_report > $JSON_PLAN_FILE" + ], + "artifacts": { + "name": "plan", + "paths": ["$PLAN"], + "reports": { + "terraform": "$JSON_PLAN_FILE" + } + } + }, + "apply": { + "stage": "deploy", + "environment": { + "name": "production" + }, + "script": ["terraform apply -input=false $PLAN"], + "dependencies": ["plan"], + "when": "manual", + "only": ["master"] + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json new file mode 100644 index 00000000000..ce59b3fbbec --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json @@ -0,0 +1,22 @@ +{ + "variables": { + "DEPLOY_ENVIRONMENT": { + "value": "staging", + "description": "The deployment target. Change this variable to 'canary' or 'production' if needed." + } + }, + "gitlab-ci-variables-string": { + "stage": "test", + "script": ["true"], + "variables": { + "TEST_VAR": "String variable" + } + }, + "gitlab-ci-variables-integer": { + "stage": "test", + "script": ["true"], + "variables": { + "canonical": 685230 + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json new file mode 100644 index 00000000000..87a9ec05b57 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json @@ -0,0 +1,10 @@ +{ + "variables": { + "SOME_STR": "--batch-mode --errors --fail-at-end --show-version", + "SOME_INT": 10, + "SOME_USER_INPUT_FLAG": { + "value": "flag value", + "description": "Some Flag!" + } + } +} diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml new file mode 100644 index 00000000000..ee533f54d3b --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml @@ -0,0 +1,15 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 +stages: + - prepare + +# invalid cache:when value +job1: + stage: prepare + cache: + when: 0 + +# invalid cache:when value +job2: + stage: prepare + cache: + when: 'never' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml new file mode 100644 index 00000000000..287150a765f --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml @@ -0,0 +1,17 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 +stages: + - prepare + +# missing file property +childPipeline: + stage: prepare + trigger: + include: + - project: 'my-group/my-pipeline-library' + +# missing project property +childPipeline2: + stage: prepare + trigger: + include: + - file: '.gitlab-ci.yml' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml new file mode 100644 index 00000000000..436c7d72699 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml @@ -0,0 +1,25 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 +stages: + - prepare + +# test for cache:when values +job1: + stage: prepare + script: + - echo 'running job' + cache: + when: 'on_success' + +job2: + stage: prepare + script: + - echo 'running job' + cache: + when: 'on_failure' + +job3: + stage: prepare + script: + - echo 'running job' + cache: + when: 'always' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml new file mode 100644 index 00000000000..2b29c24fa3c --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml @@ -0,0 +1,18 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79335 +deploy-template: + script: + - echo "hello world" + only: + - foo + except: + - bar + +# null value allowed +deploy-without-only: + extends: deploy-template + only: + +# null value allowed +deploy-without-except: + extends: deploy-template + except: diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml new file mode 100644 index 00000000000..3497be28058 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml @@ -0,0 +1,32 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 + +# test for include:rules +include: + - local: builds.yml + rules: + - if: '$INCLUDE_BUILDS == "true"' + when: always + +stages: + - prepare + +# test for trigger:include +childPipeline: + stage: prepare + script: + - echo 'creating pipeline...' + trigger: + include: + - project: 'my-group/my-pipeline-library' + file: '.gitlab-ci.yml' + +# accepts optional ref property +childPipeline2: + stage: prepare + script: + - echo 'creating pipeline...' + trigger: + include: + - project: 'my-group/my-pipeline-library' + file: '.gitlab-ci.yml' + ref: 'main' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml new file mode 100644 index 00000000000..27a199cff13 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml @@ -0,0 +1,13 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74164 + +# test for workflow:rules:changes and workflow:rules:exists +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "schedule"' + exists: + - Dockerfile + changes: + - Dockerfile + variables: + IS_A_FEATURE: 'true' + when: always diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js index f0fb4d1027c..6bf87f7b07f 100644 --- a/spec/frontend/environments/deploy_board_component_spec.js +++ b/spec/frontend/environments/deploy_board_component_spec.js @@ -23,9 +23,9 @@ describe('Deploy Board', () => { }); describe('with valid data', () => { - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent(); - nextTick(done); + return nextTick(); }); it('should render percentage with completion value provided', () => { @@ -127,14 +127,14 @@ describe('Deploy Board', () => { }); describe('with empty state', () => { - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent({ deployBoardData: {}, isLoading: false, isEmpty: true, logsPath, }); - nextTick(done); + return nextTick(); }); it('should render the empty state', () => { @@ -146,14 +146,14 @@ describe('Deploy Board', () => { }); describe('with loading state', () => { - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent({ deployBoardData: {}, isLoading: true, isEmpty: false, logsPath, }); - nextTick(done); + return nextTick(); }); it('should render loading spinner', () => { @@ -163,7 +163,7 @@ describe('Deploy Board', () => { describe('has legend component', () => { let statuses = []; - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent({ isLoading: false, isEmpty: false, @@ -171,7 +171,7 @@ describe('Deploy Board', () => { deployBoardData: deployBoardMockData, }); ({ statuses } = wrapper.vm); - nextTick(done); + return nextTick(); }); it('with all the possible statuses', () => { diff --git a/spec/frontend/environments/empty_state_spec.js b/spec/frontend/environments/empty_state_spec.js new file mode 100644 index 00000000000..974afc6d032 --- /dev/null +++ b/spec/frontend/environments/empty_state_spec.js @@ -0,0 +1,53 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { s__ } from '~/locale'; +import EmptyState from '~/environments/components/empty_state.vue'; +import { ENVIRONMENTS_SCOPE } from '~/environments/constants'; + +const HELP_PATH = '/help'; + +describe('~/environments/components/empty_state.vue', () => { + let wrapper; + + const createWrapper = ({ propsData = {} } = {}) => + mountExtended(EmptyState, { + propsData: { + scope: ENVIRONMENTS_SCOPE.AVAILABLE, + helpPath: HELP_PATH, + ...propsData, + }, + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows an empty state for available environments', () => { + wrapper = createWrapper(); + + const title = wrapper.findByRole('heading', { + name: s__("Environments|You don't have any environments."), + }); + + expect(title.exists()).toBe(true); + }); + + it('shows an empty state for stopped environments', () => { + wrapper = createWrapper({ propsData: { scope: ENVIRONMENTS_SCOPE.STOPPED } }); + + const title = wrapper.findByRole('heading', { + name: s__("Environments|You don't have any stopped environments."), + }); + + expect(title.exists()).toBe(true); + }); + + it('shows a link to the the help path', () => { + wrapper = createWrapper(); + + const link = wrapper.findByRole('link', { + name: s__('Environments|How do I create an environment?'), + }); + + expect(link.attributes('href')).toBe(HELP_PATH); + }); +}); diff --git a/spec/frontend/environments/emtpy_state_spec.js b/spec/frontend/environments/emtpy_state_spec.js deleted file mode 100644 index 862d90e50dc..00000000000 --- a/spec/frontend/environments/emtpy_state_spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import EmptyState from '~/environments/components/empty_state.vue'; - -describe('environments empty state', () => { - let vm; - - beforeEach(() => { - vm = shallowMount(EmptyState, { - propsData: { - helpPath: 'bar', - }, - }); - }); - - afterEach(() => { - vm.destroy(); - }); - - it('renders the empty state', () => { - expect(vm.find('.js-blank-state-title').text()).toEqual( - "You don't have any environments right now", - ); - }); -}); diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index 0b36d2a940d..0761d04229c 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import { format } from 'timeago.js'; import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; @@ -44,10 +45,16 @@ describe('Environment item', () => { const findAutoStop = () => wrapper.find('.js-auto-stop'); const findUpcomingDeployment = () => wrapper.find('[data-testid="upcoming-deployment"]'); + const findLastDeployment = () => wrapper.find('[data-testid="environment-deployment-id-cell"]'); const findUpcomingDeploymentContent = () => wrapper.find('[data-testid="upcoming-deployment-content"]'); const findUpcomingDeploymentStatusLink = () => wrapper.find('[data-testid="upcoming-deployment-status-link"]'); + const findLastDeploymentAvatarLink = () => findLastDeployment().findComponent(GlAvatarLink); + const findLastDeploymentAvatar = () => findLastDeployment().findComponent(GlAvatar); + const findUpcomingDeploymentAvatarLink = () => + findUpcomingDeployment().findComponent(GlAvatarLink); + const findUpcomingDeploymentAvatar = () => findUpcomingDeployment().findComponent(GlAvatar); afterEach(() => { wrapper.destroy(); @@ -79,9 +86,19 @@ describe('Environment item', () => { describe('With user information', () => { it('should render user avatar with link to profile', () => { - expect(wrapper.find('.js-deploy-user-container').props('linkHref')).toEqual( - environment.last_deployment.user.web_url, - ); + const avatarLink = findLastDeploymentAvatarLink(); + const avatar = findLastDeploymentAvatar(); + const { username, avatar_url, web_url } = environment.last_deployment.user; + + expect(avatarLink.attributes('href')).toBe(web_url); + expect(avatar.props()).toMatchObject({ + src: avatar_url, + entityName: username, + }); + expect(avatar.attributes()).toMatchObject({ + title: username, + alt: `${username}'s avatar`, + }); }); }); @@ -108,9 +125,16 @@ describe('Environment item', () => { describe('When the envionment has an upcoming deployment', () => { describe('When the upcoming deployment has a deployable', () => { it('should render the build ID and user', () => { - expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText( - '#27 by upcoming-username', - ); + const avatarLink = findUpcomingDeploymentAvatarLink(); + const avatar = findUpcomingDeploymentAvatar(); + const { username, avatar_url, web_url } = environment.upcoming_deployment.user; + + expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText('#27 by'); + expect(avatarLink.attributes('href')).toBe(web_url); + expect(avatar.props()).toMatchObject({ + src: avatar_url, + entityName: username, + }); }); it('should render a status icon with a link and tooltip', () => { @@ -139,10 +163,17 @@ describe('Environment item', () => { }); }); - it('should still renders the build ID and user', () => { - expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText( - '#27 by upcoming-username', - ); + it('should still render the build ID and user avatar', () => { + const avatarLink = findUpcomingDeploymentAvatarLink(); + const avatar = findUpcomingDeploymentAvatar(); + const { username, avatar_url, web_url } = environment.upcoming_deployment.user; + + expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText('#27 by'); + expect(avatarLink.attributes('href')).toBe(web_url); + expect(avatar.props()).toMatchObject({ + src: avatar_url, + entityName: username, + }); }); it('should not render the status icon', () => { @@ -383,7 +414,7 @@ describe('Environment item', () => { }); it('should hide non-folder properties', () => { - expect(wrapper.find('[data-testid="environment-deployment-id-cell"]').exists()).toBe(false); + expect(findLastDeployment().exists()).toBe(false); expect(wrapper.find('[data-testid="environment-build-cell"]').exists()).toBe(false); }); }); diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js index c7582e4b06d..666e87c748e 100644 --- a/spec/frontend/environments/environment_table_spec.js +++ b/spec/frontend/environments/environment_table_spec.js @@ -122,7 +122,7 @@ describe('Environment table', () => { expect(wrapper.find('.deploy-board-icon').exists()).toBe(true); }); - it('should toggle deploy board visibility when arrow is clicked', (done) => { + it('should toggle deploy board visibility when arrow is clicked', async () => { const mockItem = { name: 'review', size: 1, @@ -142,7 +142,6 @@ describe('Environment table', () => { eventHub.$on('toggleDeployBoard', (env) => { expect(env.id).toEqual(mockItem.id); - done(); }); factory({ @@ -154,7 +153,7 @@ describe('Environment table', () => { }, }); - wrapper.find('.deploy-board-icon').trigger('click'); + await wrapper.find('.deploy-board-icon').trigger('click'); }); it('should set the environment to change and weight when a change canary weight event is recevied', async () => { diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index 1b7b35702de..7e436476a8f 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -543,6 +543,7 @@ export const resolvedEnvironment = { externalUrl: 'https://example.org', environmentType: 'review', nameWithoutType: 'hello', + tier: 'development', lastDeployment: { id: 78, iid: 24, @@ -551,6 +552,7 @@ export const resolvedEnvironment = { status: 'success', createdAt: '2022-01-07T15:47:27.415Z', deployedAt: '2022-01-07T15:47:32.450Z', + tierInYaml: 'staging', tag: false, isLast: true, user: { diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index 1d7a33fb95b..cf0c8a7e7ca 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -73,6 +73,34 @@ describe('~/environments/components/new_environment_item.vue', () => { expect(name.text()).toHaveLength(80); }); + describe('tier', () => { + it('displays the tier of the environment when defined in yaml', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const tier = wrapper.findByTitle(s__('Environment|Deployment tier')); + + expect(tier.text()).toBe(resolvedEnvironment.lastDeployment.tierInYaml); + }); + + it('does not display the tier if not defined in yaml', () => { + const environment = { + ...resolvedEnvironment, + lastDeployment: { + ...resolvedEnvironment.lastDeployment, + tierInYaml: null, + }, + }; + wrapper = createWrapper({ + propsData: { environment }, + apolloProvider: createApolloProvider(), + }); + + const tier = wrapper.findByTitle(s__('Environment|Deployment tier')); + + expect(tier.exists()).toBe(false); + }); + }); + describe('url', () => { it('shows a link for the url if one is present', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); diff --git a/spec/frontend/error_tracking/store/actions_spec.js b/spec/frontend/error_tracking/store/actions_spec.js index aaaa1194a29..6bac21341a7 100644 --- a/spec/frontend/error_tracking/store/actions_spec.js +++ b/spec/frontend/error_tracking/store/actions_spec.js @@ -28,9 +28,9 @@ describe('Sentry common store actions', () => { const params = { endpoint, redirectUrl, status }; describe('updateStatus', () => { - it('should handle successful status update', (done) => { + it('should handle successful status update', async () => { mock.onPut().reply(200, {}); - testAction( + await testAction( actions.updateStatus, params, {}, @@ -41,20 +41,15 @@ describe('Sentry common store actions', () => { }, ], [], - () => { - done(); - expect(visitUrl).toHaveBeenCalledWith(redirectUrl); - }, ); + expect(visitUrl).toHaveBeenCalledWith(redirectUrl); }); - it('should handle unsuccessful status update', (done) => { + it('should handle unsuccessful status update', async () => { mock.onPut().reply(400, {}); - testAction(actions.updateStatus, params, {}, [], [], () => { - expect(visitUrl).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledTimes(1); - done(); - }); + await testAction(actions.updateStatus, params, {}, [], []); + expect(visitUrl).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js index 623cb82851d..a3a6f7cc309 100644 --- a/spec/frontend/error_tracking/store/details/actions_spec.js +++ b/spec/frontend/error_tracking/store/details/actions_spec.js @@ -28,10 +28,10 @@ describe('Sentry error details store actions', () => { describe('startPollingStacktrace', () => { const endpoint = '123/stacktrace'; - it('should commit SET_ERROR with received response', (done) => { + it('should commit SET_ERROR with received response', () => { const payload = { error: [1, 2, 3] }; mockedAdapter.onGet().reply(200, payload); - testAction( + return testAction( actions.startPollingStacktrace, { endpoint }, {}, @@ -40,37 +40,29 @@ describe('Sentry error details store actions', () => { { type: types.SET_LOADING_STACKTRACE, payload: false }, ], [], - () => { - done(); - }, ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mockedAdapter.onGet().reply(400); - testAction( + await testAction( actions.startPollingStacktrace, { endpoint }, {}, [{ type: types.SET_LOADING_STACKTRACE, payload: false }], [], - () => { - expect(createFlash).toHaveBeenCalledTimes(1); - done(); - }, ); + expect(createFlash).toHaveBeenCalledTimes(1); }); - it('should not restart polling when receiving an empty 204 response', (done) => { + it('should not restart polling when receiving an empty 204 response', async () => { mockedRestart = jest.spyOn(Poll.prototype, 'restart'); mockedAdapter.onGet().reply(204); - testAction(actions.startPollingStacktrace, { endpoint }, {}, [], [], () => { - mockedRestart = jest.spyOn(Poll.prototype, 'restart'); - expect(mockedRestart).toHaveBeenCalledTimes(0); - done(); - }); + await testAction(actions.startPollingStacktrace, { endpoint }, {}, [], []); + mockedRestart = jest.spyOn(Poll.prototype, 'restart'); + expect(mockedRestart).toHaveBeenCalledTimes(0); }); }); }); diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js index 5465bde397c..7173f68bb96 100644 --- a/spec/frontend/error_tracking/store/list/actions_spec.js +++ b/spec/frontend/error_tracking/store/list/actions_spec.js @@ -20,11 +20,11 @@ describe('error tracking actions', () => { }); describe('startPolling', () => { - it('should start polling for data', (done) => { + it('should start polling for data', () => { const payload = { errors: [{ id: 1 }, { id: 2 }] }; mock.onGet().reply(httpStatusCodes.OK, payload); - testAction( + return testAction( actions.startPolling, {}, {}, @@ -35,16 +35,13 @@ describe('error tracking actions', () => { { type: types.SET_LOADING, payload: false }, ], [{ type: 'stopPolling' }], - () => { - done(); - }, ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mock.onGet().reply(httpStatusCodes.BAD_REQUEST); - testAction( + await testAction( actions.startPolling, {}, {}, @@ -53,11 +50,8 @@ describe('error tracking actions', () => { { type: types.SET_LOADING, payload: false }, ], [], - () => { - expect(createFlash).toHaveBeenCalledTimes(1); - done(); - }, ); + expect(createFlash).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js index 1b9be042dd4..bcd816c2ae0 100644 --- a/spec/frontend/error_tracking_settings/store/actions_spec.js +++ b/spec/frontend/error_tracking_settings/store/actions_spec.js @@ -27,9 +27,9 @@ describe('error tracking settings actions', () => { refreshCurrentPage.mockClear(); }); - it('should request and transform the project list', (done) => { + it('should request and transform the project list', async () => { mock.onGet(TEST_HOST).reply(() => [200, { projects: projectList }]); - testAction( + await testAction( actions.fetchProjects, null, state, @@ -41,16 +41,13 @@ describe('error tracking settings actions', () => { payload: projectList.map(convertObjectPropsToCamelCase), }, ], - () => { - expect(mock.history.get.length).toBe(1); - done(); - }, ); + expect(mock.history.get.length).toBe(1); }); - it('should handle a server error', (done) => { + it('should handle a server error', async () => { mock.onGet(`${TEST_HOST}.json`).reply(() => [400]); - testAction( + await testAction( actions.fetchProjects, null, state, @@ -61,27 +58,23 @@ describe('error tracking settings actions', () => { type: 'receiveProjectsError', }, ], - () => { - expect(mock.history.get.length).toBe(1); - done(); - }, ); + expect(mock.history.get.length).toBe(1); }); - it('should request projects correctly', (done) => { - testAction( + it('should request projects correctly', () => { + return testAction( actions.requestProjects, null, state, [{ type: types.SET_PROJECTS_LOADING, payload: true }, { type: types.RESET_CONNECT }], [], - done, ); }); - it('should receive projects correctly', (done) => { + it('should receive projects correctly', () => { const testPayload = []; - testAction( + return testAction( actions.receiveProjectsSuccess, testPayload, state, @@ -91,13 +84,12 @@ describe('error tracking settings actions', () => { { type: types.SET_PROJECTS_LOADING, payload: false }, ], [], - done, ); }); - it('should handle errors when receiving projects', (done) => { + it('should handle errors when receiving projects', () => { const testPayload = []; - testAction( + return testAction( actions.receiveProjectsError, testPayload, state, @@ -107,7 +99,6 @@ describe('error tracking settings actions', () => { { type: types.SET_PROJECTS_LOADING, payload: false }, ], [], - done, ); }); }); @@ -126,18 +117,16 @@ describe('error tracking settings actions', () => { mock.restore(); }); - it('should save the page', (done) => { + it('should save the page', async () => { mock.onPatch(TEST_HOST).reply(200); - testAction(actions.updateSettings, null, state, [], [{ type: 'requestSettings' }], () => { - expect(mock.history.patch.length).toBe(1); - expect(refreshCurrentPage).toHaveBeenCalled(); - done(); - }); + await testAction(actions.updateSettings, null, state, [], [{ type: 'requestSettings' }]); + expect(mock.history.patch.length).toBe(1); + expect(refreshCurrentPage).toHaveBeenCalled(); }); - it('should handle a server error', (done) => { + it('should handle a server error', async () => { mock.onPatch(TEST_HOST).reply(400); - testAction( + await testAction( actions.updateSettings, null, state, @@ -149,57 +138,50 @@ describe('error tracking settings actions', () => { payload: new Error('Request failed with status code 400'), }, ], - () => { - expect(mock.history.patch.length).toBe(1); - done(); - }, ); + expect(mock.history.patch.length).toBe(1); }); - it('should request to save the page', (done) => { - testAction( + it('should request to save the page', () => { + return testAction( actions.requestSettings, null, state, [{ type: types.UPDATE_SETTINGS_LOADING, payload: true }], [], - done, ); }); - it('should handle errors when requesting to save the page', (done) => { - testAction( + it('should handle errors when requesting to save the page', () => { + return testAction( actions.receiveSettingsError, {}, state, [{ type: types.UPDATE_SETTINGS_LOADING, payload: false }], [], - done, ); }); }); describe('generic actions to update the store', () => { const testData = 'test'; - it('should reset the `connect success` flag when updating the api host', (done) => { - testAction( + it('should reset the `connect success` flag when updating the api host', () => { + return testAction( actions.updateApiHost, testData, state, [{ type: types.UPDATE_API_HOST, payload: testData }, { type: types.RESET_CONNECT }], [], - done, ); }); - it('should reset the `connect success` flag when updating the token', (done) => { - testAction( + it('should reset the `connect success` flag when updating the token', () => { + return testAction( actions.updateToken, testData, state, [{ type: types.UPDATE_TOKEN, payload: testData }, { type: types.RESET_CONNECT }], [], - done, ); }); diff --git a/spec/frontend/feature_flags/store/edit/actions_spec.js b/spec/frontend/feature_flags/store/edit/actions_spec.js index 12fccd79170..b6114cb0c9f 100644 --- a/spec/frontend/feature_flags/store/edit/actions_spec.js +++ b/spec/frontend/feature_flags/store/edit/actions_spec.js @@ -40,7 +40,7 @@ describe('Feature flags Edit Module actions', () => { }); describe('success', () => { - it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', (done) => { + it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', () => { const featureFlag = { name: 'name', description: 'description', @@ -57,7 +57,7 @@ describe('Feature flags Edit Module actions', () => { }; mock.onPut(mockedState.endpoint, mapStrategiesToRails(featureFlag)).replyOnce(200); - testAction( + return testAction( updateFeatureFlag, featureFlag, mockedState, @@ -70,16 +70,15 @@ describe('Feature flags Edit Module actions', () => { type: 'receiveUpdateFeatureFlagSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', (done) => { + it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', () => { mock.onPut(`${TEST_HOST}/endpoint.json`).replyOnce(500, { message: [] }); - testAction( + return testAction( updateFeatureFlag, { name: 'feature_flag', @@ -97,28 +96,26 @@ describe('Feature flags Edit Module actions', () => { payload: { message: [] }, }, ], - done, ); }); }); }); describe('requestUpdateFeatureFlag', () => { - it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', (done) => { - testAction( + it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', () => { + return testAction( requestUpdateFeatureFlag, null, mockedState, [{ type: types.REQUEST_UPDATE_FEATURE_FLAG }], [], - done, ); }); }); describe('receiveUpdateFeatureFlagSuccess', () => { - it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', () => { + return testAction( receiveUpdateFeatureFlagSuccess, null, mockedState, @@ -128,20 +125,18 @@ describe('Feature flags Edit Module actions', () => { }, ], [], - done, ); }); }); describe('receiveUpdateFeatureFlagError', () => { - it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', () => { + return testAction( receiveUpdateFeatureFlagError, 'There was an error', mockedState, [{ type: types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }], [], - done, ); }); }); @@ -159,10 +154,10 @@ describe('Feature flags Edit Module actions', () => { }); describe('success', () => { - it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', (done) => { + it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 1 }); - testAction( + return testAction( fetchFeatureFlag, { id: 1 }, mockedState, @@ -176,16 +171,15 @@ describe('Feature flags Edit Module actions', () => { payload: { id: 1 }, }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', (done) => { + it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); - testAction( + return testAction( fetchFeatureFlag, null, mockedState, @@ -198,41 +192,38 @@ describe('Feature flags Edit Module actions', () => { type: 'receiveFeatureFlagError', }, ], - done, ); }); }); }); describe('requestFeatureFlag', () => { - it('should commit REQUEST_FEATURE_FLAG mutation', (done) => { - testAction( + it('should commit REQUEST_FEATURE_FLAG mutation', () => { + return testAction( requestFeatureFlag, null, mockedState, [{ type: types.REQUEST_FEATURE_FLAG }], [], - done, ); }); }); describe('receiveFeatureFlagSuccess', () => { - it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', () => { + return testAction( receiveFeatureFlagSuccess, { id: 1 }, mockedState, [{ type: types.RECEIVE_FEATURE_FLAG_SUCCESS, payload: { id: 1 } }], [], - done, ); }); }); describe('receiveFeatureFlagError', () => { - it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', () => { + return testAction( receiveFeatureFlagError, null, mockedState, @@ -242,20 +233,18 @@ describe('Feature flags Edit Module actions', () => { }, ], [], - done, ); }); }); describe('toggelActive', () => { - it('should commit TOGGLE_ACTIVE mutation', (done) => { - testAction( + it('should commit TOGGLE_ACTIVE mutation', () => { + return testAction( toggleActive, true, mockedState, [{ type: types.TOGGLE_ACTIVE, payload: true }], [], - done, ); }); }); diff --git a/spec/frontend/feature_flags/store/index/actions_spec.js b/spec/frontend/feature_flags/store/index/actions_spec.js index a59f99f538c..ce62c3b0473 100644 --- a/spec/frontend/feature_flags/store/index/actions_spec.js +++ b/spec/frontend/feature_flags/store/index/actions_spec.js @@ -32,14 +32,13 @@ describe('Feature flags actions', () => { }); describe('setFeatureFlagsOptions', () => { - it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', (done) => { - testAction( + it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', () => { + return testAction( setFeatureFlagsOptions, { page: '1', scope: 'all' }, mockedState, [{ type: types.SET_FEATURE_FLAGS_OPTIONS, payload: { page: '1', scope: 'all' } }], [], - done, ); }); }); @@ -57,10 +56,10 @@ describe('Feature flags actions', () => { }); describe('success', () => { - it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', (done) => { + it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, getRequestData, {}); - testAction( + return testAction( fetchFeatureFlags, null, mockedState, @@ -74,16 +73,15 @@ describe('Feature flags actions', () => { type: 'receiveFeatureFlagsSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', (done) => { + it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); - testAction( + return testAction( fetchFeatureFlags, null, mockedState, @@ -96,28 +94,26 @@ describe('Feature flags actions', () => { type: 'receiveFeatureFlagsError', }, ], - done, ); }); }); }); describe('requestFeatureFlags', () => { - it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', () => { + return testAction( requestFeatureFlags, null, mockedState, [{ type: types.REQUEST_FEATURE_FLAGS }], [], - done, ); }); }); describe('receiveFeatureFlagsSuccess', () => { - it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', () => { + return testAction( receiveFeatureFlagsSuccess, { data: getRequestData, headers: {} }, mockedState, @@ -128,20 +124,18 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); describe('receiveFeatureFlagsError', () => { - it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', () => { + return testAction( receiveFeatureFlagsError, null, mockedState, [{ type: types.RECEIVE_FEATURE_FLAGS_ERROR }], [], - done, ); }); }); @@ -159,10 +153,10 @@ describe('Feature flags actions', () => { }); describe('success', () => { - it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', (done) => { + it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', () => { mock.onPost(`${TEST_HOST}/endpoint.json`).replyOnce(200, rotateData, {}); - testAction( + return testAction( rotateInstanceId, null, mockedState, @@ -176,16 +170,15 @@ describe('Feature flags actions', () => { type: 'receiveRotateInstanceIdSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', (done) => { + it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); - testAction( + return testAction( rotateInstanceId, null, mockedState, @@ -198,28 +191,26 @@ describe('Feature flags actions', () => { type: 'receiveRotateInstanceIdError', }, ], - done, ); }); }); }); describe('requestRotateInstanceId', () => { - it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', (done) => { - testAction( + it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', () => { + return testAction( requestRotateInstanceId, null, mockedState, [{ type: types.REQUEST_ROTATE_INSTANCE_ID }], [], - done, ); }); }); describe('receiveRotateInstanceIdSuccess', () => { - it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', () => { + return testAction( receiveRotateInstanceIdSuccess, { data: rotateData, headers: {} }, mockedState, @@ -230,20 +221,18 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); describe('receiveRotateInstanceIdError', () => { - it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', () => { + return testAction( receiveRotateInstanceIdError, null, mockedState, [{ type: types.RECEIVE_ROTATE_INSTANCE_ID_ERROR }], [], - done, ); }); }); @@ -262,10 +251,10 @@ describe('Feature flags actions', () => { mock.restore(); }); describe('success', () => { - it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', (done) => { + it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', () => { mock.onPut(featureFlag.update_path).replyOnce(200, featureFlag, {}); - testAction( + return testAction( toggleFeatureFlag, featureFlag, mockedState, @@ -280,15 +269,15 @@ describe('Feature flags actions', () => { type: 'receiveUpdateFeatureFlagSuccess', }, ], - done, ); }); }); + describe('error', () => { - it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', (done) => { + it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', () => { mock.onPut(featureFlag.update_path).replyOnce(500); - testAction( + return testAction( toggleFeatureFlag, featureFlag, mockedState, @@ -303,7 +292,6 @@ describe('Feature flags actions', () => { type: 'receiveUpdateFeatureFlagError', }, ], - done, ); }); }); @@ -315,8 +303,8 @@ describe('Feature flags actions', () => { })); }); - it('commits UPDATE_FEATURE_FLAG with the given flag', (done) => { - testAction( + it('commits UPDATE_FEATURE_FLAG with the given flag', () => { + return testAction( updateFeatureFlag, featureFlag, mockedState, @@ -327,7 +315,6 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); @@ -338,8 +325,8 @@ describe('Feature flags actions', () => { })); }); - it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', (done) => { - testAction( + it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', () => { + return testAction( receiveUpdateFeatureFlagSuccess, featureFlag, mockedState, @@ -350,7 +337,6 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); @@ -361,8 +347,8 @@ describe('Feature flags actions', () => { })); }); - it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', (done) => { - testAction( + it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', () => { + return testAction( receiveUpdateFeatureFlagError, featureFlag.id, mockedState, @@ -373,22 +359,20 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); describe('clearAlert', () => { - it('should commit RECEIVE_CLEAR_ALERT', (done) => { + it('should commit RECEIVE_CLEAR_ALERT', () => { const alertIndex = 3; - testAction( + return testAction( clearAlert, alertIndex, mockedState, [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }], [], - done, ); }); }); diff --git a/spec/frontend/feature_flags/store/new/actions_spec.js b/spec/frontend/feature_flags/store/new/actions_spec.js index 7900b200eb2..1dcd2da1d93 100644 --- a/spec/frontend/feature_flags/store/new/actions_spec.js +++ b/spec/frontend/feature_flags/store/new/actions_spec.js @@ -33,7 +33,7 @@ describe('Feature flags New Module Actions', () => { }); describe('success', () => { - it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', (done) => { + it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', () => { const actionParams = { name: 'name', description: 'description', @@ -50,7 +50,7 @@ describe('Feature flags New Module Actions', () => { }; mock.onPost(mockedState.endpoint, mapStrategiesToRails(actionParams)).replyOnce(200); - testAction( + return testAction( createFeatureFlag, actionParams, mockedState, @@ -63,13 +63,12 @@ describe('Feature flags New Module Actions', () => { type: 'receiveCreateFeatureFlagSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', (done) => { + it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', () => { const actionParams = { name: 'name', description: 'description', @@ -88,7 +87,7 @@ describe('Feature flags New Module Actions', () => { .onPost(mockedState.endpoint, mapStrategiesToRails(actionParams)) .replyOnce(500, { message: [] }); - testAction( + return testAction( createFeatureFlag, actionParams, mockedState, @@ -102,28 +101,26 @@ describe('Feature flags New Module Actions', () => { payload: { message: [] }, }, ], - done, ); }); }); }); describe('requestCreateFeatureFlag', () => { - it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', (done) => { - testAction( + it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', () => { + return testAction( requestCreateFeatureFlag, null, mockedState, [{ type: types.REQUEST_CREATE_FEATURE_FLAG }], [], - done, ); }); }); describe('receiveCreateFeatureFlagSuccess', () => { - it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', () => { + return testAction( receiveCreateFeatureFlagSuccess, null, mockedState, @@ -133,20 +130,18 @@ describe('Feature flags New Module Actions', () => { }, ], [], - done, ); }); }); describe('receiveCreateFeatureFlagError', () => { - it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', () => { + return testAction( receiveCreateFeatureFlagError, 'There was an error', mockedState, [{ type: types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }], [], - done, ); }); }); diff --git a/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js index 88b3fc236e4..212b9ffc8f9 100644 --- a/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js @@ -38,35 +38,25 @@ describe('AjaxFilter', () => { dummyList.list.appendChild(dynamicList); }); - it('calls onLoadingFinished after loading data', (done) => { + it('calls onLoadingFinished after loading data', async () => { ajaxSpy = (url) => { expect(url).toBe('dummy endpoint?dummy search key='); return Promise.resolve(dummyData); }; - AjaxFilter.trigger() - .then(() => { - expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(1); - }) - .then(done) - .catch(done.fail); + await AjaxFilter.trigger(); + expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(1); }); - it('does not call onLoadingFinished if Ajax call fails', (done) => { + it('does not call onLoadingFinished if Ajax call fails', async () => { const dummyError = new Error('My dummy is sick! :-('); ajaxSpy = (url) => { expect(url).toBe('dummy endpoint?dummy search key='); return Promise.reject(dummyError); }; - AjaxFilter.trigger() - .then(done.fail) - .catch((error) => { - expect(error).toBe(dummyError); - expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(0); - }) - .then(done) - .catch(done.fail); + await expect(AjaxFilter.trigger()).rejects.toEqual(dummyError); + expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(0); }); }); }); diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index 83e7f6c9b3f..911a507af4c 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -190,43 +190,40 @@ describe('Filtered Search Manager', () => { const defaultParams = '?scope=all'; const defaultState = '&state=opened'; - it('should search with a single word', (done) => { + it('should search with a single word', () => { initializeManager(); input.value = 'searchTerm'; visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&search=searchTerm`); - done(); }); manager.search(); }); - it('sets default state', (done) => { + it('sets default state', () => { initializeManager({ useDefaultState: true }); input.value = 'searchTerm'; visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}${defaultState}&search=searchTerm`); - done(); }); manager.search(); }); - it('should search with multiple words', (done) => { + it('should search with multiple words', () => { initializeManager(); input.value = 'awesome search terms'; visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`); - done(); }); manager.search(); }); - it('should search with special characters', (done) => { + it('should search with special characters', () => { initializeManager(); input.value = '~!@#$%^&*()_+{}:<>,.?/'; @@ -234,13 +231,12 @@ describe('Filtered Search Manager', () => { expect(url).toEqual( `${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`, ); - done(); }); manager.search(); }); - it('should use replacement URL for condition', (done) => { + it('should use replacement URL for condition', () => { initializeManager(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', '13', true), @@ -248,7 +244,6 @@ describe('Filtered Search Manager', () => { visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&milestone_title=replaced`); - done(); }); manager.filteredSearchTokenKeys.conditions.push({ @@ -261,7 +256,7 @@ describe('Filtered Search Manager', () => { manager.search(); }); - it('removes duplicated tokens', (done) => { + it('removes duplicated tokens', () => { initializeManager(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} @@ -270,7 +265,6 @@ describe('Filtered Search Manager', () => { visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&label_name[]=bug`); - done(); }); manager.search(); 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 dfa53652eb1..426a60df427 100644 --- a/spec/frontend/filtered_search/services/recent_searches_service_spec.js +++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js @@ -18,53 +18,47 @@ describe('RecentSearchesService', () => { jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true); }); - it('should default to empty array', (done) => { + it('should default to empty array', () => { const fetchItemsPromise = service.fetch(); - fetchItemsPromise - .then((items) => { - expect(items).toEqual([]); - }) - .then(done) - .catch(done.fail); + return fetchItemsPromise.then((items) => { + expect(items).toEqual([]); + }); }); - it('should reject when unable to parse', (done) => { + it('should reject when unable to parse', () => { jest.spyOn(localStorage, 'getItem').mockReturnValue('fail'); const fetchItemsPromise = service.fetch(); - fetchItemsPromise - .then(done.fail) + return fetchItemsPromise + .then(() => { + throw new Error(); + }) .catch((error) => { expect(error).toEqual(expect.any(SyntaxError)); - }) - .then(done) - .catch(done.fail); + }); }); - it('should reject when service is unavailable', (done) => { + it('should reject when service is unavailable', () => { RecentSearchesService.isAvailable.mockReturnValue(false); - service + return service .fetch() - .then(done.fail) + .then(() => { + throw new Error(); + }) .catch((error) => { expect(error).toEqual(expect.any(Error)); - }) - .then(done) - .catch(done.fail); + }); }); - it('should return items from localStorage', (done) => { + it('should return items from localStorage', () => { jest.spyOn(localStorage, 'getItem').mockReturnValue('["foo", "bar"]'); const fetchItemsPromise = service.fetch(); - fetchItemsPromise - .then((items) => { - expect(items).toEqual(['foo', 'bar']); - }) - .then(done) - .catch(done.fail); + return fetchItemsPromise.then((items) => { + expect(items).toEqual(['foo', 'bar']); + }); }); describe('if .isAvailable returns `false`', () => { @@ -74,16 +68,16 @@ describe('RecentSearchesService', () => { jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {}); }); - it('should not call .getItem', (done) => { - RecentSearchesService.prototype + it('should not call .getItem', () => { + return RecentSearchesService.prototype .fetch() - .then(done.fail) + .then(() => { + throw new Error(); + }) .catch((err) => { expect(err).toEqual(new RecentSearchesServiceError()); expect(localStorage.getItem).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + }); }); }); }); diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index 8ac5b6fbea6..bf526a8d371 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -46,7 +46,7 @@ describe('Filtered Search Visual Tokens', () => { jest.spyOn(UsersCache, 'retrieve').mockImplementation((username) => usersCacheSpy(username)); }); - it('ignores error if UsersCache throws', (done) => { + it('ignores error if UsersCache throws', async () => { const dummyError = new Error('Earth rotated backwards'); const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); const tokenValue = tokenValueElement.innerText; @@ -55,16 +55,11 @@ describe('Filtered Search Visual Tokens', () => { return Promise.reject(dummyError); }; - subject - .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) - .then(() => { - expect(createFlash.mock.calls.length).toBe(0); - }) - .then(done) - .catch(done.fail); + await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); + expect(createFlash.mock.calls.length).toBe(0); }); - it('does nothing if user cannot be found', (done) => { + it('does nothing if user cannot be found', async () => { const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); const tokenValue = tokenValueElement.innerText; usersCacheSpy = (username) => { @@ -72,16 +67,11 @@ describe('Filtered Search Visual Tokens', () => { return Promise.resolve(undefined); }; - subject - .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) - .then(() => { - expect(tokenValueElement.innerText).toBe(tokenValue); - }) - .then(done) - .catch(done.fail); + await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); + expect(tokenValueElement.innerText).toBe(tokenValue); }); - it('replaces author token with avatar and display name', (done) => { + it('replaces author token with avatar and display name', async () => { const dummyUser = { name: 'Important Person', avatar_url: 'https://host.invalid/mypics/avatar.png', @@ -93,21 +83,16 @@ describe('Filtered Search Visual Tokens', () => { return Promise.resolve(dummyUser); }; - subject - .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) - .then(() => { - expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue); - expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); - const avatar = tokenValueElement.querySelector('img.avatar'); - - expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url); - expect(avatar.getAttribute('alt')).toBe(''); - }) - .then(done) - .catch(done.fail); + await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); + expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue); + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + const avatar = tokenValueElement.querySelector('img.avatar'); + + expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url); + expect(avatar.getAttribute('alt')).toBe(''); }); - it('escapes user name when creating token', (done) => { + it('escapes user name when creating token', async () => { const dummyUser = { name: '<script>', avatar_url: `${TEST_HOST}/mypics/avatar.png`, @@ -119,16 +104,11 @@ describe('Filtered Search Visual Tokens', () => { return Promise.resolve(dummyUser); }; - subject - .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) - .then(() => { - expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); - tokenValueElement.querySelector('.avatar').remove(); + await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + tokenValueElement.querySelector('.avatar').remove(); - expect(tokenValueElement.innerHTML.trim()).toBe(escape(dummyUser.name)); - }) - .then(done) - .catch(done.fail); + expect(tokenValueElement.innerHTML.trim()).toBe(escape(dummyUser.name)); }); }); @@ -177,48 +157,33 @@ describe('Filtered Search Visual Tokens', () => { const findLabel = (tokenValue) => labelData.find((label) => tokenValue === `~${DropdownUtils.getEscapedText(label.title)}`); - it('updates the color of a label token', (done) => { + it('updates the color of a label token', async () => { const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); const tokenValue = tokenValueElement.innerText; const matchingLabel = findLabel(tokenValue); - subject - .updateLabelTokenColor(tokenValueContainer, tokenValue) - .then(() => { - expectValueContainerStyle(tokenValueContainer, matchingLabel); - }) - .then(done) - .catch(done.fail); + await subject.updateLabelTokenColor(tokenValueContainer, tokenValue); + expectValueContainerStyle(tokenValueContainer, matchingLabel); }); - it('updates the color of a label token with spaces', (done) => { + it('updates the color of a label token with spaces', async () => { const { subject, tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken); const tokenValue = tokenValueElement.innerText; const matchingLabel = findLabel(tokenValue); - subject - .updateLabelTokenColor(tokenValueContainer, tokenValue) - .then(() => { - expectValueContainerStyle(tokenValueContainer, matchingLabel); - }) - .then(done) - .catch(done.fail); + await subject.updateLabelTokenColor(tokenValueContainer, tokenValue); + expectValueContainerStyle(tokenValueContainer, matchingLabel); }); - it('does not change color of a missing label', (done) => { + it('does not change color of a missing label', async () => { const { subject, tokenValueContainer, tokenValueElement } = findElements(missingLabelToken); const tokenValue = tokenValueElement.innerText; const matchingLabel = findLabel(tokenValue); expect(matchingLabel).toBe(undefined); - subject - .updateLabelTokenColor(tokenValueContainer, tokenValue) - .then(() => { - expect(tokenValueContainer.getAttribute('style')).toBe(null); - }) - .then(done) - .catch(done.fail); + await subject.updateLabelTokenColor(tokenValueContainer, tokenValue); + expect(tokenValueContainer.getAttribute('style')).toBe(null); }); }); diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb index e19a98c3bab..cf7383fa6ca 100644 --- a/spec/frontend/fixtures/startup_css.rb +++ b/spec/frontend/fixtures/startup_css.rb @@ -41,12 +41,12 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do expect(response).to be_successful end - # This Feature Flag is off by default + # This Feature Flag is on by default # This ensures that the correct css is generated - # When the feature flag is off, the general startup will capture it + # When the feature flag is on, 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) + it "startup_css/project-#{type}-search-ff-off.html" do + stub_feature_flags(new_header_search: false) get :show, params: { namespace_id: project.namespace.to_param, diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js index fb0321545c2..3fc3eaf52a2 100644 --- a/spec/frontend/frequent_items/store/actions_spec.js +++ b/spec/frontend/frequent_items/store/actions_spec.js @@ -29,136 +29,126 @@ describe('Frequent Items Dropdown Store Actions', () => { }); describe('setNamespace', () => { - it('should set namespace', (done) => { - testAction( + it('should set namespace', () => { + return testAction( actions.setNamespace, mockNamespace, mockedState, [{ type: types.SET_NAMESPACE, payload: mockNamespace }], [], - done, ); }); }); describe('setStorageKey', () => { - it('should set storage key', (done) => { - testAction( + it('should set storage key', () => { + return testAction( actions.setStorageKey, mockStorageKey, mockedState, [{ type: types.SET_STORAGE_KEY, payload: mockStorageKey }], [], - done, ); }); }); describe('requestFrequentItems', () => { - it('should request frequent items', (done) => { - testAction( + it('should request frequent items', () => { + return testAction( actions.requestFrequentItems, null, mockedState, [{ type: types.REQUEST_FREQUENT_ITEMS }], [], - done, ); }); }); describe('receiveFrequentItemsSuccess', () => { - it('should set frequent items', (done) => { - testAction( + it('should set frequent items', () => { + return testAction( actions.receiveFrequentItemsSuccess, mockFrequentProjects, mockedState, [{ type: types.RECEIVE_FREQUENT_ITEMS_SUCCESS, payload: mockFrequentProjects }], [], - done, ); }); }); describe('receiveFrequentItemsError', () => { - it('should set frequent items error state', (done) => { - testAction( + it('should set frequent items error state', () => { + return testAction( actions.receiveFrequentItemsError, null, mockedState, [{ type: types.RECEIVE_FREQUENT_ITEMS_ERROR }], [], - done, ); }); }); describe('fetchFrequentItems', () => { - it('should dispatch `receiveFrequentItemsSuccess`', (done) => { + it('should dispatch `receiveFrequentItemsSuccess`', () => { mockedState.namespace = mockNamespace; mockedState.storageKey = mockStorageKey; - testAction( + return testAction( actions.fetchFrequentItems, null, mockedState, [], [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsSuccess', payload: [] }], - done, ); }); - it('should dispatch `receiveFrequentItemsError`', (done) => { + it('should dispatch `receiveFrequentItemsError`', () => { jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); mockedState.namespace = mockNamespace; mockedState.storageKey = mockStorageKey; - testAction( + return testAction( actions.fetchFrequentItems, null, mockedState, [], [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsError' }], - done, ); }); }); describe('requestSearchedItems', () => { - it('should request searched items', (done) => { - testAction( + it('should request searched items', () => { + return testAction( actions.requestSearchedItems, null, mockedState, [{ type: types.REQUEST_SEARCHED_ITEMS }], [], - done, ); }); }); describe('receiveSearchedItemsSuccess', () => { - it('should set searched items', (done) => { - testAction( + it('should set searched items', () => { + return testAction( actions.receiveSearchedItemsSuccess, mockSearchedProjects, mockedState, [{ type: types.RECEIVE_SEARCHED_ITEMS_SUCCESS, payload: mockSearchedProjects }], [], - done, ); }); }); describe('receiveSearchedItemsError', () => { - it('should set searched items error state', (done) => { - testAction( + it('should set searched items error state', () => { + return testAction( actions.receiveSearchedItemsError, null, mockedState, [{ type: types.RECEIVE_SEARCHED_ITEMS_ERROR }], [], - done, ); }); }); @@ -168,10 +158,10 @@ describe('Frequent Items Dropdown Store Actions', () => { gon.api_version = 'v4'; }); - it('should dispatch `receiveSearchedItemsSuccess`', (done) => { + it('should dispatch `receiveSearchedItemsSuccess`', () => { mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects, {}); - testAction( + return testAction( actions.fetchSearchedItems, null, mockedState, @@ -183,45 +173,41 @@ describe('Frequent Items Dropdown Store Actions', () => { payload: { data: mockSearchedProjects, headers: {} }, }, ], - done, ); }); - it('should dispatch `receiveSearchedItemsError`', (done) => { + it('should dispatch `receiveSearchedItemsError`', () => { gon.api_version = 'v4'; mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(500); - testAction( + return testAction( actions.fetchSearchedItems, null, mockedState, [], [{ type: 'requestSearchedItems' }, { type: 'receiveSearchedItemsError' }], - done, ); }); }); describe('setSearchQuery', () => { - it('should commit query and dispatch `fetchSearchedItems` when query is present', (done) => { - testAction( + it('should commit query and dispatch `fetchSearchedItems` when query is present', () => { + return testAction( actions.setSearchQuery, { query: 'test' }, mockedState, [{ type: types.SET_SEARCH_QUERY, payload: { query: 'test' } }], [{ type: 'fetchSearchedItems', payload: { query: 'test' } }], - done, ); }); - it('should commit query and dispatch `fetchFrequentItems` when query is empty', (done) => { - testAction( + it('should commit query and dispatch `fetchFrequentItems` when query is empty', () => { + return testAction( actions.setSearchQuery, null, mockedState, [{ type: types.SET_SEARCH_QUERY, payload: null }], [{ type: 'fetchFrequentItems' }], - done, ); }); }); diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js index 50b05fb30e0..0cafe6d3b9d 100644 --- a/spec/frontend/google_cloud/components/app_spec.js +++ b/spec/frontend/google_cloud/components/app_spec.js @@ -8,7 +8,7 @@ import GcpError from '~/google_cloud/components/errors/gcp_error.vue'; import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue'; const BASE_FEEDBACK_URL = - 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/meta/-/issues/new'; + 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new'; const SCREEN_COMPONENTS = { Home, ServiceAccountsForm, diff --git a/spec/frontend/gpg_badges_spec.js b/spec/frontend/gpg_badges_spec.js index 44c70f1ad4d..0bb50fc3e6f 100644 --- a/spec/frontend/gpg_badges_spec.js +++ b/spec/frontend/gpg_badges_spec.js @@ -40,30 +40,22 @@ describe('GpgBadges', () => { mock.restore(); }); - it('does not make a request if there is no container element', (done) => { + it('does not make a request if there is no container element', async () => { setFixtures(''); jest.spyOn(axios, 'get').mockImplementation(() => {}); - GpgBadges.fetch() - .then(() => { - expect(axios.get).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await GpgBadges.fetch(); + expect(axios.get).not.toHaveBeenCalled(); }); - it('throws an error if the endpoint is missing', (done) => { + it('throws an error if the endpoint is missing', async () => { setFixtures('<div class="js-signature-container"></div>'); jest.spyOn(axios, 'get').mockImplementation(() => {}); - GpgBadges.fetch() - .then(() => done.fail('Expected error to be thrown')) - .catch((error) => { - expect(error.message).toBe('Missing commit signatures endpoint!'); - expect(axios.get).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await expect(GpgBadges.fetch()).rejects.toEqual( + new Error('Missing commit signatures endpoint!'), + ); + expect(axios.get).not.toHaveBeenCalled(); }); it('fetches commit signatures', async () => { @@ -104,31 +96,23 @@ describe('GpgBadges', () => { }); }); - it('displays a loading spinner', (done) => { + it('displays a loading spinner', async () => { mock.onGet(dummyUrl).replyOnce(200); - GpgBadges.fetch() - .then(() => { - expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null); - const spinners = document.querySelectorAll('.js-loading-gpg-badge span.gl-spinner'); + await GpgBadges.fetch(); + expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null); + const spinners = document.querySelectorAll('.js-loading-gpg-badge span.gl-spinner'); - expect(spinners.length).toBe(1); - done(); - }) - .catch(done.fail); + expect(spinners.length).toBe(1); }); - it('replaces the loading spinner', (done) => { + it('replaces the loading spinner', async () => { mock.onGet(dummyUrl).replyOnce(200, dummyResponse); - GpgBadges.fetch() - .then(() => { - expect(document.querySelector('.js-loading-gpg-badge')).toBe(null); - const parentContainer = document.querySelector('.parent-container'); + await GpgBadges.fetch(); + expect(document.querySelector('.js-loading-gpg-badge')).toBe(null); + const parentContainer = document.querySelector('.parent-container'); - expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml); - done(); - }) - .catch(done.fail); + expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml); }); }); diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js index 9310943841e..f3652f1a410 100644 --- a/spec/frontend/groups/components/item_type_icon_spec.js +++ b/spec/frontend/groups/components/item_type_icon_spec.js @@ -8,7 +8,6 @@ describe('ItemTypeIcon', () => { const defaultProps = { itemType: ITEM_TYPE.GROUP, - isGroupOpen: false, }; const createComponent = (props = {}) => { @@ -34,20 +33,14 @@ describe('ItemTypeIcon', () => { }); it.each` - type | isGroupOpen | icon - ${ITEM_TYPE.GROUP} | ${true} | ${'folder-open'} - ${ITEM_TYPE.GROUP} | ${false} | ${'folder-o'} - ${ITEM_TYPE.PROJECT} | ${true} | ${'bookmark'} - ${ITEM_TYPE.PROJECT} | ${false} | ${'bookmark'} - `( - 'shows "$icon" icon when `itemType` is "$type" and `isGroupOpen` is $isGroupOpen', - ({ type, isGroupOpen, icon }) => { - createComponent({ - itemType: type, - isGroupOpen, - }); - expect(findGlIcon().props('name')).toBe(icon); - }, - ); + type | icon + ${ITEM_TYPE.GROUP} | ${'subgroup'} + ${ITEM_TYPE.PROJECT} | ${'project'} + `('shows "$icon" icon when `itemType` is "$type"', ({ type, icon }) => { + createComponent({ + itemType: type, + }); + expect(findGlIcon().props('name')).toBe(icon); + }); }); }); diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js index dcbeeeffb2d..f0de5b083ae 100644 --- a/spec/frontend/header_search/components/app_spec.js +++ b/spec/frontend/header_search/components/app_spec.js @@ -16,6 +16,7 @@ import { MOCK_USERNAME, MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_SORTED_AUTOCOMPLETE_OPTIONS, } from '../mock_data'; Vue.use(Vuex); @@ -108,6 +109,11 @@ describe('HeaderSearchApp', () => { search | showDefault | showScoped | showAutocomplete | showDropdownNavigation ${null} | ${true} | ${false} | ${false} | ${true} ${''} | ${true} | ${false} | ${false} | ${true} + ${'1'} | ${false} | ${false} | ${false} | ${false} + ${')'} | ${false} | ${false} | ${false} | ${false} + ${'t'} | ${false} | ${false} | ${true} | ${true} + ${'te'} | ${false} | ${true} | ${true} | ${true} + ${'tes'} | ${false} | ${true} | ${true} | ${true} ${MOCK_SEARCH} | ${false} | ${true} | ${true} | ${true} `( 'Header Search Dropdown Items', @@ -115,7 +121,13 @@ describe('HeaderSearchApp', () => { describe(`when search is ${search}`, () => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; - createComponent({ search }); + createComponent( + { search }, + { + autocompleteGroupedSearchOptions: () => + search.match(/^[A-Za-z]+$/g) ? MOCK_SORTED_AUTOCOMPLETE_OPTIONS : [], + }, + ); findHeaderSearchInput().vm.$emit('click'); }); diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js index f427482be46..7952661e2d2 100644 --- a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js +++ b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert } from '@gitlab/ui'; +import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert, GlDropdownDivider } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; @@ -8,8 +8,18 @@ import { LARGE_AVATAR_PX, PROJECTS_CATEGORY, SMALL_AVATAR_PX, + ISSUES_CATEGORY, + MERGE_REQUEST_CATEGORY, + RECENT_EPICS_CATEGORY, } from '~/header_search/constants'; -import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS } from '../mock_data'; +import { + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + MOCK_SORTED_AUTOCOMPLETE_OPTIONS, + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP, + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP, + MOCK_SEARCH, + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2, +} from '../mock_data'; Vue.use(Vuex); @@ -41,8 +51,14 @@ describe('HeaderSearchAutocompleteItems', () => { }); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findGlDropdownDividers = () => wrapper.findAllComponents(GlDropdownDivider); const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text()); + const findDropdownItemTitles = () => + findDropdownItems().wrappers.map((w) => w.findAll('span').at(1).text()); + const findDropdownItemSubTitles = () => + findDropdownItems() + .wrappers.filter((w) => w.findAll('span').length > 2) + .map((w) => w.findAll('span').at(2).text()); const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findGlAvatar = () => wrapper.findComponent(GlAvatar); @@ -87,10 +103,17 @@ describe('HeaderSearchAutocompleteItems', () => { }); it('renders titles correctly', () => { - const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.label); + const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.label); expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); }); + it('renders sub-titles correctly', () => { + const expectedSubTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.filter((o) => o.value).map( + (o) => o.label, + ); + expect(findDropdownItemSubTitles()).toStrictEqual(expectedSubTitles); + }); + it('renders links correctly', () => { const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.url); expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); @@ -98,15 +121,30 @@ describe('HeaderSearchAutocompleteItems', () => { }); describe.each` - item | showAvatar | avatarSize - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} - ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} - ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} - `('GlAvatar', ({ item, showAvatar, avatarSize }) => { + item | showAvatar | avatarSize | searchContext | entityId | entityName + ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 29 } }} | ${'29'} | ${''} + ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 12 } }} | ${'12'} | ${''} + ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'0'} | ${''} + ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} | ${null} | ${false} | ${false} + ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'1'} | ${'test1'} + ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'2'} | ${'test2'} + ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'3'} | ${'test3'} + ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'4'} | ${'test4'} + ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'5'} | ${'test5'} + ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 6, group_name: 'test6' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'6'} | ${'test6'} + ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 7, project_name: 'test7' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'7'} | ${'test7'} + ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 8, project_name: 'test8' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'8'} | ${'test8'} + ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 9, project_name: 'test9' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'9'} | ${'test9'} + ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 10, group_name: 'test10' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'10'} | ${'test10'} + ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 11, group_name: 'test11' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'11'} | ${'test11'} + ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 12, project_name: 'test12' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'12'} | ${'test12'} + ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 13, project_name: 'test13' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'13'} | ${'test13'} + ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 14, project_name: 'test14' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'14'} | ${'test14'} + ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 15, group_name: 'test15' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'15'} | ${'test15'} + `('GlAvatar', ({ item, showAvatar, avatarSize, searchContext, entityId, entityName }) => { describe(`when category is ${item.data[0].category} and avatar_url is ${item.data[0].avatar_url}`, () => { beforeEach(() => { - createComponent({}, { autocompleteGroupedSearchOptions: () => [item] }); + createComponent({ searchContext }, { autocompleteGroupedSearchOptions: () => [item] }); }); it(`should${showAvatar ? '' : ' not'} render`, () => { @@ -116,6 +154,16 @@ describe('HeaderSearchAutocompleteItems', () => { it(`should set avatarSize to ${avatarSize}`, () => { expect(findGlAvatar().exists() && findGlAvatar().attributes('size')).toBe(avatarSize); }); + + it(`should set avatar entityId to ${entityId}`, () => { + expect(findGlAvatar().exists() && findGlAvatar().attributes('entityid')).toBe(entityId); + }); + + it(`should set avatar entityName to ${entityName}`, () => { + expect(findGlAvatar().exists() && findGlAvatar().attributes('entityname')).toBe( + entityName, + ); + }); }); }); }); @@ -140,6 +188,34 @@ describe('HeaderSearchAutocompleteItems', () => { }); }); }); + + describe.each` + search | items | dividerCount + ${null} | ${[]} | ${0} + ${''} | ${[]} | ${0} + ${'1'} | ${[]} | ${0} + ${')'} | ${[]} | ${0} + ${'t'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP} | ${1} + ${'te'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP} | ${0} + ${'tes'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} + ${MOCK_SEARCH} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} + `('Header Search Dropdown Dividers', ({ search, items, dividerCount }) => { + describe(`when search is ${search}`, () => { + beforeEach(() => { + createComponent( + { search }, + { + autocompleteGroupedSearchOptions: () => items, + }, + {}, + ); + }); + + it(`component should have ${dividerCount} dividers`, () => { + expect(findGlDropdownDividers()).toHaveLength(dividerCount); + }); + }); + }); }); describe('watchers', () => { 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 index a65b4d8b813..8788fb23458 100644 --- a/spec/frontend/header_search/components/header_search_scoped_items_spec.js +++ b/spec/frontend/header_search/components/header_search_scoped_items_spec.js @@ -1,17 +1,21 @@ -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem, GlDropdownDivider } 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'; +import { + MOCK_SEARCH, + MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, +} from '../mock_data'; Vue.use(Vuex); describe('HeaderSearchScopedItems', () => { let wrapper; - const createComponent = (initialState, props) => { + const createComponent = (initialState, mockGetters, props) => { const store = new Vuex.Store({ state: { search: MOCK_SEARCH, @@ -19,6 +23,8 @@ describe('HeaderSearchScopedItems', () => { }, getters: { scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, + autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + ...mockGetters, }, }); @@ -35,6 +41,7 @@ describe('HeaderSearchScopedItems', () => { }); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findGlDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); const findFirstDropdownItem = () => findDropdownItems().at(0); const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text())); const findDropdownItemAriaLabels = () => @@ -79,7 +86,7 @@ describe('HeaderSearchScopedItems', () => { `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { beforeEach(() => { - createComponent({}, { currentFocusedOption }); + createComponent({}, {}, { currentFocusedOption }); }); it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { @@ -91,5 +98,21 @@ describe('HeaderSearchScopedItems', () => { }); }); }); + + describe.each` + autosuggestResults | showDivider + ${[]} | ${false} + ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${true} + `('scoped search items', ({ autosuggestResults, showDivider }) => { + describe(`when when we have ${autosuggestResults.length} auto-sugest results`, () => { + beforeEach(() => { + createComponent({}, { autocompleteGroupedSearchOptions: () => autosuggestResults }, {}); + }); + + it(`divider should${showDivider ? '' : ' not'} be shown`, () => { + expect(findGlDropdownDivider().exists()).toBe(showDivider); + }); + }); + }); }); }); diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js index 1d980679547..b6f0fdcc29d 100644 --- a/spec/frontend/header_search/mock_data.js +++ b/spec/frontend/header_search/mock_data.js @@ -96,19 +96,22 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ { category: 'Projects', id: 1, - label: 'MockProject1', + label: 'Gitlab Org / MockProject1', + value: 'MockProject1', url: 'project/1', }, { category: 'Groups', id: 1, - label: 'MockGroup1', + label: 'Gitlab Org / MockGroup1', + value: 'MockGroup1', url: 'group/1', }, { category: 'Projects', id: 2, - label: 'MockProject2', + label: 'Gitlab Org / MockProject2', + value: 'MockProject2', url: 'project/2', }, { @@ -123,21 +126,24 @@ export const MOCK_AUTOCOMPLETE_OPTIONS = [ category: 'Projects', html_id: 'autocomplete-Projects-0', id: 1, - label: 'MockProject1', + label: 'Gitlab Org / MockProject1', + value: 'MockProject1', url: 'project/1', }, { category: 'Groups', html_id: 'autocomplete-Groups-1', id: 1, - label: 'MockGroup1', + label: 'Gitlab Org / MockGroup1', + value: 'MockGroup1', url: 'group/1', }, { category: 'Projects', html_id: 'autocomplete-Projects-2', id: 2, - label: 'MockProject2', + label: 'Gitlab Org / MockProject2', + value: 'MockProject2', url: 'project/2', }, { @@ -157,7 +163,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ html_id: 'autocomplete-Projects-0', id: 1, - label: 'MockProject1', + label: 'Gitlab Org / MockProject1', + value: 'MockProject1', url: 'project/1', }, { @@ -165,7 +172,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ html_id: 'autocomplete-Projects-2', id: 2, - label: 'MockProject2', + label: 'Gitlab Org / MockProject2', + value: 'MockProject2', url: 'project/2', }, ], @@ -178,7 +186,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ html_id: 'autocomplete-Groups-1', id: 1, - label: 'MockGroup1', + label: 'Gitlab Org / MockGroup1', + value: 'MockGroup1', url: 'group/1', }, ], @@ -202,21 +211,24 @@ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ category: 'Projects', html_id: 'autocomplete-Projects-0', id: 1, - label: 'MockProject1', + label: 'Gitlab Org / MockProject1', + value: 'MockProject1', url: 'project/1', }, { category: 'Projects', html_id: 'autocomplete-Projects-2', id: 2, - label: 'MockProject2', + label: 'Gitlab Org / MockProject2', + value: 'MockProject2', url: 'project/2', }, { category: 'Groups', html_id: 'autocomplete-Groups-1', id: 1, - label: 'MockGroup1', + label: 'Gitlab Org / MockGroup1', + value: 'MockGroup1', url: 'group/1', }, { @@ -226,3 +238,98 @@ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ url: 'help/gitlab', }, ]; + +export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP = [ + { + category: 'Help', + data: [ + { + html_id: 'autocomplete-Help-1', + category: 'Help', + label: 'Rake Tasks Help', + url: '/help/raketasks/index', + }, + { + html_id: 'autocomplete-Help-2', + category: 'Help', + label: 'System Hooks Help', + url: '/help/system_hooks/system_hooks', + }, + ], + }, +]; + +export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP = [ + { + category: 'Settings', + data: [ + { + html_id: 'autocomplete-Settings-0', + category: 'Settings', + label: 'User settings', + url: '/-/profile', + }, + { + html_id: 'autocomplete-Settings-3', + category: 'Settings', + label: 'Admin Section', + url: '/admin', + }, + ], + }, + { + category: 'Help', + data: [ + { + html_id: 'autocomplete-Help-1', + category: 'Help', + label: 'Rake Tasks Help', + url: '/help/raketasks/index', + }, + { + html_id: 'autocomplete-Help-2', + category: 'Help', + label: 'System Hooks Help', + url: '/help/system_hooks/system_hooks', + }, + ], + }, +]; + +export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2 = [ + { + category: 'Groups', + data: [ + { + html_id: 'autocomplete-Groups-0', + category: 'Groups', + id: 148, + label: 'Jashkenas / Test Subgroup / test-subgroup', + url: '/jashkenas/test-subgroup/test-subgroup', + avatar_url: '', + }, + { + html_id: 'autocomplete-Groups-1', + category: 'Groups', + id: 147, + label: 'Jashkenas / Test Subgroup', + url: '/jashkenas/test-subgroup', + avatar_url: '', + }, + ], + }, + { + category: 'Projects', + data: [ + { + html_id: 'autocomplete-Projects-2', + category: 'Projects', + id: 1, + value: 'Gitlab Test', + label: 'Gitlab Org / Gitlab Test', + url: '/gitlab-org/gitlab-test', + avatar_url: '/uploads/-/system/project/avatar/1/icons8-gitlab-512.png', + }, + ], + }, +]; diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 0d43accb7e5..937bc9aa478 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -60,7 +60,6 @@ describe('Header', () => { setFixtures(` <li class="js-nav-user-dropdown"> <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); @@ -81,14 +80,5 @@ describe('Header', () => { property: 'user_dropdown', }); }); - - it('sends a tracking event when the dropdown is opened and contains Upgrade link', () => { - $('.js-nav-user-dropdown').trigger('shown.bs.dropdown'); - - expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_upgrade_link', { - label: 'free', - property: 'user_dropdown', - }); - }); }); }); diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index d3b2923ac6c..28f62a9775a 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -120,7 +120,7 @@ describe('IDE commit form', () => { it('renders commit button in compact mode', () => { expect(findBeginCommitButton().exists()).toBe(true); - expect(findBeginCommitButton().text()).toBe('Commit…'); + expect(findBeginCommitButton().text()).toBe('Create commit...'); }); it('does not render form', () => { diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js index 34f14ef23a4..ace8988b8c9 100644 --- a/spec/frontend/ide/components/ide_side_bar_spec.js +++ b/spec/frontend/ide/components/ide_side_bar_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js index 7303f81aad0..5a7419d6dce 100644 --- a/spec/frontend/ide/components/new_dropdown/upload_spec.js +++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js @@ -69,25 +69,21 @@ describe('new dropdown upload', () => { jest.spyOn(FileReader.prototype, 'readAsText'); }); - it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', (done) => { + it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', async () => { const waitForCreate = new Promise((resolve) => vm.$on('create', resolve)); vm.createFile(textTarget, textFile); expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile); - waitForCreate - .then(() => { - expect(vm.$emit).toHaveBeenCalledWith('create', { - name: textFile.name, - type: 'blob', - content: 'plain text', - rawPath: '', - mimeType: 'test/mime-text', - }); - }) - .then(done) - .catch(done.fail); + await waitForCreate; + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: textFile.name, + type: 'blob', + content: 'plain text', + rawPath: '', + mimeType: 'test/mime-text', + }); }); it('creates a blob URL for the content if binary', () => { diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js index e62811a4517..5592e2664c4 100644 --- a/spec/frontend/ide/stores/actions/merge_request_spec.js +++ b/spec/frontend/ide/stores/actions/merge_request_spec.js @@ -63,56 +63,47 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).reply(200, mockData); }); - it('calls getProjectMergeRequests service method', (done) => { - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) - .then(() => { - expect(service.getProjectMergeRequests).toHaveBeenCalledWith(TEST_PROJECT, { - source_branch: 'bar', - source_project_id: TEST_PROJECT_ID, - state: 'opened', - order_by: 'created_at', - per_page: 1, - }); - - done(); - }) - .catch(done.fail); + it('calls getProjectMergeRequests service method', async () => { + await store.dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'bar', + }); + expect(service.getProjectMergeRequests).toHaveBeenCalledWith(TEST_PROJECT, { + source_branch: 'bar', + source_project_id: TEST_PROJECT_ID, + state: 'opened', + order_by: 'created_at', + per_page: 1, + }); }); - it('sets the "Merge Request" Object', (done) => { - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) - .then(() => { - expect(store.state.projects.abcproject.mergeRequests).toEqual({ - 2: expect.objectContaining(mrData), - }); - done(); - }) - .catch(done.fail); + it('sets the "Merge Request" Object', async () => { + await store.dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'bar', + }); + expect(store.state.projects.abcproject.mergeRequests).toEqual({ + 2: expect.objectContaining(mrData), + }); }); - it('sets "Current Merge Request" object to the most recent MR', (done) => { - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) - .then(() => { - expect(store.state.currentMergeRequestId).toEqual('2'); - done(); - }) - .catch(done.fail); + it('sets "Current Merge Request" object to the most recent MR', async () => { + await store.dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'bar', + }); + expect(store.state.currentMergeRequestId).toEqual('2'); }); - it('does nothing if user cannot read MRs', (done) => { + it('does nothing if user cannot read MRs', async () => { store.state.projects[TEST_PROJECT].userPermissions[PERMISSION_READ_MR] = false; - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) - .then(() => { - expect(service.getProjectMergeRequests).not.toHaveBeenCalled(); - expect(store.state.currentMergeRequestId).toBe(''); - }) - .then(done) - .catch(done.fail); + await store.dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'bar', + }); + expect(service.getProjectMergeRequests).not.toHaveBeenCalled(); + expect(store.state.currentMergeRequestId).toBe(''); }); }); @@ -122,15 +113,13 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).reply(200, []); }); - it('does not fail if there are no merge requests for current branch', (done) => { - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'foo' }) - .then(() => { - expect(store.state.projects[TEST_PROJECT].mergeRequests).toEqual({}); - expect(store.state.currentMergeRequestId).toEqual(''); - done(); - }) - .catch(done.fail); + it('does not fail if there are no merge requests for current branch', async () => { + await store.dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'foo', + }); + expect(store.state.projects[TEST_PROJECT].mergeRequests).toEqual({}); + expect(store.state.currentMergeRequestId).toEqual(''); }); }); }); @@ -140,17 +129,18 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).networkError(); }); - it('flashes message, if error', (done) => { - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) + it('flashes message, if error', () => { + return store + .dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'bar', + }) .catch(() => { expect(createFlash).toHaveBeenCalled(); expect(createFlash.mock.calls[0][0].message).toBe( 'Error fetching merge requests for bar', ); - }) - .then(done) - .catch(done.fail); + }); }); }); }); @@ -165,29 +155,15 @@ describe('IDE store merge request actions', () => { .reply(200, { title: 'mergerequest' }); }); - it('calls getProjectMergeRequestData service method', (done) => { - store - .dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(service.getProjectMergeRequestData).toHaveBeenCalledWith(TEST_PROJECT, 1); - - done(); - }) - .catch(done.fail); + it('calls getProjectMergeRequestData service method', async () => { + await store.dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 }); + expect(service.getProjectMergeRequestData).toHaveBeenCalledWith(TEST_PROJECT, 1); }); - it('sets the Merge Request Object', (done) => { - store - .dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(store.state.currentMergeRequestId).toBe(1); - expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].title).toBe( - 'mergerequest', - ); - - done(); - }) - .catch(done.fail); + it('sets the Merge Request Object', async () => { + await store.dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 }); + expect(store.state.currentMergeRequestId).toBe(1); + expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].title).toBe('mergerequest'); }); }); @@ -196,32 +172,28 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1/).networkError(); }); - it('dispatches error action', (done) => { + it('dispatches error action', () => { const dispatch = jest.fn(); - getMergeRequestData( + return getMergeRequestData( { commit() {}, dispatch, state: store.state, }, { projectId: TEST_PROJECT, mergeRequestId: 1 }, - ) - .then(done.fail) - .catch(() => { - expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { - text: 'An error occurred while loading the merge request.', - action: expect.any(Function), - actionText: 'Please try again', - actionPayload: { - projectId: TEST_PROJECT, - mergeRequestId: 1, - force: false, - }, - }); - - done(); + ).catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading the merge request.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: TEST_PROJECT, + mergeRequestId: 1, + force: false, + }, }); + }); }); }); }); @@ -240,27 +212,22 @@ describe('IDE store merge request actions', () => { .reply(200, { title: 'mergerequest' }); }); - it('calls getProjectMergeRequestChanges service method', (done) => { - store - .dispatch('getMergeRequestChanges', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(service.getProjectMergeRequestChanges).toHaveBeenCalledWith(TEST_PROJECT, 1); - - done(); - }) - .catch(done.fail); + it('calls getProjectMergeRequestChanges service method', async () => { + await store.dispatch('getMergeRequestChanges', { + projectId: TEST_PROJECT, + mergeRequestId: 1, + }); + expect(service.getProjectMergeRequestChanges).toHaveBeenCalledWith(TEST_PROJECT, 1); }); - it('sets the Merge Request Changes Object', (done) => { - store - .dispatch('getMergeRequestChanges', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].changes.title).toBe( - 'mergerequest', - ); - done(); - }) - .catch(done.fail); + it('sets the Merge Request Changes Object', async () => { + await store.dispatch('getMergeRequestChanges', { + projectId: TEST_PROJECT, + mergeRequestId: 1, + }); + expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].changes.title).toBe( + 'mergerequest', + ); }); }); @@ -269,32 +236,30 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/changes/).networkError(); }); - it('dispatches error action', (done) => { + it('dispatches error action', async () => { const dispatch = jest.fn(); - getMergeRequestChanges( - { - commit() {}, - dispatch, - state: store.state, + await expect( + getMergeRequestChanges( + { + commit() {}, + dispatch, + state: store.state, + }, + { projectId: TEST_PROJECT, mergeRequestId: 1 }, + ), + ).rejects.toEqual(new Error('Merge request changes not loaded abcproject')); + + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading the merge request changes.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: TEST_PROJECT, + mergeRequestId: 1, + force: false, }, - { projectId: TEST_PROJECT, mergeRequestId: 1 }, - ) - .then(done.fail) - .catch(() => { - expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { - text: 'An error occurred while loading the merge request changes.', - action: expect.any(Function), - actionText: 'Please try again', - actionPayload: { - projectId: TEST_PROJECT, - mergeRequestId: 1, - force: false, - }, - }); - - done(); - }); + }); }); }); }); @@ -312,25 +277,20 @@ describe('IDE store merge request actions', () => { jest.spyOn(service, 'getProjectMergeRequestVersions'); }); - it('calls getProjectMergeRequestVersions service method', (done) => { - store - .dispatch('getMergeRequestVersions', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(service.getProjectMergeRequestVersions).toHaveBeenCalledWith(TEST_PROJECT, 1); - - done(); - }) - .catch(done.fail); + it('calls getProjectMergeRequestVersions service method', async () => { + await store.dispatch('getMergeRequestVersions', { + projectId: TEST_PROJECT, + mergeRequestId: 1, + }); + expect(service.getProjectMergeRequestVersions).toHaveBeenCalledWith(TEST_PROJECT, 1); }); - it('sets the Merge Request Versions Object', (done) => { - store - .dispatch('getMergeRequestVersions', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].versions.length).toBe(1); - done(); - }) - .catch(done.fail); + it('sets the Merge Request Versions Object', async () => { + await store.dispatch('getMergeRequestVersions', { + projectId: TEST_PROJECT, + mergeRequestId: 1, + }); + expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].versions.length).toBe(1); }); }); @@ -339,32 +299,28 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/versions/).networkError(); }); - it('dispatches error action', (done) => { + it('dispatches error action', () => { const dispatch = jest.fn(); - getMergeRequestVersions( + return getMergeRequestVersions( { commit() {}, dispatch, state: store.state, }, { projectId: TEST_PROJECT, mergeRequestId: 1 }, - ) - .then(done.fail) - .catch(() => { - expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { - text: 'An error occurred while loading the merge request version data.', - action: expect.any(Function), - actionText: 'Please try again', - actionPayload: { - projectId: TEST_PROJECT, - mergeRequestId: 1, - force: false, - }, - }); - - done(); + ).catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading the merge request version data.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: TEST_PROJECT, + mergeRequestId: 1, + force: false, + }, }); + }); }); }); }); @@ -503,37 +459,36 @@ describe('IDE store merge request actions', () => { ); }); - it('dispatches actions for merge request data', (done) => { - openMergeRequest({ state: store.state, dispatch: store.dispatch, getters: mockGetters }, mr) - .then(() => { - expect(store.dispatch.mock.calls).toEqual([ - ['getMergeRequestData', mr], - ['setCurrentBranchId', testMergeRequest.source_branch], - [ - 'getBranchData', - { - projectId: mr.projectId, - branchId: testMergeRequest.source_branch, - }, - ], - [ - 'getFiles', - { - projectId: mr.projectId, - branchId: testMergeRequest.source_branch, - ref: 'abcd2322', - }, - ], - ['getMergeRequestVersions', mr], - ['getMergeRequestChanges', mr], - ['openMergeRequestChanges', testMergeRequestChanges.changes], - ]); - }) - .then(done) - .catch(done.fail); + it('dispatches actions for merge request data', async () => { + await openMergeRequest( + { state: store.state, dispatch: store.dispatch, getters: mockGetters }, + mr, + ); + expect(store.dispatch.mock.calls).toEqual([ + ['getMergeRequestData', mr], + ['setCurrentBranchId', testMergeRequest.source_branch], + [ + 'getBranchData', + { + projectId: mr.projectId, + branchId: testMergeRequest.source_branch, + }, + ], + [ + 'getFiles', + { + projectId: mr.projectId, + branchId: testMergeRequest.source_branch, + ref: 'abcd2322', + }, + ], + ['getMergeRequestVersions', mr], + ['getMergeRequestChanges', mr], + ['openMergeRequestChanges', testMergeRequestChanges.changes], + ]); }); - it('updates activity bar view and gets file data, if changes are found', (done) => { + it('updates activity bar view and gets file data, if changes are found', async () => { store.state.entries.foo = { type: 'blob', path: 'foo', @@ -548,28 +503,24 @@ describe('IDE store merge request actions', () => { { new_path: 'bar', path: 'bar' }, ]; - openMergeRequest({ state: store.state, dispatch: store.dispatch, getters: mockGetters }, mr) - .then(() => { - expect(store.dispatch).toHaveBeenCalledWith( - 'openMergeRequestChanges', - testMergeRequestChanges.changes, - ); - }) - .then(done) - .catch(done.fail); + await openMergeRequest( + { state: store.state, dispatch: store.dispatch, getters: mockGetters }, + mr, + ); + expect(store.dispatch).toHaveBeenCalledWith( + 'openMergeRequestChanges', + testMergeRequestChanges.changes, + ); }); - it('flashes message, if error', (done) => { + it('flashes message, if error', () => { store.dispatch.mockRejectedValue(); - openMergeRequest(store, mr) - .catch(() => { - expect(createFlash).toHaveBeenCalledWith({ - message: expect.any(String), - }); - }) - .then(done) - .catch(done.fail); + return openMergeRequest(store, mr).catch(() => { + expect(createFlash).toHaveBeenCalledWith({ + message: expect.any(String), + }); + }); }); }); }); diff --git a/spec/frontend/ide/stores/actions/project_spec.js b/spec/frontend/ide/stores/actions/project_spec.js index e07dcf22860..cc7d39b4d43 100644 --- a/spec/frontend/ide/stores/actions/project_spec.js +++ b/spec/frontend/ide/stores/actions/project_spec.js @@ -146,22 +146,16 @@ describe('IDE store project actions', () => { }); }); - it('calls the service', (done) => { - store - .dispatch('refreshLastCommitData', { - projectId: store.state.currentProjectId, - branchId: store.state.currentBranchId, - }) - .then(() => { - expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'main'); - - done(); - }) - .catch(done.fail); + it('calls the service', async () => { + await store.dispatch('refreshLastCommitData', { + projectId: store.state.currentProjectId, + branchId: store.state.currentBranchId, + }); + expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'main'); }); - it('commits getBranchData', (done) => { - testAction( + it('commits getBranchData', () => { + return testAction( refreshLastCommitData, { projectId: store.state.currentProjectId, @@ -181,14 +175,13 @@ describe('IDE store project actions', () => { ], // action [], - done, ); }); }); describe('showBranchNotFoundError', () => { - it('dispatches setErrorMessage', (done) => { - testAction( + it('dispatches setErrorMessage', () => { + return testAction( showBranchNotFoundError, 'main', null, @@ -204,7 +197,6 @@ describe('IDE store project actions', () => { }, }, ], - done, ); }); }); @@ -216,8 +208,8 @@ describe('IDE store project actions', () => { jest.spyOn(api, 'createBranch').mockResolvedValue(); }); - it('calls API', (done) => { - createNewBranchFromDefault( + it('calls API', async () => { + await createNewBranchFromDefault( { state: { currentProjectId: 'project-path', @@ -230,21 +222,17 @@ describe('IDE store project actions', () => { dispatch() {}, }, 'new-branch-name', - ) - .then(() => { - expect(api.createBranch).toHaveBeenCalledWith('project-path', { - ref: 'main', - branch: 'new-branch-name', - }); - }) - .then(done) - .catch(done.fail); + ); + expect(api.createBranch).toHaveBeenCalledWith('project-path', { + ref: 'main', + branch: 'new-branch-name', + }); }); - it('clears error message', (done) => { + it('clears error message', async () => { const dispatchSpy = jest.fn().mockName('dispatch'); - createNewBranchFromDefault( + await createNewBranchFromDefault( { state: { currentProjectId: 'project-path', @@ -257,16 +245,12 @@ describe('IDE store project actions', () => { dispatch: dispatchSpy, }, 'new-branch-name', - ) - .then(() => { - expect(dispatchSpy).toHaveBeenCalledWith('setErrorMessage', null); - }) - .then(done) - .catch(done.fail); + ); + expect(dispatchSpy).toHaveBeenCalledWith('setErrorMessage', null); }); - it('reloads window', (done) => { - createNewBranchFromDefault( + it('reloads window', async () => { + await createNewBranchFromDefault( { state: { currentProjectId: 'project-path', @@ -279,18 +263,14 @@ describe('IDE store project actions', () => { dispatch() {}, }, 'new-branch-name', - ) - .then(() => { - expect(window.location.reload).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + ); + expect(window.location.reload).toHaveBeenCalled(); }); }); describe('loadEmptyBranch', () => { - it('creates a blank tree and sets loading state to false', (done) => { - testAction( + it('creates a blank tree and sets loading state to false', () => { + return testAction( loadEmptyBranch, { projectId: TEST_PROJECT_ID, branchId: 'main' }, store.state, @@ -302,20 +282,18 @@ describe('IDE store project actions', () => { }, ], expect.any(Object), - done, ); }); - it('does nothing, if tree already exists', (done) => { + it('does nothing, if tree already exists', () => { const trees = { [`${TEST_PROJECT_ID}/main`]: [] }; - testAction( + return testAction( loadEmptyBranch, { projectId: TEST_PROJECT_ID, branchId: 'main' }, { trees }, [], [], - done, ); }); }); @@ -372,56 +350,48 @@ describe('IDE store project actions', () => { const branchId = '123-lorem'; const ref = 'abcd2322'; - it('when empty repo, loads empty branch', (done) => { + it('when empty repo, loads empty branch', () => { const mockGetters = { emptyRepo: true }; - testAction( + return testAction( loadBranch, { projectId, branchId }, { ...store.state, ...mockGetters }, [], [{ type: 'loadEmptyBranch', payload: { projectId, branchId } }], - done, ); }); - it('when branch already exists, does nothing', (done) => { + it('when branch already exists, does nothing', () => { store.state.projects[projectId].branches[branchId] = {}; - testAction(loadBranch, { projectId, branchId }, store.state, [], [], done); + return testAction(loadBranch, { projectId, branchId }, store.state, [], []); }); - it('fetches branch data', (done) => { + it('fetches branch data', async () => { const mockGetters = { findBranch: () => ({ commit: { id: ref } }) }; jest.spyOn(store, 'dispatch').mockResolvedValue(); - loadBranch( + await loadBranch( { getters: mockGetters, state: store.state, dispatch: store.dispatch }, { projectId, branchId }, - ) - .then(() => { - expect(store.dispatch.mock.calls).toEqual([ - ['getBranchData', { projectId, branchId }], - ['getMergeRequestsForBranch', { projectId, branchId }], - ['getFiles', { projectId, branchId, ref }], - ]); - }) - .then(done) - .catch(done.fail); + ); + expect(store.dispatch.mock.calls).toEqual([ + ['getBranchData', { projectId, branchId }], + ['getMergeRequestsForBranch', { projectId, branchId }], + ['getFiles', { projectId, branchId, ref }], + ]); }); - it('shows an error if branch can not be fetched', (done) => { + it('shows an error if branch can not be fetched', async () => { jest.spyOn(store, 'dispatch').mockReturnValue(Promise.reject()); - loadBranch(store, { projectId, branchId }) - .then(done.fail) - .catch(() => { - expect(store.dispatch.mock.calls).toEqual([ - ['getBranchData', { projectId, branchId }], - ['showBranchNotFoundError', branchId], - ]); - done(); - }); + await expect(loadBranch(store, { projectId, branchId })).rejects.toBeUndefined(); + + expect(store.dispatch.mock.calls).toEqual([ + ['getBranchData', { projectId, branchId }], + ['showBranchNotFoundError', branchId], + ]); }); }); @@ -449,17 +419,13 @@ describe('IDE store project actions', () => { jest.spyOn(store, 'dispatch').mockResolvedValue(); }); - it('dispatches branch actions', (done) => { - openBranch(store, branch) - .then(() => { - expect(store.dispatch.mock.calls).toEqual([ - ['setCurrentBranchId', branchId], - ['loadBranch', { projectId, branchId }], - ['loadFile', { basePath: undefined }], - ]); - }) - .then(done) - .catch(done.fail); + it('dispatches branch actions', async () => { + await openBranch(store, branch); + expect(store.dispatch.mock.calls).toEqual([ + ['setCurrentBranchId', branchId], + ['loadBranch', { projectId, branchId }], + ['loadFile', { basePath: undefined }], + ]); }); }); @@ -468,22 +434,18 @@ describe('IDE store project actions', () => { jest.spyOn(store, 'dispatch').mockReturnValue(Promise.reject()); }); - it('dispatches correct branch actions', (done) => { - openBranch(store, branch) - .then((val) => { - expect(store.dispatch.mock.calls).toEqual([ - ['setCurrentBranchId', branchId], - ['loadBranch', { projectId, branchId }], - ]); - - expect(val).toEqual( - new Error( - `An error occurred while getting files for - <strong>${projectId}/${branchId}</strong>`, - ), - ); - }) - .then(done) - .catch(done.fail); + it('dispatches correct branch actions', async () => { + const val = await openBranch(store, branch); + expect(store.dispatch.mock.calls).toEqual([ + ['setCurrentBranchId', branchId], + ['loadBranch', { projectId, branchId }], + ]); + + expect(val).toEqual( + new Error( + `An error occurred while getting files for - <strong>${projectId}/${branchId}</strong>`, + ), + ); }); }); }); diff --git a/spec/frontend/ide/stores/actions/tree_spec.js b/spec/frontend/ide/stores/actions/tree_spec.js index 8d7328725e9..fc44cbb21ae 100644 --- a/spec/frontend/ide/stores/actions/tree_spec.js +++ b/spec/frontend/ide/stores/actions/tree_spec.js @@ -62,27 +62,21 @@ describe('Multi-file store tree actions', () => { }); }); - it('adds data into tree', (done) => { - store - .dispatch('getFiles', basicCallParameters) - .then(() => { - projectTree = store.state.trees['abcproject/main']; - - expect(projectTree.tree.length).toBe(2); - expect(projectTree.tree[0].type).toBe('tree'); - expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); - expect(projectTree.tree[1].type).toBe('blob'); - expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); - expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); - - done(); - }) - .catch(done.fail); + it('adds data into tree', async () => { + await store.dispatch('getFiles', basicCallParameters); + projectTree = store.state.trees['abcproject/main']; + + expect(projectTree.tree.length).toBe(2); + expect(projectTree.tree[0].type).toBe('tree'); + expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); + expect(projectTree.tree[1].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); }); }); describe('error', () => { - it('dispatches error action', (done) => { + it('dispatches error action', async () => { const dispatch = jest.fn(); store.state.projects = { @@ -103,28 +97,26 @@ describe('Multi-file store tree actions', () => { mock.onGet(/(.*)/).replyOnce(500); - getFiles( - { - commit() {}, - dispatch, - state: store.state, - getters, - }, - { - projectId: 'abc/def', - branchId: 'main-testing', - }, - ) - .then(done.fail) - .catch(() => { - expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { - text: 'An error occurred while loading all the files.', - action: expect.any(Function), - actionText: 'Please try again', - actionPayload: { projectId: 'abc/def', branchId: 'main-testing' }, - }); - done(); - }); + await expect( + getFiles( + { + commit() {}, + dispatch, + state: store.state, + getters, + }, + { + projectId: 'abc/def', + branchId: 'main-testing', + }, + ), + ).rejects.toEqual(new Error('Request failed with status code 500')); + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading all the files.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { projectId: 'abc/def', branchId: 'main-testing' }, + }); }); }); }); @@ -137,15 +129,9 @@ describe('Multi-file store tree actions', () => { store.state.entries[tree.path] = tree; }); - it('toggles the tree open', (done) => { - store - .dispatch('toggleTreeOpen', tree.path) - .then(() => { - expect(tree.opened).toBeTruthy(); - - done(); - }) - .catch(done.fail); + it('toggles the tree open', async () => { + await store.dispatch('toggleTreeOpen', tree.path); + expect(tree.opened).toBeTruthy(); }); }); @@ -163,24 +149,23 @@ describe('Multi-file store tree actions', () => { Object.assign(store.state.entries, createEntriesFromPaths(paths)); }); - it('opens the parents', (done) => { - testAction( + it('opens the parents', () => { + return testAction( showTreeEntry, 'grandparent/parent/child.txt', store.state, [{ type: types.SET_TREE_OPEN, payload: 'grandparent/parent' }], [{ type: 'showTreeEntry', payload: 'grandparent/parent' }], - done, ); }); }); describe('setDirectoryData', () => { - it('sets tree correctly if there are no opened files yet', (done) => { + it('sets tree correctly if there are no opened files yet', () => { const treeFile = file({ name: 'README.md' }); store.state.trees['abcproject/main'] = {}; - testAction( + return testAction( setDirectoryData, { projectId: 'abcproject', branchId: 'main', treeList: [treeFile] }, store.state, @@ -201,7 +186,6 @@ describe('Multi-file store tree actions', () => { }, ], [], - done, ); }); }); diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js index be43c618095..3889c4f11c3 100644 --- a/spec/frontend/ide/stores/actions_spec.js +++ b/spec/frontend/ide/stores/actions_spec.js @@ -43,15 +43,9 @@ describe('Multi-file store actions', () => { }); describe('redirectToUrl', () => { - it('calls visitUrl', (done) => { - store - .dispatch('redirectToUrl', 'test') - .then(() => { - expect(visitUrl).toHaveBeenCalledWith('test'); - - done(); - }) - .catch(done.fail); + it('calls visitUrl', async () => { + await store.dispatch('redirectToUrl', 'test'); + expect(visitUrl).toHaveBeenCalledWith('test'); }); }); @@ -89,15 +83,10 @@ describe('Multi-file store actions', () => { expect(store.dispatch.mock.calls).toEqual(expect.arrayContaining(expectedCalls)); }); - it('removes all files from changedFiles state', (done) => { - store - .dispatch('discardAllChanges') - .then(() => { - expect(store.state.changedFiles.length).toBe(0); - expect(store.state.openFiles.length).toBe(2); - }) - .then(done) - .catch(done.fail); + it('removes all files from changedFiles state', async () => { + await store.dispatch('discardAllChanges'); + expect(store.state.changedFiles.length).toBe(0); + expect(store.state.openFiles.length).toBe(2); }); }); @@ -121,24 +110,18 @@ describe('Multi-file store actions', () => { }); describe('tree', () => { - it('creates temp tree', (done) => { - store - .dispatch('createTempEntry', { - name: 'test', - type: 'tree', - }) - .then(() => { - const entry = store.state.entries.test; - - expect(entry).not.toBeNull(); - expect(entry.type).toBe('tree'); + it('creates temp tree', async () => { + await store.dispatch('createTempEntry', { + name: 'test', + type: 'tree', + }); + const entry = store.state.entries.test; - done(); - }) - .catch(done.fail); + expect(entry).not.toBeNull(); + expect(entry.type).toBe('tree'); }); - it('creates new folder inside another tree', (done) => { + it('creates new folder inside another tree', async () => { const tree = { type: 'tree', name: 'testing', @@ -148,22 +131,16 @@ describe('Multi-file store actions', () => { store.state.entries[tree.path] = tree; - store - .dispatch('createTempEntry', { - name: 'testing/test', - type: 'tree', - }) - .then(() => { - expect(tree.tree[0].tempFile).toBeTruthy(); - expect(tree.tree[0].name).toBe('test'); - expect(tree.tree[0].type).toBe('tree'); - - done(); - }) - .catch(done.fail); + await store.dispatch('createTempEntry', { + name: 'testing/test', + type: 'tree', + }); + expect(tree.tree[0].tempFile).toBeTruthy(); + expect(tree.tree[0].name).toBe('test'); + expect(tree.tree[0].type).toBe('tree'); }); - it('does not create new tree if already exists', (done) => { + it('does not create new tree if already exists', async () => { const tree = { type: 'tree', path: 'testing', @@ -173,76 +150,52 @@ describe('Multi-file store actions', () => { store.state.entries[tree.path] = tree; - store - .dispatch('createTempEntry', { - name: 'testing', - type: 'tree', - }) - .then(() => { - expect(store.state.entries[tree.path].tempFile).toEqual(false); - expect(document.querySelector('.flash-alert')).not.toBeNull(); - - done(); - }) - .catch(done.fail); + await store.dispatch('createTempEntry', { + name: 'testing', + type: 'tree', + }); + expect(store.state.entries[tree.path].tempFile).toEqual(false); + expect(document.querySelector('.flash-alert')).not.toBeNull(); }); }); describe('blob', () => { - it('creates temp file', (done) => { + it('creates temp file', async () => { const name = 'test'; - store - .dispatch('createTempEntry', { - name, - type: 'blob', - mimeType: 'test/mime', - }) - .then(() => { - const f = store.state.entries[name]; - - expect(f.tempFile).toBeTruthy(); - expect(f.mimeType).toBe('test/mime'); - expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1); - - done(); - }) - .catch(done.fail); + await store.dispatch('createTempEntry', { + name, + type: 'blob', + mimeType: 'test/mime', + }); + const f = store.state.entries[name]; + + expect(f.tempFile).toBeTruthy(); + expect(f.mimeType).toBe('test/mime'); + expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1); }); - it('adds tmp file to open files', (done) => { + it('adds tmp file to open files', async () => { const name = 'test'; - store - .dispatch('createTempEntry', { - name, - type: 'blob', - }) - .then(() => { - const f = store.state.entries[name]; - - expect(store.state.openFiles.length).toBe(1); - expect(store.state.openFiles[0].name).toBe(f.name); + await store.dispatch('createTempEntry', { + name, + type: 'blob', + }); + const f = store.state.entries[name]; - done(); - }) - .catch(done.fail); + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(f.name); }); - it('adds tmp file to staged files', (done) => { + it('adds tmp file to staged files', async () => { const name = 'test'; - store - .dispatch('createTempEntry', { - name, - type: 'blob', - }) - .then(() => { - expect(store.state.stagedFiles).toEqual([expect.objectContaining({ name })]); - - done(); - }) - .catch(done.fail); + await store.dispatch('createTempEntry', { + name, + type: 'blob', + }); + expect(store.state.stagedFiles).toEqual([expect.objectContaining({ name })]); }); it('sets tmp file as active', () => { @@ -251,24 +204,18 @@ describe('Multi-file store actions', () => { expect(store.dispatch).toHaveBeenCalledWith('setFileActive', 'test'); }); - it('creates flash message if file already exists', (done) => { + it('creates flash message if file already exists', async () => { const f = file('test', '1', 'blob'); store.state.trees['abcproject/mybranch'].tree = [f]; store.state.entries[f.path] = f; - store - .dispatch('createTempEntry', { - name: 'test', - type: 'blob', - }) - .then(() => { - expect(document.querySelector('.flash-alert')?.textContent.trim()).toEqual( - `The name "${f.name}" is already taken in this directory.`, - ); - - done(); - }) - .catch(done.fail); + await store.dispatch('createTempEntry', { + name: 'test', + type: 'blob', + }); + expect(document.querySelector('.flash-alert')?.textContent.trim()).toEqual( + `The name "${f.name}" is already taken in this directory.`, + ); }); }); }); @@ -372,45 +319,38 @@ describe('Multi-file store actions', () => { }); describe('updateViewer', () => { - it('updates viewer state', (done) => { - store - .dispatch('updateViewer', 'diff') - .then(() => { - expect(store.state.viewer).toBe('diff'); - }) - .then(done) - .catch(done.fail); + it('updates viewer state', async () => { + await store.dispatch('updateViewer', 'diff'); + expect(store.state.viewer).toBe('diff'); }); }); describe('updateActivityBarView', () => { - it('commits UPDATE_ACTIVITY_BAR_VIEW', (done) => { - testAction( + it('commits UPDATE_ACTIVITY_BAR_VIEW', () => { + return testAction( updateActivityBarView, 'test', {}, [{ type: 'UPDATE_ACTIVITY_BAR_VIEW', payload: 'test' }], [], - done, ); }); }); describe('setEmptyStateSvgs', () => { - it('commits setEmptyStateSvgs', (done) => { - testAction( + it('commits setEmptyStateSvgs', () => { + return testAction( setEmptyStateSvgs, 'svg', {}, [{ type: 'SET_EMPTY_STATE_SVGS', payload: 'svg' }], [], - done, ); }); }); describe('updateTempFlagForEntry', () => { - it('commits UPDATE_TEMP_FLAG', (done) => { + it('commits UPDATE_TEMP_FLAG', () => { const f = { ...file(), path: 'test', @@ -418,17 +358,16 @@ describe('Multi-file store actions', () => { }; store.state.entries[f.path] = f; - testAction( + return testAction( updateTempFlagForEntry, { file: f, tempFile: false }, store.state, [{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }], [], - done, ); }); - it('commits UPDATE_TEMP_FLAG and dispatches for parent', (done) => { + it('commits UPDATE_TEMP_FLAG and dispatches for parent', () => { const parent = { ...file(), path: 'testing', @@ -441,17 +380,16 @@ describe('Multi-file store actions', () => { store.state.entries[parent.path] = parent; store.state.entries[f.path] = f; - testAction( + return testAction( updateTempFlagForEntry, { file: f, tempFile: false }, store.state, [{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }], [{ type: 'updateTempFlagForEntry', payload: { file: parent, tempFile: false } }], - done, ); }); - it('does not dispatch for parent, if parent does not exist', (done) => { + it('does not dispatch for parent, if parent does not exist', () => { const f = { ...file(), path: 'test', @@ -459,71 +397,66 @@ describe('Multi-file store actions', () => { }; store.state.entries[f.path] = f; - testAction( + return testAction( updateTempFlagForEntry, { file: f, tempFile: false }, store.state, [{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }], [], - done, ); }); }); describe('setCurrentBranchId', () => { - it('commits setCurrentBranchId', (done) => { - testAction( + it('commits setCurrentBranchId', () => { + return testAction( setCurrentBranchId, 'branchId', {}, [{ type: 'SET_CURRENT_BRANCH', payload: 'branchId' }], [], - done, ); }); }); describe('toggleFileFinder', () => { - it('commits TOGGLE_FILE_FINDER', (done) => { - testAction( + it('commits TOGGLE_FILE_FINDER', () => { + return testAction( toggleFileFinder, true, null, [{ type: 'TOGGLE_FILE_FINDER', payload: true }], [], - done, ); }); }); describe('setErrorMessage', () => { - it('commis error messsage', (done) => { - testAction( + it('commis error messsage', () => { + return testAction( setErrorMessage, 'error', null, [{ type: types.SET_ERROR_MESSAGE, payload: 'error' }], [], - done, ); }); }); describe('deleteEntry', () => { - it('commits entry deletion', (done) => { + it('commits entry deletion', () => { store.state.entries.path = 'testing'; - testAction( + return testAction( deleteEntry, 'path', store.state, [{ type: types.DELETE_ENTRY, payload: 'path' }], [{ type: 'stageChange', payload: 'path' }, createTriggerChangeAction()], - done, ); }); - it('does not delete a folder after it is emptied', (done) => { + it('does not delete a folder after it is emptied', () => { const testFolder = { type: 'tree', tree: [], @@ -540,7 +473,7 @@ describe('Multi-file store actions', () => { 'testFolder/entry-to-delete': testEntry, }; - testAction( + return testAction( deleteEntry, 'testFolder/entry-to-delete', store.state, @@ -549,7 +482,6 @@ describe('Multi-file store actions', () => { { type: 'stageChange', payload: 'testFolder/entry-to-delete' }, createTriggerChangeAction(), ], - done, ); }); @@ -569,8 +501,8 @@ describe('Multi-file store actions', () => { }); describe('and previous does not exist', () => { - it('reverts the rename before deleting', (done) => { - testAction( + it('reverts the rename before deleting', () => { + return testAction( deleteEntry, testEntry.path, store.state, @@ -589,7 +521,6 @@ describe('Multi-file store actions', () => { payload: testEntry.prevPath, }, ], - done, ); }); }); @@ -604,21 +535,20 @@ describe('Multi-file store actions', () => { store.state.entries[oldEntry.path] = oldEntry; }); - it('does not revert rename before deleting', (done) => { - testAction( + it('does not revert rename before deleting', () => { + return testAction( deleteEntry, testEntry.path, store.state, [{ type: types.DELETE_ENTRY, payload: testEntry.path }], [{ type: 'stageChange', payload: testEntry.path }, createTriggerChangeAction()], - done, ); }); - it('when previous is deleted, it reverts rename before deleting', (done) => { + it('when previous is deleted, it reverts rename before deleting', () => { store.state.entries[testEntry.prevPath].deleted = true; - testAction( + return testAction( deleteEntry, testEntry.path, store.state, @@ -637,7 +567,6 @@ describe('Multi-file store actions', () => { payload: testEntry.prevPath, }, ], - done, ); }); }); @@ -650,7 +579,7 @@ describe('Multi-file store actions', () => { jest.spyOn(eventHub, '$emit').mockImplementation(); }); - it('does not purge model cache for temporary entries that got renamed', (done) => { + it('does not purge model cache for temporary entries that got renamed', async () => { Object.assign(store.state.entries, { test: { ...file('test'), @@ -660,19 +589,14 @@ describe('Multi-file store actions', () => { }, }); - store - .dispatch('renameEntry', { - path: 'test', - name: 'new', - }) - .then(() => { - expect(eventHub.$emit.mock.calls).not.toContain('editor.update.model.dispose.foo-bar'); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: 'test', + name: 'new', + }); + expect(eventHub.$emit.mock.calls).not.toContain('editor.update.model.dispose.foo-bar'); }); - it('purges model cache for renamed entry', (done) => { + it('purges model cache for renamed entry', async () => { Object.assign(store.state.entries, { test: { ...file('test'), @@ -682,17 +606,12 @@ describe('Multi-file store actions', () => { }, }); - store - .dispatch('renameEntry', { - path: 'test', - name: 'new', - }) - .then(() => { - expect(eventHub.$emit).toHaveBeenCalled(); - expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.foo-key`); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: 'test', + name: 'new', + }); + expect(eventHub.$emit).toHaveBeenCalled(); + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.foo-key`); }); }); @@ -731,8 +650,8 @@ describe('Multi-file store actions', () => { ]); }); - it('if not changed, completely unstages and discards entry if renamed to original', (done) => { - testAction( + it('if not changed, completely unstages and discards entry if renamed to original', () => { + return testAction( renameEntry, { path: 'renamed', name: 'orig' }, store.state, @@ -751,24 +670,22 @@ describe('Multi-file store actions', () => { }, ], [createTriggerRenameAction('renamed', 'orig')], - done, ); }); - it('if already in changed, does not add to change', (done) => { + it('if already in changed, does not add to change', () => { store.state.changedFiles.push(renamedEntry); - testAction( + return testAction( renameEntry, { path: 'orig', name: 'renamed' }, store.state, [expect.objectContaining({ type: types.RENAME_ENTRY })], [createTriggerRenameAction('orig', 'renamed')], - done, ); }); - it('routes to the renamed file if the original file has been opened', (done) => { + it('routes to the renamed file if the original file has been opened', async () => { store.state.currentProjectId = 'test/test'; store.state.currentBranchId = 'main'; @@ -776,17 +693,12 @@ describe('Multi-file store actions', () => { opened: true, }); - store - .dispatch('renameEntry', { - path: 'orig', - name: 'renamed', - }) - .then(() => { - expect(router.push.mock.calls).toHaveLength(1); - expect(router.push).toHaveBeenCalledWith(`/project/test/test/tree/main/-/renamed/`); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: 'orig', + name: 'renamed', + }); + expect(router.push.mock.calls).toHaveLength(1); + expect(router.push).toHaveBeenCalledWith(`/project/test/test/tree/main/-/renamed/`); }); }); @@ -809,25 +721,20 @@ describe('Multi-file store actions', () => { }); }); - it('updates entries in a folder correctly, when folder is renamed', (done) => { - store - .dispatch('renameEntry', { - path: 'folder', - name: 'new-folder', - }) - .then(() => { - const keys = Object.keys(store.state.entries); - - expect(keys.length).toBe(3); - expect(keys.indexOf('new-folder')).toBe(0); - expect(keys.indexOf('new-folder/file-1')).toBe(1); - expect(keys.indexOf('new-folder/file-2')).toBe(2); - }) - .then(done) - .catch(done.fail); + it('updates entries in a folder correctly, when folder is renamed', async () => { + await store.dispatch('renameEntry', { + path: 'folder', + name: 'new-folder', + }); + const keys = Object.keys(store.state.entries); + + expect(keys.length).toBe(3); + expect(keys.indexOf('new-folder')).toBe(0); + expect(keys.indexOf('new-folder/file-1')).toBe(1); + expect(keys.indexOf('new-folder/file-2')).toBe(2); }); - it('discards renaming of an entry if the root folder is renamed back to a previous name', (done) => { + it('discards renaming of an entry if the root folder is renamed back to a previous name', async () => { const rootFolder = file('old-folder', 'old-folder', 'tree'); const testEntry = file('test', 'test', 'blob', rootFolder); @@ -841,53 +748,45 @@ describe('Multi-file store actions', () => { }, }); - store - .dispatch('renameEntry', { - path: 'old-folder', - name: 'new-folder', - }) - .then(() => { - const { entries } = store.state; - - expect(Object.keys(entries).length).toBe(2); - expect(entries['old-folder']).toBeUndefined(); - expect(entries['old-folder/test']).toBeUndefined(); - - expect(entries['new-folder']).toBeDefined(); - expect(entries['new-folder/test']).toEqual( - expect.objectContaining({ - path: 'new-folder/test', - name: 'test', - prevPath: 'old-folder/test', - prevName: 'test', - }), - ); - }) - .then(() => - store.dispatch('renameEntry', { - path: 'new-folder', - name: 'old-folder', - }), - ) - .then(() => { - const { entries } = store.state; - - expect(Object.keys(entries).length).toBe(2); - expect(entries['new-folder']).toBeUndefined(); - expect(entries['new-folder/test']).toBeUndefined(); - - expect(entries['old-folder']).toBeDefined(); - expect(entries['old-folder/test']).toEqual( - expect.objectContaining({ - path: 'old-folder/test', - name: 'test', - prevPath: undefined, - prevName: undefined, - }), - ); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: 'old-folder', + name: 'new-folder', + }); + const { entries } = store.state; + + expect(Object.keys(entries).length).toBe(2); + expect(entries['old-folder']).toBeUndefined(); + expect(entries['old-folder/test']).toBeUndefined(); + + expect(entries['new-folder']).toBeDefined(); + expect(entries['new-folder/test']).toEqual( + expect.objectContaining({ + path: 'new-folder/test', + name: 'test', + prevPath: 'old-folder/test', + prevName: 'test', + }), + ); + + await store.dispatch('renameEntry', { + path: 'new-folder', + name: 'old-folder', + }); + const { entries: newEntries } = store.state; + + expect(Object.keys(newEntries).length).toBe(2); + expect(newEntries['new-folder']).toBeUndefined(); + expect(newEntries['new-folder/test']).toBeUndefined(); + + expect(newEntries['old-folder']).toBeDefined(); + expect(newEntries['old-folder/test']).toEqual( + expect.objectContaining({ + path: 'old-folder/test', + name: 'test', + prevPath: undefined, + prevName: undefined, + }), + ); }); describe('with file in directory', () => { @@ -919,24 +818,21 @@ describe('Multi-file store actions', () => { }); }); - it('creates new directory', (done) => { + it('creates new directory', async () => { expect(store.state.entries[newParentPath]).toBeUndefined(); - store - .dispatch('renameEntry', { path: filePath, name: fileName, parentPath: newParentPath }) - .then(() => { - expect(store.state.entries[newParentPath]).toEqual( - expect.objectContaining({ - path: newParentPath, - type: 'tree', - tree: expect.arrayContaining([ - store.state.entries[`${newParentPath}/${fileName}`], - ]), - }), - ); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: filePath, + name: fileName, + parentPath: newParentPath, + }); + expect(store.state.entries[newParentPath]).toEqual( + expect.objectContaining({ + path: newParentPath, + type: 'tree', + tree: expect.arrayContaining([store.state.entries[`${newParentPath}/${fileName}`]]), + }), + ); }); describe('when new directory exists', () => { @@ -949,40 +845,30 @@ describe('Multi-file store actions', () => { rootDir.tree.push(newDir); }); - it('inserts in new directory', (done) => { + it('inserts in new directory', async () => { expect(newDir.tree).toEqual([]); - store - .dispatch('renameEntry', { - path: filePath, - name: fileName, - parentPath: newParentPath, - }) - .then(() => { - expect(newDir.tree).toEqual([store.state.entries[`${newParentPath}/${fileName}`]]); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: filePath, + name: fileName, + parentPath: newParentPath, + }); + expect(newDir.tree).toEqual([store.state.entries[`${newParentPath}/${fileName}`]]); }); - it('when new directory is deleted, it undeletes it', (done) => { - store.dispatch('deleteEntry', newParentPath); + it('when new directory is deleted, it undeletes it', async () => { + await store.dispatch('deleteEntry', newParentPath); expect(store.state.entries[newParentPath].deleted).toBe(true); expect(rootDir.tree.some((x) => x.path === newParentPath)).toBe(false); - store - .dispatch('renameEntry', { - path: filePath, - name: fileName, - parentPath: newParentPath, - }) - .then(() => { - expect(store.state.entries[newParentPath].deleted).toBe(false); - expect(rootDir.tree.some((x) => x.path === newParentPath)).toBe(true); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: filePath, + name: fileName, + parentPath: newParentPath, + }); + expect(store.state.entries[newParentPath].deleted).toBe(false); + expect(rootDir.tree.some((x) => x.path === newParentPath)).toBe(true); }); }); }); @@ -1023,30 +909,25 @@ describe('Multi-file store actions', () => { document.querySelector('.flash-container').remove(); }); - it('passes the error further unchanged without dispatching any action when response is 404', (done) => { + it('passes the error further unchanged without dispatching any action when response is 404', async () => { mock.onGet(/(.*)/).replyOnce(404); - getBranchData(...callParams) - .then(done.fail) - .catch((e) => { - expect(dispatch.mock.calls).toHaveLength(0); - expect(e.response.status).toEqual(404); - expect(document.querySelector('.flash-alert')).toBeNull(); - done(); - }); + await expect(getBranchData(...callParams)).rejects.toEqual( + new Error('Request failed with status code 404'), + ); + expect(dispatch.mock.calls).toHaveLength(0); + expect(document.querySelector('.flash-alert')).toBeNull(); }); - it('does not pass the error further and flashes an alert if error is not 404', (done) => { + it('does not pass the error further and flashes an alert if error is not 404', async () => { mock.onGet(/(.*)/).replyOnce(418); - getBranchData(...callParams) - .then(done.fail) - .catch((e) => { - expect(dispatch.mock.calls).toHaveLength(0); - expect(e.response).toBeUndefined(); - expect(document.querySelector('.flash-alert')).not.toBeNull(); - done(); - }); + await expect(getBranchData(...callParams)).rejects.toEqual( + new Error('Branch not loaded - <strong>abc/def/main-testing</strong>'), + ); + + expect(dispatch.mock.calls).toHaveLength(0); + expect(document.querySelector('.flash-alert')).not.toBeNull(); }); }); }); diff --git a/spec/frontend/ide/stores/modules/branches/actions_spec.js b/spec/frontend/ide/stores/modules/branches/actions_spec.js index 135dbc1f746..306330e3ba2 100644 --- a/spec/frontend/ide/stores/modules/branches/actions_spec.js +++ b/spec/frontend/ide/stores/modules/branches/actions_spec.js @@ -42,21 +42,20 @@ describe('IDE branches actions', () => { }); describe('requestBranches', () => { - it('should commit request', (done) => { - testAction( + it('should commit request', () => { + return testAction( requestBranches, null, mockedContext.state, [{ type: types.REQUEST_BRANCHES }], [], - done, ); }); }); describe('receiveBranchesError', () => { - it('should commit error', (done) => { - testAction( + it('should commit error', () => { + return testAction( receiveBranchesError, { search: TEST_SEARCH }, mockedContext.state, @@ -72,20 +71,18 @@ describe('IDE branches actions', () => { }, }, ], - done, ); }); }); describe('receiveBranchesSuccess', () => { - it('should commit received data', (done) => { - testAction( + it('should commit received data', () => { + return testAction( receiveBranchesSuccess, branches, mockedContext.state, [{ type: types.RECEIVE_BRANCHES_SUCCESS, payload: branches }], [], - done, ); }); }); @@ -110,8 +107,8 @@ describe('IDE branches actions', () => { }); }); - it('dispatches success with received data', (done) => { - testAction( + it('dispatches success with received data', () => { + return testAction( fetchBranches, { search: TEST_SEARCH }, mockedState, @@ -121,7 +118,6 @@ describe('IDE branches actions', () => { { type: 'resetBranches' }, { type: 'receiveBranchesSuccess', payload: branches }, ], - done, ); }); }); @@ -131,8 +127,8 @@ describe('IDE branches actions', () => { mock.onGet(/\/api\/v4\/projects\/\d+\/repository\/branches(.*)$/).replyOnce(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( fetchBranches, { search: TEST_SEARCH }, mockedState, @@ -142,20 +138,18 @@ describe('IDE branches actions', () => { { type: 'resetBranches' }, { type: 'receiveBranchesError', payload: { search: TEST_SEARCH } }, ], - done, ); }); }); describe('resetBranches', () => { - it('commits reset', (done) => { - testAction( + it('commits reset', () => { + return testAction( resetBranches, null, mockedContext.state, [{ type: types.RESET_BRANCHES }], [], - done, ); }); }); diff --git a/spec/frontend/ide/stores/modules/clientside/actions_spec.js b/spec/frontend/ide/stores/modules/clientside/actions_spec.js index d2777623b0d..c2b9de192d9 100644 --- a/spec/frontend/ide/stores/modules/clientside/actions_spec.js +++ b/spec/frontend/ide/stores/modules/clientside/actions_spec.js @@ -26,15 +26,13 @@ describe('IDE store module clientside actions', () => { }); describe('pingUsage', () => { - it('posts to usage endpoint', (done) => { + it('posts to usage endpoint', async () => { const usageSpy = jest.fn(() => [200]); mock.onPost(TEST_USAGE_URL).reply(() => usageSpy()); - testAction(actions.pingUsage, PING_USAGE_PREVIEW_KEY, rootGetters, [], [], () => { - expect(usageSpy).toHaveBeenCalled(); - done(); - }); + await testAction(actions.pingUsage, PING_USAGE_PREVIEW_KEY, rootGetters, [], []); + expect(usageSpy).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js index cb6bb7c1202..d65039e89cc 100644 --- a/spec/frontend/ide/stores/modules/commit/actions_spec.js +++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js @@ -57,40 +57,25 @@ describe('IDE commit module actions', () => { }); describe('updateCommitMessage', () => { - it('updates store with new commit message', (done) => { - store - .dispatch('commit/updateCommitMessage', 'testing') - .then(() => { - expect(store.state.commit.commitMessage).toBe('testing'); - }) - .then(done) - .catch(done.fail); + it('updates store with new commit message', async () => { + await store.dispatch('commit/updateCommitMessage', 'testing'); + expect(store.state.commit.commitMessage).toBe('testing'); }); }); describe('discardDraft', () => { - it('resets commit message to blank', (done) => { + it('resets commit message to blank', async () => { store.state.commit.commitMessage = 'testing'; - store - .dispatch('commit/discardDraft') - .then(() => { - expect(store.state.commit.commitMessage).not.toBe('testing'); - }) - .then(done) - .catch(done.fail); + await store.dispatch('commit/discardDraft'); + expect(store.state.commit.commitMessage).not.toBe('testing'); }); }); describe('updateCommitAction', () => { - it('updates store with new commit action', (done) => { - store - .dispatch('commit/updateCommitAction', '1') - .then(() => { - expect(store.state.commit.commitAction).toBe('1'); - }) - .then(done) - .catch(done.fail); + it('updates store with new commit action', async () => { + await store.dispatch('commit/updateCommitAction', '1'); + expect(store.state.commit.commitAction).toBe('1'); }); }); @@ -139,34 +124,24 @@ describe('IDE commit module actions', () => { }); }); - it('updates commit message with short_id', (done) => { - store - .dispatch('commit/setLastCommitMessage', { short_id: '123' }) - .then(() => { - expect(store.state.lastCommitMsg).toContain( - 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a>', - ); - }) - .then(done) - .catch(done.fail); + it('updates commit message with short_id', async () => { + await store.dispatch('commit/setLastCommitMessage', { short_id: '123' }); + expect(store.state.lastCommitMsg).toContain( + 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a>', + ); }); - it('updates commit message with stats', (done) => { - store - .dispatch('commit/setLastCommitMessage', { - short_id: '123', - stats: { - additions: '1', - deletions: '2', - }, - }) - .then(() => { - expect(store.state.lastCommitMsg).toBe( - 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', - ); - }) - .then(done) - .catch(done.fail); + it('updates commit message with stats', async () => { + await store.dispatch('commit/setLastCommitMessage', { + short_id: '123', + stats: { + additions: '1', + deletions: '2', + }, + }); + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); }); }); @@ -221,74 +196,49 @@ describe('IDE commit module actions', () => { }); }); - it('updates stores working reference', (done) => { - store - .dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) - .then(() => { - expect(store.state.projects.abcproject.branches.main.workingReference).toBe(data.id); - }) - .then(done) - .catch(done.fail); + it('updates stores working reference', async () => { + await store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }); + expect(store.state.projects.abcproject.branches.main.workingReference).toBe(data.id); }); - it('resets all files changed status', (done) => { - store - .dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) - .then(() => { - store.state.openFiles.forEach((entry) => { - expect(entry.changed).toBeFalsy(); - }); - }) - .then(done) - .catch(done.fail); + it('resets all files changed status', async () => { + await store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }); + store.state.openFiles.forEach((entry) => { + expect(entry.changed).toBeFalsy(); + }); }); - it('sets files commit data', (done) => { - store - .dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) - .then(() => { - expect(f.lastCommitSha).toBe(data.id); - }) - .then(done) - .catch(done.fail); + it('sets files commit data', async () => { + await store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }); + expect(f.lastCommitSha).toBe(data.id); }); - it('updates raw content for changed file', (done) => { - store - .dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) - .then(() => { - expect(f.raw).toBe(f.content); - }) - .then(done) - .catch(done.fail); + it('updates raw content for changed file', async () => { + await store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }); + expect(f.raw).toBe(f.content); }); - it('emits changed event for file', (done) => { - store - .dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) - .then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.key}`, { - content: f.content, - changed: false, - }); - }) - .then(done) - .catch(done.fail); + it('emits changed event for file', async () => { + await store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }); + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.key}`, { + content: f.content, + changed: false, + }); }); }); @@ -349,138 +299,93 @@ describe('IDE commit module actions', () => { jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE }); }); - it('calls service', (done) => { - store - .dispatch('commit/commitChanges') - .then(() => { - expect(service.commit).toHaveBeenCalledWith('abcproject', { - branch: expect.anything(), - commit_message: 'testing 123', - actions: [ - { - action: commitActionTypes.update, - file_path: expect.anything(), - content: '\n', - encoding: expect.anything(), - last_commit_id: undefined, - previous_path: undefined, - }, - ], - start_sha: TEST_COMMIT_SHA, - }); - - done(); - }) - .catch(done.fail); + it('calls service', async () => { + await store.dispatch('commit/commitChanges'); + expect(service.commit).toHaveBeenCalledWith('abcproject', { + branch: expect.anything(), + commit_message: 'testing 123', + actions: [ + { + action: commitActionTypes.update, + file_path: expect.anything(), + content: '\n', + encoding: expect.anything(), + last_commit_id: undefined, + previous_path: undefined, + }, + ], + start_sha: TEST_COMMIT_SHA, + }); }); - it('sends lastCommit ID when not creating new branch', (done) => { + it('sends lastCommit ID when not creating new branch', async () => { store.state.commit.commitAction = '1'; - store - .dispatch('commit/commitChanges') - .then(() => { - expect(service.commit).toHaveBeenCalledWith('abcproject', { - branch: expect.anything(), - commit_message: 'testing 123', - actions: [ - { - action: commitActionTypes.update, - file_path: expect.anything(), - content: '\n', - encoding: expect.anything(), - last_commit_id: TEST_COMMIT_SHA, - previous_path: undefined, - }, - ], - start_sha: undefined, - }); - - done(); - }) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(service.commit).toHaveBeenCalledWith('abcproject', { + branch: expect.anything(), + commit_message: 'testing 123', + actions: [ + { + action: commitActionTypes.update, + file_path: expect.anything(), + content: '\n', + encoding: expect.anything(), + last_commit_id: TEST_COMMIT_SHA, + previous_path: undefined, + }, + ], + start_sha: undefined, + }); }); - it('sets last Commit Msg', (done) => { - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.state.lastCommitMsg).toBe( - 'Your changes have been committed. Commit <a href="webUrl/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', - ); - - done(); - }) - .catch(done.fail); + it('sets last Commit Msg', async () => { + await store.dispatch('commit/commitChanges'); + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="webUrl/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); }); - it('adds commit data to files', (done) => { - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.state.entries[store.state.openFiles[0].path].lastCommitSha).toBe( - COMMIT_RESPONSE.id, - ); - - done(); - }) - .catch(done.fail); + it('adds commit data to files', async () => { + await store.dispatch('commit/commitChanges'); + expect(store.state.entries[store.state.openFiles[0].path].lastCommitSha).toBe( + COMMIT_RESPONSE.id, + ); }); - it('resets stores commit actions', (done) => { + it('resets stores commit actions', async () => { store.state.commit.commitAction = COMMIT_TO_NEW_BRANCH; - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.state.commit.commitAction).not.toBe(COMMIT_TO_NEW_BRANCH); - }) - .then(done) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(store.state.commit.commitAction).not.toBe(COMMIT_TO_NEW_BRANCH); }); - it('removes all staged files', (done) => { - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.state.stagedFiles.length).toBe(0); - }) - .then(done) - .catch(done.fail); + it('removes all staged files', async () => { + await store.dispatch('commit/commitChanges'); + expect(store.state.stagedFiles.length).toBe(0); }); describe('merge request', () => { - it('redirects to new merge request page', (done) => { + it('redirects to new merge request page', async () => { jest.spyOn(eventHub, '$on').mockImplementation(); store.state.commit.commitAction = COMMIT_TO_NEW_BRANCH; store.state.commit.shouldCreateMR = true; - store - .dispatch('commit/commitChanges') - .then(() => { - expect(visitUrl).toHaveBeenCalledWith( - `webUrl/-/merge_requests/new?merge_request[source_branch]=${store.getters['commit/placeholderBranchName']}&merge_request[target_branch]=main&nav_source=webide`, - ); - - done(); - }) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(visitUrl).toHaveBeenCalledWith( + `webUrl/-/merge_requests/new?merge_request[source_branch]=${store.getters['commit/placeholderBranchName']}&merge_request[target_branch]=main&nav_source=webide`, + ); }); - it('does not redirect to new merge request page when shouldCreateMR is not checked', (done) => { + it('does not redirect to new merge request page when shouldCreateMR is not checked', async () => { jest.spyOn(eventHub, '$on').mockImplementation(); store.state.commit.commitAction = COMMIT_TO_NEW_BRANCH; store.state.commit.shouldCreateMR = false; - store - .dispatch('commit/commitChanges') - .then(() => { - expect(visitUrl).not.toHaveBeenCalled(); - done(); - }) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(visitUrl).not.toHaveBeenCalled(); }); it('does not redirect to merge request page if shouldCreateMR is checked, but branch is the default branch', async () => { @@ -514,17 +419,11 @@ describe('IDE commit module actions', () => { }); }); - it('shows failed message', (done) => { - store - .dispatch('commit/commitChanges') - .then(() => { - const alert = document.querySelector('.flash-container'); - - expect(alert.textContent.trim()).toBe('failed message'); + it('shows failed message', async () => { + await store.dispatch('commit/commitChanges'); + const alert = document.querySelector('.flash-container'); - done(); - }) - .catch(done.fail); + expect(alert.textContent.trim()).toBe('failed message'); }); }); @@ -548,52 +447,37 @@ describe('IDE commit module actions', () => { }); describe('first commit of a branch', () => { - it('commits TOGGLE_EMPTY_STATE mutation on empty repo', (done) => { + it('commits TOGGLE_EMPTY_STATE mutation on empty repo', async () => { jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE }); jest.spyOn(store, 'commit'); - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.commit.mock.calls).toEqual( - expect.arrayContaining([ - ['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)], - ]), - ); - done(); - }) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(store.commit.mock.calls).toEqual( + expect.arrayContaining([['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)]]), + ); }); - it('does not commmit TOGGLE_EMPTY_STATE mutation on existing project', (done) => { + it('does not commmit TOGGLE_EMPTY_STATE mutation on existing project', async () => { COMMIT_RESPONSE.parent_ids.push('1234'); jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE }); jest.spyOn(store, 'commit'); - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.commit.mock.calls).not.toEqual( - expect.arrayContaining([ - ['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)], - ]), - ); - done(); - }) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(store.commit.mock.calls).not.toEqual( + expect.arrayContaining([['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)]]), + ); }); }); }); describe('toggleShouldCreateMR', () => { - it('commits both toggle and interacting with MR checkbox actions', (done) => { - testAction( + it('commits both toggle and interacting with MR checkbox actions', () => { + return testAction( actions.toggleShouldCreateMR, {}, store.state, [{ type: mutationTypes.TOGGLE_SHOULD_CREATE_MR }], [], - done, ); }); }); diff --git a/spec/frontend/ide/stores/modules/file_templates/actions_spec.js b/spec/frontend/ide/stores/modules/file_templates/actions_spec.js index 9ff950b0875..1080a30d2d8 100644 --- a/spec/frontend/ide/stores/modules/file_templates/actions_spec.js +++ b/spec/frontend/ide/stores/modules/file_templates/actions_spec.js @@ -20,21 +20,20 @@ describe('IDE file templates actions', () => { }); describe('requestTemplateTypes', () => { - it('commits REQUEST_TEMPLATE_TYPES', (done) => { - testAction( + it('commits REQUEST_TEMPLATE_TYPES', () => { + return testAction( actions.requestTemplateTypes, null, state, [{ type: types.REQUEST_TEMPLATE_TYPES }], [], - done, ); }); }); describe('receiveTemplateTypesError', () => { - it('commits RECEIVE_TEMPLATE_TYPES_ERROR and dispatches setErrorMessage', (done) => { - testAction( + it('commits RECEIVE_TEMPLATE_TYPES_ERROR and dispatches setErrorMessage', () => { + return testAction( actions.receiveTemplateTypesError, null, state, @@ -49,20 +48,18 @@ describe('IDE file templates actions', () => { }, }, ], - done, ); }); }); describe('receiveTemplateTypesSuccess', () => { - it('commits RECEIVE_TEMPLATE_TYPES_SUCCESS', (done) => { - testAction( + it('commits RECEIVE_TEMPLATE_TYPES_SUCCESS', () => { + return testAction( actions.receiveTemplateTypesSuccess, 'test', state, [{ type: types.RECEIVE_TEMPLATE_TYPES_SUCCESS, payload: 'test' }], [], - done, ); }); }); @@ -81,23 +78,17 @@ describe('IDE file templates actions', () => { }); }); - it('rejects if selectedTemplateType is empty', (done) => { + it('rejects if selectedTemplateType is empty', async () => { const dispatch = jest.fn().mockName('dispatch'); - actions - .fetchTemplateTypes({ dispatch, state }) - .then(done.fail) - .catch(() => { - expect(dispatch).not.toHaveBeenCalled(); - - done(); - }); + await expect(actions.fetchTemplateTypes({ dispatch, state })).rejects.toBeUndefined(); + expect(dispatch).not.toHaveBeenCalled(); }); - it('dispatches actions', (done) => { + it('dispatches actions', () => { state.selectedTemplateType = { key: 'licenses' }; - testAction( + return testAction( actions.fetchTemplateTypes, null, state, @@ -111,7 +102,6 @@ describe('IDE file templates actions', () => { payload: pages[0].concat(pages[1]).concat(pages[2]), }, ], - done, ); }); }); @@ -121,16 +111,15 @@ describe('IDE file templates actions', () => { mock.onGet(/api\/(.*)\/templates\/licenses/).replyOnce(500); }); - it('dispatches actions', (done) => { + it('dispatches actions', () => { state.selectedTemplateType = { key: 'licenses' }; - testAction( + return testAction( actions.fetchTemplateTypes, null, state, [], [{ type: 'requestTemplateTypes' }, { type: 'receiveTemplateTypesError' }], - done, ); }); }); @@ -184,8 +173,8 @@ describe('IDE file templates actions', () => { }); describe('receiveTemplateError', () => { - it('dispatches setErrorMessage', (done) => { - testAction( + it('dispatches setErrorMessage', () => { + return testAction( actions.receiveTemplateError, 'test', state, @@ -201,7 +190,6 @@ describe('IDE file templates actions', () => { }, }, ], - done, ); }); }); @@ -217,46 +205,43 @@ describe('IDE file templates actions', () => { .replyOnce(200, { content: 'testing content' }); }); - it('dispatches setFileTemplate if template already has content', (done) => { + it('dispatches setFileTemplate if template already has content', () => { const template = { content: 'already has content' }; - testAction( + return testAction( actions.fetchTemplate, template, state, [], [{ type: 'setFileTemplate', payload: template }], - done, ); }); - it('dispatches success', (done) => { + it('dispatches success', () => { const template = { key: 'mit' }; state.selectedTemplateType = { key: 'licenses' }; - testAction( + return testAction( actions.fetchTemplate, template, state, [], [{ type: 'setFileTemplate', payload: { content: 'MIT content' } }], - done, ); }); - it('dispatches success and uses name key for API call', (done) => { + it('dispatches success and uses name key for API call', () => { const template = { name: 'testing' }; state.selectedTemplateType = { key: 'licenses' }; - testAction( + return testAction( actions.fetchTemplate, template, state, [], [{ type: 'setFileTemplate', payload: { content: 'testing content' } }], - done, ); }); }); @@ -266,18 +251,17 @@ describe('IDE file templates actions', () => { mock.onGet(/api\/(.*)\/templates\/licenses\/mit/).replyOnce(500); }); - it('dispatches error', (done) => { + it('dispatches error', () => { const template = { name: 'testing' }; state.selectedTemplateType = { key: 'licenses' }; - testAction( + return testAction( actions.fetchTemplate, template, state, [], [{ type: 'receiveTemplateError', payload: template }], - done, ); }); }); diff --git a/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js b/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js index e1f2b165dd9..344fe3a41c3 100644 --- a/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js +++ b/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js @@ -28,21 +28,20 @@ describe('IDE merge requests actions', () => { }); describe('requestMergeRequests', () => { - it('should commit request', (done) => { - testAction( + it('should commit request', () => { + return testAction( requestMergeRequests, null, mockedState, [{ type: types.REQUEST_MERGE_REQUESTS }], [], - done, ); }); }); describe('receiveMergeRequestsError', () => { - it('should commit error', (done) => { - testAction( + it('should commit error', () => { + return testAction( receiveMergeRequestsError, { type: 'created', search: '' }, mockedState, @@ -58,20 +57,18 @@ describe('IDE merge requests actions', () => { }, }, ], - done, ); }); }); describe('receiveMergeRequestsSuccess', () => { - it('should commit received data', (done) => { - testAction( + it('should commit received data', () => { + return testAction( receiveMergeRequestsSuccess, mergeRequests, mockedState, [{ type: types.RECEIVE_MERGE_REQUESTS_SUCCESS, payload: mergeRequests }], [], - done, ); }); }); @@ -118,8 +115,8 @@ describe('IDE merge requests actions', () => { }); }); - it('dispatches success with received data', (done) => { - testAction( + it('dispatches success with received data', () => { + return testAction( fetchMergeRequests, { type: 'created' }, mockedState, @@ -129,7 +126,6 @@ describe('IDE merge requests actions', () => { { type: 'resetMergeRequests' }, { type: 'receiveMergeRequestsSuccess', payload: mergeRequests }, ], - done, ); }); }); @@ -156,8 +152,8 @@ describe('IDE merge requests actions', () => { ); }); - it('dispatches success with received data', (done) => { - testAction( + it('dispatches success with received data', () => { + return testAction( fetchMergeRequests, { type: null }, { ...mockedState, ...mockedRootState }, @@ -167,7 +163,6 @@ describe('IDE merge requests actions', () => { { type: 'resetMergeRequests' }, { type: 'receiveMergeRequestsSuccess', payload: mergeRequests }, ], - done, ); }); }); @@ -177,8 +172,8 @@ describe('IDE merge requests actions', () => { mock.onGet(/\/api\/v4\/merge_requests(.*)$/).replyOnce(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( fetchMergeRequests, { type: 'created', search: '' }, mockedState, @@ -188,21 +183,19 @@ describe('IDE merge requests actions', () => { { type: 'resetMergeRequests' }, { type: 'receiveMergeRequestsError', payload: { type: 'created', search: '' } }, ], - done, ); }); }); }); describe('resetMergeRequests', () => { - it('commits reset', (done) => { - testAction( + it('commits reset', () => { + return testAction( resetMergeRequests, null, mockedState, [{ type: types.RESET_MERGE_REQUESTS }], [], - done, ); }); }); diff --git a/spec/frontend/ide/stores/modules/pane/actions_spec.js b/spec/frontend/ide/stores/modules/pane/actions_spec.js index 42fe8b400b8..98c4f22dac8 100644 --- a/spec/frontend/ide/stores/modules/pane/actions_spec.js +++ b/spec/frontend/ide/stores/modules/pane/actions_spec.js @@ -7,19 +7,19 @@ describe('IDE pane module actions', () => { const TEST_VIEW_KEEP_ALIVE = { name: 'test-keep-alive', keepAlive: true }; describe('toggleOpen', () => { - it('dispatches open if closed', (done) => { - testAction(actions.toggleOpen, TEST_VIEW, { isOpen: false }, [], [{ type: 'open' }], done); + it('dispatches open if closed', () => { + return testAction(actions.toggleOpen, TEST_VIEW, { isOpen: false }, [], [{ type: 'open' }]); }); - it('dispatches close if opened', (done) => { - testAction(actions.toggleOpen, TEST_VIEW, { isOpen: true }, [], [{ type: 'close' }], done); + it('dispatches close if opened', () => { + return testAction(actions.toggleOpen, TEST_VIEW, { isOpen: true }, [], [{ type: 'close' }]); }); }); describe('open', () => { describe('with a view specified', () => { - it('commits SET_OPEN and SET_CURRENT_VIEW', (done) => { - testAction( + it('commits SET_OPEN and SET_CURRENT_VIEW', () => { + return testAction( actions.open, TEST_VIEW, {}, @@ -28,12 +28,11 @@ describe('IDE pane module actions', () => { { type: types.SET_CURRENT_VIEW, payload: TEST_VIEW.name }, ], [], - done, ); }); - it('commits KEEP_ALIVE_VIEW if keepAlive is true', (done) => { - testAction( + it('commits KEEP_ALIVE_VIEW if keepAlive is true', () => { + return testAction( actions.open, TEST_VIEW_KEEP_ALIVE, {}, @@ -43,28 +42,26 @@ describe('IDE pane module actions', () => { { type: types.KEEP_ALIVE_VIEW, payload: TEST_VIEW_KEEP_ALIVE.name }, ], [], - done, ); }); }); describe('without a view specified', () => { - it('commits SET_OPEN', (done) => { - testAction( + it('commits SET_OPEN', () => { + return testAction( actions.open, undefined, {}, [{ type: types.SET_OPEN, payload: true }], [], - done, ); }); }); }); describe('close', () => { - it('commits SET_OPEN', (done) => { - testAction(actions.close, null, {}, [{ type: types.SET_OPEN, payload: false }], [], done); + it('commits SET_OPEN', () => { + return testAction(actions.close, null, {}, [{ type: types.SET_OPEN, payload: false }], []); }); }); }); diff --git a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js index 3ede37e2eed..b76b673c3a2 100644 --- a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js +++ b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js @@ -25,6 +25,7 @@ import { import * as types from '~/ide/stores/modules/pipelines/mutation_types'; import state from '~/ide/stores/modules/pipelines/state'; import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; import { pipelines, jobs } from '../../../mock_data'; describe('IDE pipelines actions', () => { @@ -44,32 +45,30 @@ describe('IDE pipelines actions', () => { }); describe('requestLatestPipeline', () => { - it('commits request', (done) => { - testAction( + it('commits request', () => { + return testAction( requestLatestPipeline, null, mockedState, [{ type: types.REQUEST_LATEST_PIPELINE }], [], - done, ); }); }); describe('receiveLatestPipelineError', () => { - it('commits error', (done) => { - testAction( + it('commits error', () => { + return testAction( receiveLatestPipelineError, { status: 404 }, mockedState, [{ type: types.RECEIVE_LASTEST_PIPELINE_ERROR }], [{ type: 'stopPipelinePolling' }], - done, ); }); - it('dispatches setErrorMessage is not 404', (done) => { - testAction( + it('dispatches setErrorMessage is not 404', () => { + return testAction( receiveLatestPipelineError, { status: 500 }, mockedState, @@ -86,7 +85,6 @@ describe('IDE pipelines actions', () => { }, { type: 'stopPipelinePolling' }, ], - done, ); }); }); @@ -123,7 +121,7 @@ describe('IDE pipelines actions', () => { .reply(200, { data: { foo: 'bar' } }, { 'poll-interval': '10000' }); }); - it('dispatches request', (done) => { + it('dispatches request', async () => { jest.spyOn(axios, 'get'); jest.spyOn(Visibility, 'hidden').mockReturnValue(false); @@ -133,34 +131,21 @@ describe('IDE pipelines actions', () => { currentProject: { path_with_namespace: 'abc/def' }, }; - fetchLatestPipeline({ dispatch, rootGetters }); + await fetchLatestPipeline({ dispatch, rootGetters }); expect(dispatch).toHaveBeenCalledWith('requestLatestPipeline'); - jest.advanceTimersByTime(1000); - - new Promise((resolve) => requestAnimationFrame(resolve)) - .then(() => { - expect(axios.get).toHaveBeenCalled(); - expect(axios.get).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledWith( - 'receiveLatestPipelineSuccess', - expect.anything(), - ); - - jest.advanceTimersByTime(10000); - }) - .then(() => new Promise((resolve) => requestAnimationFrame(resolve))) - .then(() => { - expect(axios.get).toHaveBeenCalled(); - expect(axios.get).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenCalledWith( - 'receiveLatestPipelineSuccess', - expect.anything(), - ); - }) - .then(done) - .catch(done.fail); + await waitForPromises(); + + expect(axios.get).toHaveBeenCalled(); + expect(axios.get).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineSuccess', expect.anything()); + + jest.advanceTimersByTime(10000); + + expect(axios.get).toHaveBeenCalled(); + expect(axios.get).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineSuccess', expect.anything()); }); }); @@ -169,27 +154,22 @@ describe('IDE pipelines actions', () => { mock.onGet('/abc/def/commit/abc123def456ghi789jkl/pipelines').reply(500); }); - it('dispatches error', (done) => { + it('dispatches error', async () => { const dispatch = jest.fn().mockName('dispatch'); const rootGetters = { lastCommit: { id: 'abc123def456ghi789jkl' }, currentProject: { path_with_namespace: 'abc/def' }, }; - fetchLatestPipeline({ dispatch, rootGetters }); + await fetchLatestPipeline({ dispatch, rootGetters }); - jest.advanceTimersByTime(1500); + await waitForPromises(); - new Promise((resolve) => requestAnimationFrame(resolve)) - .then(() => { - expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineError', expect.anything()); - }) - .then(done) - .catch(done.fail); + expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineError', expect.anything()); }); }); - it('sets latest pipeline to `null` and stops polling on empty project', (done) => { + it('sets latest pipeline to `null` and stops polling on empty project', () => { mockedState = { ...mockedState, rootGetters: { @@ -197,26 +177,31 @@ describe('IDE pipelines actions', () => { }, }; - testAction( + return testAction( fetchLatestPipeline, {}, mockedState, [{ type: types.RECEIVE_LASTEST_PIPELINE_SUCCESS, payload: null }], [{ type: 'stopPipelinePolling' }], - done, ); }); }); describe('requestJobs', () => { - it('commits request', (done) => { - testAction(requestJobs, 1, mockedState, [{ type: types.REQUEST_JOBS, payload: 1 }], [], done); + it('commits request', () => { + return testAction( + requestJobs, + 1, + mockedState, + [{ type: types.REQUEST_JOBS, payload: 1 }], + [], + ); }); }); describe('receiveJobsError', () => { - it('commits error', (done) => { - testAction( + it('commits error', () => { + return testAction( receiveJobsError, { id: 1 }, mockedState, @@ -232,20 +217,18 @@ describe('IDE pipelines actions', () => { }, }, ], - done, ); }); }); describe('receiveJobsSuccess', () => { - it('commits data', (done) => { - testAction( + it('commits data', () => { + return testAction( receiveJobsSuccess, { id: 1, data: jobs }, mockedState, [{ type: types.RECEIVE_JOBS_SUCCESS, payload: { id: 1, data: jobs } }], [], - done, ); }); }); @@ -258,8 +241,8 @@ describe('IDE pipelines actions', () => { mock.onGet(stage.dropdownPath).replyOnce(200, jobs); }); - it('dispatches request', (done) => { - testAction( + it('dispatches request', () => { + return testAction( fetchJobs, stage, mockedState, @@ -268,7 +251,6 @@ describe('IDE pipelines actions', () => { { type: 'requestJobs', payload: stage.id }, { type: 'receiveJobsSuccess', payload: { id: stage.id, data: jobs } }, ], - done, ); }); }); @@ -278,8 +260,8 @@ describe('IDE pipelines actions', () => { mock.onGet(stage.dropdownPath).replyOnce(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( fetchJobs, stage, mockedState, @@ -288,69 +270,64 @@ describe('IDE pipelines actions', () => { { type: 'requestJobs', payload: stage.id }, { type: 'receiveJobsError', payload: stage }, ], - done, ); }); }); }); describe('toggleStageCollapsed', () => { - it('commits collapse', (done) => { - testAction( + it('commits collapse', () => { + return testAction( toggleStageCollapsed, 1, mockedState, [{ type: types.TOGGLE_STAGE_COLLAPSE, payload: 1 }], [], - done, ); }); }); describe('setDetailJob', () => { - it('commits job', (done) => { - testAction( + it('commits job', () => { + return testAction( setDetailJob, 'job', mockedState, [{ type: types.SET_DETAIL_JOB, payload: 'job' }], [{ type: 'rightPane/open', payload: rightSidebarViews.jobsDetail }], - done, ); }); - it('dispatches rightPane/open as pipeline when job is null', (done) => { - testAction( + it('dispatches rightPane/open as pipeline when job is null', () => { + return testAction( setDetailJob, null, mockedState, [{ type: types.SET_DETAIL_JOB, payload: null }], [{ type: 'rightPane/open', payload: rightSidebarViews.pipelines }], - done, ); }); - it('dispatches rightPane/open as job', (done) => { - testAction( + it('dispatches rightPane/open as job', () => { + return testAction( setDetailJob, 'job', mockedState, [{ type: types.SET_DETAIL_JOB, payload: 'job' }], [{ type: 'rightPane/open', payload: rightSidebarViews.jobsDetail }], - done, ); }); }); describe('requestJobLogs', () => { - it('commits request', (done) => { - testAction(requestJobLogs, null, mockedState, [{ type: types.REQUEST_JOB_LOGS }], [], done); + it('commits request', () => { + return testAction(requestJobLogs, null, mockedState, [{ type: types.REQUEST_JOB_LOGS }], []); }); }); describe('receiveJobLogsError', () => { - it('commits error', (done) => { - testAction( + it('commits error', () => { + return testAction( receiveJobLogsError, null, mockedState, @@ -366,20 +343,18 @@ describe('IDE pipelines actions', () => { }, }, ], - done, ); }); }); describe('receiveJobLogsSuccess', () => { - it('commits data', (done) => { - testAction( + it('commits data', () => { + return testAction( receiveJobLogsSuccess, 'data', mockedState, [{ type: types.RECEIVE_JOB_LOGS_SUCCESS, payload: 'data' }], [], - done, ); }); }); @@ -395,8 +370,8 @@ describe('IDE pipelines actions', () => { mock.onGet(`${TEST_HOST}/project/builds/trace`).replyOnce(200, { html: 'html' }); }); - it('dispatches request', (done) => { - testAction( + it('dispatches request', () => { + return testAction( fetchJobLogs, null, mockedState, @@ -405,7 +380,6 @@ describe('IDE pipelines actions', () => { { type: 'requestJobLogs' }, { type: 'receiveJobLogsSuccess', payload: { html: 'html' } }, ], - done, ); }); @@ -426,22 +400,21 @@ describe('IDE pipelines actions', () => { mock.onGet(`${TEST_HOST}/project/builds/trace`).replyOnce(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( fetchJobLogs, null, mockedState, [], [{ type: 'requestJobLogs' }, { type: 'receiveJobLogsError' }], - done, ); }); }); }); describe('resetLatestPipeline', () => { - it('commits reset mutations', (done) => { - testAction( + it('commits reset mutations', () => { + return testAction( resetLatestPipeline, null, mockedState, @@ -450,7 +423,6 @@ describe('IDE pipelines actions', () => { { type: types.SET_DETAIL_JOB, payload: null }, ], [], - done, ); }); }); diff --git a/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js b/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js index 22b0615c6d0..448fd909f39 100644 --- a/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js +++ b/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js @@ -22,43 +22,37 @@ describe('ide/stores/modules/terminal_sync/actions', () => { }); describe('upload', () => { - it('uploads to mirror and sets success', (done) => { + it('uploads to mirror and sets success', async () => { mirror.upload.mockReturnValue(Promise.resolve()); - testAction( + await testAction( actions.upload, null, rootState, [{ type: types.START_LOADING }, { type: types.SET_SUCCESS }], [], - () => { - expect(mirror.upload).toHaveBeenCalledWith(rootState); - done(); - }, ); + expect(mirror.upload).toHaveBeenCalledWith(rootState); }); - it('sets error when failed', (done) => { + it('sets error when failed', () => { const err = { message: 'it failed!' }; mirror.upload.mockReturnValue(Promise.reject(err)); - testAction( + return testAction( actions.upload, null, rootState, [{ type: types.START_LOADING }, { type: types.SET_ERROR, payload: err }], [], - done, ); }); }); describe('stop', () => { - it('disconnects from mirror', (done) => { - testAction(actions.stop, null, rootState, [{ type: types.STOP }], [], () => { - expect(mirror.disconnect).toHaveBeenCalled(); - done(); - }); + it('disconnects from mirror', async () => { + await testAction(actions.stop, null, rootState, [{ type: types.STOP }], []); + expect(mirror.disconnect).toHaveBeenCalled(); }); }); @@ -83,20 +77,17 @@ describe('ide/stores/modules/terminal_sync/actions', () => { }; }); - it('connects to mirror and sets success', (done) => { + it('connects to mirror and sets success', async () => { mirror.connect.mockReturnValue(Promise.resolve()); - testAction( + await testAction( actions.start, null, rootState, [{ type: types.START_LOADING }, { type: types.SET_SUCCESS }], [], - () => { - expect(mirror.connect).toHaveBeenCalledWith(TEST_SESSION.proxyWebsocketPath); - done(); - }, ); + expect(mirror.connect).toHaveBeenCalledWith(TEST_SESSION.proxyWebsocketPath); }); it('sets error if connection fails', () => { diff --git a/spec/frontend/ide/stores/plugins/terminal_spec.js b/spec/frontend/ide/stores/plugins/terminal_spec.js index 912de88cb39..193300540fd 100644 --- a/spec/frontend/ide/stores/plugins/terminal_spec.js +++ b/spec/frontend/ide/stores/plugins/terminal_spec.js @@ -6,10 +6,10 @@ import { SET_BRANCH_WORKING_REFERENCE } from '~/ide/stores/mutation_types'; import createTerminalPlugin from '~/ide/stores/plugins/terminal'; const TEST_DATASET = { - eeWebTerminalSvgPath: `${TEST_HOST}/web/terminal/svg`, - eeWebTerminalHelpPath: `${TEST_HOST}/web/terminal/help`, - eeWebTerminalConfigHelpPath: `${TEST_HOST}/web/terminal/config/help`, - eeWebTerminalRunnersHelpPath: `${TEST_HOST}/web/terminal/runners/help`, + webTerminalSvgPath: `${TEST_HOST}/web/terminal/svg`, + webTerminalHelpPath: `${TEST_HOST}/web/terminal/help`, + webTerminalConfigHelpPath: `${TEST_HOST}/web/terminal/config/help`, + webTerminalRunnersHelpPath: `${TEST_HOST}/web/terminal/runners/help`, }; Vue.use(Vuex); @@ -40,10 +40,10 @@ describe('ide/stores/extend', () => { it('dispatches terminal/setPaths', () => { expect(store.dispatch).toHaveBeenCalledWith('terminal/setPaths', { - webTerminalSvgPath: TEST_DATASET.eeWebTerminalSvgPath, - webTerminalHelpPath: TEST_DATASET.eeWebTerminalHelpPath, - webTerminalConfigHelpPath: TEST_DATASET.eeWebTerminalConfigHelpPath, - webTerminalRunnersHelpPath: TEST_DATASET.eeWebTerminalRunnersHelpPath, + webTerminalSvgPath: TEST_DATASET.webTerminalSvgPath, + webTerminalHelpPath: TEST_DATASET.webTerminalHelpPath, + webTerminalConfigHelpPath: TEST_DATASET.webTerminalConfigHelpPath, + webTerminalRunnersHelpPath: TEST_DATASET.webTerminalRunnersHelpPath, }); }); diff --git a/spec/frontend/image_diff/init_discussion_tab_spec.js b/spec/frontend/image_diff/init_discussion_tab_spec.js index 5bc0c738944..f6f05037c95 100644 --- a/spec/frontend/image_diff/init_discussion_tab_spec.js +++ b/spec/frontend/image_diff/init_discussion_tab_spec.js @@ -11,23 +11,21 @@ describe('initDiscussionTab', () => { `); }); - it('should pass canCreateNote as false to initImageDiff', (done) => { + it('should pass canCreateNote as false to initImageDiff', () => { jest .spyOn(initImageDiffHelper, 'initImageDiff') .mockImplementation((diffFileEl, canCreateNote) => { expect(canCreateNote).toEqual(false); - done(); }); initDiscussionTab(); }); - it('should pass renderCommentBadge as true to initImageDiff', (done) => { + it('should pass renderCommentBadge as true to initImageDiff', () => { jest .spyOn(initImageDiffHelper, 'initImageDiff') .mockImplementation((diffFileEl, canCreateNote, renderCommentBadge) => { expect(renderCommentBadge).toEqual(true); - done(); }); initDiscussionTab(); diff --git a/spec/frontend/image_diff/replaced_image_diff_spec.js b/spec/frontend/image_diff/replaced_image_diff_spec.js index cc4a2530fc4..2b401fc46bf 100644 --- a/spec/frontend/image_diff/replaced_image_diff_spec.js +++ b/spec/frontend/image_diff/replaced_image_diff_spec.js @@ -176,34 +176,36 @@ describe('ReplacedImageDiff', () => { expect(ImageDiff.prototype.bindEvents).toHaveBeenCalled(); }); - it('should register click eventlistener to 2-up view mode', (done) => { - jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation((viewMode) => { - expect(viewMode).toEqual(viewTypes.TWO_UP); - done(); - }); + it('should register click eventlistener to 2-up view mode', () => { + const changeViewSpy = jest + .spyOn(ReplacedImageDiff.prototype, 'changeView') + .mockImplementation(() => {}); replacedImageDiff.bindEvents(); replacedImageDiff.viewModesEls[viewTypes.TWO_UP].click(); + + expect(changeViewSpy).toHaveBeenCalledWith(viewTypes.TWO_UP, expect.any(Object)); }); - it('should register click eventlistener to swipe view mode', (done) => { - jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation((viewMode) => { - expect(viewMode).toEqual(viewTypes.SWIPE); - done(); - }); + it('should register click eventlistener to swipe view mode', () => { + const changeViewSpy = jest + .spyOn(ReplacedImageDiff.prototype, 'changeView') + .mockImplementation(() => {}); replacedImageDiff.bindEvents(); replacedImageDiff.viewModesEls[viewTypes.SWIPE].click(); + + expect(changeViewSpy).toHaveBeenCalledWith(viewTypes.SWIPE, expect.any(Object)); }); - it('should register click eventlistener to onion skin view mode', (done) => { - jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation((viewMode) => { - expect(viewMode).toEqual(viewTypes.SWIPE); - done(); - }); + it('should register click eventlistener to onion skin view mode', () => { + const changeViewSpy = jest + .spyOn(ReplacedImageDiff.prototype, 'changeView') + .mockImplementation(() => {}); replacedImageDiff.bindEvents(); replacedImageDiff.viewModesEls[viewTypes.SWIPE].click(); + expect(changeViewSpy).toHaveBeenCalledWith(viewTypes.SWIPE, expect.any(Object)); }); }); @@ -325,32 +327,34 @@ describe('ReplacedImageDiff', () => { setupImageFrameEls(); }); - it('should pass showCommentIndicator normalized indicator values', (done) => { + it('should pass showCommentIndicator normalized indicator values', () => { jest.spyOn(imageDiffHelper, 'showCommentIndicator').mockImplementation(() => {}); - jest + const resizeCoordinatesToImageElementSpy = jest .spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement') - .mockImplementation((imageEl, meta) => { - expect(meta.x).toEqual(indicator.x); - expect(meta.y).toEqual(indicator.y); - expect(meta.width).toEqual(indicator.image.width); - expect(meta.height).toEqual(indicator.image.height); - done(); - }); + .mockImplementation(() => {}); + replacedImageDiff.renderNewView(indicator); + + expect(resizeCoordinatesToImageElementSpy).toHaveBeenCalledWith(undefined, { + x: indicator.x, + y: indicator.y, + width: indicator.image.width, + height: indicator.image.height, + }); }); - it('should call showCommentIndicator', (done) => { + it('should call showCommentIndicator', () => { const normalized = { normalized: true, }; jest.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').mockReturnValue(normalized); - jest + const showCommentIndicatorSpy = jest .spyOn(imageDiffHelper, 'showCommentIndicator') - .mockImplementation((imageFrameEl, normalizedIndicator) => { - expect(normalizedIndicator).toEqual(normalized); - done(); - }); + .mockImplementation(() => {}); + replacedImageDiff.renderNewView(indicator); + + expect(showCommentIndicatorSpy).toHaveBeenCalledWith(undefined, normalized); }); }); }); diff --git a/spec/frontend/import_entities/components/import_status_spec.js b/spec/frontend/import_entities/components/import_status_spec.js new file mode 100644 index 00000000000..686a21e3923 --- /dev/null +++ b/spec/frontend/import_entities/components/import_status_spec.js @@ -0,0 +1,145 @@ +import { GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ImportStatus from '~/import_entities/components/import_status.vue'; +import { STATUSES } from '~/import_entities/constants'; + +describe('Import entities status component', () => { + let wrapper; + + const createComponent = (propsData) => { + wrapper = shallowMount(ImportStatus, { + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('success status', () => { + const getStatusText = () => wrapper.findComponent(GlBadge).text(); + + it('displays finished status as complete when no stats are provided', () => { + createComponent({ + status: STATUSES.FINISHED, + }); + expect(getStatusText()).toBe('Complete'); + }); + + it('displays finished status as complete when all stats items were processed', () => { + const statItems = { label: 100, note: 200 }; + + createComponent({ + status: STATUSES.FINISHED, + stats: { + fetched: { ...statItems }, + imported: { ...statItems }, + }, + }); + + expect(getStatusText()).toBe('Complete'); + }); + + it('displays finished status as partial when all stats items were processed', () => { + const statItems = { label: 100, note: 200 }; + + createComponent({ + status: STATUSES.FINISHED, + stats: { + fetched: { ...statItems }, + imported: { ...statItems, label: 50 }, + }, + }); + + expect(getStatusText()).toBe('Partial import'); + }); + }); + + describe('details drawer', () => { + const findDetailsDrawer = () => wrapper.findComponent(GlAccordionItem); + + it('renders details drawer to be present when stats are provided', () => { + createComponent({ + status: 'created', + stats: { fetched: { label: 1 }, imported: { label: 0 } }, + }); + + expect(findDetailsDrawer().exists()).toBe(true); + }); + + it('does not render details drawer when no stats are provided', () => { + createComponent({ + status: 'created', + }); + + expect(findDetailsDrawer().exists()).toBe(false); + }); + + it('does not render details drawer when stats are empty', () => { + createComponent({ + status: 'created', + stats: { fetched: {}, imported: {} }, + }); + + expect(findDetailsDrawer().exists()).toBe(false); + }); + + it('does not render details drawer when no known stats are provided', () => { + createComponent({ + status: 'created', + stats: { + fetched: { + UNKNOWN_STAT: 100, + }, + imported: { + UNKNOWN_STAT: 0, + }, + }, + }); + + expect(findDetailsDrawer().exists()).toBe(false); + }); + }); + + describe('stats display', () => { + const getStatusIcon = () => + wrapper.findComponent(GlAccordionItem).findComponent(GlIcon).props().name; + + const createComponentWithStats = ({ fetched, imported }) => { + createComponent({ + status: 'created', + stats: { + fetched: { label: fetched }, + imported: { label: imported }, + }, + }); + }; + + it('displays scheduled status when imported is 0', () => { + createComponentWithStats({ + fetched: 100, + imported: 0, + }); + + expect(getStatusIcon()).toBe('status-scheduled'); + }); + + it('displays running status when imported is not equal to fetched', () => { + createComponentWithStats({ + fetched: 100, + imported: 10, + }); + + expect(getStatusIcon()).toBe('status-running'); + }); + + it('displays success status when imported is equal to fetched', () => { + createComponentWithStats({ + fetched: 100, + imported: 100, + }); + + expect(getStatusIcon()).toBe('status-success'); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js index 16adf88700f..88fcedd31b2 100644 --- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js @@ -31,7 +31,7 @@ describe('ImportProjectsTable', () => { const findImportAllButton = () => wrapper .findAll(GlButton) - .filter((w) => w.props().variant === 'success') + .filter((w) => w.props().variant === 'confirm') .at(0); const findImportAllModal = () => wrapper.find({ ref: 'importAllModal' }); diff --git a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js index c8afa9ea57d..41a005199e1 100644 --- a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js @@ -98,6 +98,8 @@ describe('ProviderRepoTableRow', () => { }); describe('when rendering imported project', () => { + const FAKE_STATS = {}; + const repo = { importSource: { id: 'remote-1', @@ -109,6 +111,7 @@ describe('ProviderRepoTableRow', () => { fullPath: 'fullPath', importSource: 'importSource', importStatus: STATUSES.FINISHED, + stats: FAKE_STATS, }, }; @@ -134,6 +137,10 @@ describe('ProviderRepoTableRow', () => { it('does not render import button', () => { expect(findImportButton().exists()).toBe(false); }); + + it('passes stats to import status component', () => { + expect(wrapper.find(ImportStatus).props().stats).toBe(FAKE_STATS); + }); }); describe('when rendering incompatible project', () => { diff --git a/spec/frontend/import_entities/import_projects/store/mutations_spec.js b/spec/frontend/import_entities/import_projects/store/mutations_spec.js index e062d889325..77fae951300 100644 --- a/spec/frontend/import_entities/import_projects/store/mutations_spec.js +++ b/spec/frontend/import_entities/import_projects/store/mutations_spec.js @@ -232,6 +232,35 @@ describe('import_projects store mutations', () => { updatedProjects[0].importStatus, ); }); + + it('updates import stats of project', () => { + const repoId = 1; + state = { + repositories: [ + { importedProject: { id: repoId, stats: {} }, importStatus: STATUSES.STARTED }, + ], + }; + const newStats = { + fetched: { + label: 10, + }, + imported: { + label: 1, + }, + }; + + const updatedProjects = [ + { + id: repoId, + importStatus: STATUSES.FINISHED, + stats: newStats, + }, + ]; + + mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects); + + expect(state.repositories[0].importedProject.stats).toStrictEqual(newStats); + }); }); describe(`${types.REQUEST_NAMESPACES}`, () => { diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index 9ed0294e876..a556f3c17f3 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -7,6 +7,7 @@ import { I18N, TH_CREATED_AT_TEST_ID, TH_SEVERITY_TEST_ID, + TH_ESCALATION_STATUS_TEST_ID, TH_PUBLISHED_TEST_ID, TH_INCIDENT_SLA_TEST_ID, trackIncidentCreateNewOptions, @@ -170,6 +171,7 @@ describe('Incidents List', () => { expect(link.text()).toBe(title); expect(link.attributes('href')).toContain(`issues/incident/${iid}`); + expect(link.find('.gl-text-truncate').exists()).toBe(true); }); describe('Assignees', () => { @@ -200,15 +202,14 @@ describe('Incidents List', () => { describe('Escalation status', () => { it('renders escalation status per row', () => { - expect(findEscalationStatus().length).toBe(mockIncidents.length); - - const actualStatuses = findEscalationStatus().wrappers.map((status) => status.text()); - expect(actualStatuses).toEqual([ - 'Triggered', - 'Acknowledged', - 'Resolved', - I18N.noEscalationStatus, - ]); + const statuses = findEscalationStatus().wrappers; + const expectedStatuses = ['Triggered', 'Acknowledged', 'Resolved', I18N.noEscalationStatus]; + + expect(statuses.length).toBe(mockIncidents.length); + statuses.forEach((status, index) => { + expect(status.text()).toEqual(expectedStatuses[index]); + expect(status.classes('gl-text-truncate')).toBe(true); + }); }); describe('when feature is disabled', () => { @@ -294,11 +295,12 @@ describe('Incidents List', () => { const noneSort = 'none'; it.each` - description | selector | initialSort | firstSort | nextSort - ${'creation date'} | ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort} - ${'severity'} | ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} - ${'publish date'} | ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} - ${'due date'} | ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort} + description | selector | initialSort | firstSort | nextSort + ${'creation date'} | ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort} + ${'severity'} | ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${'status'} | ${TH_ESCALATION_STATUS_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${'publish date'} | ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${'due date'} | ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort} `( 'updates sort with new direction when sorting by $description', async ({ selector, initialSort, firstSort, nextSort }) => { diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index c4569070d09..ca481e009cf 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -9,8 +9,6 @@ import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; import IntegrationForm from '~/integrations/edit/components/integration_form.vue'; -import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; -import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; @@ -55,7 +53,6 @@ describe('IntegrationForm', () => { OverrideDropdown, ActiveCheckbox, ConfirmationModal, - JiraTriggerFields, TriggerFields, }, mocks: { @@ -74,8 +71,6 @@ describe('IntegrationForm', () => { const findProjectSaveButton = () => wrapper.findByTestId('save-button'); const findInstanceOrGroupSaveButton = () => wrapper.findByTestId('save-button-instance-group'); const findTestButton = () => wrapper.findByTestId('test-button'); - const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields); - const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields); const findTriggerFields = () => wrapper.findComponent(TriggerFields); const findGlForm = () => wrapper.findComponent(GlForm); const findRedirectToField = () => wrapper.findByTestId('redirect-to-field'); @@ -198,49 +193,6 @@ describe('IntegrationForm', () => { }); }); - describe('type is "slack"', () => { - beforeEach(() => { - createComponent({ - customStateProps: { type: 'slack' }, - }); - }); - - it('does not render JiraTriggerFields', () => { - expect(findJiraTriggerFields().exists()).toBe(false); - }); - - it('does not render JiraIssuesFields', () => { - expect(findJiraIssuesFields().exists()).toBe(false); - }); - }); - - describe('type is "jira"', () => { - beforeEach(() => { - jest.spyOn(document, 'querySelector').mockReturnValue(document.createElement('form')); - - createComponent({ - customStateProps: { type: 'jira', testPath: '/test' }, - mountFn: mountExtended, - }); - }); - - it('renders JiraTriggerFields', () => { - expect(findJiraTriggerFields().exists()).toBe(true); - }); - - it('renders JiraIssuesFields', () => { - expect(findJiraIssuesFields().exists()).toBe(true); - }); - - describe('when JiraIssueFields emits `request-jira-issue-types` event', () => { - it('dispatches `requestJiraIssueTypes` action', () => { - findJiraIssuesFields().vm.$emit('request-jira-issue-types'); - - expect(dispatch).toHaveBeenCalledWith('requestJiraIssueTypes', expect.any(FormData)); - }); - }); - }); - describe('triggerEvents is present', () => { it('renders TriggerFields', () => { const events = [{ title: 'push' }]; @@ -272,9 +224,6 @@ describe('IntegrationForm', () => { ]; createComponent({ - provide: { - glFeatures: { integrationFormSections: true }, - }, customStateProps: { sections: [mockSectionConnection], fields: [...sectionFields, ...nonSectionFields], @@ -363,9 +312,6 @@ describe('IntegrationForm', () => { describe('when integration has sections', () => { beforeEach(() => { createComponent({ - provide: { - glFeatures: { integrationFormSections: true }, - }, customStateProps: { sections: [mockSectionConnection], }, @@ -396,9 +342,6 @@ describe('IntegrationForm', () => { ]; createComponent({ - provide: { - glFeatures: { integrationFormSections: true }, - }, customStateProps: { sections: [mockSectionConnection], fields: [...sectionFields, ...nonSectionFields], @@ -417,9 +360,6 @@ describe('IntegrationForm', () => { ({ formActive, novalidate }) => { beforeEach(() => { createComponent({ - provide: { - glFeatures: { integrationFormSections: true }, - }, customStateProps: { sections: [mockSectionConnection], showActive: true, @@ -441,9 +381,6 @@ describe('IntegrationForm', () => { jest.spyOn(document, 'querySelector').mockReturnValue(document.createElement('form')); createComponent({ - provide: { - glFeatures: { integrationFormSections: true }, - }, customStateProps: { sections: [mockSectionConnection], testPath: '/test', diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index 33fd08a5959..94e370a485f 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -10,7 +10,6 @@ describe('JiraIssuesFields', () => { let wrapper; const defaultProps = { - editProjectPath: '/edit', showJiraIssuesIntegration: true, showJiraVulnerabilitiesIntegration: true, upgradePlanPath: 'https://gitlab.com', @@ -46,7 +45,6 @@ describe('JiraIssuesFields', () => { const findPremiumUpgradeCTA = () => wrapper.findByTestId('premium-upgrade-cta'); const findUltimateUpgradeCTA = () => wrapper.findByTestId('ultimate-upgrade-cta'); const findJiraForVulnerabilities = () => wrapper.findByTestId('jira-for-vulnerabilities'); - const findConflictWarning = () => wrapper.findByTestId('conflict-warning-text'); const setEnableCheckbox = async (isEnabled = true) => findEnableCheckbox().vm.$emit('input', isEnabled); @@ -75,10 +73,9 @@ describe('JiraIssuesFields', () => { }); if (showJiraIssuesIntegration) { - it('renders checkbox and input field', () => { + it('renders enable checkbox', () => { expect(findEnableCheckbox().exists()).toBe(true); expect(findEnableCheckboxDisabled()).toBeUndefined(); - expect(findProjectKey().exists()).toBe(true); }); it('does not render the Premium CTA', () => { @@ -98,9 +95,8 @@ describe('JiraIssuesFields', () => { }); } } else { - it('does not render checkbox and input field', () => { + it('does not render enable checkbox', () => { expect(findEnableCheckbox().exists()).toBe(false); - expect(findProjectKey().exists()).toBe(false); }); it('renders the Premium CTA', () => { @@ -122,12 +118,8 @@ describe('JiraIssuesFields', () => { createComponent({ props: { initialProjectKey: '' } }); }); - it('renders disabled project_key input', () => { - const projectKey = findProjectKey(); - - expect(projectKey.exists()).toBe(true); - expect(projectKey.attributes('disabled')).toBe('disabled'); - expect(projectKey.attributes('required')).toBeUndefined(); + it('does not render project_key input', () => { + expect(findProjectKey().exists()).toBe(false); }); // As per https://vuejs.org/v2/guide/forms.html#Checkbox-1, @@ -137,45 +129,23 @@ describe('JiraIssuesFields', () => { }); describe('when isInheriting = true', () => { - it('disables checkbox and sets input as readonly', () => { + it('disables checkbox', () => { createComponent({ isInheriting: true }); expect(findEnableCheckboxDisabled()).toBe('disabled'); - expect(findProjectKey().attributes('readonly')).toBe('readonly'); }); }); describe('on enable issues', () => { - it('enables project_key input as required', async () => { + it('renders project_key input as required', async () => { await setEnableCheckbox(true); - expect(findProjectKey().attributes('disabled')).toBeUndefined(); + expect(findProjectKey().exists()).toBe(true); expect(findProjectKey().attributes('required')).toBe('required'); }); }); }); - it('contains link to editProjectPath', () => { - createComponent(); - - expect(wrapper.find(`a[href="${defaultProps.editProjectPath}"]`).exists()).toBe(true); - }); - - describe('GitLab issues warning', () => { - it.each` - gitlabIssuesEnabled | scenario - ${true} | ${'displays conflict warning'} - ${false} | ${'does not display conflict warning'} - `( - '$scenario when `gitlabIssuesEnabled` is `$gitlabIssuesEnabled`', - ({ gitlabIssuesEnabled }) => { - createComponent({ props: { gitlabIssuesEnabled } }); - - expect(findConflictWarning().exists()).toBe(gitlabIssuesEnabled); - }, - ); - }); - describe('Vulnerabilities creation', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js index 192f3fdd381..e1563a7bb3a 100644 --- a/spec/frontend/invite_members/components/group_select_spec.js +++ b/spec/frontend/invite_members/components/group_select_spec.js @@ -4,7 +4,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import * as groupsApi from '~/api/groups_api'; import GroupSelect from '~/invite_members/components/group_select.vue'; -const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; const group1 = { id: 1, full_name: 'Group One', avatar_url: 'test' }; const group2 = { id: 2, full_name: 'Group Two', avatar_url: 'test' }; const allGroups = [group1, group2]; @@ -13,7 +12,6 @@ const createComponent = (props = {}) => { return mount(GroupSelect, { propsData: { invalidGroups: [], - accessLevels, ...props, }, }); @@ -66,9 +64,8 @@ describe('GroupSelect', () => { resolveApiRequest({ data: allGroups }); expect(groupsApi.getGroups).toHaveBeenCalledWith(group1.name, { - active: true, exclude_internal: true, - min_access_level: accessLevels.Guest, + active: true, }); }); diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js index 8085f48f6e2..f9cb4a149f2 100644 --- a/spec/frontend/invite_members/components/invite_groups_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js @@ -42,18 +42,19 @@ describe('InviteGroupsModal', () => { wrapper = null; }); + const findModal = () => wrapper.findComponent(GlModal); const findGroupSelect = () => wrapper.findComponent(GroupSelect); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findInviteButton = () => wrapper.findByTestId('invite-button'); const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); const membersFormGroupInvalidFeedback = () => findMembersFormGroup().attributes('invalid-feedback'); - const clickInviteButton = () => findInviteButton().vm.$emit('click'); - const clickCancelButton = () => findCancelButton().vm.$emit('click'); - const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val); const findBase = () => wrapper.findComponent(InviteModalBase); - const hideModal = () => wrapper.findComponent(GlModal).vm.$emit('hide'); + const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val); + const emitEventFromModal = (eventName) => () => + findModal().vm.$emit(eventName, { preventDefault: jest.fn() }); + const hideModal = emitEventFromModal('hidden'); + const clickInviteButton = emitEventFromModal('primary'); + const clickCancelButton = emitEventFromModal('cancel'); describe('displaying the correct introText and form group description', () => { describe('when inviting to a project', () => { diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index dd16bb48cb8..84317da39e6 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -23,7 +23,7 @@ import ContentTransition from '~/vue_shared/components/content_transition.vue'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import { getParameterValues } from '~/lib/utils/url_utility'; -import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; +import { GROUPS_INVITATIONS_PATH, invitationsApiResponse } from '../mock_data/api_responses'; import { propsData, inviteSource, @@ -85,12 +85,13 @@ describe('InviteMembersModal', () => { mock.restore(); }); + const findModal = () => wrapper.findComponent(GlModal); const findBase = () => wrapper.findComponent(InviteModalBase); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findInviteButton = () => wrapper.findByTestId('invite-button'); - const clickInviteButton = () => findInviteButton().vm.$emit('click'); - const clickCancelButton = () => findCancelButton().vm.$emit('click'); + const emitEventFromModal = (eventName) => () => + findModal().vm.$emit(eventName, { preventDefault: jest.fn() }); + const clickInviteButton = emitEventFromModal('primary'); + const clickCancelButton = emitEventFromModal('cancel'); const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); const membersFormGroupInvalidFeedback = () => findMembersFormGroup().attributes('invalid-feedback'); @@ -276,7 +277,7 @@ describe('InviteMembersModal', () => { }); it('renders the modal with the correct title', () => { - expect(wrapper.findComponent(GlModal).props('title')).toBe(MEMBERS_MODAL_CELEBRATE_TITLE); + expect(findModal().props('title')).toBe(MEMBERS_MODAL_CELEBRATE_TITLE); }); it('includes the correct celebration text and emoji', () => { @@ -300,11 +301,8 @@ describe('InviteMembersModal', () => { }); describe('submitting the invite form', () => { - const mockMembersApi = (code, data) => { - mock.onPost(apiPaths.GROUPS_MEMBERS).reply(code, data); - }; const mockInvitationsApi = (code, data) => { - mock.onPost(apiPaths.GROUPS_INVITATIONS).reply(code, data); + mock.onPost(GROUPS_INVITATIONS_PATH).reply(code, data); }; const expectedEmailRestrictedError = @@ -328,7 +326,7 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user1, user2]); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); + jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData }); }); describe('when triggered from regular mounting', () => { @@ -336,12 +334,8 @@ describe('InviteMembersModal', () => { clickInviteButton(); }); - it('sets isLoading on the Invite button when it is clicked', () => { - expect(findInviteButton().props('loading')).toBe(true); - }); - - it('calls Api addGroupMembersByUserId with the correct params', () => { - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, postData); + it('calls Api inviteGroupMembers with the correct params', () => { + expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData); }); it('displays the successful toastMessage', () => { @@ -371,21 +365,9 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user1]); }); - it('displays "Member already exists" api message for http status conflict', async () => { - mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS); - - clickInviteButton(); - - await waitForPromises(); - - expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); - expect(findMembersSelect().props('validationState')).toBe(false); - expect(findInviteButton().props('loading')).toBe(false); - }); - describe('clearing the invalid state and message', () => { beforeEach(async () => { - mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN); clickInviteButton(); @@ -393,7 +375,9 @@ describe('InviteMembersModal', () => { }); it('clears the error when the list of members to invite is cleared', async () => { - expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); + expect(membersFormGroupInvalidFeedback()).toBe( + Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0], + ); expect(findMembersSelect().props('validationState')).toBe(false); findMembersSelect().vm.$emit('clear'); @@ -414,7 +398,7 @@ describe('InviteMembersModal', () => { }); it('clears the error when the modal is hidden', async () => { - wrapper.findComponent(GlModal).vm.$emit('hide'); + findModal().vm.$emit('hidden'); await nextTick(); @@ -424,15 +408,17 @@ describe('InviteMembersModal', () => { }); it('clears the invalid state and message once the list of members to invite is cleared', async () => { - mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN); clickInviteButton(); await waitForPromises(); - expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); + expect(membersFormGroupInvalidFeedback()).toBe( + Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0], + ); expect(findMembersSelect().props('validationState')).toBe(false); - expect(findInviteButton().props('loading')).toBe(false); + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); findMembersSelect().vm.$emit('clear'); @@ -440,11 +426,14 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toBe(''); expect(findMembersSelect().props('validationState')).toBe(null); - expect(findInviteButton().props('loading')).toBe(false); + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); }); it('displays the generic error for http server error', async () => { - mockMembersApi(httpStatus.INTERNAL_SERVER_ERROR, 'Request failed with status code 500'); + mockInvitationsApi( + httpStatus.INTERNAL_SERVER_ERROR, + 'Request failed with status code 500', + ); clickInviteButton(); @@ -454,7 +443,7 @@ describe('InviteMembersModal', () => { }); it('displays the restricted user api message for response with bad request', async () => { - mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_RESTRICTED); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED); clickInviteButton(); @@ -464,7 +453,7 @@ describe('InviteMembersModal', () => { }); it('displays the first part of the error when multiple existing users are restricted by email', async () => { - mockMembersApi(httpStatus.CREATED, membersApiResponse.MULTIPLE_USERS_RESTRICTED); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); clickInviteButton(); @@ -475,19 +464,6 @@ describe('InviteMembersModal', () => { ); expect(findMembersSelect().props('validationState')).toBe(false); }); - - it('displays an access_level error message received for the existing user', async () => { - mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_ACCESS_LEVEL); - - clickInviteButton(); - - await waitForPromises(); - - expect(membersFormGroupInvalidFeedback()).toBe( - 'should be greater than or equal to Owner inherited membership from group Gitlab Org', - ); - expect(findMembersSelect().props('validationState')).toBe(false); - }); }); }); @@ -508,7 +484,7 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user3]); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); + jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData }); }); describe('when triggered from regular mounting', () => { @@ -516,8 +492,8 @@ describe('InviteMembersModal', () => { clickInviteButton(); }); - it('calls Api inviteGroupMembersByEmail with the correct params', () => { - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, postData); + it('calls Api inviteGroupMembers with the correct params', () => { + expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData); }); it('displays the successful toastMessage', () => { @@ -542,7 +518,7 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); expect(findMembersSelect().props('validationState')).toBe(false); - expect(findInviteButton().props('loading')).toBe(false); + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); }); it('displays the restricted email error when restricted email is invited', async () => { @@ -554,23 +530,11 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError); expect(findMembersSelect().props('validationState')).toBe(false); - expect(findInviteButton().props('loading')).toBe(false); - }); - - it('displays the successful toast message when email has already been invited', async () => { - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN); - wrapper.vm.$toast = { show: jest.fn() }; - - clickInviteButton(); - - await waitForPromises(); - - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); - expect(findMembersSelect().props('validationState')).toBe(null); + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); }); it('displays the first error message when multiple emails return a restricted error message', async () => { - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); clickInviteButton(); @@ -617,19 +581,17 @@ describe('InviteMembersModal', () => { format: 'json', tasks_to_be_done: [], tasks_project_id: '', + user_id: '1', + email: 'email@example.com', }; - const emailPostData = { ...postData, email: 'email@example.com' }; - const idPostData = { ...postData, user_id: '1' }; - describe('when invites are sent successfully', () => { beforeEach(async () => { createComponent(); await triggerMembersTokenSelect([user1, user3]); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); - jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); + jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData }); }); describe('when triggered from regular mounting', () => { @@ -637,12 +599,8 @@ describe('InviteMembersModal', () => { clickInviteButton(); }); - it('calls Api inviteGroupMembersByEmail with the correct params', () => { - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, emailPostData); - }); - - it('calls Api addGroupMembersByUserId with the correct params', () => { - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, idPostData); + it('calls Api inviteGroupMembers with the correct params', () => { + expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData); }); it('displays the successful toastMessage', () => { @@ -655,12 +613,8 @@ describe('InviteMembersModal', () => { clickInviteButton(); - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, { - ...emailPostData, - invite_source: '_invite_source_', - }); - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, { - ...idPostData, + expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, { + ...postData, invite_source: '_invite_source_', }); }); @@ -673,7 +627,6 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user1, user3]); mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); - mockMembersApi(httpStatus.OK, '200 OK'); clickInviteButton(); }); @@ -692,7 +645,7 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user3]); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({}); + jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({}); }); it('tracks the view for learn_gitlab source', () => { diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js index 9e17112fb15..8355ae67f20 100644 --- a/spec/frontend/invite_members/components/invite_modal_base_spec.js +++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js @@ -49,8 +49,6 @@ describe('InviteModalBase', () => { const findDatepicker = () => wrapper.findComponent(GlDatepicker); const findLink = () => wrapper.findComponent(GlLink); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findInviteButton = () => wrapper.findByTestId('invite-button'); const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); describe('rendering the modal', () => { @@ -67,15 +65,21 @@ describe('InviteModalBase', () => { }); it('renders the Cancel button text correctly', () => { - expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT); - }); - - it('renders the Invite button text correctly', () => { - expect(findInviteButton().text()).toBe(INVITE_BUTTON_TEXT); + expect(wrapper.findComponent(GlModal).props('actionCancel')).toMatchObject({ + text: CANCEL_BUTTON_TEXT, + }); }); - it('renders the Invite button modal without isLoading', () => { - expect(findInviteButton().props('loading')).toBe(false); + it('renders the Invite button correctly', () => { + expect(wrapper.findComponent(GlModal).props('actionPrimary')).toMatchObject({ + text: INVITE_BUTTON_TEXT, + attributes: { + variant: 'confirm', + disabled: false, + loading: false, + 'data-qa-selector': 'invite_button', + }, + }); }); describe('rendering the access levels dropdown', () => { @@ -114,7 +118,7 @@ describe('InviteModalBase', () => { isLoading: true, }); - expect(findInviteButton().props('loading')).toBe(true); + expect(wrapper.findComponent(GlModal).props('actionPrimary').attributes.loading).toBe(true); }); it('with invalidFeedbackMessage, set members form group validation state', () => { diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js index 196a716d08c..bf5564e4d63 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -95,7 +95,7 @@ describe('MembersTokenSelect', () => { expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, { active: true, - exclude_internal: true, + without_project_bots: true, }); expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); }); @@ -172,7 +172,7 @@ describe('MembersTokenSelect', () => { expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, { active: true, - exclude_internal: true, + without_project_bots: true, saml_provider_id: samlProviderId, }); }); diff --git a/spec/frontend/invite_members/components/user_limit_notification_spec.js b/spec/frontend/invite_members/components/user_limit_notification_spec.js new file mode 100644 index 00000000000..c779cf2ee3f --- /dev/null +++ b/spec/frontend/invite_members/components/user_limit_notification_spec.js @@ -0,0 +1,71 @@ +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import UserLimitNotification from '~/invite_members/components/user_limit_notification.vue'; + +describe('UserLimitNotification', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + + const createComponent = (providers = {}) => { + wrapper = shallowMountExtended(UserLimitNotification, { + provide: { + name: 'my group', + newTrialRegistrationPath: 'newTrialRegistrationPath', + purchasePath: 'purchasePath', + freeUsersLimit: 5, + membersCount: 1, + ...providers, + }, + stubs: { GlSprintf }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when limit is not reached', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders empty block', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('when close to limit', () => { + beforeEach(() => { + createComponent({ membersCount: 3 }); + }); + + it("renders user's limit notification", () => { + const alert = findAlert(); + + expect(alert.attributes('title')).toEqual( + 'You only have space for 2 more members in my group', + ); + + expect(alert.text()).toEqual( + 'To get more members an owner of this namespace can start a trial or upgrade to a paid tier.', + ); + }); + }); + + describe('when limit is reached', () => { + beforeEach(() => { + createComponent({ membersCount: 5 }); + }); + + it("renders user's limit notification", () => { + const alert = findAlert(); + + expect(alert.attributes('title')).toEqual("You've reached your 5 members limit for my group"); + + expect(alert.text()).toEqual( + 'New members will be unable to participate. You can manage your members by removing ones you no longer need. To get more members an owner of this namespace can start a trial or upgrade to a paid tier.', + ); + }); + }); +}); diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js index a3e426376d8..4ad3b6aeb66 100644 --- a/spec/frontend/invite_members/mock_data/api_responses.js +++ b/spec/frontend/invite_members/mock_data/api_responses.js @@ -1,12 +1,12 @@ -const INVITATIONS_API_EMAIL_INVALID = { +const EMAIL_INVALID = { message: { error: 'email contains an invalid email address' }, }; -const INVITATIONS_API_ERROR_EMAIL_INVALID = { +const ERROR_EMAIL_INVALID = { error: 'email contains an invalid email address', }; -const INVITATIONS_API_EMAIL_RESTRICTED = { +const EMAIL_RESTRICTED = { message: { 'email@example.com': "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", @@ -14,65 +14,31 @@ const INVITATIONS_API_EMAIL_RESTRICTED = { status: 'error', }; -const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = { +const MULTIPLE_RESTRICTED = { message: { 'email@example.com': "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", 'email4@example.com': "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist.", - }, - status: 'error', -}; - -const INVITATIONS_API_EMAIL_TAKEN = { - message: { - 'email@example.org': 'Invite email has already been taken', - }, - status: 'error', -}; - -const MEMBERS_API_MEMBER_ALREADY_EXISTS = { - message: 'Member already exists', -}; - -const MEMBERS_API_SINGLE_USER_RESTRICTED = { - message: { - user: [ + root: "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", - ], }, + status: 'error', }; -const MEMBERS_API_SINGLE_USER_ACCESS_LEVEL = { +const EMAIL_TAKEN = { message: { - access_level: [ - 'should be greater than or equal to Owner inherited membership from group Gitlab Org', - ], + 'email@example.org': "The member's email address has already been taken", }, -}; - -const MEMBERS_API_MULTIPLE_USERS_RESTRICTED = { - message: - "root: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups. and user18: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist. and john_doe31: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Email restrictions for sign-ups.", status: 'error', }; -export const apiPaths = { - GROUPS_MEMBERS: '/api/v4/groups/1/members', - GROUPS_INVITATIONS: '/api/v4/groups/1/invitations', -}; - -export const membersApiResponse = { - MEMBER_ALREADY_EXISTS: MEMBERS_API_MEMBER_ALREADY_EXISTS, - SINGLE_USER_ACCESS_LEVEL: MEMBERS_API_SINGLE_USER_ACCESS_LEVEL, - SINGLE_USER_RESTRICTED: MEMBERS_API_SINGLE_USER_RESTRICTED, - MULTIPLE_USERS_RESTRICTED: MEMBERS_API_MULTIPLE_USERS_RESTRICTED, -}; +export const GROUPS_INVITATIONS_PATH = '/api/v4/groups/1/invitations'; export const invitationsApiResponse = { - EMAIL_INVALID: INVITATIONS_API_EMAIL_INVALID, - ERROR_EMAIL_INVALID: INVITATIONS_API_ERROR_EMAIL_INVALID, - EMAIL_RESTRICTED: INVITATIONS_API_EMAIL_RESTRICTED, - MULTIPLE_EMAIL_RESTRICTED: INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED, - EMAIL_TAKEN: INVITATIONS_API_EMAIL_TAKEN, + EMAIL_INVALID, + ERROR_EMAIL_INVALID, + EMAIL_RESTRICTED, + MULTIPLE_RESTRICTED, + EMAIL_TAKEN, }; diff --git a/spec/frontend/invite_members/mock_data/group_modal.js b/spec/frontend/invite_members/mock_data/group_modal.js index c05c4edb7d0..c8588683885 100644 --- a/spec/frontend/invite_members/mock_data/group_modal.js +++ b/spec/frontend/invite_members/mock_data/group_modal.js @@ -1,5 +1,6 @@ export const propsData = { id: '1', + rootId: '1', name: 'test name', isProject: false, invalidGroups: [], diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js index 590502909b2..1b0cc57fb5b 100644 --- a/spec/frontend/invite_members/mock_data/member_modal.js +++ b/spec/frontend/invite_members/mock_data/member_modal.js @@ -1,5 +1,6 @@ export const propsData = { id: '1', + rootId: '1', name: 'test name', isProject: false, accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, diff --git a/spec/frontend/invite_members/utils/response_message_parser_spec.js b/spec/frontend/invite_members/utils/response_message_parser_spec.js index e2cc87c8547..8b2064df374 100644 --- a/spec/frontend/invite_members/utils/response_message_parser_spec.js +++ b/spec/frontend/invite_members/utils/response_message_parser_spec.js @@ -2,23 +2,19 @@ import { responseMessageFromSuccess, responseMessageFromError, } from '~/invite_members/utils/response_message_parser'; -import { membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; +import { invitationsApiResponse } from '../mock_data/api_responses'; describe('Response message parser', () => { const expectedMessage = 'expected display and message.'; describe('parse message from successful response', () => { const exampleKeyedMsg = { 'email@example.com': expectedMessage }; - const exampleFirstPartMultiple = 'username1: expected display and message.'; - const exampleUserMsgMultiple = - ' and username2: id not found and restricted email. and username3: email is restricted.'; it.each([ - [[{ data: { message: expectedMessage } }]], - [[{ data: { message: exampleFirstPartMultiple + exampleUserMsgMultiple } }]], - [[{ data: { error: expectedMessage } }]], - [[{ data: { message: [expectedMessage] } }]], - [[{ data: { message: exampleKeyedMsg } }]], + [{ data: { message: expectedMessage } }], + [{ data: { error: expectedMessage } }], + [{ data: { message: [expectedMessage] } }], + [{ data: { message: exampleKeyedMsg } }], ])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => { expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage); }); @@ -27,8 +23,6 @@ describe('Response message parser', () => { describe('message from error response', () => { it.each([ [{ response: { data: { error: expectedMessage } } }], - [{ response: { data: { message: { user: [expectedMessage] } } } }], - [{ response: { data: { message: { access_level: [expectedMessage] } } } }], [{ response: { data: { message: { error: expectedMessage } } } }], [{ response: { data: { message: expectedMessage } } }], ])(`returns "${expectedMessage}" from error response: %j`, (errorResponse) => { @@ -41,18 +35,10 @@ describe('Response message parser', () => { "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups."; it.each([ - [[{ data: membersApiResponse.MULTIPLE_USERS_RESTRICTED }]], - [[{ data: invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED }]], - [[{ data: invitationsApiResponse.EMAIL_RESTRICTED }]], + [{ data: invitationsApiResponse.MULTIPLE_RESTRICTED }], + [{ data: invitationsApiResponse.EMAIL_RESTRICTED }], ])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse) => { expect(responseMessageFromSuccess(restrictedResponse)).toBe(expected); }); - - it.each([[{ response: { data: membersApiResponse.SINGLE_USER_RESTRICTED } }]])( - `returns "${expectedMessage}" from error response: %j`, - (singleRestrictedResponse) => { - expect(responseMessageFromError(singleRestrictedResponse)).toBe(expected); - }, - ); }); }); diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js index 321c61ead1e..99ed18cf5bd 100644 --- a/spec/frontend/issuable/issuable_form_spec.js +++ b/spec/frontend/issuable/issuable_form_spec.js @@ -1,20 +1,46 @@ import $ from 'jquery'; import IssuableForm from '~/issuable/issuable_form'; - -function createIssuable() { - const instance = new IssuableForm($(document.createElement('form'))); - - instance.titleField = $(document.createElement('input')); - - return instance; -} +import setWindowLocation from 'helpers/set_window_location_helper'; describe('IssuableForm', () => { let instance; + const createIssuable = (form) => { + instance = new IssuableForm(form); + }; + beforeEach(() => { - instance = createIssuable(); + setFixtures(` + <form> + <input name="[title]" /> + </form> + `); + createIssuable($('form')); + }); + + describe('initAutosave', () => { + it('creates autosave with the searchTerm included', () => { + setWindowLocation('https://gitlab.test/foo?bar=true'); + const autosave = instance.initAutosave(); + + expect(autosave.key.includes('bar=true')).toBe(true); + }); + + it("creates autosave fields without the searchTerm if it's an issue new form", () => { + setFixtures(` + <form data-new-issue-path="/issues/new"> + <input name="[title]" /> + </form> + `); + createIssuable($('form')); + + setWindowLocation('https://gitlab.test/issues/new?bar=true'); + + const autosave = instance.initAutosave(); + + expect(autosave.key.includes('bar=true')).toBe(false); + }); }); describe('removeWip', () => { diff --git a/spec/frontend/issues/create_merge_request_dropdown_spec.js b/spec/frontend/issues/create_merge_request_dropdown_spec.js index c2cfb16fdf7..20b26f5abba 100644 --- a/spec/frontend/issues/create_merge_request_dropdown_spec.js +++ b/spec/frontend/issues/create_merge_request_dropdown_spec.js @@ -15,7 +15,7 @@ describe('CreateMergeRequestDropdown', () => { <div id="dummy-wrapper-element"> <div class="available"></div> <div class="unavailable"> - <div class="gl-spinner"></div> + <div class="js-create-mr-spinner"></div> <div class="text"></div> </div> <div class="js-ref"></div> @@ -38,21 +38,16 @@ describe('CreateMergeRequestDropdown', () => { }); describe('getRef', () => { - it('escapes branch names correctly', (done) => { + it('escapes branch names correctly', async () => { const endpoint = `${dropdown.refsPath}contains%23hash`; jest.spyOn(axios, 'get'); axiosMock.onGet(endpoint).replyOnce({}); - dropdown - .getRef('contains#hash') - .then(() => { - expect(axios.get).toHaveBeenCalledWith( - endpoint, - expect.objectContaining({ cancelToken: expect.anything() }), - ); - }) - .then(done) - .catch(done.fail); + await dropdown.getRef('contains#hash'); + expect(axios.get).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ cancelToken: expect.anything() }), + ); }); }); diff --git a/spec/frontend/issues/list/components/issue_card_time_info_spec.js b/spec/frontend/issues/list/components/issue_card_time_info_spec.js index e9c48b60da4..c3f13ca6f9a 100644 --- a/spec/frontend/issues/list/components/issue_card_time_info_spec.js +++ b/spec/frontend/issues/list/components/issue_card_time_info_spec.js @@ -1,10 +1,11 @@ import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; +import { IssuableStatus } from '~/issues/constants'; import IssueCardTimeInfo from '~/issues/list/components/issue_card_time_info.vue'; describe('CE IssueCardTimeInfo component', () => { - useFakeDate(2020, 11, 11); + useFakeDate(2020, 11, 11); // 2020 Dec 11 let wrapper; @@ -24,7 +25,7 @@ describe('CE IssueCardTimeInfo component', () => { const findDueDate = () => wrapper.find('[data-testid="issuable-due-date"]'); const mountComponent = ({ - closedAt = null, + state = IssuableStatus.Open, dueDate = issue.dueDate, milestoneDueDate = issue.milestone.dueDate, milestoneStartDate = issue.milestone.startDate, @@ -38,7 +39,7 @@ describe('CE IssueCardTimeInfo component', () => { dueDate: milestoneDueDate, startDate: milestoneStartDate, }, - closedAt, + state, dueDate, }, }, @@ -91,7 +92,7 @@ describe('CE IssueCardTimeInfo component', () => { describe('when in the past', () => { describe('when issue is open', () => { it('renders in red', () => { - wrapper = mountComponent({ dueDate: new Date('2020-10-10') }); + wrapper = mountComponent({ dueDate: '2020-10-10' }); expect(findDueDate().classes()).toContain('gl-text-red-500'); }); @@ -100,8 +101,8 @@ describe('CE IssueCardTimeInfo component', () => { describe('when issue is closed', () => { it('does not render in red', () => { wrapper = mountComponent({ - dueDate: new Date('2020-10-10'), - closedAt: '2020-09-05T13:06:25Z', + dueDate: '2020-10-10', + state: IssuableStatus.Closed, }); expect(findDueDate().classes()).not.toContain('gl-text-red-500'); 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 33c7ccac180..5a9bd1ff8e4 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -452,13 +452,26 @@ describe('CE IssuesListApp component', () => { }); describe('IssuableByEmail component', () => { - describe.each([true, false])(`when issue creation by email is enabled=%s`, (enabled) => { - it(`${enabled ? 'renders' : 'does not render'}`, () => { - wrapper = mountComponent({ provide: { initialEmail: enabled } }); - - expect(findIssuableByEmail().exists()).toBe(enabled); - }); - }); + describe.each` + initialEmail | hasAnyIssues | isSignedIn | exists + ${false} | ${false} | ${false} | ${false} + ${false} | ${true} | ${false} | ${false} + ${false} | ${false} | ${true} | ${false} + ${false} | ${true} | ${true} | ${false} + ${true} | ${false} | ${false} | ${false} + ${true} | ${true} | ${false} | ${false} + ${true} | ${false} | ${true} | ${true} + ${true} | ${true} | ${true} | ${true} + `( + `when issue creation by email is enabled=$initialEmail`, + ({ initialEmail, hasAnyIssues, isSignedIn, exists }) => { + it(`${initialEmail ? 'renders' : 'does not render'}`, () => { + wrapper = mountComponent({ provide: { initialEmail, hasAnyIssues, isSignedIn } }); + + expect(findIssuableByEmail().exists()).toBe(exists); + }); + }, + ); }); describe('empty states', () => { diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js index c883b20682e..b1a135ceb18 100644 --- a/spec/frontend/issues/list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js @@ -21,7 +21,6 @@ export const getIssuesQueryResponse = { __typename: 'Issue', id: 'gid://gitlab/Issue/123456', iid: '789', - closedAt: null, confidential: false, createdAt: '2021-05-22T04:08:01Z', downvotes: 2, @@ -30,6 +29,7 @@ export const getIssuesQueryResponse = { humanTimeEstimate: null, mergeRequestsCount: false, moved: false, + state: 'opened', title: 'Issue title', updatedAt: '2021-05-22T04:08:01Z', upvotes: 3, diff --git a/spec/frontend/issues/related_merge_requests/store/actions_spec.js b/spec/frontend/issues/related_merge_requests/store/actions_spec.js index 5f232fee09b..4327fac15d4 100644 --- a/spec/frontend/issues/related_merge_requests/store/actions_spec.js +++ b/spec/frontend/issues/related_merge_requests/store/actions_spec.js @@ -23,90 +23,82 @@ describe('RelatedMergeRequest store actions', () => { }); describe('setInitialState', () => { - it('commits types.SET_INITIAL_STATE with given props', (done) => { + it('commits types.SET_INITIAL_STATE with given props', () => { const props = { a: 1, b: 2 }; - testAction( + return testAction( actions.setInitialState, props, {}, [{ type: types.SET_INITIAL_STATE, payload: props }], [], - done, ); }); }); describe('requestData', () => { - it('commits types.REQUEST_DATA', (done) => { - testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], [], done); + it('commits types.REQUEST_DATA', () => { + return testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], []); }); }); describe('receiveDataSuccess', () => { - it('commits types.RECEIVE_DATA_SUCCESS with data', (done) => { + it('commits types.RECEIVE_DATA_SUCCESS with data', () => { const data = { a: 1, b: 2 }; - testAction( + return testAction( actions.receiveDataSuccess, data, {}, [{ type: types.RECEIVE_DATA_SUCCESS, payload: data }], [], - done, ); }); }); describe('receiveDataError', () => { - it('commits types.RECEIVE_DATA_ERROR', (done) => { - testAction( + it('commits types.RECEIVE_DATA_ERROR', () => { + return testAction( actions.receiveDataError, null, {}, [{ type: types.RECEIVE_DATA_ERROR }], [], - done, ); }); }); describe('fetchMergeRequests', () => { describe('for a successful request', () => { - it('should dispatch success action', (done) => { + it('should dispatch success action', () => { const data = { a: 1 }; mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(200, data, { 'x-total': 2 }); - testAction( + return testAction( actions.fetchMergeRequests, null, state, [], [{ type: 'requestData' }, { type: 'receiveDataSuccess', payload: { data, total: 2 } }], - done, ); }); }); describe('for a failing request', () => { - it('should dispatch error action', (done) => { + it('should dispatch error action', async () => { mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(400); - testAction( + await testAction( actions.fetchMergeRequests, null, state, [], [{ type: 'requestData' }, { type: 'receiveDataError' }], - () => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringMatching('Something went wrong'), - }); - - done(); - }, ); + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('Something went wrong'), + }); }); }); }); diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js index ac2717a5028..5ab64d8e9ca 100644 --- a/spec/frontend/issues/show/components/app_spec.js +++ b/spec/frontend/issues/show/components/app_spec.js @@ -2,11 +2,14 @@ import { GlIntersectionObserver } from '@gitlab/ui'; 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 { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import '~/behaviors/markdown/render_gfm'; import { IssuableStatus, IssuableStatusText } from '~/issues/constants'; import IssuableApp from '~/issues/show/components/app.vue'; import DescriptionComponent from '~/issues/show/components/description.vue'; +import EditedComponent from '~/issues/show/components/edited.vue'; +import FormComponent from '~/issues/show/components/form.vue'; +import TitleComponent from '~/issues/show/components/title.vue'; import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue'; import PinnedLinks from '~/issues/show/components/pinned_links.vue'; import { POLLING_DELAY } from '~/issues/show/constants'; @@ -21,10 +24,6 @@ import { zoomMeetingUrl, } from '../mock_data/mock_data'; -function formatText(text) { - return text.trim().replace(/\s\s+/g, ' '); -} - jest.mock('~/lib/utils/url_utility'); jest.mock('~/issues/show/event_hub'); @@ -39,10 +38,15 @@ describe('Issuable output', () => { const findLockedBadge = () => wrapper.findByTestId('locked'); const findConfidentialBadge = () => wrapper.findByTestId('confidential'); const findHiddenBadge = () => wrapper.findByTestId('hidden'); - const findAlert = () => wrapper.find('.alert'); + + const findTitle = () => wrapper.findComponent(TitleComponent); + const findDescription = () => wrapper.findComponent(DescriptionComponent); + const findEdited = () => wrapper.findComponent(EditedComponent); + const findForm = () => wrapper.findComponent(FormComponent); + const findPinnedLinks = () => wrapper.findComponent(PinnedLinks); const mountComponent = (props = {}, options = {}, data = {}) => { - wrapper = mountExtended(IssuableApp, { + wrapper = shallowMountExtended(IssuableApp, { directives: { GlTooltip: createMockDirective(), }, @@ -104,23 +108,15 @@ describe('Issuable output', () => { }); it('should render a title/description/edited and update title/description/edited on update', () => { - let editedText; return axios .waitForAll() .then(() => { - editedText = wrapper.find('.edited-text'); - }) - .then(() => { - expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); - expect(wrapper.find('.title').text()).toContain('this is a title'); - expect(wrapper.find('.md').text()).toContain('this is a description!'); - expect(wrapper.find('.js-task-list-field').element.value).toContain( - 'this is a description', - ); + expect(findTitle().props('titleText')).toContain('this is a title'); + expect(findDescription().props('descriptionText')).toContain('this is a description'); - expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/); - expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/); - expect(editedText.find('time').text()).toBeTruthy(); + expect(findEdited().exists()).toBe(true); + expect(findEdited().props('updatedByPath')).toMatch(/\/some_user$/); + expect(findEdited().props('updatedAt')).toBeTruthy(); expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version); }) .then(() => { @@ -128,20 +124,13 @@ describe('Issuable output', () => { return axios.waitForAll(); }) .then(() => { - expect(document.querySelector('title').innerText).toContain('2 (#1)'); - expect(wrapper.find('.title').text()).toContain('2'); - expect(wrapper.find('.md').text()).toContain('42'); - expect(wrapper.find('.js-task-list-field').element.value).toContain('42'); - expect(wrapper.find('.edited-text').text()).toBeTruthy(); - expect(formatText(wrapper.find('.edited-text').text())).toMatch( - /Edited[\s\S]+?by Other User/, - ); + expect(findTitle().props('titleText')).toContain('2'); + expect(findDescription().props('descriptionText')).toContain('42'); - expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/); - expect(editedText.find('time').text()).toBeTruthy(); - // As the lock_version value does not differ from the server, - // we should not see an alert - expect(findAlert().exists()).toBe(false); + expect(findEdited().exists()).toBe(true); + expect(findEdited().props('updatedByName')).toBe('Other User'); + expect(findEdited().props('updatedByPath')).toMatch(/\/other_user$/); + expect(findEdited().props('updatedAt')).toBeTruthy(); }); }); @@ -149,7 +138,7 @@ describe('Issuable output', () => { wrapper.vm.showForm = true; await nextTick(); - expect(wrapper.find('.markdown-selector').exists()).toBe(true); + expect(findForm().exists()).toBe(true); }); it('does not show actions if permissions are incorrect', async () => { @@ -157,7 +146,7 @@ describe('Issuable output', () => { wrapper.setProps({ canUpdate: false }); await nextTick(); - expect(wrapper.find('.markdown-selector').exists()).toBe(false); + expect(findForm().exists()).toBe(false); }); it('does not update formState if form is already open', async () => { @@ -177,8 +166,7 @@ describe('Issuable output', () => { ${'zoomMeetingUrl'} | ${zoomMeetingUrl} ${'publishedIncidentUrl'} | ${publishedIncidentUrl} `('sets the $prop correctly on underlying pinned links', ({ prop, value }) => { - expect(wrapper.vm[prop]).toBe(value); - expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value); + expect(findPinnedLinks().props(prop)).toBe(value); }); }); @@ -327,7 +315,6 @@ describe('Issuable output', () => { expect(wrapper.vm.formState.lockedWarningVisible).toBe(true); expect(wrapper.vm.formState.lock_version).toBe(1); - expect(findAlert().exists()).toBe(true); }); }); @@ -374,15 +361,22 @@ describe('Issuable output', () => { }); describe('show inline edit button', () => { - it('should not render by default', () => { - expect(wrapper.find('.btn-edit').exists()).toBe(true); + it('should render by default', () => { + expect(findTitle().props('showInlineEditButton')).toBe(true); }); it('should render if showInlineEditButton', async () => { wrapper.setProps({ showInlineEditButton: true }); await nextTick(); - expect(wrapper.find('.btn-edit').exists()).toBe(true); + expect(findTitle().props('showInlineEditButton')).toBe(true); + }); + + it('should not render if showInlineEditButton is false', async () => { + wrapper.setProps({ showInlineEditButton: false }); + + await nextTick(); + expect(findTitle().props('showInlineEditButton')).toBe(false); }); }); @@ -533,13 +527,11 @@ describe('Issuable output', () => { describe('Composable description component', () => { const findIncidentTabs = () => wrapper.findComponent(IncidentTabs); - const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent); - const findPinnedLinks = () => wrapper.findComponent(PinnedLinks); const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'; describe('when using description component', () => { it('renders the description component', () => { - expect(findDescriptionComponent().exists()).toBe(true); + expect(findDescription().exists()).toBe(true); }); it('does not render incident tabs', () => { @@ -572,8 +564,8 @@ describe('Issuable output', () => { ); }); - it('renders the description component', () => { - expect(findDescriptionComponent().exists()).toBe(true); + it('does not the description component', () => { + expect(findDescription().exists()).toBe(false); }); it('renders incident tabs', () => { diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 08f8996de6f..0b3daadae1d 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -1,26 +1,35 @@ import $ from 'jquery'; import { nextTick } from 'vue'; import '~/behaviors/markdown/render_gfm'; -import { GlPopover, GlModal } from '@gitlab/ui'; +import { GlTooltip, GlModal } from '@gitlab/ui'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { stubComponent } from 'helpers/stub_component'; import { TEST_HOST } from 'helpers/test_constants'; import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createFlash from '~/flash'; import Description from '~/issues/show/components/description.vue'; +import { updateHistory } from '~/lib/utils/url_utility'; import TaskList from '~/task_list'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import { descriptionProps as initialProps, descriptionHtmlWithCheckboxes, + descriptionHtmlWithTask, } from '../mock_data/mock_data'; jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); jest.mock('~/task_list'); const showModal = jest.fn(); const hideModal = jest.fn(); +const $toast = { + show: jest.fn(), +}; describe('Description component', () => { let wrapper; @@ -28,10 +37,9 @@ describe('Description component', () => { const findGfmContent = () => wrapper.find('[data-testid="gfm-content"]'); const findTextarea = () => wrapper.find('[data-testid="textarea"]'); const findTaskActionButtons = () => wrapper.findAll('.js-add-task'); - const findConvertToTaskButton = () => wrapper.find('[data-testid="convert-to-task"]'); - const findTaskSvg = () => wrapper.find('[data-testid="issue-open-m-icon"]'); + const findConvertToTaskButton = () => wrapper.find('.js-add-task'); - const findPopovers = () => wrapper.findAllComponents(GlPopover); + const findTooltips = () => wrapper.findAllComponents(GlTooltip); const findModal = () => wrapper.findComponent(GlModal); const findCreateWorkItem = () => wrapper.findComponent(CreateWorkItem); const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal); @@ -39,10 +47,14 @@ describe('Description component', () => { function createComponent({ props = {}, provide = {} } = {}) { wrapper = shallowMountExtended(Description, { propsData: { + issueId: 1, ...initialProps, ...props, }, provide, + mocks: { + $toast, + }, stubs: { GlModal: stubComponent(GlModal, { methods: { @@ -50,12 +62,13 @@ describe('Description component', () => { hide: hideModal, }, }), - GlPopover, }, }); } beforeEach(() => { + setWindowLocation(TEST_HOST); + if (!document.querySelector('.issuable-meta')) { const metaData = document.createElement('div'); metaData.classList.add('issuable-meta'); @@ -253,9 +266,9 @@ describe('Description component', () => { expect(findTaskActionButtons()).toHaveLength(3); }); - it('renders a list of popovers corresponding to checkboxes in description HTML', () => { - expect(findPopovers()).toHaveLength(3); - expect(findPopovers().at(0).props('target')).toBe( + it('renders a list of tooltips corresponding to checkboxes in description HTML', () => { + expect(findTooltips()).toHaveLength(3); + expect(findTooltips().at(0).props('target')).toBe( findTaskActionButtons().at(0).attributes('id'), ); }); @@ -264,92 +277,113 @@ describe('Description component', () => { expect(findModal().props('visible')).toBe(false); }); - it('opens a modal when a button on popover is clicked and displays correct title', async () => { - findConvertToTaskButton().vm.$emit('click'); - expect(showModal).toHaveBeenCalled(); - await nextTick(); + it('opens a modal when a button is clicked and displays correct title', async () => { + await findConvertToTaskButton().trigger('click'); expect(findCreateWorkItem().props('initialTitle').trim()).toBe('todo 1'); }); - it('closes the modal on `closeCreateTaskModal` event', () => { - findConvertToTaskButton().vm.$emit('click'); + it('closes the modal on `closeCreateTaskModal` event', async () => { + await findConvertToTaskButton().trigger('click'); findCreateWorkItem().vm.$emit('closeModal'); expect(hideModal).toHaveBeenCalled(); }); - it('updates description HTML on `onCreate` event', async () => { - const newTitle = 'New title'; - findConvertToTaskButton().vm.$emit('click'); - findCreateWorkItem().vm.$emit('onCreate', { title: newTitle }); + it('emits `updateDescription` on `onCreate` event', () => { + const newDescription = `<p>New description</p>`; + findCreateWorkItem().vm.$emit('onCreate', newDescription); expect(hideModal).toHaveBeenCalled(); - await nextTick(); + expect(wrapper.emitted('updateDescription')).toEqual([[newDescription]]); + }); + + it('shows toast after delete success', async () => { + findWorkItemDetailModal().vm.$emit('workItemDeleted'); - expect(findTaskSvg().exists()).toBe(true); - expect(wrapper.text()).toContain(newTitle); + expect($toast.show).toHaveBeenCalledWith('Work item deleted'); }); }); describe('work items detail', () => { - const id = '1'; - const title = 'my first task'; - const type = 'task'; + const findTaskLink = () => wrapper.find('a.gfm-issue'); - const createThenClickOnTask = () => { - findConvertToTaskButton().vm.$emit('click'); - findCreateWorkItem().vm.$emit('onCreate', { id, title, type }); - return wrapper.findByRole('button', { name: title }).trigger('click'); - }; - - beforeEach(() => { - createComponent({ - props: { - descriptionHtml: descriptionHtmlWithCheckboxes, - }, - provide: { - glFeatures: { workItems: true }, - }, + describe('when opening and closing', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: descriptionHtmlWithTask, + }, + provide: { + glFeatures: { workItems: true }, + }, + }); + return nextTick(); }); - return nextTick(); - }); - it('opens when task button is clicked', async () => { - expect(findWorkItemDetailModal().props('visible')).toBe(false); + it('opens when task button is clicked', async () => { + expect(findWorkItemDetailModal().props('visible')).toBe(false); - await createThenClickOnTask(); + await findTaskLink().trigger('click'); - expect(findWorkItemDetailModal().props('visible')).toBe(true); - }); + expect(findWorkItemDetailModal().props('visible')).toBe(true); + expect(updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?work_item_id=2`, + replace: true, + }); + }); - it('closes from an open state', async () => { - await createThenClickOnTask(); + it('closes from an open state', async () => { + await findTaskLink().trigger('click'); - expect(findWorkItemDetailModal().props('visible')).toBe(true); + expect(findWorkItemDetailModal().props('visible')).toBe(true); - findWorkItemDetailModal().vm.$emit('close'); - await nextTick(); + findWorkItemDetailModal().vm.$emit('close'); + await nextTick(); - expect(findWorkItemDetailModal().props('visible')).toBe(false); - }); + expect(findWorkItemDetailModal().props('visible')).toBe(false); + expect(updateHistory).toHaveBeenLastCalledWith({ + url: `${TEST_HOST}/`, + replace: true, + }); + }); - it('shows error on error', async () => { - const message = 'I am error'; + it('tracks when opened', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - await createThenClickOnTask(); - findWorkItemDetailModal().vm.$emit('error', message); + await findTaskLink().trigger('click'); - expect(createFlash).toHaveBeenCalledWith({ message }); + expect(trackingSpy).toHaveBeenCalledWith( + 'workItems:show', + 'viewed_work_item_from_modal', + { + category: 'workItems:show', + label: 'work_item_view', + property: 'type_task', + }, + ); + }); }); - it('tracks when opened', async () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - await createThenClickOnTask(); - - expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'viewed_work_item_from_modal', { - category: 'workItems:show', - label: 'work_item_view', - property: 'type_task', - }); + describe('when url query `work_item_id` exists', () => { + it.each` + behavior | workItemId | visible + ${'opens'} | ${'123'} | ${true} + ${'does not open'} | ${'123e'} | ${false} + ${'does not open'} | ${'12e3'} | ${false} + ${'does not open'} | ${'1e23'} | ${false} + ${'does not open'} | ${'x'} | ${false} + ${'does not open'} | ${'undefined'} | ${false} + `( + '$behavior when url contains `work_item_id=$workItemId`', + async ({ workItemId, visible }) => { + setWindowLocation(`?work_item_id=${workItemId}`); + + createComponent({ + props: { descriptionHtml: descriptionHtmlWithTask }, + provide: { glFeatures: { workItems: true } }, + }); + + expect(findWorkItemDetailModal().props('visible')).toBe(visible); + }, + ); }); }); }); diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index dd511c3945c..0dcd70ac19b 100644 --- a/spec/frontend/issues/show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -14,9 +14,8 @@ describe('Description field component', () => { propsData: { markdownPreviewPath: '/', markdownDocsPath: '/', - formState: { - description, - }, + quickActionsDocsPath: '/', + value: description, }, stubs: { MarkdownField, diff --git a/spec/frontend/issues/show/components/fields/description_template_spec.js b/spec/frontend/issues/show/components/fields/description_template_spec.js index abe2805e5b2..79a3bfa9840 100644 --- a/spec/frontend/issues/show/components/fields/description_template_spec.js +++ b/spec/frontend/issues/show/components/fields/description_template_spec.js @@ -1,74 +1,65 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; import descriptionTemplate from '~/issues/show/components/fields/description_template.vue'; describe('Issue description template component with templates as hash', () => { - let vm; - let formState; + let wrapper; + const defaultOptions = { + propsData: { + value: 'test', + issuableTemplates: { + test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + }, + projectId: 1, + projectPath: '/', + namespacePath: '/', + projectNamespace: '/', + }, + }; - beforeEach(() => { - const Component = Vue.extend(descriptionTemplate); - formState = { - description: 'test', - }; + const findIssuableSelector = () => wrapper.find('.js-issuable-selector'); - vm = new Component({ - propsData: { - formState, - issuableTemplates: { - test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], - }, - projectId: 1, - projectPath: '/', - namespacePath: '/', - projectNamespace: '/', - }, - }).$mount(); + const createComponent = (options = defaultOptions) => { + wrapper = shallowMount(descriptionTemplate, options); + }; + + afterEach(() => { + wrapper.destroy(); }); it('renders templates as JSON hash in data attribute', () => { - expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe( + createComponent(); + expect(findIssuableSelector().attributes('data-data')).toBe( '{"test":[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]}', ); }); - it('updates formState when changing template', () => { - vm.issuableTemplate.editor.setValue('test new template'); + it('emits input event', () => { + createComponent(); + wrapper.vm.issuableTemplate.editor.setValue('test new template'); - expect(formState.description).toBe('test new template'); + expect(wrapper.emitted('input')).toEqual([['test new template']]); }); - it('returns formState description with editor getValue', () => { - formState.description = 'testing new template'; - - expect(vm.issuableTemplate.editor.getValue()).toBe('testing new template'); + it('returns value with editor getValue', () => { + createComponent(); + expect(wrapper.vm.issuableTemplate.editor.getValue()).toBe('test'); }); -}); - -describe('Issue description template component with templates as array', () => { - let vm; - let formState; - beforeEach(() => { - const Component = Vue.extend(descriptionTemplate); - formState = { - description: 'test', - }; - - vm = new Component({ - propsData: { - formState, - issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], - projectId: 1, - projectPath: '/', - namespacePath: '/', - projectNamespace: '/', - }, - }).$mount(); - }); - - it('renders templates as JSON array in data attribute', () => { - expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe( - '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]', - ); + describe('Issue description template component with templates as array', () => { + it('renders templates as JSON array in data attribute', () => { + createComponent({ + propsData: { + value: 'test', + issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + projectId: 1, + projectPath: '/', + namespacePath: '/', + projectNamespace: '/', + }, + }); + expect(findIssuableSelector().attributes('data-data')).toBe( + '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]', + ); + }); }); }); diff --git a/spec/frontend/issues/show/components/fields/title_spec.js b/spec/frontend/issues/show/components/fields/title_spec.js index efd0b6fbd30..de04405d89b 100644 --- a/spec/frontend/issues/show/components/fields/title_spec.js +++ b/spec/frontend/issues/show/components/fields/title_spec.js @@ -12,9 +12,7 @@ describe('Title field component', () => { wrapper = shallowMount(TitleField, { propsData: { - formState: { - title: 'test', - }, + value: 'test', }, }); }); diff --git a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js index 20c6cda33d4..35acca60de7 100644 --- a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js @@ -34,8 +34,9 @@ describe('Incident Tabs component', () => { provide: { fullPath: '', iid: '', + projectId: '', uploadMetricsFeatureAvailable: true, - glFeatures: { incidentTimelineEventTab: true, incidentTimelineEvents: true }, + glFeatures: { incidentTimeline: true, incidentTimelineEvents: true }, }, data() { return { alert: mockAlert, ...data }; @@ -57,7 +58,6 @@ describe('Incident Tabs component', () => { const findTabs = () => wrapper.findAll(GlTab); const findSummaryTab = () => findTabs().at(0); - const findMetricsTab = () => wrapper.find('[data-testid="metrics-tab"]'); const findAlertDetailsTab = () => wrapper.find('[data-testid="alert-details-tab"]'); const findAlertDetailsComponent = () => wrapper.find(AlertDetailsTable); const findDescriptionComponent = () => wrapper.find(DescriptionComponent); @@ -111,20 +111,6 @@ describe('Incident Tabs component', () => { }); }); - describe('upload metrics feature available', () => { - it('shows the metric tab when metrics are available', () => { - mountComponent({}, { provide: { uploadMetricsFeatureAvailable: true } }); - - expect(findMetricsTab().exists()).toBe(true); - }); - - it('hides the tab when metrics are not available', () => { - mountComponent({}, { provide: { uploadMetricsFeatureAvailable: false } }); - - expect(findMetricsTab().exists()).toBe(false); - }); - }); - describe('Snowplow tracking', () => { beforeEach(() => { jest.spyOn(Tracking, 'event'); diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js index 89653ff82b2..7b0b8ca686a 100644 --- a/spec/frontend/issues/show/mock_data/mock_data.js +++ b/spec/frontend/issues/show/mock_data/mock_data.js @@ -72,3 +72,18 @@ export const descriptionHtmlWithCheckboxes = ` </li> </ul> `; + +export const descriptionHtmlWithTask = ` + <ul data-sourcepos="1:1-3:7" class="task-list" dir="auto"> + <li data-sourcepos="1:1-1:10" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> + <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip">1 (#48)</a> + </li> + <li data-sourcepos="2:1-2:7" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> 2 + </li> + <li data-sourcepos="3:1-3:7" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> 3 + </li> + </ul> +`; diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js index b0d5859cd31..3d7bf7acb41 100644 --- a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js @@ -72,7 +72,7 @@ describe('GroupsListItem', () => { expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path); expect(persistAlert).toHaveBeenCalledWith({ - linkUrl: '/help/integration/jira_development_panel.html#usage', + linkUrl: '/help/integration/jira_development_panel.html#use-the-integration', message: 'You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', title: 'Namespace successfully linked', diff --git a/spec/frontend/jira_connect/subscriptions/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js index 6b3ca7ffd65..ce02144f22f 100644 --- a/spec/frontend/jira_connect/subscriptions/components/app_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js @@ -6,10 +6,12 @@ import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue'; import SignInPage from '~/jira_connect/subscriptions/pages/sign_in.vue'; import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions.vue'; import UserLink from '~/jira_connect/subscriptions/components/user_link.vue'; +import BrowserSupportAlert from '~/jira_connect/subscriptions/components/browser_support_alert.vue'; import createStore from '~/jira_connect/subscriptions/store'; import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants'; import { __ } from '~/locale'; +import AccessorUtilities from '~/lib/utils/accessor'; import { mockSubscription } from '../mock_data'; jest.mock('~/jira_connect/subscriptions/utils', () => ({ @@ -26,6 +28,7 @@ describe('JiraConnectApp', () => { const findSignInPage = () => wrapper.findComponent(SignInPage); const findSubscriptionsPage = () => wrapper.findComponent(SubscriptionsPage); const findUserLink = () => wrapper.findComponent(UserLink); + const findBrowserSupportAlert = () => wrapper.findComponent(BrowserSupportAlert); const createComponent = ({ provide, mountFn = shallowMountExtended } = {}) => { store = createStore(); @@ -207,4 +210,29 @@ describe('JiraConnectApp', () => { }); }); }); + + describe.each` + jiraConnectOauthEnabled | canUseCrypto | shouldShowAlert + ${false} | ${false} | ${false} + ${false} | ${true} | ${false} + ${true} | ${false} | ${true} + ${true} | ${true} | ${false} + `( + 'when `jiraConnectOauth` feature flag is $jiraConnectOauthEnabled and `AccessorUtilities.canUseCrypto` returns $canUseCrypto', + ({ jiraConnectOauthEnabled, canUseCrypto, shouldShowAlert }) => { + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'canUseCrypto').mockReturnValue(canUseCrypto); + + createComponent({ provide: { glFeatures: { jiraConnectOauth: jiraConnectOauthEnabled } } }); + }); + + it(`does ${shouldShowAlert ? '' : 'not'} render BrowserSupportAlert component`, () => { + expect(findBrowserSupportAlert().exists()).toBe(shouldShowAlert); + }); + + it(`does ${!shouldShowAlert ? '' : 'not'} render the main Jira Connect app template`, () => { + expect(wrapper.findByTestId('jira-connect-app').exists()).toBe(!shouldShowAlert); + }); + }, + ); }); diff --git a/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js b/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js new file mode 100644 index 00000000000..aa93a6be3c8 --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js @@ -0,0 +1,37 @@ +import { GlAlert, GlLink } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import BrowserSupportAlert from '~/jira_connect/subscriptions/components/browser_support_alert.vue'; + +describe('BrowserSupportAlert', () => { + let wrapper; + + const createComponent = ({ mountFn = shallowMount } = {}) => { + wrapper = mountFn(BrowserSupportAlert); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a non-dismissible alert', () => { + createComponent(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().props()).toMatchObject({ + dismissible: false, + title: BrowserSupportAlert.i18n.title, + variant: 'danger', + }); + }); + + it('renders help link with target="_blank" and rel="noopener noreferrer"', () => { + createComponent({ mountFn: mount }); + expect(findLink().attributes()).toMatchObject({ + target: '_blank', + rel: 'noopener', + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js b/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js index f8ee8c2c664..5f38a0acb9d 100644 --- a/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js @@ -29,7 +29,7 @@ describe('CompatibilityAlert', () => { createComponent({ mountFn: mount }); expect(findLink().attributes()).toMatchObject({ target: '_blank', - rel: 'noopener noreferrer', + rel: 'noopener', }); }); diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js index 175896c4ab0..97d1b077164 100644 --- a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js +++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js @@ -5,7 +5,7 @@ import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_ import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue'; import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue'; import createStore from '~/jira_connect/subscriptions/store'; -import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '../../../../../app/assets/javascripts/jira_connect/subscriptions/constants'; +import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '~/jira_connect/subscriptions/constants'; jest.mock('~/jira_connect/subscriptions/utils'); diff --git a/spec/frontend/jira_import/components/jira_import_form_spec.js b/spec/frontend/jira_import/components/jira_import_form_spec.js index 7a550d85204..41d3cd46d01 100644 --- a/spec/frontend/jira_import/components/jira_import_form_spec.js +++ b/spec/frontend/jira_import/components/jira_import_form_spec.js @@ -6,7 +6,7 @@ import { GlFormSelect, GlLabel, GlSearchBoxByType, - GlTable, + GlTableLite, } from '@gitlab/ui'; import { getByRole } from '@testing-library/dom'; import { mount, shallowMount } from '@vue/test-utils'; @@ -34,19 +34,19 @@ describe('JiraImportForm', () => { const currentUsername = 'mrgitlab'; - const getAlert = () => wrapper.find(GlAlert); + const getAlert = () => wrapper.findComponent(GlAlert); - const getSelectDropdown = () => wrapper.find(GlFormSelect); + const getSelectDropdown = () => wrapper.findComponent(GlFormSelect); - const getContinueButton = () => wrapper.find(GlButton); + const getContinueButton = () => wrapper.findComponent(GlButton); - const getCancelButton = () => wrapper.findAll(GlButton).at(1); + const getCancelButton = () => wrapper.findAllComponents(GlButton).at(1); - const getLabel = () => wrapper.find(GlLabel); + const getLabel = () => wrapper.findComponent(GlLabel); - const getTable = () => wrapper.find(GlTable); + const getTable = () => wrapper.findComponent(GlTableLite); - const getUserDropdown = () => getTable().find(GlDropdown); + const getUserDropdown = () => getTable().findComponent(GlDropdown); const getHeader = (name) => getByRole(wrapper.element, 'columnheader', { name }); @@ -107,14 +107,13 @@ describe('JiraImportForm', () => { mutateSpy.mockRestore(); querySpy.mockRestore(); wrapper.destroy(); - wrapper = null; }); describe('select dropdown project selection', () => { it('is shown', () => { wrapper = mountComponent(); - expect(wrapper.find(GlFormSelect).exists()).toBe(true); + expect(getSelectDropdown().exists()).toBe(true); }); it('contains a list of Jira projects to select from', () => { @@ -273,7 +272,7 @@ describe('JiraImportForm', () => { wrapper = mountComponent({ mountFunction: mount }); - wrapper.find(GlSearchBoxByType).vm.$emit('input', 'fred'); + wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'fred'); }); it('makes a GraphQL call', () => { diff --git a/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js new file mode 100644 index 00000000000..322cfa3ba1f --- /dev/null +++ b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js @@ -0,0 +1,49 @@ +import { GlFilteredSearch } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue'; +import { mockFailedSearchToken } from '../../mock_data'; + +describe('Jobs filtered search', () => { + let wrapper; + + const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const getSearchToken = (type) => + findFilteredSearch() + .props('availableTokens') + .find((token) => token.type === type); + + const findStatusToken = () => getSearchToken('status'); + + const createComponent = () => { + wrapper = shallowMount(JobsFilteredSearch); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays filtered search', () => { + expect(findFilteredSearch().exists()).toBe(true); + }); + + it('displays status token', () => { + expect(findStatusToken()).toMatchObject({ + type: 'status', + icon: 'status', + title: 'Status', + unique: true, + operators: OPERATOR_IS_ONLY, + }); + }); + + it('emits filter token to parent component', () => { + findFilteredSearch().vm.$emit('submit', mockFailedSearchToken); + + expect(wrapper.emitted('filterJobsBySearch')).toEqual([[mockFailedSearchToken]]); + }); +}); diff --git a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js new file mode 100644 index 00000000000..ce8e482cc16 --- /dev/null +++ b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js @@ -0,0 +1,57 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import JobStatusToken from '~/jobs/components/filtered_search/tokens/job_status_token.vue'; + +describe('Job Status Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findAllGlIcons = () => wrapper.findAllComponents(GlIcon); + + const defaultProps = { + config: { + type: 'status', + icon: 'status', + title: 'Status', + unique: true, + }, + value: { + data: '', + }, + }; + + const createComponent = () => { + wrapper = shallowMount(JobStatusToken, { + propsData: { + ...defaultProps, + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="suggestions"></slot></div>`, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('renders all job statuses available', () => { + const expectedLength = 11; + + expect(findAllFilteredSearchSuggestions()).toHaveLength(expectedLength); + expect(findAllGlIcons()).toHaveLength(expectedLength); + }); +}); diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index 06ebcd7f134..9abe66b4696 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -375,8 +375,8 @@ describe('Job App', () => { }); describe('sidebar', () => { - it('has no blank blocks', (done) => { - setupAndMount({ + it('has no blank blocks', async () => { + await setupAndMount({ jobData: { duration: null, finished_at: null, @@ -387,17 +387,14 @@ describe('Job App', () => { tags: [], cancel_path: null, }, - }) - .then(() => { - const blocks = wrapper.findAll('.blocks-container > *').wrappers; - expect(blocks.length).toBeGreaterThan(0); - - blocks.forEach((block) => { - expect(block.text().trim()).not.toBe(''); - }); - }) - .then(done) - .catch(done.fail); + }); + + const blocks = wrapper.findAll('.blocks-container > *').wrappers; + expect(blocks.length).toBeGreaterThan(0); + + blocks.forEach((block) => { + expect(block.text().trim()).not.toBe(''); + }); }); }); }); diff --git a/spec/frontend/jobs/components/table/graphql/cache_config_spec.js b/spec/frontend/jobs/components/table/graphql/cache_config_spec.js index ac79186cb46..88c97285b85 100644 --- a/spec/frontend/jobs/components/table/graphql/cache_config_spec.js +++ b/spec/frontend/jobs/components/table/graphql/cache_config_spec.js @@ -33,6 +33,26 @@ describe('jobs/components/table/graphql/cache_config', () => { ); }); + it('should not add to existing cache if the incoming elements are the same', () => { + // simulate that this is the last page + const finalExistingCache = { + ...CIJobConnectionExistingCache, + pageInfo: { + hasNextPage: false, + }, + }; + + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + finalExistingCache, + { + args: firstLoadArgs, + }, + ); + + expect(res.nodes).toHaveLength(CIJobConnectionExistingCache.nodes.length); + }); + it('should contain the pageInfo key as part of the result', () => { const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { args: firstLoadArgs, diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js index 4d51624dfff..986fba21fb9 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -1,30 +1,48 @@ -import { GlSkeletonLoader, GlAlert, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui'; +import { + GlSkeletonLoader, + GlAlert, + GlEmptyState, + GlIntersectionObserver, + GlLoadingIcon, +} from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { s__ } from '~/locale'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query.graphql'; import JobsTable from '~/jobs/components/table/jobs_table.vue'; import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; -import { mockJobsQueryResponse, mockJobsQueryEmptyResponse } from '../../mock_data'; +import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue'; +import { + mockJobsQueryResponse, + mockJobsQueryEmptyResponse, + mockFailedSearchToken, +} from '../../mock_data'; const projectPath = 'gitlab-org/gitlab'; Vue.use(VueApollo); +jest.mock('~/flash'); + describe('Job table app', () => { let wrapper; + let jobsTableVueSearch = true; const successHandler = jest.fn().mockResolvedValue(mockJobsQueryResponse); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); const emptyHandler = jest.fn().mockResolvedValue(mockJobsQueryEmptyResponse); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); const findTable = () => wrapper.findComponent(JobsTable); const findTabs = () => wrapper.findComponent(JobsTableTabs); const findAlert = () => wrapper.findComponent(GlAlert); const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findFilteredSearch = () => wrapper.findComponent(JobsFilteredSearch); const triggerInfiniteScroll = () => wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); @@ -48,6 +66,7 @@ describe('Job table app', () => { }, provide: { fullPath: projectPath, + glFeatures: { jobsTableVueSearch }, }, apolloProvider: createMockApolloProvider(handler), }); @@ -58,11 +77,21 @@ describe('Job table app', () => { }); describe('loading state', () => { - it('should display skeleton loader when loading', () => { + beforeEach(() => { createComponent(); + }); + it('should display skeleton loader when loading', () => { expect(findSkeletonLoader().exists()).toBe(true); expect(findTable().exists()).toBe(false); + expect(findLoadingSpinner().exists()).toBe(false); + }); + + it('when switching tabs only the skeleton loader should show', () => { + findTabs().vm.$emit('fetchJobsByStatus', null); + + expect(findSkeletonLoader().exists()).toBe(true); + expect(findLoadingSpinner().exists()).toBe(false); }); }); @@ -76,6 +105,7 @@ describe('Job table app', () => { it('should display the jobs table with data', () => { expect(findTable().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(false); + expect(findLoadingSpinner().exists()).toBe(false); }); it('should refetch jobs query on fetchJobsByStatus event', async () => { @@ -98,8 +128,12 @@ describe('Job table app', () => { }); it('handles infinite scrolling by calling fetch more', async () => { + expect(findLoadingSpinner().exists()).toBe(true); + await waitForPromises(); + expect(findLoadingSpinner().exists()).toBe(false); + expect(successHandler).toHaveBeenCalledWith({ after: 'eyJpZCI6IjIzMTcifQ', fullPath: 'gitlab-org/gitlab', @@ -137,4 +171,69 @@ describe('Job table app', () => { expect(findTable().exists()).toBe(true); }); }); + + describe('filtered search', () => { + it('should display filtered search', () => { + createComponent(); + + expect(findFilteredSearch().exists()).toBe(true); + }); + + // this test should be updated once BE supports tab and filtered search filtering + // https://gitlab.com/gitlab-org/gitlab/-/issues/356210 + it.each` + scope | shouldDisplay + ${null} | ${true} + ${['FAILED', 'SUCCESS', 'CANCELED']} | ${false} + `( + 'with tab scope $scope the filtered search displays $shouldDisplay', + async ({ scope, shouldDisplay }) => { + createComponent(); + + await waitForPromises(); + + await findTabs().vm.$emit('fetchJobsByStatus', scope); + + expect(findFilteredSearch().exists()).toBe(shouldDisplay); + }, + ); + + it('refetches jobs query when filtering', async () => { + createComponent(); + + jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); + + expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1); + }); + + it('shows raw text warning when user inputs raw text', async () => { + const expectedWarning = { + message: s__( + 'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.', + ), + type: 'warning', + }; + + createComponent(); + + jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']); + + expect(createFlash).toHaveBeenCalledWith(expectedWarning); + expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + }); + + it('should not display filtered search', () => { + jobsTableVueSearch = false; + + createComponent(); + + expect(findFilteredSearch().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js b/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js index ac9b45be932..23632001060 100644 --- a/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js +++ b/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js @@ -1,3 +1,4 @@ +import { GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -7,16 +8,31 @@ describe('Jobs Table Tabs', () => { let wrapper; const defaultProps = { - jobCounts: { all: 848, pending: 0, running: 0, finished: 704 }, + allJobsCount: 286, + loading: false, }; - const findTab = (testId) => wrapper.findByTestId(testId); + const statuses = { + success: 'SUCCESS', + failed: 'FAILED', + canceled: 'CANCELED', + }; + + const findAllTab = () => wrapper.findByTestId('jobs-all-tab'); + const findFinishedTab = () => wrapper.findByTestId('jobs-finished-tab'); + + const triggerTabChange = (index) => wrapper.findAllComponents(GlTab).at(index).vm.$emit('click'); - const createComponent = () => { + const createComponent = (props = defaultProps) => { wrapper = extendedWrapper( mount(JobsTableTabs, { provide: { - ...defaultProps, + jobStatuses: { + ...statuses, + }, + }, + propsData: { + ...props, }, }), ); @@ -30,13 +46,21 @@ describe('Jobs Table Tabs', () => { wrapper.destroy(); }); + it('displays All tab with count', () => { + expect(trimText(findAllTab().text())).toBe(`All ${defaultProps.allJobsCount}`); + }); + + it('displays Finished tab with no count', () => { + expect(findFinishedTab().text()).toBe('Finished'); + }); + it.each` - tabId | text | count - ${'jobs-all-tab'} | ${'All'} | ${defaultProps.jobCounts.all} - ${'jobs-pending-tab'} | ${'Pending'} | ${defaultProps.jobCounts.pending} - ${'jobs-running-tab'} | ${'Running'} | ${defaultProps.jobCounts.running} - ${'jobs-finished-tab'} | ${'Finished'} | ${defaultProps.jobCounts.finished} - `('displays the right tab text and badge count', ({ tabId, text, count }) => { - expect(trimText(findTab(tabId).text())).toBe(`${text} ${count}`); + tabIndex | expectedScope + ${0} | ${null} + ${1} | ${[statuses.success, statuses.failed, statuses.canceled]} + `('emits fetchJobsByStatus with $expectedScope on tab change', ({ tabIndex, expectedScope }) => { + triggerTabChange(tabIndex); + + expect(wrapper.emitted()).toEqual({ fetchJobsByStatus: [[expectedScope]] }); }); }); diff --git a/spec/frontend/jobs/components/trigger_block_spec.js b/spec/frontend/jobs/components/trigger_block_spec.js index e0eb873dc2f..78596612d23 100644 --- a/spec/frontend/jobs/components/trigger_block_spec.js +++ b/spec/frontend/jobs/components/trigger_block_spec.js @@ -1,12 +1,12 @@ -import { GlButton, GlTable } from '@gitlab/ui'; +import { GlButton, GlTableLite } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import TriggerBlock from '~/jobs/components/trigger_block.vue'; describe('Trigger block', () => { let wrapper; - const findRevealButton = () => wrapper.find(GlButton); - const findVariableTable = () => wrapper.find(GlTable); + const findRevealButton = () => wrapper.findComponent(GlButton); + const findVariableTable = () => wrapper.findComponent(GlTableLite); const findShortToken = () => wrapper.find('[data-testid="trigger-short-token"]'); const findVariableValue = (index) => wrapper.findAll('[data-testid="trigger-build-value"]').at(index); @@ -22,7 +22,6 @@ describe('Trigger block', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('with short token and no variables', () => { diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index 73b9df1853d..27b6c04eded 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -1481,6 +1481,7 @@ export const mockJobsQueryResponse = { project: { id: '1', jobs: { + count: 1, pageInfo: { endCursor: 'eyJpZCI6IjIzMTcifQ', hasNextPage: true, @@ -1911,10 +1912,19 @@ export const CIJobConnectionIncomingCacheRunningStatus = { }; export const CIJobConnectionExistingCache = { + pageInfo: { + __typename: 'PageInfo', + endCursor: 'eyJpZCI6IjIwNTEifQ', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjIxNzMifQ', + }, nodes: [ - { __ref: 'CiJob:gid://gitlab/Ci::Build/2057' }, - { __ref: 'CiJob:gid://gitlab/Ci::Build/2056' }, - { __ref: 'CiJob:gid://gitlab/Ci::Build/2051' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2100' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2101' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2102' }, ], statuses: 'PENDING', }; + +export const mockFailedSearchToken = { type: 'status', value: { data: 'FAILED', operator: '=' } }; diff --git a/spec/frontend/jobs/store/actions_spec.js b/spec/frontend/jobs/store/actions_spec.js index 16448d6a3ca..b9f97a3c3ae 100644 --- a/spec/frontend/jobs/store/actions_spec.js +++ b/spec/frontend/jobs/store/actions_spec.js @@ -39,62 +39,60 @@ describe('Job State actions', () => { }); describe('setJobEndpoint', () => { - it('should commit SET_JOB_ENDPOINT mutation', (done) => { - testAction( + it('should commit SET_JOB_ENDPOINT mutation', () => { + return testAction( setJobEndpoint, 'job/872324.json', mockedState, [{ type: types.SET_JOB_ENDPOINT, payload: 'job/872324.json' }], [], - done, ); }); }); describe('setJobLogOptions', () => { - it('should commit SET_JOB_LOG_OPTIONS mutation', (done) => { - testAction( + it('should commit SET_JOB_LOG_OPTIONS mutation', () => { + return testAction( setJobLogOptions, { pagePath: 'job/872324/trace.json' }, mockedState, [{ type: types.SET_JOB_LOG_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }], [], - done, ); }); }); describe('hideSidebar', () => { - it('should commit HIDE_SIDEBAR mutation', (done) => { - testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], [], done); + it('should commit HIDE_SIDEBAR mutation', () => { + return testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], []); }); }); describe('showSidebar', () => { - it('should commit HIDE_SIDEBAR mutation', (done) => { - testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], [], done); + it('should commit SHOW_SIDEBAR mutation', () => { + return testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], []); }); }); describe('toggleSidebar', () => { describe('when isSidebarOpen is true', () => { - it('should dispatch hideSidebar', (done) => { - testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }], done); + it('should dispatch hideSidebar', () => { + return testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }]); }); }); describe('when isSidebarOpen is false', () => { - it('should dispatch showSidebar', (done) => { + it('should dispatch showSidebar', () => { mockedState.isSidebarOpen = false; - testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }], done); + return testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }]); }); }); }); describe('requestJob', () => { - it('should commit REQUEST_JOB mutation', (done) => { - testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], [], done); + it('should commit REQUEST_JOB mutation', () => { + return testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], []); }); }); @@ -113,10 +111,10 @@ describe('Job State actions', () => { }); describe('success', () => { - it('dispatches requestJob and receiveJobSuccess ', (done) => { + it('dispatches requestJob and receiveJobSuccess ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 121212, name: 'karma' }); - testAction( + return testAction( fetchJob, null, mockedState, @@ -130,7 +128,6 @@ describe('Job State actions', () => { type: 'receiveJobSuccess', }, ], - done, ); }); }); @@ -140,8 +137,8 @@ describe('Job State actions', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); }); - it('dispatches requestJob and receiveJobError ', (done) => { - testAction( + it('dispatches requestJob and receiveJobError ', () => { + return testAction( fetchJob, null, mockedState, @@ -154,46 +151,50 @@ describe('Job State actions', () => { type: 'receiveJobError', }, ], - done, ); }); }); }); describe('receiveJobSuccess', () => { - it('should commit RECEIVE_JOB_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_JOB_SUCCESS mutation', () => { + return testAction( receiveJobSuccess, { id: 121232132 }, mockedState, [{ type: types.RECEIVE_JOB_SUCCESS, payload: { id: 121232132 } }], [], - done, ); }); }); describe('receiveJobError', () => { - it('should commit RECEIVE_JOB_ERROR mutation', (done) => { - testAction(receiveJobError, null, mockedState, [{ type: types.RECEIVE_JOB_ERROR }], [], done); + it('should commit RECEIVE_JOB_ERROR mutation', () => { + return testAction( + receiveJobError, + null, + mockedState, + [{ type: types.RECEIVE_JOB_ERROR }], + [], + ); }); }); describe('scrollTop', () => { - it('should dispatch toggleScrollButtons action', (done) => { - testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done); + it('should dispatch toggleScrollButtons action', () => { + return testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }]); }); }); describe('scrollBottom', () => { - it('should dispatch toggleScrollButtons action', (done) => { - testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done); + it('should dispatch toggleScrollButtons action', () => { + return testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }]); }); }); describe('requestJobLog', () => { - it('should commit REQUEST_JOB_LOG mutation', (done) => { - testAction(requestJobLog, null, mockedState, [{ type: types.REQUEST_JOB_LOG }], [], done); + it('should commit REQUEST_JOB_LOG mutation', () => { + return testAction(requestJobLog, null, mockedState, [{ type: types.REQUEST_JOB_LOG }], []); }); }); @@ -212,13 +213,13 @@ describe('Job State actions', () => { }); describe('success', () => { - it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', (done) => { + it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', () => { mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, { html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', complete: true, }); - testAction( + return testAction( fetchJobLog, null, mockedState, @@ -239,7 +240,6 @@ describe('Job State actions', () => { type: 'stopPollingJobLog', }, ], - done, ); }); @@ -255,8 +255,8 @@ describe('Job State actions', () => { mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, jobLogPayload); }); - it('dispatches startPollingJobLog', (done) => { - testAction( + it('dispatches startPollingJobLog', () => { + return testAction( fetchJobLog, null, mockedState, @@ -266,14 +266,13 @@ describe('Job State actions', () => { { type: 'receiveJobLogSuccess', payload: jobLogPayload }, { type: 'startPollingJobLog' }, ], - done, ); }); - it('does not dispatch startPollingJobLog when timeout is non-empty', (done) => { + it('does not dispatch startPollingJobLog when timeout is non-empty', () => { mockedState.jobLogTimeout = 1; - testAction( + return testAction( fetchJobLog, null, mockedState, @@ -282,7 +281,6 @@ describe('Job State actions', () => { { type: 'toggleScrollisInBottom', payload: true }, { type: 'receiveJobLogSuccess', payload: jobLogPayload }, ], - done, ); }); }); @@ -293,8 +291,8 @@ describe('Job State actions', () => { mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(500); }); - it('dispatches requestJobLog and receiveJobLogError ', (done) => { - testAction( + it('dispatches requestJobLog and receiveJobLogError ', () => { + return testAction( fetchJobLog, null, mockedState, @@ -304,7 +302,6 @@ describe('Job State actions', () => { type: 'receiveJobLogError', }, ], - done, ); }); }); @@ -358,65 +355,58 @@ describe('Job State actions', () => { window.clearTimeout = origTimeout; }); - it('should commit STOP_POLLING_JOB_LOG mutation ', (done) => { + it('should commit STOP_POLLING_JOB_LOG mutation ', async () => { const jobLogTimeout = 7; - testAction( + await testAction( stopPollingJobLog, null, { ...mockedState, jobLogTimeout }, [{ type: types.SET_JOB_LOG_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_JOB_LOG }], [], - ) - .then(() => { - expect(window.clearTimeout).toHaveBeenCalledWith(jobLogTimeout); - }) - .then(done) - .catch(done.fail); + ); + expect(window.clearTimeout).toHaveBeenCalledWith(jobLogTimeout); }); }); describe('receiveJobLogSuccess', () => { - it('should commit RECEIVE_JOB_LOG_SUCCESS mutation ', (done) => { - testAction( + it('should commit RECEIVE_JOB_LOG_SUCCESS mutation ', () => { + return testAction( receiveJobLogSuccess, 'hello world', mockedState, [{ type: types.RECEIVE_JOB_LOG_SUCCESS, payload: 'hello world' }], [], - done, ); }); }); describe('receiveJobLogError', () => { - it('should commit stop polling job log', (done) => { - testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }], done); + it('should commit stop polling job log', () => { + return testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }]); }); }); describe('toggleCollapsibleLine', () => { - it('should commit TOGGLE_COLLAPSIBLE_LINE mutation ', (done) => { - testAction( + it('should commit TOGGLE_COLLAPSIBLE_LINE mutation ', () => { + return testAction( toggleCollapsibleLine, { isClosed: true }, mockedState, [{ type: types.TOGGLE_COLLAPSIBLE_LINE, payload: { isClosed: true } }], [], - done, ); }); }); describe('requestJobsForStage', () => { - it('should commit REQUEST_JOBS_FOR_STAGE mutation ', (done) => { - testAction( + it('should commit REQUEST_JOBS_FOR_STAGE mutation ', () => { + return testAction( requestJobsForStage, { name: 'deploy' }, mockedState, [{ type: types.REQUEST_JOBS_FOR_STAGE, payload: { name: 'deploy' } }], [], - done, ); }); }); @@ -433,12 +423,12 @@ describe('Job State actions', () => { }); describe('success', () => { - it('dispatches requestJobsForStage and receiveJobsForStageSuccess ', (done) => { + it('dispatches requestJobsForStage and receiveJobsForStageSuccess ', () => { mock .onGet(`${TEST_HOST}/jobs.json`) .replyOnce(200, { latest_statuses: [{ id: 121212, name: 'build' }], retried: [] }); - testAction( + return testAction( fetchJobsForStage, { dropdown_path: `${TEST_HOST}/jobs.json` }, mockedState, @@ -453,7 +443,6 @@ describe('Job State actions', () => { type: 'receiveJobsForStageSuccess', }, ], - done, ); }); }); @@ -463,8 +452,8 @@ describe('Job State actions', () => { mock.onGet(`${TEST_HOST}/jobs.json`).reply(500); }); - it('dispatches requestJobsForStage and receiveJobsForStageError', (done) => { - testAction( + it('dispatches requestJobsForStage and receiveJobsForStageError', () => { + return testAction( fetchJobsForStage, { dropdown_path: `${TEST_HOST}/jobs.json` }, mockedState, @@ -478,34 +467,31 @@ describe('Job State actions', () => { type: 'receiveJobsForStageError', }, ], - done, ); }); }); }); describe('receiveJobsForStageSuccess', () => { - it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation ', (done) => { - testAction( + it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation ', () => { + return testAction( receiveJobsForStageSuccess, [{ id: 121212, name: 'karma' }], mockedState, [{ type: types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, payload: [{ id: 121212, name: 'karma' }] }], [], - done, ); }); }); describe('receiveJobsForStageError', () => { - it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation ', (done) => { - testAction( + it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation ', () => { + return testAction( receiveJobsForStageError, null, mockedState, [{ type: types.RECEIVE_JOBS_FOR_STAGE_ERROR }], [], - done, ); }); }); diff --git a/spec/frontend/labels/components/promote_label_modal_spec.js b/spec/frontend/labels/components/promote_label_modal_spec.js index d2fbdfc9a8d..8cfaba6f98a 100644 --- a/spec/frontend/labels/components/promote_label_modal_spec.js +++ b/spec/frontend/labels/components/promote_label_modal_spec.js @@ -50,7 +50,7 @@ describe('Promote label modal', () => { vm.$destroy(); }); - it('redirects when a label is promoted', (done) => { + it('redirects when a label is promoted', () => { const responseURL = `${TEST_HOST}/dummy/endpoint`; jest.spyOn(axios, 'post').mockImplementation((url) => { expect(url).toBe(labelMockData.url); @@ -65,39 +65,35 @@ describe('Promote label modal', () => { }); }); - vm.onSubmit() - .then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { - labelUrl: labelMockData.url, - successful: true, - }); - }) - .then(done) - .catch(done.fail); + return vm.onSubmit().then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { + labelUrl: labelMockData.url, + successful: true, + }); + }); }); - it('displays an error if promoting a label failed', (done) => { + it('displays an error if promoting a label failed', () => { const dummyError = new Error('promoting label failed'); dummyError.response = { status: 500 }; + jest.spyOn(axios, 'post').mockImplementation((url) => { expect(url).toBe(labelMockData.url); expect(eventHub.$emit).toHaveBeenCalledWith( 'promoteLabelModal.requestStarted', labelMockData.url, ); + return Promise.reject(dummyError); }); - vm.onSubmit() - .catch((error) => { - expect(error).toBe(dummyError); - expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { - labelUrl: labelMockData.url, - successful: false, - }); - }) - .then(done) - .catch(done.fail); + return vm.onSubmit().catch((error) => { + expect(error).toBe(dummyError); + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { + labelUrl: labelMockData.url, + successful: false, + }); + }); }); }); }); diff --git a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js index 971ba8b583c..5ac7a7985a8 100644 --- a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js +++ b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js @@ -82,34 +82,39 @@ describe('getSuppressNetworkErrorsDuringNavigationLink', () => { isNavigatingAway.mockReturnValue(false); }); - it('forwards successful requests', (done) => { + it('forwards successful requests', () => { createSubscription(makeMockSuccessLink(), { next({ data }) { expect(data).toEqual({ foo: { id: 1 } }); }, - error: () => done.fail('Should not happen'), - complete: () => done(), + error: () => { + throw new Error('Should not happen'); + }, }); }); - it('forwards GraphQL errors', (done) => { + it('forwards GraphQL errors', () => { createSubscription(makeMockGraphQLErrorLink(), { next({ errors }) { expect(errors).toEqual([{ message: 'foo' }]); }, - error: () => done.fail('Should not happen'), - complete: () => done(), + error: () => { + throw new Error('Should not happen'); + }, }); }); - it('forwards network errors', (done) => { + it('forwards network errors', () => { createSubscription(makeMockNetworkErrorLink(), { - next: () => done.fail('Should not happen'), + next: () => { + throw new Error('Should not happen'); + }, error: (error) => { expect(error.message).toBe('NetworkError'); - done(); }, - complete: () => done.fail('Should not happen'), + complete: () => { + throw new Error('Should not happen'); + }, }); }); }); @@ -119,23 +124,25 @@ describe('getSuppressNetworkErrorsDuringNavigationLink', () => { isNavigatingAway.mockReturnValue(true); }); - it('forwards successful requests', (done) => { + it('forwards successful requests', () => { createSubscription(makeMockSuccessLink(), { next({ data }) { expect(data).toEqual({ foo: { id: 1 } }); }, - error: () => done.fail('Should not happen'), - complete: () => done(), + error: () => { + throw new Error('Should not happen'); + }, }); }); - it('forwards GraphQL errors', (done) => { + it('forwards GraphQL errors', () => { createSubscription(makeMockGraphQLErrorLink(), { next({ errors }) { expect(errors).toEqual([{ message: 'foo' }]); }, - error: () => done.fail('Should not happen'), - complete: () => done(), + error: () => { + throw new Error('Should not happen'); + }, }); }); }); diff --git a/spec/frontend/lib/gfm/index_spec.js b/spec/frontend/lib/gfm/index_spec.js new file mode 100644 index 00000000000..5c72b5a51a7 --- /dev/null +++ b/spec/frontend/lib/gfm/index_spec.js @@ -0,0 +1,46 @@ +import { render } from '~/lib/gfm'; + +describe('gfm', () => { + describe('render', () => { + it('processes Commonmark and provides an ast to the renderer function', async () => { + let result; + + await render({ + markdown: 'This is text', + renderer: (tree) => { + result = tree; + }, + }); + + expect(result.type).toBe('root'); + }); + + it('transforms raw HTML into individual nodes in the AST', async () => { + let result; + + await render({ + markdown: '<strong>This is bold text</strong>', + renderer: (tree) => { + result = tree; + }, + }); + + expect(result.children[0].children[0]).toMatchObject({ + type: 'element', + tagName: 'strong', + properties: {}, + }); + }); + + it('returns the result of executing the renderer function', async () => { + const result = await render({ + markdown: '<strong>This is bold text</strong>', + renderer: () => { + return 'rendered tree'; + }, + }); + + expect(result).toBe('rendered tree'); + }); + }); +}); diff --git a/spec/frontend/lib/utils/apollo_startup_js_link_spec.js b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js index e58bc063004..06573f346e0 100644 --- a/spec/frontend/lib/utils/apollo_startup_js_link_spec.js +++ b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js @@ -58,17 +58,16 @@ describe('StartupJSLink', () => { link = ApolloLink.from([startupLink, new ApolloLink(() => Observable.of(FORWARDED_RESPONSE))]); }; - it('forwards requests if no calls are set up', (done) => { + it('forwards requests if no calls are set up', () => { setupLink(); link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls).toBe(null); expect(startupLink.request).toEqual(StartupJSLink.noopRequest); - done(); }); }); - it('forwards requests if the operation is not pre-loaded', (done) => { + it('forwards requests if the operation is not pre-loaded', () => { window.gl = { startup_graphql_calls: [ { @@ -82,12 +81,11 @@ describe('StartupJSLink', () => { link.request(mockOperation({ operationName: 'notLoaded' })).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(1); - done(); }); }); describe('variable match errors: ', () => { - it('forwards requests if the variables are not matching', (done) => { + it('forwards requests if the variables are not matching', () => { window.gl = { startup_graphql_calls: [ { @@ -101,11 +99,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards requests if more variables are set in the operation', (done) => { + it('forwards requests if more variables are set in the operation', () => { window.gl = { startup_graphql_calls: [ { @@ -118,11 +115,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards requests if less variables are set in the operation', (done) => { + it('forwards requests if less variables are set in the operation', () => { window.gl = { startup_graphql_calls: [ { @@ -136,11 +132,10 @@ describe('StartupJSLink', () => { link.request(mockOperation({ variables: { id: 3 } })).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards requests if different variables are set', (done) => { + it('forwards requests if different variables are set', () => { window.gl = { startup_graphql_calls: [ { @@ -154,11 +149,10 @@ describe('StartupJSLink', () => { link.request(mockOperation({ variables: { id: 3 } })).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards requests if array variables have a different order', (done) => { + it('forwards requests if array variables have a different order', () => { window.gl = { startup_graphql_calls: [ { @@ -172,13 +166,12 @@ describe('StartupJSLink', () => { link.request(mockOperation({ variables: { id: [4, 3] } })).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); }); describe('error handling', () => { - it('forwards the call if the fetchCall is failing with a HTTP Error', (done) => { + it('forwards the call if the fetchCall is failing with a HTTP Error', () => { window.gl = { startup_graphql_calls: [ { @@ -192,11 +185,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards the call if it errors (e.g. failing JSON)', (done) => { + it('forwards the call if it errors (e.g. failing JSON)', () => { window.gl = { startup_graphql_calls: [ { @@ -210,11 +202,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards the call if the response contains an error', (done) => { + it('forwards the call if the response contains an error', () => { window.gl = { startup_graphql_calls: [ { @@ -228,11 +219,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it("forwards the call if the response doesn't contain a data object", (done) => { + it("forwards the call if the response doesn't contain a data object", () => { window.gl = { startup_graphql_calls: [ { @@ -246,12 +236,11 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); }); - it('resolves the request if the operation is matching', (done) => { + it('resolves the request if the operation is matching', () => { window.gl = { startup_graphql_calls: [ { @@ -265,11 +254,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(STARTUP_JS_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('resolves the request exactly once', (done) => { + it('resolves the request exactly once', () => { window.gl = { startup_graphql_calls: [ { @@ -285,12 +273,11 @@ describe('StartupJSLink', () => { expect(startupLink.startupCalls.size).toBe(0); link.request(mockOperation()).subscribe((result2) => { expect(result2).toEqual(FORWARDED_RESPONSE); - done(); }); }); }); - it('resolves the request if the variables have a different order', (done) => { + it('resolves the request if the variables have a different order', () => { window.gl = { startup_graphql_calls: [ { @@ -304,11 +291,10 @@ describe('StartupJSLink', () => { link.request(mockOperation({ variables: { name: 'foo', id: 3 } })).subscribe((result) => { expect(result).toEqual(STARTUP_JS_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('resolves the request if the variables have undefined values', (done) => { + it('resolves the request if the variables have undefined values', () => { window.gl = { startup_graphql_calls: [ { @@ -324,11 +310,10 @@ describe('StartupJSLink', () => { .subscribe((result) => { expect(result).toEqual(STARTUP_JS_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('resolves the request if the variables are of an array format', (done) => { + it('resolves the request if the variables are of an array format', () => { window.gl = { startup_graphql_calls: [ { @@ -342,11 +327,10 @@ describe('StartupJSLink', () => { link.request(mockOperation({ variables: { id: [3, 4] } })).subscribe((result) => { expect(result).toEqual(STARTUP_JS_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('resolves multiple requests correctly', (done) => { + it('resolves multiple requests correctly', () => { window.gl = { startup_graphql_calls: [ { @@ -368,7 +352,6 @@ describe('StartupJSLink', () => { link.request(mockOperation({ operationName: OPERATION_NAME })).subscribe((result2) => { expect(result2).toEqual(STARTUP_JS_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); }); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 0be0bf89210..763a9bd30fe 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -266,15 +266,18 @@ describe('common_utils', () => { }); describe('debounceByAnimationFrame', () => { - it('debounces a function to allow a maximum of one call per animation frame', (done) => { + it('debounces a function to allow a maximum of one call per animation frame', () => { const spy = jest.fn(); const debouncedSpy = commonUtils.debounceByAnimationFrame(spy); - window.requestAnimationFrame(() => { - debouncedSpy(); - debouncedSpy(); + + return new Promise((resolve) => { window.requestAnimationFrame(() => { - expect(spy).toHaveBeenCalledTimes(1); - done(); + debouncedSpy(); + debouncedSpy(); + window.requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(1); + resolve(); + }); }); }); }); @@ -372,28 +375,24 @@ describe('common_utils', () => { jest.spyOn(window, 'setTimeout'); }); - it('solves the promise from the callback', (done) => { + it('solves the promise from the callback', () => { const expectedResponseValue = 'Success!'; - commonUtils + return commonUtils .backOff((next, stop) => new Promise((resolve) => { resolve(expectedResponseValue); - }) - .then((resp) => { - stop(resp); - }) - .catch(done.fail), + }).then((resp) => { + stop(resp); + }), ) .then((respBackoff) => { expect(respBackoff).toBe(expectedResponseValue); - done(); - }) - .catch(done.fail); + }); }); - it('catches the rejected promise from the callback ', (done) => { + it('catches the rejected promise from the callback ', () => { const errorMessage = 'Mistakes were made!'; - commonUtils + return commonUtils .backOff((next, stop) => { new Promise((resolve, reject) => { reject(new Error(errorMessage)); @@ -406,39 +405,34 @@ describe('common_utils', () => { .catch((errBackoffResp) => { expect(errBackoffResp instanceof Error).toBe(true); expect(errBackoffResp.message).toBe(errorMessage); - done(); }); }); - it('solves the promise correctly after retrying a third time', (done) => { + it('solves the promise correctly after retrying a third time', () => { let numberOfCalls = 1; const expectedResponseValue = 'Success!'; - commonUtils + return commonUtils .backOff((next, stop) => - Promise.resolve(expectedResponseValue) - .then((resp) => { - if (numberOfCalls < 3) { - numberOfCalls += 1; - next(); - jest.runOnlyPendingTimers(); - } else { - stop(resp); - } - }) - .catch(done.fail), + Promise.resolve(expectedResponseValue).then((resp) => { + if (numberOfCalls < 3) { + numberOfCalls += 1; + next(); + jest.runOnlyPendingTimers(); + } else { + stop(resp); + } + }), ) .then((respBackoff) => { const timeouts = window.setTimeout.mock.calls.map(([, timeout]) => timeout); expect(timeouts).toEqual([2000, 4000]); expect(respBackoff).toBe(expectedResponseValue); - done(); - }) - .catch(done.fail); + }); }); - it('rejects the backOff promise after timing out', (done) => { - commonUtils + it('rejects the backOff promise after timing out', () => { + return commonUtils .backOff((next) => { next(); jest.runOnlyPendingTimers(); @@ -449,7 +443,6 @@ describe('common_utils', () => { expect(timeouts).toEqual([2000, 4000, 8000, 16000, 32000, 32000]); expect(errBackoffResp instanceof Error).toBe(true); expect(errBackoffResp.message).toBe('BACKOFF_TIMEOUT'); - done(); }); }); }); diff --git a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js index e06d1384610..d6131b1a1d7 100644 --- a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js +++ b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js @@ -5,12 +5,23 @@ import ConfirmModal from '~/lib/utils/confirm_via_gl_modal/confirm_modal.vue'; describe('Confirm Modal', () => { let wrapper; let modal; + const SECONDARY_TEXT = 'secondaryText'; + const SECONDARY_VARIANT = 'danger'; - const createComponent = ({ primaryText, primaryVariant, title, hideCancel = false } = {}) => { + const createComponent = ({ + primaryText, + primaryVariant, + secondaryText, + secondaryVariant, + title, + hideCancel = false, + } = {}) => { wrapper = mount(ConfirmModal, { propsData: { primaryText, primaryVariant, + secondaryText, + secondaryVariant, hideCancel, title, }, @@ -65,6 +76,19 @@ describe('Confirm Modal', () => { expect(props.actionCancel).toBeNull(); }); + it('should not show secondary Button when secondary Text is not set', () => { + createComponent(); + const props = findGlModal().props(); + expect(props.actionSecondary).toBeNull(); + }); + + it('should show secondary Button when secondaryText is set', () => { + createComponent({ secondaryText: SECONDARY_TEXT, secondaryVariant: SECONDARY_VARIANT }); + const actionSecondary = findGlModal().props('actionSecondary'); + expect(actionSecondary.text).toEqual(SECONDARY_TEXT); + expect(actionSecondary.attributes.variant).toEqual(SECONDARY_VARIANT); + }); + it('should set the modal title when the `title` prop is set', () => { const title = 'Modal title'; createComponent({ title }); diff --git a/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js new file mode 100644 index 00000000000..47bb512cbb5 --- /dev/null +++ b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js @@ -0,0 +1,17 @@ +import { newDateAsLocaleTime } from '~/lib/utils/datetime/date_calculation_utility'; + +describe('newDateAsLocaleTime', () => { + it.each` + string | expected + ${'2022-03-22'} | ${new Date('2022-03-22T00:00:00.000Z')} + ${'2022-03-22T00:00:00.000Z'} | ${new Date('2022-03-22T00:00:00.000Z')} + ${2022} | ${null} + ${[]} | ${null} + ${{}} | ${null} + ${true} | ${null} + ${null} | ${null} + ${undefined} | ${null} + `('returns $expected given $string', ({ string, expected }) => { + expect(newDateAsLocaleTime(string)).toEqual(expected); + }); +}); diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js index 1adc70450e8..018ae12c908 100644 --- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js @@ -133,3 +133,15 @@ describe('formatTimeAsSummary', () => { expect(utils.formatTimeAsSummary({ [unit]: value })).toBe(result); }); }); + +describe('durationTimeFormatted', () => { + it.each` + duration | expectedOutput + ${87} | ${'00:01:27'} + ${141} | ${'00:02:21'} + ${12} | ${'00:00:12'} + ${60} | ${'00:01:00'} + `('returns $expectedOutput when provided $duration', ({ duration, expectedOutput }) => { + expect(utils.durationTimeFormatted(duration)).toBe(expectedOutput); + }); +}); diff --git a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js index 2314ec678d3..1ef7047d959 100644 --- a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js @@ -1,4 +1,4 @@ -import { getTimeago, localTimeAgo, timeFor } from '~/lib/utils/datetime/timeago_utility'; +import { getTimeago, localTimeAgo, timeFor, duration } from '~/lib/utils/datetime/timeago_utility'; import { s__ } from '~/locale'; import '~/commons/bootstrap'; @@ -66,6 +66,54 @@ describe('TimeAgo utils', () => { }); }); + describe('duration', () => { + const ONE_DAY = 24 * 60 * 60; + + it.each` + secs | formatted + ${0} | ${'0 seconds'} + ${30} | ${'30 seconds'} + ${59} | ${'59 seconds'} + ${60} | ${'1 minute'} + ${-60} | ${'1 minute'} + ${2 * 60} | ${'2 minutes'} + ${60 * 60} | ${'1 hour'} + ${2 * 60 * 60} | ${'2 hours'} + ${ONE_DAY} | ${'1 day'} + ${2 * ONE_DAY} | ${'2 days'} + ${7 * ONE_DAY} | ${'1 week'} + ${14 * ONE_DAY} | ${'2 weeks'} + ${31 * ONE_DAY} | ${'1 month'} + ${61 * ONE_DAY} | ${'2 months'} + ${365 * ONE_DAY} | ${'1 year'} + ${365 * 2 * ONE_DAY} | ${'2 years'} + `('formats $secs as "$formatted"', ({ secs, formatted }) => { + const ms = secs * 1000; + + expect(duration(ms)).toBe(formatted); + }); + + // `duration` can be used to format Rails month durations. + // Ensure formatting for quantities such as `2.months.to_i` + // based on ActiveSupport::Duration::SECONDS_PER_MONTH. + // See: https://api.rubyonrails.org/classes/ActiveSupport/Duration.html + const SECONDS_PER_MONTH = 2629746; // 1.month.to_i + + it.each` + duration | secs | formatted + ${'1.month'} | ${SECONDS_PER_MONTH} | ${'1 month'} + ${'2.months'} | ${SECONDS_PER_MONTH * 2} | ${'2 months'} + ${'3.months'} | ${SECONDS_PER_MONTH * 3} | ${'3 months'} + `( + 'formats ActiveSupport::Duration of `$duration` ($secs) as "$formatted"', + ({ secs, formatted }) => { + const ms = secs * 1000; + + expect(duration(ms)).toBe(formatted); + }, + ); + }); + describe('localTimeAgo', () => { beforeEach(() => { document.body.innerHTML = diff --git a/spec/frontend/lib/utils/poll_spec.js b/spec/frontend/lib/utils/poll_spec.js index 861808e3ad8..1f150599983 100644 --- a/spec/frontend/lib/utils/poll_spec.js +++ b/spec/frontend/lib/utils/poll_spec.js @@ -50,58 +50,48 @@ describe('Poll', () => { }; }); - it('calls the success callback when no header for interval is provided', (done) => { + it('calls the success callback when no header for interval is provided', () => { mockServiceCall({ status: 200 }); setup(); - waitForAllCallsToFinish(1, () => { + return waitForAllCallsToFinish(1, () => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - - done(); }); }); - it('calls the error callback when the http request returns an error', (done) => { + it('calls the error callback when the http request returns an error', () => { mockServiceCall({ status: 500 }, true); setup(); - waitForAllCallsToFinish(1, () => { + return waitForAllCallsToFinish(1, () => { expect(callbacks.success).not.toHaveBeenCalled(); expect(callbacks.error).toHaveBeenCalled(); - - done(); }); }); - it('skips the error callback when request is aborted', (done) => { + it('skips the error callback when request is aborted', () => { mockServiceCall({ status: 0 }, true); setup(); - waitForAllCallsToFinish(1, () => { + return waitForAllCallsToFinish(1, () => { expect(callbacks.success).not.toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); expect(callbacks.notification).toHaveBeenCalled(); - - done(); }); }); - it('should call the success callback when the interval header is -1', (done) => { + it('should call the success callback when the interval header is -1', () => { mockServiceCall({ status: 200, headers: { 'poll-interval': -1 } }); - setup() - .then(() => { - expect(callbacks.success).toHaveBeenCalled(); - expect(callbacks.error).not.toHaveBeenCalled(); - - done(); - }) - .catch(done.fail); + return setup().then(() => { + expect(callbacks.success).toHaveBeenCalled(); + expect(callbacks.error).not.toHaveBeenCalled(); + }); }); describe('for 2xx status code', () => { successCodes.forEach((httpCode) => { - it(`starts polling when http status is ${httpCode} and interval header is provided`, (done) => { + it(`starts polling when http status is ${httpCode} and interval header is provided`, () => { mockServiceCall({ status: httpCode, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ @@ -114,22 +104,20 @@ describe('Poll', () => { Polling.makeRequest(); - waitForAllCallsToFinish(2, () => { + return waitForAllCallsToFinish(2, () => { Polling.stop(); expect(service.fetch.mock.calls).toHaveLength(2); expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - - done(); }); }); }); }); describe('with delayed initial request', () => { - it('delays the first request', async (done) => { + it('delays the first request', async () => { mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ @@ -144,21 +132,19 @@ describe('Poll', () => { expect(Polling.timeoutID).toBeTruthy(); - waitForAllCallsToFinish(2, () => { + return waitForAllCallsToFinish(2, () => { Polling.stop(); expect(service.fetch.mock.calls).toHaveLength(2); expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - - done(); }); }); }); describe('stop', () => { - it('stops polling when method is called', (done) => { + it('stops polling when method is called', () => { mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ @@ -175,18 +161,16 @@ describe('Poll', () => { Polling.makeRequest(); - waitForAllCallsToFinish(1, () => { + return waitForAllCallsToFinish(1, () => { expect(service.fetch.mock.calls).toHaveLength(1); expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(Polling.stop).toHaveBeenCalled(); - - done(); }); }); }); describe('enable', () => { - it('should enable polling upon a response', (done) => { + it('should enable polling upon a response', () => { mockServiceCall({ status: 200 }); const Polling = new Poll({ resource: service, @@ -200,19 +184,18 @@ describe('Poll', () => { response: { status: 200, headers: { 'poll-interval': 1 } }, }); - waitForAllCallsToFinish(1, () => { + return waitForAllCallsToFinish(1, () => { Polling.stop(); expect(service.fetch.mock.calls).toHaveLength(1); expect(service.fetch).toHaveBeenCalledWith({ page: 4 }); expect(Polling.options.data).toEqual({ page: 4 }); - done(); }); }); }); describe('restart', () => { - it('should restart polling when its called', (done) => { + it('should restart polling when its called', () => { mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ @@ -238,7 +221,7 @@ describe('Poll', () => { Polling.makeRequest(); - waitForAllCallsToFinish(2, () => { + return waitForAllCallsToFinish(2, () => { Polling.stop(); expect(service.fetch.mock.calls).toHaveLength(2); @@ -247,7 +230,6 @@ describe('Poll', () => { expect(Polling.enable).toHaveBeenCalled(); expect(Polling.restart).toHaveBeenCalled(); expect(Polling.options.data).toEqual({ page: 4 }); - done(); }); }); }); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index a5877aa6e3e..103305f0797 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -178,12 +178,23 @@ describe('init markdown', () => { it.each` text | expected ${'- item'} | ${'- item\n- '} + ${'* item'} | ${'* item\n* '} + ${'+ item'} | ${'+ item\n+ '} ${'- [ ] item'} | ${'- [ ] item\n- [ ] '} - ${'- [x] item'} | ${'- [x] item\n- [x] '} + ${'- [x] item'} | ${'- [x] item\n- [ ] '} + ${'- [X] item'} | ${'- [X] item\n- [ ] '} + ${'- [ ] nbsp (U+00A0)'} | ${'- [ ] nbsp (U+00A0)\n- [ ] '} ${'- item\n - second'} | ${'- item\n - second\n - '} + ${'- - -'} | ${'- - -'} + ${'- --'} | ${'- --'} + ${'* **'} | ${'* **'} + ${' ** * ** * ** * **'} | ${' ** * ** * ** * **'} + ${'- - -x'} | ${'- - -x\n- '} + ${'+ ++'} | ${'+ ++\n+ '} ${'1. item'} | ${'1. item\n2. '} ${'1. [ ] item'} | ${'1. [ ] item\n2. [ ] '} - ${'1. [x] item'} | ${'1. [x] item\n2. [x] '} + ${'1. [x] item'} | ${'1. [x] item\n2. [ ] '} + ${'1. [X] item'} | ${'1. [X] item\n2. [ ] '} ${'108. item'} | ${'108. item\n109. '} ${'108. item\n - second'} | ${'108. item\n - second\n - '} ${'108. item\n 1. second'} | ${'108. item\n 1. second\n 2. '} @@ -207,10 +218,12 @@ describe('init markdown', () => { ${'- item\n- '} | ${'- item\n'} ${'- [ ] item\n- [ ] '} | ${'- [ ] item\n'} ${'- [x] item\n- [x] '} | ${'- [x] item\n'} + ${'- [X] item\n- [X] '} | ${'- [X] item\n'} ${'- item\n - second\n - '} | ${'- item\n - second\n'} ${'1. item\n2. '} | ${'1. item\n'} ${'1. [ ] item\n2. [ ] '} | ${'1. [ ] item\n'} ${'1. [x] item\n2. [x] '} | ${'1. [x] item\n'} + ${'1. [X] item\n2. [X] '} | ${'1. [X] item\n'} ${'108. item\n109. '} | ${'108. item\n'} ${'108. item\n - second\n - '} | ${'108. item\n - second\n'} ${'108. item\n 1. second\n 1. '} | ${'108. item\n 1. second\n'} diff --git a/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js b/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js index 0ca70e0a77e..9632d0f98f4 100644 --- a/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js +++ b/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js @@ -31,12 +31,17 @@ describe('unit_format/formatter_factory', () => { expect(formatNumber(12.345, 4)).toBe('12.3450'); }); - it('formats a large integer with a length limit', () => { + it('formats a large integer with a max length - using legacy positional argument', () => { expect(formatNumber(10 ** 7, undefined)).toBe('10,000,000'); expect(formatNumber(10 ** 7, undefined, 9)).toBe('1.00e+7'); expect(formatNumber(10 ** 7, undefined, 10)).toBe('10,000,000'); }); + it('formats a large integer with a max length', () => { + expect(formatNumber(10 ** 7, undefined, { maxLength: 9 })).toBe('1.00e+7'); + expect(formatNumber(10 ** 7, undefined, { maxLength: 10 })).toBe('10,000,000'); + }); + describe('formats with a different locale', () => { let originalLang; @@ -92,7 +97,7 @@ describe('unit_format/formatter_factory', () => { expect(formatSuffix(-1000000)).toBe('-1,000,000pop.'); }); - it('formats a floating point nugative number', () => { + it('formats a floating point negative number', () => { expect(formatSuffix(-0.1)).toBe('-0.1pop.'); expect(formatSuffix(-0.1, 0)).toBe('-0pop.'); expect(formatSuffix(-0.1, 2)).toBe('-0.10pop.'); @@ -108,10 +113,20 @@ describe('unit_format/formatter_factory', () => { expect(formatSuffix(10 ** 10)).toBe('10,000,000,000pop.'); }); - it('formats a large integer with a length limit', () => { + it('formats using a unit separator', () => { + expect(formatSuffix(10, 0, { unitSeparator: ' ' })).toBe('10 pop.'); + expect(formatSuffix(10, 0, { unitSeparator: ' x ' })).toBe('10 x pop.'); + }); + + it('formats a large integer with a max length - using legacy positional argument', () => { expect(formatSuffix(10 ** 7, undefined, 10)).toBe('1.00e+7pop.'); expect(formatSuffix(10 ** 10, undefined, 10)).toBe('1.00e+10pop.'); }); + + it('formats a large integer with a max length', () => { + expect(formatSuffix(10 ** 7, undefined, { maxLength: 10 })).toBe('1.00e+7pop.'); + expect(formatSuffix(10 ** 10, undefined, { maxLength: 10 })).toBe('1.00e+10pop.'); + }); }); describe('scaledSIFormatter', () => { @@ -143,6 +158,10 @@ describe('unit_format/formatter_factory', () => { expect(formatGibibytes(10 ** 10)).toBe('10GB'); expect(formatGibibytes(10 ** 11)).toBe('100GB'); }); + + it('formats bytes using a unit separator', () => { + expect(formatGibibytes(1, 0, { unitSeparator: ' ' })).toBe('1 B'); + }); }); describe('scaled format with offset', () => { @@ -174,6 +193,19 @@ describe('unit_format/formatter_factory', () => { expect(formatGigaBytes(10 ** 9)).toBe('1EB'); }); + it('formats bytes using a unit separator', () => { + expect(formatGigaBytes(1, undefined, { unitSeparator: ' ' })).toBe('1 GB'); + }); + + it('formats long byte numbers with max length - using legacy positional argument', () => { + expect(formatGigaBytes(1, 8, 7)).toBe('1.00e+0GB'); + }); + + it('formats long byte numbers with max length', () => { + expect(formatGigaBytes(1, 8)).toBe('1.00000000GB'); + expect(formatGigaBytes(1, 8, { maxLength: 7 })).toBe('1.00e+0GB'); + }); + it('formatting of too large numbers is not suported', () => { // formatting YB is out of range expect(() => scaledSIFormatter('B', 9)).toThrow(); @@ -216,6 +248,10 @@ describe('unit_format/formatter_factory', () => { expect(formatMilligrams(-100)).toBe('-100mg'); expect(formatMilligrams(-(10 ** 4))).toBe('-10g'); }); + + it('formats using a unit separator', () => { + expect(formatMilligrams(1, undefined, { unitSeparator: ' ' })).toBe('1 mg'); + }); }); }); @@ -253,6 +289,10 @@ describe('unit_format/formatter_factory', () => { expect(formatScaledBin(10 * 1024 ** 3)).toBe('10GiB'); expect(formatScaledBin(100 * 1024 ** 3)).toBe('100GiB'); }); + + it('formats using a unit separator', () => { + expect(formatScaledBin(1, undefined, { unitSeparator: ' ' })).toBe('1 B'); + }); }); describe('scaled format with offset', () => { @@ -288,6 +328,10 @@ describe('unit_format/formatter_factory', () => { expect(formatGibibytes(100 * 1024 ** 3)).toBe('100EiB'); }); + it('formats using a unit separator', () => { + expect(formatGibibytes(1, undefined, { unitSeparator: ' ' })).toBe('1 GiB'); + }); + it('formatting of too large numbers is not suported', () => { // formatting YB is out of range expect(() => scaledBinaryFormatter('B', 9)).toThrow(); diff --git a/spec/frontend/lib/utils/unit_format/index_spec.js b/spec/frontend/lib/utils/unit_format/index_spec.js index 7fd273f1b58..dc9d6ece48e 100644 --- a/spec/frontend/lib/utils/unit_format/index_spec.js +++ b/spec/frontend/lib/utils/unit_format/index_spec.js @@ -74,10 +74,13 @@ describe('unit_format', () => { it('seconds', () => { expect(seconds(1)).toBe('1s'); + expect(seconds(1, undefined, { unitSeparator: ' ' })).toBe('1 s'); }); it('milliseconds', () => { expect(milliseconds(1)).toBe('1ms'); + expect(milliseconds(1, undefined, { unitSeparator: ' ' })).toBe('1 ms'); + expect(milliseconds(100)).toBe('100ms'); expect(milliseconds(1000)).toBe('1,000ms'); expect(milliseconds(10_000)).toBe('10,000ms'); @@ -87,6 +90,7 @@ describe('unit_format', () => { it('decimalBytes', () => { expect(decimalBytes(1)).toBe('1B'); expect(decimalBytes(1, 1)).toBe('1.0B'); + expect(decimalBytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 B'); expect(decimalBytes(10)).toBe('10B'); expect(decimalBytes(10 ** 2)).toBe('100B'); @@ -104,31 +108,37 @@ describe('unit_format', () => { it('kilobytes', () => { expect(kilobytes(1)).toBe('1kB'); expect(kilobytes(1, 1)).toBe('1.0kB'); + expect(kilobytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 kB'); }); it('megabytes', () => { expect(megabytes(1)).toBe('1MB'); expect(megabytes(1, 1)).toBe('1.0MB'); + expect(megabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 MB'); }); it('gigabytes', () => { expect(gigabytes(1)).toBe('1GB'); expect(gigabytes(1, 1)).toBe('1.0GB'); + expect(gigabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 GB'); }); it('terabytes', () => { expect(terabytes(1)).toBe('1TB'); expect(terabytes(1, 1)).toBe('1.0TB'); + expect(terabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 TB'); }); it('petabytes', () => { expect(petabytes(1)).toBe('1PB'); expect(petabytes(1, 1)).toBe('1.0PB'); + expect(petabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 PB'); }); it('bytes', () => { expect(bytes(1)).toBe('1B'); expect(bytes(1, 1)).toBe('1.0B'); + expect(bytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 B'); expect(bytes(10)).toBe('10B'); expect(bytes(100)).toBe('100B'); @@ -142,26 +152,31 @@ describe('unit_format', () => { it('kibibytes', () => { expect(kibibytes(1)).toBe('1KiB'); expect(kibibytes(1, 1)).toBe('1.0KiB'); + expect(kibibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 KiB'); }); it('mebibytes', () => { expect(mebibytes(1)).toBe('1MiB'); expect(mebibytes(1, 1)).toBe('1.0MiB'); + expect(mebibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 MiB'); }); it('gibibytes', () => { expect(gibibytes(1)).toBe('1GiB'); expect(gibibytes(1, 1)).toBe('1.0GiB'); + expect(gibibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 GiB'); }); it('tebibytes', () => { expect(tebibytes(1)).toBe('1TiB'); expect(tebibytes(1, 1)).toBe('1.0TiB'); + expect(tebibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 TiB'); }); it('pebibytes', () => { expect(pebibytes(1)).toBe('1PiB'); expect(pebibytes(1, 1)).toBe('1.0PiB'); + expect(pebibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 PiB'); }); describe('getFormatter', () => { diff --git a/spec/frontend/lib/utils/users_cache_spec.js b/spec/frontend/lib/utils/users_cache_spec.js index 4034f39ee9c..30bdddd8e73 100644 --- a/spec/frontend/lib/utils/users_cache_spec.js +++ b/spec/frontend/lib/utils/users_cache_spec.js @@ -93,7 +93,7 @@ describe('UsersCache', () => { .mockImplementation((query, options) => apiSpy(query, options)); }); - it('stores and returns data from API call if cache is empty', (done) => { + it('stores and returns data from API call if cache is empty', async () => { apiSpy = (query, options) => { expect(query).toBe(''); expect(options).toEqual({ @@ -105,16 +105,12 @@ describe('UsersCache', () => { }); }; - UsersCache.retrieve(dummyUsername) - .then((user) => { - expect(user).toBe(dummyUser); - expect(UsersCache.internalStorage[dummyUsername]).toBe(dummyUser); - }) - .then(done) - .catch(done.fail); + const user = await UsersCache.retrieve(dummyUsername); + expect(user).toBe(dummyUser); + expect(UsersCache.internalStorage[dummyUsername]).toBe(dummyUser); }); - it('returns undefined if Ajax call fails and cache is empty', (done) => { + it('returns undefined if Ajax call fails and cache is empty', async () => { const dummyError = new Error('server exploded'); apiSpy = (query, options) => { @@ -126,26 +122,18 @@ describe('UsersCache', () => { return Promise.reject(dummyError); }; - UsersCache.retrieve(dummyUsername) - .then((user) => done.fail(`Received unexpected user: ${JSON.stringify(user)}`)) - .catch((error) => { - expect(error).toBe(dummyError); - }) - .then(done) - .catch(done.fail); + await expect(UsersCache.retrieve(dummyUsername)).rejects.toEqual(dummyError); }); - it('makes no Ajax call if matching data exists', (done) => { + it('makes no Ajax call if matching data exists', async () => { UsersCache.internalStorage[dummyUsername] = dummyUser; - apiSpy = () => done.fail(new Error('expected no Ajax call!')); + apiSpy = () => { + throw new Error('expected no Ajax call!'); + }; - UsersCache.retrieve(dummyUsername) - .then((user) => { - expect(user).toBe(dummyUser); - }) - .then(done) - .catch(done.fail); + const user = await UsersCache.retrieve(dummyUsername); + expect(user).toBe(dummyUser); }); }); @@ -156,7 +144,7 @@ describe('UsersCache', () => { jest.spyOn(UserApi, 'getUser').mockImplementation((id) => apiSpy(id)); }); - it('stores and returns data from API call if cache is empty', (done) => { + it('stores and returns data from API call if cache is empty', async () => { apiSpy = (id) => { expect(id).toBe(dummyUserId); @@ -165,16 +153,12 @@ describe('UsersCache', () => { }); }; - UsersCache.retrieveById(dummyUserId) - .then((user) => { - expect(user).toBe(dummyUser); - expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser); - }) - .then(done) - .catch(done.fail); + const user = await UsersCache.retrieveById(dummyUserId); + expect(user).toBe(dummyUser); + expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser); }); - it('returns undefined if Ajax call fails and cache is empty', (done) => { + it('returns undefined if Ajax call fails and cache is empty', async () => { const dummyError = new Error('server exploded'); apiSpy = (id) => { @@ -183,26 +167,18 @@ describe('UsersCache', () => { return Promise.reject(dummyError); }; - UsersCache.retrieveById(dummyUserId) - .then((user) => done.fail(`Received unexpected user: ${JSON.stringify(user)}`)) - .catch((error) => { - expect(error).toBe(dummyError); - }) - .then(done) - .catch(done.fail); + await expect(UsersCache.retrieveById(dummyUserId)).rejects.toEqual(dummyError); }); - it('makes no Ajax call if matching data exists', (done) => { + it('makes no Ajax call if matching data exists', async () => { UsersCache.internalStorage[dummyUserId] = dummyUser; - apiSpy = () => done.fail(new Error('expected no Ajax call!')); + apiSpy = () => { + throw new Error('expected no Ajax call!'); + }; - UsersCache.retrieveById(dummyUserId) - .then((user) => { - expect(user).toBe(dummyUser); - }) - .then(done) - .catch(done.fail); + const user = await UsersCache.retrieveById(dummyUserId); + expect(user).toBe(dummyUser); }); }); @@ -213,7 +189,7 @@ describe('UsersCache', () => { jest.spyOn(UserApi, 'getUserStatus').mockImplementation((id) => apiSpy(id)); }); - it('stores and returns data from API call if cache is empty', (done) => { + it('stores and returns data from API call if cache is empty', async () => { apiSpy = (id) => { expect(id).toBe(dummyUserId); @@ -222,16 +198,12 @@ describe('UsersCache', () => { }); }; - UsersCache.retrieveStatusById(dummyUserId) - .then((userStatus) => { - expect(userStatus).toBe(dummyUserStatus); - expect(UsersCache.internalStorage[dummyUserId].status).toBe(dummyUserStatus); - }) - .then(done) - .catch(done.fail); + const userStatus = await UsersCache.retrieveStatusById(dummyUserId); + expect(userStatus).toBe(dummyUserStatus); + expect(UsersCache.internalStorage[dummyUserId].status).toBe(dummyUserStatus); }); - it('returns undefined if Ajax call fails and cache is empty', (done) => { + it('returns undefined if Ajax call fails and cache is empty', async () => { const dummyError = new Error('server exploded'); apiSpy = (id) => { @@ -240,28 +212,20 @@ describe('UsersCache', () => { return Promise.reject(dummyError); }; - UsersCache.retrieveStatusById(dummyUserId) - .then((userStatus) => done.fail(`Received unexpected user: ${JSON.stringify(userStatus)}`)) - .catch((error) => { - expect(error).toBe(dummyError); - }) - .then(done) - .catch(done.fail); + await expect(UsersCache.retrieveStatusById(dummyUserId)).rejects.toEqual(dummyError); }); - it('makes no Ajax call if matching data exists', (done) => { + it('makes no Ajax call if matching data exists', async () => { UsersCache.internalStorage[dummyUserId] = { status: dummyUserStatus, }; - apiSpy = () => done.fail(new Error('expected no Ajax call!')); + apiSpy = () => { + throw new Error('expected no Ajax call!'); + }; - UsersCache.retrieveStatusById(dummyUserId) - .then((userStatus) => { - expect(userStatus).toBe(dummyUserStatus); - }) - .then(done) - .catch(done.fail); + const userStatus = await UsersCache.retrieveStatusById(dummyUserId); + expect(userStatus).toBe(dummyUserStatus); }); }); }); diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index b2756e506eb..298a01e4f4d 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -10,6 +10,7 @@ import MemberAvatar from '~/members/components/table/member_avatar.vue'; import MemberSource from '~/members/components/table/member_source.vue'; import MembersTable from '~/members/components/table/members_table.vue'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; +import UserDate from '~/vue_shared/components/user_date.vue'; import { MEMBER_TYPES, MEMBER_STATE_CREATED, @@ -106,14 +107,16 @@ describe('MembersTable', () => { }; it.each` - field | label | member | expectedComponent - ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} - ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} - ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} - ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} - ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} - ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} - ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} + field | label | member | expectedComponent + ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} + ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} + ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} + ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} + ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} + ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} + ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} + ${'userCreatedAt'} | ${'Created on'} | ${memberMock} | ${UserDate} + ${'lastActivityOn'} | ${'Last activity'} | ${memberMock} | ${UserDate} `('renders the $label field', ({ field, label, member, expectedComponent }) => { createComponent({ members: [member], diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index 83856a00a15..06ccd107ce3 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -17,6 +17,7 @@ export const member = { state: MEMBER_STATE_CREATED, user: { id: 123, + createdAt: '2022-03-10T18:03:04.812Z', name: 'Administrator', username: 'root', webUrl: 'https://gitlab.com/root', @@ -26,6 +27,7 @@ export const member = { oncallSchedules: [{ name: 'schedule 1' }], escalationPolicies: [{ name: 'policy 1' }], availability: null, + lastActivityOn: '2022-03-15', showStatus: true, }, id: 238, @@ -56,6 +58,7 @@ export const group = { webUrl: 'https://gitlab.com/groups/parent-group/commit451', }, id: 3, + isDirectMember: true, createdAt: '2020-08-06T15:31:07.662Z', expiresAt: null, validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js index 3e1774a6d56..1b6a0f9e977 100644 --- a/spec/frontend/merge_conflicts/store/actions_spec.js +++ b/spec/frontend/merge_conflicts/store/actions_spec.js @@ -34,9 +34,9 @@ describe('merge conflicts actions', () => { describe('fetchConflictsData', () => { const conflictsPath = 'conflicts/path/mock'; - it('on success dispatches setConflictsData', (done) => { + it('on success dispatches setConflictsData', () => { mock.onGet(conflictsPath).reply(200, {}); - testAction( + return testAction( actions.fetchConflictsData, conflictsPath, {}, @@ -45,13 +45,12 @@ describe('merge conflicts actions', () => { { type: types.SET_LOADING_STATE, payload: false }, ], [{ type: 'setConflictsData', payload: {} }], - done, ); }); - it('when data has type equal to error ', (done) => { + it('when data has type equal to error ', () => { mock.onGet(conflictsPath).reply(200, { type: 'error', message: 'error message' }); - testAction( + return testAction( actions.fetchConflictsData, conflictsPath, {}, @@ -61,13 +60,12 @@ describe('merge conflicts actions', () => { { type: types.SET_LOADING_STATE, payload: false }, ], [], - done, ); }); - it('when request fails ', (done) => { + it('when request fails ', () => { mock.onGet(conflictsPath).reply(400); - testAction( + return testAction( actions.fetchConflictsData, conflictsPath, {}, @@ -77,15 +75,14 @@ describe('merge conflicts actions', () => { { type: types.SET_LOADING_STATE, payload: false }, ], [], - done, ); }); }); describe('setConflictsData', () => { - it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => { + it('INTERACTIVE_RESOLVE_MODE updates the correct file ', () => { decorateFiles.mockReturnValue([{ bar: 'baz' }]); - testAction( + return testAction( actions.setConflictsData, { files, foo: 'bar' }, {}, @@ -96,7 +93,6 @@ describe('merge conflicts actions', () => { }, ], [], - done, ); }); }); @@ -105,24 +101,21 @@ describe('merge conflicts actions', () => { useMockLocationHelper(); const resolveConflictsPath = 'resolve/conflicts/path/mock'; - it('on success reloads the page', (done) => { + it('on success reloads the page', async () => { mock.onPost(resolveConflictsPath).reply(200, { redirect_to: 'hrefPath' }); - testAction( + await testAction( actions.submitResolvedConflicts, resolveConflictsPath, {}, [{ type: types.SET_SUBMIT_STATE, payload: true }], [], - () => { - expect(window.location.assign).toHaveBeenCalledWith('hrefPath'); - done(); - }, ); + expect(window.location.assign).toHaveBeenCalledWith('hrefPath'); }); - it('on errors shows flash', (done) => { + it('on errors shows flash', async () => { mock.onPost(resolveConflictsPath).reply(400); - testAction( + await testAction( actions.submitResolvedConflicts, resolveConflictsPath, {}, @@ -131,13 +124,10 @@ describe('merge conflicts actions', () => { { type: types.SET_SUBMIT_STATE, payload: false }, ], [], - () => { - expect(createFlash).toHaveBeenCalledWith({ - message: 'Failed to save merge conflicts resolutions. Please try again!', - }); - done(); - }, ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Failed to save merge conflicts resolutions. Please try again!', + }); }); }); @@ -193,9 +183,9 @@ describe('merge conflicts actions', () => { }); describe('setViewType', () => { - it('commits the right mutation', (done) => { + it('commits the right mutation', async () => { const payload = 'viewType'; - testAction( + await testAction( actions.setViewType, payload, {}, @@ -206,14 +196,11 @@ describe('merge conflicts actions', () => { }, ], [], - () => { - expect(Cookies.set).toHaveBeenCalledWith('diff_view', payload, { - expires: 365, - secure: false, - }); - done(); - }, ); + expect(Cookies.set).toHaveBeenCalledWith('diff_view', payload, { + expires: 365, + secure: false, + }); }); }); @@ -252,8 +239,8 @@ describe('merge conflicts actions', () => { }); describe('setFileResolveMode', () => { - it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => { - testAction( + it('INTERACTIVE_RESOLVE_MODE updates the correct file ', () => { + return testAction( actions.setFileResolveMode, { file: files[0], mode: INTERACTIVE_RESOLVE_MODE }, { conflictsData: { files }, getFileIndex: () => 0 }, @@ -267,11 +254,10 @@ describe('merge conflicts actions', () => { }, ], [], - done, ); }); - it('EDIT_RESOLVE_MODE updates the correct file ', (done) => { + it('EDIT_RESOLVE_MODE updates the correct file ', async () => { restoreFileLinesState.mockReturnValue([]); const file = { ...files[0], @@ -280,7 +266,7 @@ describe('merge conflicts actions', () => { resolutionData: {}, resolveMode: EDIT_RESOLVE_MODE, }; - testAction( + await testAction( actions.setFileResolveMode, { file: files[0], mode: EDIT_RESOLVE_MODE }, { conflictsData: { files }, getFileIndex: () => 0 }, @@ -294,17 +280,14 @@ describe('merge conflicts actions', () => { }, ], [], - () => { - expect(restoreFileLinesState).toHaveBeenCalledWith(file); - done(); - }, ); + expect(restoreFileLinesState).toHaveBeenCalledWith(file); }); }); describe('setPromptConfirmationState', () => { - it('updates the correct file ', (done) => { - testAction( + it('updates the correct file ', () => { + return testAction( actions.setPromptConfirmationState, { file: files[0], promptDiscardConfirmation: true }, { conflictsData: { files }, getFileIndex: () => 0 }, @@ -318,7 +301,6 @@ describe('merge conflicts actions', () => { }, ], [], - done, ); }); }); @@ -333,11 +315,11 @@ describe('merge conflicts actions', () => { ], }; - it('updates the correct file ', (done) => { + it('updates the correct file ', async () => { const marLikeMockReturn = { foo: 'bar' }; markLine.mockReturnValue(marLikeMockReturn); - testAction( + await testAction( actions.handleSelected, { file, line: { id: 1, section: 'baz' } }, { conflictsData: { files }, getFileIndex: () => 0 }, @@ -359,11 +341,8 @@ describe('merge conflicts actions', () => { }, ], [], - () => { - expect(markLine).toHaveBeenCalledTimes(3); - done(); - }, ); + expect(markLine).toHaveBeenCalledTimes(3); }); }); }); diff --git a/spec/frontend/milestones/components/delete_milestone_modal_spec.js b/spec/frontend/milestones/components/delete_milestone_modal_spec.js index 8978de0e0e0..b9ba0833c4f 100644 --- a/spec/frontend/milestones/components/delete_milestone_modal_spec.js +++ b/spec/frontend/milestones/components/delete_milestone_modal_spec.js @@ -32,7 +32,7 @@ describe('delete_milestone_modal.vue', () => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); - it('deletes milestone and redirects to overview page', (done) => { + it('deletes milestone and redirects to overview page', async () => { const responseURL = `${TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`; jest.spyOn(axios, 'delete').mockImplementation((url) => { expect(url).toBe(props.milestoneUrl); @@ -48,19 +48,15 @@ describe('delete_milestone_modal.vue', () => { }); }); - vm.onSubmit() - .then(() => { - expect(redirectTo).toHaveBeenCalledWith(responseURL); - expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { - milestoneUrl: props.milestoneUrl, - successful: true, - }); - }) - .then(done) - .catch(done.fail); + await vm.onSubmit(); + expect(redirectTo).toHaveBeenCalledWith(responseURL); + expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { + milestoneUrl: props.milestoneUrl, + successful: true, + }); }); - it('displays error if deleting milestone failed', (done) => { + it('displays error if deleting milestone failed', async () => { const dummyError = new Error('deleting milestone failed'); dummyError.response = { status: 418 }; jest.spyOn(axios, 'delete').mockImplementation((url) => { @@ -73,17 +69,12 @@ describe('delete_milestone_modal.vue', () => { return Promise.reject(dummyError); }); - vm.onSubmit() - .catch((error) => { - expect(error).toBe(dummyError); - expect(redirectTo).not.toHaveBeenCalled(); - expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { - milestoneUrl: props.milestoneUrl, - successful: false, - }); - }) - .then(done) - .catch(done.fail); + await expect(vm.onSubmit()).rejects.toEqual(dummyError); + expect(redirectTo).not.toHaveBeenCalled(); + expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { + milestoneUrl: props.milestoneUrl, + successful: false, + }); }); }); diff --git a/spec/frontend/milestones/components/milestone_combobox_spec.js b/spec/frontend/milestones/components/milestone_combobox_spec.js index 1af39aff30c..afd85fb78ce 100644 --- a/spec/frontend/milestones/components/milestone_combobox_spec.js +++ b/spec/frontend/milestones/components/milestone_combobox_spec.js @@ -340,7 +340,9 @@ describe('Milestone combobox component', () => { await nextTick(); expect( - findFirstProjectMilestonesDropdownItem().find('span').classes('selected-item'), + findFirstProjectMilestonesDropdownItem() + .find('svg') + .classes('gl-new-dropdown-item-check-icon'), ).toBe(true); selectFirstProjectMilestone(); @@ -348,8 +350,8 @@ describe('Milestone combobox component', () => { await nextTick(); expect( - findFirstProjectMilestonesDropdownItem().find('span').classes('selected-item'), - ).toBe(false); + findFirstProjectMilestonesDropdownItem().find('svg').classes('gl-visibility-hidden'), + ).toBe(true); }); describe('when a project milestones is selected', () => { @@ -464,17 +466,19 @@ describe('Milestone combobox component', () => { await nextTick(); - expect(findFirstGroupMilestonesDropdownItem().find('span').classes('selected-item')).toBe( - true, - ); + expect( + findFirstGroupMilestonesDropdownItem() + .find('svg') + .classes('gl-new-dropdown-item-check-icon'), + ).toBe(true); selectFirstGroupMilestone(); await nextTick(); - expect(findFirstGroupMilestonesDropdownItem().find('span').classes('selected-item')).toBe( - false, - ); + expect( + findFirstGroupMilestonesDropdownItem().find('svg').classes('gl-visibility-hidden'), + ).toBe(true); }); describe('when a group milestones is selected', () => { 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 bd2e818df4f..28039321428 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -5,7 +5,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` class="prometheus-graphs" data-qa-selector="prometheus_graphs" environmentstate="available" - metricsdashboardbasepath="/monitoring/monitor-project/-/environments/1/metrics" + metricsdashboardbasepath="/monitoring/monitor-project/-/metrics?environment=1" metricsendpoint="/monitoring/monitor-project/-/environments/1/additional_metrics.json" > <div> @@ -17,11 +17,11 @@ exports[`Dashboard template matches the default snapshot 1`] = ` primarybuttontext="" secondarybuttonlink="" secondarybuttontext="" - title="Feature deprecation and removal" - variant="danger" + title="Feature deprecation" + variant="warning" > <gl-sprintf-stub - message="The metrics, logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0." + message="The metrics feature was deprecated in GitLab 14.7." /> <gl-sprintf-stub diff --git a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap index 4f8a82692b8..08487a7a796 100644 --- a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap @@ -6,6 +6,7 @@ exports[`EmptyState shows gettingStarted state 1`] = ` <gl-empty-state-stub description="Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments." + invertindarkmode="true" primarybuttonlink="/clustersPath" primarybuttontext="Install on clusters" secondarybuttonlink="/settingsPath" @@ -22,6 +23,7 @@ exports[`EmptyState shows noData state 1`] = ` <gl-empty-state-stub description="You are connected to the Prometheus server, but there is currently no data to display." + invertindarkmode="true" primarybuttonlink="/settingsPath" primarybuttontext="Configure Prometheus" secondarybuttonlink="" @@ -38,6 +40,7 @@ exports[`EmptyState shows unableToConnect state 1`] = ` <gl-empty-state-stub description="Ensure connectivity is available from the GitLab server to the Prometheus server" + invertindarkmode="true" primarybuttonlink="/documentationPath" primarybuttontext="View documentation" secondarybuttonlink="/settingsPath" diff --git a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap index 9b2aa3a5b5b..1d7ff420a17 100644 --- a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap @@ -4,6 +4,7 @@ exports[`GroupEmptyState given state BAD_QUERY passes the expected props to GlEm Object { "compact": true, "description": null, + "invertInDarkMode": true, "primaryButtonLink": "/path/to/settings", "primaryButtonText": "Verify configuration", "secondaryButtonLink": null, @@ -31,6 +32,7 @@ exports[`GroupEmptyState given state CONNECTION_FAILED passes the expected props Object { "compact": true, "description": "We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating.", + "invertInDarkMode": true, "primaryButtonLink": "/path/to/settings", "primaryButtonText": "Verify configuration", "secondaryButtonLink": null, @@ -47,6 +49,7 @@ exports[`GroupEmptyState given state FOO STATE passes the expected props to GlEm Object { "compact": true, "description": "An error occurred while loading the data. Please try again.", + "invertInDarkMode": true, "primaryButtonLink": null, "primaryButtonText": null, "secondaryButtonLink": null, @@ -63,6 +66,7 @@ exports[`GroupEmptyState given state LOADING passes the expected props to GlEmpt Object { "compact": true, "description": "Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.", + "invertInDarkMode": true, "primaryButtonLink": null, "primaryButtonText": null, "secondaryButtonLink": null, @@ -79,6 +83,7 @@ exports[`GroupEmptyState given state NO_DATA passes the expected props to GlEmpt Object { "compact": true, "description": null, + "invertInDarkMode": true, "primaryButtonLink": null, "primaryButtonText": null, "secondaryButtonLink": null, @@ -106,6 +111,7 @@ exports[`GroupEmptyState given state TIMEOUT passes the expected props to GlEmpt Object { "compact": true, "description": null, + "invertInDarkMode": true, "primaryButtonLink": null, "primaryButtonText": null, "secondaryButtonLink": null, @@ -133,6 +139,7 @@ exports[`GroupEmptyState given state UNKNOWN_ERROR passes the expected props to Object { "compact": true, "description": "An error occurred while loading the data. Please try again.", + "invertInDarkMode": true, "primaryButtonLink": null, "primaryButtonText": null, "secondaryButtonLink": null, diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js index d0d0c3071d5..d74f959ac0f 100644 --- a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js +++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js @@ -109,7 +109,7 @@ describe('Actions menu', () => { describe('adding new metric from modal', () => { let origPage; - beforeEach((done) => { + beforeEach(() => { jest.spyOn(Tracking, 'event').mockReturnValue(); createShallowWrapper(); @@ -118,7 +118,7 @@ describe('Actions menu', () => { origPage = document.body.dataset.page; document.body.dataset.page = 'projects:environments:metrics'; - nextTick(done); + return nextTick(); }); afterEach(() => { diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index 246dd598d19..64c48100b31 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -126,7 +126,7 @@ describe('dashboard invalid url parameters', () => { }); it('redirects to different time range', async () => { - const toUrl = `${mockProjectDir}/-/environments/1/metrics`; + const toUrl = `${mockProjectDir}/-/metrics?environment=1`; removeParams.mockReturnValueOnce(toUrl); createMountedWrapper(); diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index f60c531e3f6..d1a13fbf9cd 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -7,9 +7,9 @@ import * as commonUtils from '~/lib/utils/common_utils'; import statusCodes from '~/lib/utils/http_status'; import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants'; -import getAnnotations from '~/monitoring/queries/getAnnotations.query.graphql'; -import getDashboardValidationWarnings from '~/monitoring/queries/getDashboardValidationWarnings.query.graphql'; -import getEnvironments from '~/monitoring/queries/getEnvironments.query.graphql'; +import getAnnotations from '~/monitoring/queries/get_annotations.query.graphql'; +import getDashboardValidationWarnings from '~/monitoring/queries/get_dashboard_validation_warnings.query.graphql'; +import getEnvironments from '~/monitoring/queries/get_environments.query.graphql'; import { createStore } from '~/monitoring/stores'; import { setGettingStartedEmptyState, @@ -88,8 +88,8 @@ describe('Monitoring store actions', () => { // Setup describe('setGettingStartedEmptyState', () => { - it('should commit SET_GETTING_STARTED_EMPTY_STATE mutation', (done) => { - testAction( + it('should commit SET_GETTING_STARTED_EMPTY_STATE mutation', () => { + return testAction( setGettingStartedEmptyState, null, state, @@ -99,14 +99,13 @@ describe('Monitoring store actions', () => { }, ], [], - done, ); }); }); describe('setInitialState', () => { - it('should commit SET_INITIAL_STATE mutation', (done) => { - testAction( + it('should commit SET_INITIAL_STATE mutation', () => { + return testAction( setInitialState, { currentDashboard: '.gitlab/dashboards/dashboard.yml', @@ -123,7 +122,6 @@ describe('Monitoring store actions', () => { }, ], [], - done, ); }); }); @@ -233,51 +231,39 @@ describe('Monitoring store actions', () => { }; }); - it('dispatches a failure', (done) => { - result() - .then(() => { - expect(commit).toHaveBeenCalledWith( - types.SET_ALL_DASHBOARDS, - mockDashboardsErrorResponse.all_dashboards, - ); - expect(dispatch).toHaveBeenCalledWith( - 'receiveMetricsDashboardFailure', - new Error('Request failed with status code 500'), - ); - expect(createFlash).toHaveBeenCalled(); - done(); - }) - .catch(done.fail); + it('dispatches a failure', async () => { + await result(); + expect(commit).toHaveBeenCalledWith( + types.SET_ALL_DASHBOARDS, + mockDashboardsErrorResponse.all_dashboards, + ); + expect(dispatch).toHaveBeenCalledWith( + 'receiveMetricsDashboardFailure', + new Error('Request failed with status code 500'), + ); + expect(createFlash).toHaveBeenCalled(); }); - it('dispatches a failure action when a message is returned', (done) => { - result() - .then(() => { - expect(dispatch).toHaveBeenCalledWith( - 'receiveMetricsDashboardFailure', - new Error('Request failed with status code 500'), - ); - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringContaining(mockDashboardsErrorResponse.message), - }); - done(); - }) - .catch(done.fail); + it('dispatches a failure action when a message is returned', async () => { + await result(); + expect(dispatch).toHaveBeenCalledWith( + 'receiveMetricsDashboardFailure', + new Error('Request failed with status code 500'), + ); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringContaining(mockDashboardsErrorResponse.message), + }); }); - it('does not show a flash error when showErrorBanner is disabled', (done) => { + it('does not show a flash error when showErrorBanner is disabled', async () => { state.showErrorBanner = false; - result() - .then(() => { - expect(dispatch).toHaveBeenCalledWith( - 'receiveMetricsDashboardFailure', - new Error('Request failed with status code 500'), - ); - expect(createFlash).not.toHaveBeenCalled(); - done(); - }) - .catch(done.fail); + await result(); + expect(dispatch).toHaveBeenCalledWith( + 'receiveMetricsDashboardFailure', + new Error('Request failed with status code 500'), + ); + expect(createFlash).not.toHaveBeenCalled(); }); }); }); @@ -322,38 +308,30 @@ describe('Monitoring store actions', () => { state.timeRange = defaultTimeRange; }); - it('commits empty state when state.groups is empty', (done) => { + it('commits empty state when state.groups is empty', async () => { const localGetters = { metricsWithData: () => [], }; - fetchDashboardData({ state, commit, dispatch, getters: localGetters }) - .then(() => { - expect(Tracking.event).toHaveBeenCalledWith( - document.body.dataset.page, - 'dashboard_fetch', - { - label: 'custom_metrics_dashboard', - property: 'count', - value: 0, - }, - ); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); - expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { - defaultQueryParams: { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }, - }); + await fetchDashboardData({ state, commit, dispatch, getters: localGetters }); + expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'dashboard_fetch', { + label: 'custom_metrics_dashboard', + property: 'count', + value: 0, + }); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); + expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { + defaultQueryParams: { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }, + }); - expect(createFlash).not.toHaveBeenCalled(); - done(); - }) - .catch(done.fail); + expect(createFlash).not.toHaveBeenCalled(); }); - it('dispatches fetchPrometheusMetric for each panel query', (done) => { + it('dispatches fetchPrometheusMetric for each panel query', async () => { state.dashboard.panelGroups = convertObjectPropsToCamelCase( metricsDashboardResponse.dashboard.panel_groups, ); @@ -363,34 +341,24 @@ describe('Monitoring store actions', () => { metricsWithData: () => [metric.id], }; - fetchDashboardData({ state, commit, dispatch, getters: localGetters }) - .then(() => { - expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { - metric, - defaultQueryParams: { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }, - }); - - expect(Tracking.event).toHaveBeenCalledWith( - document.body.dataset.page, - 'dashboard_fetch', - { - label: 'custom_metrics_dashboard', - property: 'count', - value: 1, - }, - ); + await fetchDashboardData({ state, commit, dispatch, getters: localGetters }); + expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { + metric, + defaultQueryParams: { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }, + }); - done(); - }) - .catch(done.fail); - done(); + expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'dashboard_fetch', { + label: 'custom_metrics_dashboard', + property: 'count', + value: 1, + }); }); - it('dispatches fetchPrometheusMetric for each panel query, handles an error', (done) => { + it('dispatches fetchPrometheusMetric for each panel query, handles an error', async () => { state.dashboard.panelGroups = metricsDashboardViewModel.panelGroups; const metric = state.dashboard.panelGroups[0].panels[0].metrics[0]; @@ -400,30 +368,24 @@ describe('Monitoring store actions', () => { dispatch.mockRejectedValueOnce(new Error('Error fetching this metric')); dispatch.mockResolvedValue(); - fetchDashboardData({ state, commit, dispatch }) - .then(() => { - const defaultQueryParams = { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }; - - expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 2); // plus 1 for deployments - expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); - expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { - defaultQueryParams, - }); - expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { - metric, - defaultQueryParams, - }); + await fetchDashboardData({ state, commit, dispatch }); + const defaultQueryParams = { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }; - expect(createFlash).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 2); // plus 1 for deployments + expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); + expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { + defaultQueryParams, + }); + expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { + metric, + defaultQueryParams, + }); - done(); - }) - .catch(done.fail); - done(); + expect(createFlash).toHaveBeenCalledTimes(1); }); }); @@ -449,10 +411,10 @@ describe('Monitoring store actions', () => { }; }); - it('commits result', (done) => { + it('commits result', () => { mock.onGet(prometheusEndpointPath).reply(200, { data }); // One attempt - testAction( + return testAction( fetchPrometheusMetric, { metric, defaultQueryParams }, state, @@ -472,10 +434,7 @@ describe('Monitoring store actions', () => { }, ], [], - () => { - done(); - }, - ).catch(done.fail); + ); }); describe('without metric defined step', () => { @@ -485,10 +444,10 @@ describe('Monitoring store actions', () => { step: 60, }; - it('uses calculated step', (done) => { + it('uses calculated step', async () => { mock.onGet(prometheusEndpointPath).reply(200, { data }); // One attempt - testAction( + await testAction( fetchPrometheusMetric, { metric, defaultQueryParams }, state, @@ -508,11 +467,8 @@ describe('Monitoring store actions', () => { }, ], [], - () => { - expect(mock.history.get[0].params).toEqual(expectedParams); - done(); - }, - ).catch(done.fail); + ); + expect(mock.history.get[0].params).toEqual(expectedParams); }); }); @@ -527,10 +483,10 @@ describe('Monitoring store actions', () => { step: 7, }; - it('uses metric step', (done) => { + it('uses metric step', async () => { mock.onGet(prometheusEndpointPath).reply(200, { data }); // One attempt - testAction( + await testAction( fetchPrometheusMetric, { metric, defaultQueryParams }, state, @@ -550,43 +506,39 @@ describe('Monitoring store actions', () => { }, ], [], - () => { - expect(mock.history.get[0].params).toEqual(expectedParams); - done(); - }, - ).catch(done.fail); + ); + expect(mock.history.get[0].params).toEqual(expectedParams); }); }); - it('commits failure, when waiting for results and getting a server error', (done) => { + it('commits failure, when waiting for results and getting a server error', async () => { mock.onGet(prometheusEndpointPath).reply(500); const error = new Error('Request failed with status code 500'); - testAction( - fetchPrometheusMetric, - { metric, defaultQueryParams }, - state, - [ - { - type: types.REQUEST_METRIC_RESULT, - payload: { - metricId: metric.metricId, + await expect( + testAction( + fetchPrometheusMetric, + { metric, defaultQueryParams }, + state, + [ + { + type: types.REQUEST_METRIC_RESULT, + payload: { + metricId: metric.metricId, + }, }, - }, - { - type: types.RECEIVE_METRIC_RESULT_FAILURE, - payload: { - metricId: metric.metricId, - error, + { + type: types.RECEIVE_METRIC_RESULT_FAILURE, + payload: { + metricId: metric.metricId, + error, + }, }, - }, - ], - [], - ).catch((e) => { - expect(e).toEqual(error); - done(); - }); + ], + [], + ), + ).rejects.toEqual(error); }); }); @@ -991,20 +943,16 @@ describe('Monitoring store actions', () => { state.dashboardsEndpoint = '/dashboards.json'; }); - it('Succesful POST request resolves', (done) => { + it('Succesful POST request resolves', async () => { mock.onPost(state.dashboardsEndpoint).reply(statusCodes.CREATED, { dashboard: dashboardGitResponse[1], }); - testAction(duplicateSystemDashboard, {}, state, [], []) - .then(() => { - expect(mock.history.post).toHaveLength(1); - done(); - }) - .catch(done.fail); + await testAction(duplicateSystemDashboard, {}, state, [], []); + expect(mock.history.post).toHaveLength(1); }); - it('Succesful POST request resolves to a dashboard', (done) => { + it('Succesful POST request resolves to a dashboard', async () => { const mockCreatedDashboard = dashboardGitResponse[1]; const params = { @@ -1025,50 +973,40 @@ describe('Monitoring store actions', () => { dashboard: mockCreatedDashboard, }); - testAction(duplicateSystemDashboard, params, state, [], []) - .then((result) => { - expect(mock.history.post).toHaveLength(1); - expect(mock.history.post[0].data).toEqual(expectedPayload); - expect(result).toEqual(mockCreatedDashboard); - - done(); - }) - .catch(done.fail); + const result = await testAction(duplicateSystemDashboard, params, state, [], []); + expect(mock.history.post).toHaveLength(1); + expect(mock.history.post[0].data).toEqual(expectedPayload); + expect(result).toEqual(mockCreatedDashboard); }); - it('Failed POST request throws an error', (done) => { + it('Failed POST request throws an error', async () => { mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST); - testAction(duplicateSystemDashboard, {}, state, [], []).catch((err) => { - expect(mock.history.post).toHaveLength(1); - expect(err).toEqual(expect.any(String)); - - done(); - }); + await expect(testAction(duplicateSystemDashboard, {}, state, [], [])).rejects.toEqual( + 'There was an error creating the dashboard.', + ); + expect(mock.history.post).toHaveLength(1); }); - it('Failed POST request throws an error with a description', (done) => { + it('Failed POST request throws an error with a description', async () => { const backendErrorMsg = 'This file already exists!'; mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST, { error: backendErrorMsg, }); - testAction(duplicateSystemDashboard, {}, state, [], []).catch((err) => { - expect(mock.history.post).toHaveLength(1); - expect(err).toEqual(expect.any(String)); - expect(err).toEqual(expect.stringContaining(backendErrorMsg)); - - done(); - }); + await expect(testAction(duplicateSystemDashboard, {}, state, [], [])).rejects.toEqual( + `There was an error creating the dashboard. ${backendErrorMsg}`, + ); + expect(mock.history.post).toHaveLength(1); }); }); // Variables manipulation describe('updateVariablesAndFetchData', () => { - it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', (done) => { - testAction( + it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', () => { + return testAction( updateVariablesAndFetchData, { pod: 'POD' }, state, @@ -1083,7 +1021,6 @@ describe('Monitoring store actions', () => { type: 'fetchDashboardData', }, ], - done, ); }); }); diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js index 697bdb9185f..c25de8caa95 100644 --- a/spec/frontend/monitoring/store/utils_spec.js +++ b/spec/frontend/monitoring/store/utils_spec.js @@ -547,7 +547,7 @@ describe('parseEnvironmentsResponse', () => { { id: 1, name: 'env-1', - metrics_path: `${projectPath}/environments/1/metrics`, + metrics_path: `${projectPath}/-/metrics?environment=1`, }, ], }, @@ -562,7 +562,7 @@ describe('parseEnvironmentsResponse', () => { { id: 12, name: 'env-12', - metrics_path: `${projectPath}/environments/12/metrics`, + metrics_path: `${projectPath}/-/metrics?environment=12`, }, ], }, diff --git a/spec/frontend/mr_notes/stores/actions_spec.js b/spec/frontend/mr_notes/stores/actions_spec.js index c6578453d85..568c1b930c9 100644 --- a/spec/frontend/mr_notes/stores/actions_spec.js +++ b/spec/frontend/mr_notes/stores/actions_spec.js @@ -1,64 +1,37 @@ import MockAdapter from 'axios-mock-adapter'; - -import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; -import { setEndpoints, setMrMetadata, fetchMrMetadata } from '~/mr_notes/stores/actions'; -import mutationTypes from '~/mr_notes/stores/mutation_types'; +import { createStore } from '~/mr_notes/stores'; describe('MR Notes Mutator Actions', () => { + let store; + + beforeEach(() => { + store = createStore(); + }); + describe('setEndpoints', () => { - it('should trigger the SET_ENDPOINTS state mutation', (done) => { + it('sets endpoints', async () => { const endpoints = { endpointA: 'a' }; - testAction( - setEndpoints, - endpoints, - {}, - [ - { - type: mutationTypes.SET_ENDPOINTS, - payload: endpoints, - }, - ], - [], - done, - ); - }); - }); + await store.dispatch('setEndpoints', endpoints); - describe('setMrMetadata', () => { - it('should trigger the SET_MR_METADATA state mutation', async () => { - const mrMetadata = { propA: 'a', propB: 'b' }; - - await testAction( - setMrMetadata, - mrMetadata, - {}, - [ - { - type: mutationTypes.SET_MR_METADATA, - payload: mrMetadata, - }, - ], - [], - ); + expect(store.state.page.endpoints).toEqual(endpoints); }); }); describe('fetchMrMetadata', () => { const mrMetadata = { meta: true, data: 'foo' }; - const state = { - endpoints: { - metadata: 'metadata', - }, - }; + const metadata = 'metadata'; + const endpoints = { metadata }; let mock; - beforeEach(() => { + beforeEach(async () => { + await store.dispatch('setEndpoints', endpoints); + mock = new MockAdapter(axios); - mock.onGet(state.endpoints.metadata).reply(200, mrMetadata); + mock.onGet(metadata).reply(200, mrMetadata); }); afterEach(() => { @@ -66,27 +39,26 @@ describe('MR Notes Mutator Actions', () => { }); it('should fetch the data from the API', async () => { - await fetchMrMetadata({ state, dispatch: () => {} }); + await store.dispatch('fetchMrMetadata'); await axios.waitForAll(); expect(mock.history.get).toHaveLength(1); - expect(mock.history.get[0].url).toBe(state.endpoints.metadata); + expect(mock.history.get[0].url).toBe(metadata); + }); + + it('should set the fetched data into state', async () => { + await store.dispatch('fetchMrMetadata'); + + expect(store.state.page.mrMetadata).toEqual(mrMetadata); }); - it('should set the fetched data into state', () => { - return testAction( - fetchMrMetadata, - {}, - state, - [], - [ - { - type: 'setMrMetadata', - payload: mrMetadata, - }, - ], - ); + it('should set failedToLoadMetadata flag when request fails', async () => { + mock.onGet(metadata).reply(500); + + await store.dispatch('fetchMrMetadata'); + + expect(store.state.page.failedToLoadMetadata).toBe(true); }); }); }); diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js index 9f94dd693cb..7878737fd31 100644 --- a/spec/frontend/notes/components/diff_discussion_header_spec.js +++ b/spec/frontend/notes/components/diff_discussion_header_spec.js @@ -4,7 +4,7 @@ import { nextTick } from 'vue'; import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue'; import createStore from '~/notes/stores'; -import mockDiffFile from '../../diffs/mock_data/diff_discussions'; +import mockDiffFile from 'jest/diffs/mock_data/diff_discussions'; import { discussionMock } from '../mock_data'; describe('diff_discussion_header component', () => { diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index 780f24b3aa8..bf5a6b4966a 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -87,8 +87,7 @@ describe('noteActions', () => { }); it('should render emoji link', () => { - expect(wrapper.find('.js-add-award').exists()).toBe(true); - expect(wrapper.find('.js-add-award').attributes('data-position')).toBe('right'); + expect(wrapper.find('[data-testid="note-emoji-button"]').exists()).toBe(true); }); describe('actions dropdown', () => { diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 3e80b24f128..b709141f4ac 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -81,7 +81,6 @@ describe('issue_note_form component', () => { it('should show conflict message if note changes outside the component', async () => { wrapper.setProps({ ...props, - isEditing: true, noteBody: 'Foo', }); @@ -111,6 +110,12 @@ describe('issue_note_form component', () => { ); }); + it('should set data-supports-quick-actions to enable autocomplete', () => { + const textarea = wrapper.find('textarea'); + + expect(textarea.attributes('data-supports-quick-actions')).toBe('true'); + }); + it('should link to markdown docs', () => { const { markdownDocsPath } = notesDataMock; const markdownField = wrapper.find(MarkdownField); @@ -171,7 +176,6 @@ describe('issue_note_form component', () => { it('should be possible to cancel', async () => { wrapper.setProps({ ...props, - isEditing: true, }); await nextTick(); @@ -185,7 +189,6 @@ describe('issue_note_form component', () => { it('should be possible to update the note', async () => { wrapper.setProps({ ...props, - isEditing: true, }); await nextTick(); diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js index 4671d33219d..3513b562e0a 100644 --- a/spec/frontend/notes/components/note_header_spec.js +++ b/spec/frontend/notes/components/note_header_spec.js @@ -263,7 +263,7 @@ describe('NoteHeader component', () => { }); describe('when author username link is hovered', () => { - it('toggles hover specific CSS classes on author name link', (done) => { + it('toggles hover specific CSS classes on author name link', async () => { createComponent({ author }); const authorUsernameLink = wrapper.find({ ref: 'authorUsernameLink' }); @@ -271,19 +271,15 @@ describe('NoteHeader component', () => { authorUsernameLink.trigger('mouseenter'); - nextTick(() => { - expect(authorNameLink.classes()).toContain('hover'); - expect(authorNameLink.classes()).toContain('text-underline'); + await nextTick(); + expect(authorNameLink.classes()).toContain('hover'); + expect(authorNameLink.classes()).toContain('text-underline'); - authorUsernameLink.trigger('mouseleave'); + authorUsernameLink.trigger('mouseleave'); - nextTick(() => { - expect(authorNameLink.classes()).not.toContain('hover'); - expect(authorNameLink.classes()).not.toContain('text-underline'); - - done(); - }); - }); + await nextTick(); + expect(authorNameLink.classes()).not.toContain('hover'); + expect(authorNameLink.classes()).not.toContain('text-underline'); }); }); @@ -296,5 +292,13 @@ describe('NoteHeader component', () => { createComponent({ isConfidential: status }); expect(findConfidentialIndicator().exists()).toBe(status); }); + + it('shows confidential indicator tooltip for project context', () => { + createComponent({ isConfidential: true, noteableType: 'issue' }); + + expect(findConfidentialIndicator().attributes('title')).toBe( + 'This comment is confidential and only visible to project members', + ); + }); }); }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index 727ef02dcbb..c46d3bbe5b2 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -86,7 +86,6 @@ describe('noteable_discussion component', () => { const noteFormProps = noteForm.props(); expect(noteFormProps.discussion).toBe(discussionMock); - expect(noteFormProps.isEditing).toBe(false); expect(noteFormProps.line).toBe(null); expect(noteFormProps.saveButtonTitle).toBe('Comment'); expect(noteFormProps.autosaveKey).toBe(`Note/Issue/${discussionMock.id}/Reply`); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index c7115a5911b..385edc59eb6 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -11,6 +11,7 @@ import NoteBody from '~/notes/components/note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; import issueNote from '~/notes/components/noteable_note.vue'; import NotesModule from '~/notes/stores/modules'; +import { NOTEABLE_TYPE_MAPPING } from '~/notes/constants'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -226,6 +227,7 @@ describe('issue_note', () => { expect(noteHeaderProps.author).toBe(note.author); expect(noteHeaderProps.createdAt).toBe(note.created_at); expect(noteHeaderProps.noteId).toBe(note.id); + expect(noteHeaderProps.noteableType).toBe(NOTEABLE_TYPE_MAPPING[note.noteable_type]); }); it('should render note actions', () => { diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index bf36d6cb7a2..e227af88d3f 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -300,16 +300,18 @@ describe('note_app', () => { await nextTick(); expect(wrapper.find(`.edit-note a[href="${markdownDocsPath}"]`).text().trim()).toEqual( - 'Markdown is supported', + 'Markdown', ); }); - it('should not render quick actions docs url', async () => { + it('should render quick actions docs url', async () => { wrapper.find('.js-note-edit').trigger('click'); const { quickActionsDocsPath } = mockData.notesDataMock; await nextTick(); - expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).exists()).toBe(false); + expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).text().trim()).toEqual( + 'quick actions', + ); }); }); diff --git a/spec/frontend/notes/components/sort_discussion_spec.js b/spec/frontend/notes/components/sort_discussion_spec.js index a279dfd1ef3..bde27b7e5fc 100644 --- a/spec/frontend/notes/components/sort_discussion_spec.js +++ b/spec/frontend/notes/components/sort_discussion_spec.js @@ -38,8 +38,8 @@ describe('Sort Discussion component', () => { createComponent(); }); - it('has local storage sync', () => { - expect(findLocalStorageSync().exists()).toBe(true); + it('has local storage sync with the correct props', () => { + expect(findLocalStorageSync().props('asString')).toBe(true); }); it('calls setDiscussionSortDirection when update is emitted', () => { diff --git a/spec/frontend/notes/deprecated_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js index 7c52920da90..7193475c96a 100644 --- a/spec/frontend/notes/deprecated_notes_spec.js +++ b/spec/frontend/notes/deprecated_notes_spec.js @@ -561,7 +561,7 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { }); describe('postComment', () => { - it('disables the submit button', (done) => { + it('disables the submit button', async () => { const $submitButton = $form.find('.js-comment-submit-button'); expect($submitButton).not.toBeDisabled(); @@ -574,13 +574,8 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { return [200, note]; }); - notes - .postComment(dummyEvent) - .then(() => { - expect($submitButton).not.toBeDisabled(); - }) - .then(done) - .catch(done.fail); + await notes.postComment(dummyEvent); + expect($submitButton).not.toBeDisabled(); }); }); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 7424a87bc0f..75e7756cd6b 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -62,118 +62,109 @@ describe('Actions Notes Store', () => { }); describe('setNotesData', () => { - it('should set received notes data', (done) => { - testAction( + it('should set received notes data', () => { + return testAction( actions.setNotesData, notesDataMock, { notesData: {} }, [{ type: 'SET_NOTES_DATA', payload: notesDataMock }], [], - done, ); }); }); describe('setNoteableData', () => { - it('should set received issue data', (done) => { - testAction( + it('should set received issue data', () => { + return testAction( actions.setNoteableData, noteableDataMock, { noteableData: {} }, [{ type: 'SET_NOTEABLE_DATA', payload: noteableDataMock }], [], - done, ); }); }); describe('setUserData', () => { - it('should set received user data', (done) => { - testAction( + it('should set received user data', () => { + return testAction( actions.setUserData, userDataMock, { userData: {} }, [{ type: 'SET_USER_DATA', payload: userDataMock }], [], - done, ); }); }); describe('setLastFetchedAt', () => { - it('should set received timestamp', (done) => { - testAction( + it('should set received timestamp', () => { + return testAction( actions.setLastFetchedAt, 'timestamp', { lastFetchedAt: {} }, [{ type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' }], [], - done, ); }); }); describe('setInitialNotes', () => { - it('should set initial notes', (done) => { - testAction( + it('should set initial notes', () => { + return testAction( actions.setInitialNotes, [individualNote], { notes: [] }, [{ type: 'ADD_OR_UPDATE_DISCUSSIONS', payload: [individualNote] }], [], - done, ); }); }); describe('setTargetNoteHash', () => { - it('should set target note hash', (done) => { - testAction( + it('should set target note hash', () => { + return testAction( actions.setTargetNoteHash, 'hash', { notes: [] }, [{ type: 'SET_TARGET_NOTE_HASH', payload: 'hash' }], [], - done, ); }); }); describe('toggleDiscussion', () => { - it('should toggle discussion', (done) => { - testAction( + it('should toggle discussion', () => { + return testAction( actions.toggleDiscussion, { discussionId: discussionMock.id }, { notes: [discussionMock] }, [{ type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } }], [], - done, ); }); }); describe('expandDiscussion', () => { - it('should expand discussion', (done) => { - testAction( + it('should expand discussion', () => { + return testAction( actions.expandDiscussion, { discussionId: discussionMock.id }, { notes: [discussionMock] }, [{ type: 'EXPAND_DISCUSSION', payload: { discussionId: discussionMock.id } }], [{ type: 'diffs/renderFileForDiscussionId', payload: discussionMock.id }], - done, ); }); }); describe('collapseDiscussion', () => { - it('should commit collapse discussion', (done) => { - testAction( + it('should commit collapse discussion', () => { + return testAction( actions.collapseDiscussion, { discussionId: discussionMock.id }, { notes: [discussionMock] }, [{ type: 'COLLAPSE_DISCUSSION', payload: { discussionId: discussionMock.id } }], [], - done, ); }); }); @@ -184,28 +175,18 @@ describe('Actions Notes Store', () => { }); describe('closeMergeRequest', () => { - it('sets state as closed', (done) => { - store - .dispatch('closeIssuable', { notesData: { closeIssuePath: '' } }) - .then(() => { - expect(store.state.noteableData.state).toEqual('closed'); - expect(store.state.isToggleStateButtonLoading).toEqual(false); - done(); - }) - .catch(done.fail); + it('sets state as closed', async () => { + await store.dispatch('closeIssuable', { notesData: { closeIssuePath: '' } }); + expect(store.state.noteableData.state).toEqual('closed'); + expect(store.state.isToggleStateButtonLoading).toEqual(false); }); }); describe('reopenMergeRequest', () => { - it('sets state as reopened', (done) => { - store - .dispatch('reopenIssuable', { notesData: { reopenIssuePath: '' } }) - .then(() => { - expect(store.state.noteableData.state).toEqual('reopened'); - expect(store.state.isToggleStateButtonLoading).toEqual(false); - done(); - }) - .catch(done.fail); + it('sets state as reopened', async () => { + await store.dispatch('reopenIssuable', { notesData: { reopenIssuePath: '' } }); + expect(store.state.noteableData.state).toEqual('reopened'); + expect(store.state.isToggleStateButtonLoading).toEqual(false); }); }); }); @@ -222,42 +203,39 @@ describe('Actions Notes Store', () => { }); describe('toggleStateButtonLoading', () => { - it('should set loading as true', (done) => { - testAction( + it('should set loading as true', () => { + return testAction( actions.toggleStateButtonLoading, true, {}, [{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: true }], [], - done, ); }); - it('should set loading as false', (done) => { - testAction( + it('should set loading as false', () => { + return testAction( actions.toggleStateButtonLoading, false, {}, [{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: false }], [], - done, ); }); }); describe('toggleIssueLocalState', () => { - it('sets issue state as closed', (done) => { - testAction(actions.toggleIssueLocalState, 'closed', {}, [{ type: 'CLOSE_ISSUE' }], [], done); + it('sets issue state as closed', () => { + return testAction(actions.toggleIssueLocalState, 'closed', {}, [{ type: 'CLOSE_ISSUE' }], []); }); - it('sets issue state as reopened', (done) => { - testAction( + it('sets issue state as reopened', () => { + return testAction( actions.toggleIssueLocalState, 'reopened', {}, [{ type: 'REOPEN_ISSUE' }], [], - done, ); }); }); @@ -291,8 +269,8 @@ describe('Actions Notes Store', () => { return store.dispatch('stopPolling'); }; - beforeEach((done) => { - store.dispatch('setNotesData', notesDataMock).then(done).catch(done.fail); + beforeEach(() => { + return store.dispatch('setNotesData', notesDataMock); }); afterEach(() => { @@ -405,14 +383,13 @@ describe('Actions Notes Store', () => { }); describe('setNotesFetchedState', () => { - it('should set notes fetched state', (done) => { - testAction( + it('should set notes fetched state', () => { + return testAction( actions.setNotesFetchedState, true, {}, [{ type: 'SET_NOTES_FETCHED_STATE', payload: true }], [], - done, ); }); }); @@ -432,10 +409,10 @@ describe('Actions Notes Store', () => { document.body.setAttribute('data-page', ''); }); - it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', (done) => { + it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', () => { const note = { path: endpoint, id: 1 }; - testAction( + return testAction( actions.removeNote, note, store.state, @@ -453,16 +430,15 @@ describe('Actions Notes Store', () => { type: 'updateResolvableDiscussionsCounts', }, ], - done, ); }); - it('dispatches removeDiscussionsFromDiff on merge request page', (done) => { + it('dispatches removeDiscussionsFromDiff on merge request page', () => { const note = { path: endpoint, id: 1 }; document.body.setAttribute('data-page', 'projects:merge_requests:show'); - testAction( + return testAction( actions.removeNote, note, store.state, @@ -483,7 +459,6 @@ describe('Actions Notes Store', () => { type: 'diffs/removeDiscussionsFromDiff', }, ], - done, ); }); }); @@ -503,10 +478,10 @@ describe('Actions Notes Store', () => { document.body.setAttribute('data-page', ''); }); - it('dispatches removeNote', (done) => { + it('dispatches removeNote', () => { const note = { path: endpoint, id: 1 }; - testAction( + return testAction( actions.deleteNote, note, {}, @@ -520,7 +495,6 @@ describe('Actions Notes Store', () => { }, }, ], - done, ); }); }); @@ -536,8 +510,8 @@ describe('Actions Notes Store', () => { axiosMock.onAny().reply(200, res); }); - it('commits ADD_NEW_NOTE and dispatches updateMergeRequestWidget', (done) => { - testAction( + it('commits ADD_NEW_NOTE and dispatches updateMergeRequestWidget', () => { + return testAction( actions.createNewNote, { endpoint: `${TEST_HOST}`, data: {} }, store.state, @@ -558,7 +532,6 @@ describe('Actions Notes Store', () => { type: 'updateResolvableDiscussionsCounts', }, ], - done, ); }); }); @@ -572,14 +545,13 @@ describe('Actions Notes Store', () => { axiosMock.onAny().replyOnce(200, res); }); - it('does not commit ADD_NEW_NOTE or dispatch updateMergeRequestWidget', (done) => { - testAction( + it('does not commit ADD_NEW_NOTE or dispatch updateMergeRequestWidget', () => { + return testAction( actions.createNewNote, { endpoint: `${TEST_HOST}`, data: {} }, store.state, [], [], - done, ); }); }); @@ -595,8 +567,8 @@ describe('Actions Notes Store', () => { }); describe('as note', () => { - it('commits UPDATE_NOTE and dispatches updateMergeRequestWidget', (done) => { - testAction( + it('commits UPDATE_NOTE and dispatches updateMergeRequestWidget', () => { + return testAction( actions.toggleResolveNote, { endpoint: `${TEST_HOST}`, isResolved: true, discussion: false }, store.state, @@ -614,14 +586,13 @@ describe('Actions Notes Store', () => { type: 'updateMergeRequestWidget', }, ], - done, ); }); }); describe('as discussion', () => { - it('commits UPDATE_DISCUSSION and dispatches updateMergeRequestWidget', (done) => { - testAction( + it('commits UPDATE_DISCUSSION and dispatches updateMergeRequestWidget', () => { + return testAction( actions.toggleResolveNote, { endpoint: `${TEST_HOST}`, isResolved: true, discussion: true }, store.state, @@ -639,7 +610,6 @@ describe('Actions Notes Store', () => { type: 'updateMergeRequestWidget', }, ], - done, ); }); }); @@ -656,41 +626,38 @@ describe('Actions Notes Store', () => { }); describe('setCommentsDisabled', () => { - it('should set comments disabled state', (done) => { - testAction( + it('should set comments disabled state', () => { + return testAction( actions.setCommentsDisabled, true, null, [{ type: 'DISABLE_COMMENTS', payload: true }], [], - done, ); }); }); describe('updateResolvableDiscussionsCounts', () => { - it('commits UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', (done) => { - testAction( + it('commits UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', () => { + return testAction( actions.updateResolvableDiscussionsCounts, null, {}, [{ type: 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS' }], [], - done, ); }); }); describe('convertToDiscussion', () => { - it('commits CONVERT_TO_DISCUSSION with noteId', (done) => { + it('commits CONVERT_TO_DISCUSSION with noteId', () => { const noteId = 'dummy-note-id'; - testAction( + return testAction( actions.convertToDiscussion, noteId, {}, [{ type: 'CONVERT_TO_DISCUSSION', payload: noteId }], [], - done, ); }); }); @@ -786,11 +753,11 @@ describe('Actions Notes Store', () => { describe('replyToDiscussion', () => { const payload = { endpoint: TEST_HOST, data: {} }; - it('updates discussion if response contains disussion', (done) => { + it('updates discussion if response contains disussion', () => { const discussion = { notes: [] }; axiosMock.onAny().reply(200, { discussion }); - testAction( + return testAction( actions.replyToDiscussion, payload, { @@ -802,15 +769,14 @@ describe('Actions Notes Store', () => { { type: 'startTaskList' }, { type: 'updateResolvableDiscussionsCounts' }, ], - done, ); }); - it('adds a reply to a discussion', (done) => { + it('adds a reply to a discussion', () => { const res = {}; axiosMock.onAny().reply(200, res); - testAction( + return testAction( actions.replyToDiscussion, payload, { @@ -818,21 +784,19 @@ describe('Actions Notes Store', () => { }, [{ type: mutationTypes.ADD_NEW_REPLY_TO_DISCUSSION, payload: res }], [], - done, ); }); }); describe('removeConvertedDiscussion', () => { - it('commits CONVERT_TO_DISCUSSION with noteId', (done) => { + it('commits CONVERT_TO_DISCUSSION with noteId', () => { const noteId = 'dummy-id'; - testAction( + return testAction( actions.removeConvertedDiscussion, noteId, {}, [{ type: 'REMOVE_CONVERTED_DISCUSSION', payload: noteId }], [], - done, ); }); }); @@ -849,8 +813,8 @@ describe('Actions Notes Store', () => { }; }); - it('when unresolved, dispatches action', (done) => { - testAction( + it('when unresolved, dispatches action', () => { + return testAction( actions.resolveDiscussion, { discussionId }, { ...state, ...getters }, @@ -865,20 +829,18 @@ describe('Actions Notes Store', () => { }, }, ], - done, ); }); - it('when resolved, does nothing', (done) => { + it('when resolved, does nothing', () => { getters.isDiscussionResolved = (id) => id === discussionId; - testAction( + return testAction( actions.resolveDiscussion, { discussionId }, { ...state, ...getters }, [], [], - done, ); }); }); @@ -891,22 +853,17 @@ describe('Actions Notes Store', () => { const res = { errors: { something: ['went wrong'] } }; const error = { message: 'Unprocessable entity', response: { data: res } }; - it('throws an error', (done) => { - actions - .saveNote( + it('throws an error', async () => { + await expect( + actions.saveNote( { commit() {}, dispatch: () => Promise.reject(error), }, payload, - ) - .then(() => done.fail('Expected error to be thrown!')) - .catch((err) => { - expect(err).toBe(error); - expect(createFlash).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + ), + ).rejects.toEqual(error); + expect(createFlash).not.toHaveBeenCalled(); }); }); @@ -914,46 +871,35 @@ describe('Actions Notes Store', () => { const res = { errors: { base: ['something went wrong'] } }; const error = { message: 'Unprocessable entity', response: { data: res } }; - it('sets flash alert using errors.base message', (done) => { - actions - .saveNote( - { - commit() {}, - dispatch: () => Promise.reject(error), - }, - { ...payload, flashContainer }, - ) - .then((resp) => { - expect(resp.hasFlash).toBe(true); - expect(createFlash).toHaveBeenCalledWith({ - message: 'Your comment could not be submitted because something went wrong', - parent: flashContainer, - }); - }) - .catch(() => done.fail('Expected success response!')) - .then(done) - .catch(done.fail); + it('sets flash alert using errors.base message', async () => { + const resp = await actions.saveNote( + { + commit() {}, + dispatch: () => Promise.reject(error), + }, + { ...payload, flashContainer }, + ); + expect(resp.hasFlash).toBe(true); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Your comment could not be submitted because something went wrong', + parent: flashContainer, + }); }); }); describe('if response contains no errors', () => { const res = { valid: true }; - it('returns the response', (done) => { - actions - .saveNote( - { - commit() {}, - dispatch: () => Promise.resolve(res), - }, - payload, - ) - .then((data) => { - expect(data).toBe(res); - expect(createFlash).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + it('returns the response', async () => { + const data = await actions.saveNote( + { + commit() {}, + dispatch: () => Promise.resolve(res), + }, + payload, + ); + expect(data).toBe(res); + expect(createFlash).not.toHaveBeenCalled(); }); }); }); @@ -970,19 +916,17 @@ describe('Actions Notes Store', () => { flashContainer = {}; }); - const testSubmitSuggestion = (done, expectFn) => { - actions - .submitSuggestion( - { commit, dispatch }, - { discussionId, noteId, suggestionId, flashContainer }, - ) - .then(expectFn) - .then(done) - .catch(done.fail); + const testSubmitSuggestion = async (expectFn) => { + await actions.submitSuggestion( + { commit, dispatch }, + { discussionId, noteId, suggestionId, flashContainer }, + ); + + expectFn(); }; - it('when service success, commits and resolves discussion', (done) => { - testSubmitSuggestion(done, () => { + it('when service success, commits and resolves discussion', () => { + testSubmitSuggestion(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_RESOLVING_DISCUSSION, false], @@ -997,12 +941,12 @@ describe('Actions Notes Store', () => { }); }); - it('when service fails, flashes error message', (done) => { + it('when service fails, flashes error message', () => { const response = { response: { data: { message: TEST_ERROR_MESSAGE } } }; Api.applySuggestion.mockReturnValue(Promise.reject(response)); - testSubmitSuggestion(done, () => { + return testSubmitSuggestion(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_RESOLVING_DISCUSSION, false], @@ -1015,12 +959,12 @@ describe('Actions Notes Store', () => { }); }); - it('when service fails, and no error message available, uses default message', (done) => { + it('when service fails, and no error message available, uses default message', () => { const response = { response: 'foo' }; Api.applySuggestion.mockReturnValue(Promise.reject(response)); - testSubmitSuggestion(done, () => { + return testSubmitSuggestion(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_RESOLVING_DISCUSSION, false], @@ -1033,10 +977,10 @@ describe('Actions Notes Store', () => { }); }); - it('when resolve discussion fails, fail gracefully', (done) => { + it('when resolve discussion fails, fail gracefully', () => { dispatch.mockReturnValue(Promise.reject()); - testSubmitSuggestion(done, () => { + return testSubmitSuggestion(() => { expect(createFlash).not.toHaveBeenCalled(); }); }); @@ -1056,16 +1000,14 @@ describe('Actions Notes Store', () => { flashContainer = {}; }); - const testSubmitSuggestionBatch = (done, expectFn) => { - actions - .submitSuggestionBatch({ commit, dispatch, state }, { flashContainer }) - .then(expectFn) - .then(done) - .catch(done.fail); + const testSubmitSuggestionBatch = async (expectFn) => { + await actions.submitSuggestionBatch({ commit, dispatch, state }, { flashContainer }); + + expectFn(); }; - it('when service succeeds, commits, resolves discussions, resets batch and applying batch state', (done) => { - testSubmitSuggestionBatch(done, () => { + it('when service succeeds, commits, resolves discussions, resets batch and applying batch state', () => { + testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], @@ -1085,12 +1027,12 @@ describe('Actions Notes Store', () => { }); }); - it('when service fails, flashes error message, resets applying batch state', (done) => { + it('when service fails, flashes error message, resets applying batch state', () => { const response = { response: { data: { message: TEST_ERROR_MESSAGE } } }; Api.applySuggestionBatch.mockReturnValue(Promise.reject(response)); - testSubmitSuggestionBatch(done, () => { + testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], @@ -1106,12 +1048,12 @@ describe('Actions Notes Store', () => { }); }); - it('when service fails, and no error message available, uses default message', (done) => { + it('when service fails, and no error message available, uses default message', () => { const response = { response: 'foo' }; Api.applySuggestionBatch.mockReturnValue(Promise.reject(response)); - testSubmitSuggestionBatch(done, () => { + testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], @@ -1128,10 +1070,10 @@ describe('Actions Notes Store', () => { }); }); - it('when resolve discussions fails, fails gracefully, resets batch and applying batch state', (done) => { + it('when resolve discussions fails, fails gracefully, resets batch and applying batch state', () => { dispatch.mockReturnValue(Promise.reject()); - testSubmitSuggestionBatch(done, () => { + testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], @@ -1148,14 +1090,13 @@ describe('Actions Notes Store', () => { describe('addSuggestionInfoToBatch', () => { const suggestionInfo = batchSuggestionsInfoMock[0]; - it("adds a suggestion's info to the current batch", (done) => { - testAction( + it("adds a suggestion's info to the current batch", () => { + return testAction( actions.addSuggestionInfoToBatch, suggestionInfo, { batchSuggestionsInfo: [] }, [{ type: 'ADD_SUGGESTION_TO_BATCH', payload: suggestionInfo }], [], - done, ); }); }); @@ -1163,14 +1104,13 @@ describe('Actions Notes Store', () => { describe('removeSuggestionInfoFromBatch', () => { const suggestionInfo = batchSuggestionsInfoMock[0]; - it("removes a suggestion's info the current batch", (done) => { - testAction( + it("removes a suggestion's info the current batch", () => { + return testAction( actions.removeSuggestionInfoFromBatch, suggestionInfo.suggestionId, { batchSuggestionsInfo: [suggestionInfo] }, [{ type: 'REMOVE_SUGGESTION_FROM_BATCH', payload: suggestionInfo.suggestionId }], [], - done, ); }); }); @@ -1209,8 +1149,8 @@ describe('Actions Notes Store', () => { }); describe('setDiscussionSortDirection', () => { - it('calls the correct mutation with the correct args', (done) => { - testAction( + it('calls the correct mutation with the correct args', () => { + return testAction( actions.setDiscussionSortDirection, { direction: notesConstants.DESC, persist: false }, {}, @@ -1221,20 +1161,18 @@ describe('Actions Notes Store', () => { }, ], [], - done, ); }); }); describe('setSelectedCommentPosition', () => { - it('calls the correct mutation with the correct args', (done) => { - testAction( + it('calls the correct mutation with the correct args', () => { + return testAction( actions.setSelectedCommentPosition, {}, {}, [{ type: mutationTypes.SET_SELECTED_COMMENT_POSITION, payload: {} }], [], - done, ); }); }); @@ -1248,9 +1186,9 @@ describe('Actions Notes Store', () => { }; describe('if response contains no errors', () => { - it('dispatches requestDeleteDescriptionVersion', (done) => { + it('dispatches requestDeleteDescriptionVersion', () => { axiosMock.onDelete(endpoint).replyOnce(200); - testAction( + return testAction( actions.softDeleteDescriptionVersion, payload, {}, @@ -1264,35 +1202,33 @@ describe('Actions Notes Store', () => { payload: payload.versionId, }, ], - done, ); }); }); describe('if response contains errors', () => { const errorMessage = 'Request failed with status code 503'; - it('dispatches receiveDeleteDescriptionVersionError and throws an error', (done) => { + it('dispatches receiveDeleteDescriptionVersionError and throws an error', async () => { axiosMock.onDelete(endpoint).replyOnce(503); - testAction( - actions.softDeleteDescriptionVersion, - payload, - {}, - [], - [ - { - type: 'requestDeleteDescriptionVersion', - }, - { - type: 'receiveDeleteDescriptionVersionError', - payload: new Error(errorMessage), - }, - ], - ) - .then(() => done.fail('Expected error to be thrown')) - .catch(() => { - expect(createFlash).toHaveBeenCalled(); - done(); - }); + await expect( + testAction( + actions.softDeleteDescriptionVersion, + payload, + {}, + [], + [ + { + type: 'requestDeleteDescriptionVersion', + }, + { + type: 'receiveDeleteDescriptionVersionError', + payload: new Error(errorMessage), + }, + ], + ), + ).rejects.toEqual(new Error()); + + expect(createFlash).toHaveBeenCalled(); }); }); }); @@ -1306,14 +1242,13 @@ describe('Actions Notes Store', () => { }); describe('updateAssignees', () => { - it('update the assignees state', (done) => { - testAction( + it('update the assignees state', () => { + return testAction( actions.updateAssignees, [userDataMock.id], { state: noteableDataMock }, [{ type: mutationTypes.UPDATE_ASSIGNEES, payload: [userDataMock.id] }], [], - done, ); }); }); @@ -1376,28 +1311,26 @@ describe('Actions Notes Store', () => { }); describe('updateDiscussionPosition', () => { - it('update the assignees state', (done) => { + it('update the assignees state', () => { const updatedPosition = { discussionId: 1, position: { test: true } }; - testAction( + return testAction( actions.updateDiscussionPosition, updatedPosition, { state: { discussions: [] } }, [{ type: mutationTypes.UPDATE_DISCUSSION_POSITION, payload: updatedPosition }], [], - done, ); }); }); describe('setFetchingState', () => { - it('commits SET_NOTES_FETCHING_STATE', (done) => { - testAction( + it('commits SET_NOTES_FETCHING_STATE', () => { + return testAction( actions.setFetchingState, true, null, [{ type: mutationTypes.SET_NOTES_FETCHING_STATE, payload: true }], [], - done, ); }); }); @@ -1409,9 +1342,9 @@ describe('Actions Notes Store', () => { window.gon = {}; }); - it('updates the discussions and dispatches `updateResolvableDiscussionsCounts`', (done) => { + it('updates the discussions and dispatches `updateResolvableDiscussionsCounts`', () => { axiosMock.onAny().reply(200, { discussion }); - testAction( + return testAction( actions.fetchDiscussions, {}, null, @@ -1420,14 +1353,13 @@ describe('Actions Notes Store', () => { { type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false }, ], [{ type: 'updateResolvableDiscussionsCounts' }], - done, ); }); - it('dispatches `fetchDiscussionsBatch` action if `paginatedIssueDiscussions` feature flag is enabled', (done) => { + it('dispatches `fetchDiscussionsBatch` action if `paginatedIssueDiscussions` feature flag is enabled', () => { window.gon = { features: { paginatedIssueDiscussions: true } }; - testAction( + return testAction( actions.fetchDiscussions, { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' }, null, @@ -1444,7 +1376,6 @@ describe('Actions Notes Store', () => { }, }, ], - done, ); }); }); @@ -1458,9 +1389,9 @@ describe('Actions Notes Store', () => { const actionPayload = { config, path: 'test-path', perPage: 20 }; - it('updates the discussions and dispatches `updateResolvableDiscussionsCounts if there are no headers', (done) => { + it('updates the discussions and dispatches `updateResolvableDiscussionsCounts if there are no headers', () => { axiosMock.onAny().reply(200, { discussion }, {}); - testAction( + return testAction( actions.fetchDiscussionsBatch, actionPayload, null, @@ -1469,13 +1400,12 @@ describe('Actions Notes Store', () => { { type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false }, ], [{ type: 'updateResolvableDiscussionsCounts' }], - done, ); }); - it('dispatches itself if there is `x-next-page-cursor` header', (done) => { + it('dispatches itself if there is `x-next-page-cursor` header', () => { axiosMock.onAny().reply(200, { discussion }, { 'x-next-page-cursor': 1 }); - testAction( + return testAction( actions.fetchDiscussionsBatch, actionPayload, null, @@ -1486,7 +1416,6 @@ describe('Actions Notes Store', () => { payload: { ...actionPayload, perPage: 30, cursor: 1 }, }, ], - done, ); }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js index 6d7bf528495..ad67128502a 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js @@ -1,7 +1,7 @@ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlTooltip, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import component from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue'; +import { LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION } from '~/packages_and_registries/container_registry/explorer/constants/list'; describe('delete_button', () => { let wrapper; @@ -12,6 +12,7 @@ describe('delete_button', () => { }; const findButton = () => wrapper.find(GlButton); + const findTooltip = () => wrapper.find(GlTooltip); const mountComponent = (props) => { wrapper = shallowMount(component, { @@ -19,8 +20,9 @@ describe('delete_button', () => { ...defaultProps, ...props, }, - directives: { - GlTooltip: createMockDirective(), + stubs: { + GlTooltip, + GlSprintf, }, }); }; @@ -33,41 +35,50 @@ describe('delete_button', () => { describe('tooltip', () => { it('the title is controlled by tooltipTitle prop', () => { mountComponent(); - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + const tooltip = findTooltip(); expect(tooltip).toBeDefined(); - expect(tooltip.value.title).toBe(defaultProps.tooltipTitle); + expect(tooltip.text()).toBe(defaultProps.tooltipTitle); }); it('is disabled when tooltipTitle is disabled', () => { mountComponent({ tooltipDisabled: true }); - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(tooltip.value.disabled).toBe(true); + expect(findTooltip().props('disabled')).toBe(true); }); - describe('button', () => { - it('exists', () => { - mountComponent(); - expect(findButton().exists()).toBe(true); + it('works with a link', () => { + mountComponent({ + tooltipTitle: LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION, + tooltipLink: 'foo', }); + expect(findTooltip().text()).toMatchInterpolatedText( + LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION, + ); + }); + }); - it('has the correct props/attributes bound', () => { - mountComponent({ disabled: true }); - expect(findButton().attributes()).toMatchObject({ - 'aria-label': 'Foo title', - icon: 'remove', - title: 'Foo title', - variant: 'danger', - disabled: 'true', - category: 'secondary', - }); - }); + describe('button', () => { + it('exists', () => { + mountComponent(); + expect(findButton().exists()).toBe(true); + }); - it('emits a delete event', () => { - mountComponent(); - expect(wrapper.emitted('delete')).toEqual(undefined); - findButton().vm.$emit('click'); - expect(wrapper.emitted('delete')).toEqual([[]]); + it('has the correct props/attributes bound', () => { + mountComponent({ disabled: true }); + expect(findButton().attributes()).toMatchObject({ + 'aria-label': 'Foo title', + icon: 'remove', + title: 'Foo title', + variant: 'danger', + disabled: 'true', + category: 'secondary', }); }); + + it('emits a delete event', () => { + mountComponent(); + expect(wrapper.emitted('delete')).toEqual(undefined); + findButton().vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[]]); + }); }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js index 411bef54e40..690d827ec67 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js @@ -10,6 +10,7 @@ import { LIST_DELETE_BUTTON_DISABLED, REMOVE_REPOSITORY_LABEL, IMAGE_DELETE_SCHEDULED_STATUS, + IMAGE_MIGRATING_STATE, SCHEDULED_STATUS, ROOT_IMAGE_TEXT, } from '~/packages_and_registries/container_registry/explorer/constants'; @@ -41,6 +42,9 @@ describe('Image List Row', () => { item, ...props, }, + provide: { + config: {}, + }, directives: { GlTooltip: createMockDirective(), }, @@ -178,6 +182,12 @@ describe('Image List Row', () => { expect(findDeleteBtn().props('disabled')).toBe(state); }, ); + + it('is disabled when migrationState is importing', () => { + mountComponent({ item: { ...item, migrationState: IMAGE_MIGRATING_STATE } }); + + expect(findDeleteBtn().props('disabled')).toBe(true); + }); }); describe('tags count', () => { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js index c91a9c0f0fb..7d09c09d03b 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js @@ -1,4 +1,4 @@ -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue'; @@ -6,6 +6,7 @@ import { CONTAINER_REGISTRY_TITLE, LIST_INTRO_TEXT, EXPIRATION_POLICY_DISABLED_TEXT, + SET_UP_CLEANUP, } from '~/packages_and_registries/container_registry/explorer/constants'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -21,6 +22,7 @@ describe('registry_header', () => { const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]'); const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]'); const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]'); + const findSetupCleanUpLink = () => wrapper.findComponent(GlLink); const mountComponent = async (propsData, slots) => { wrapper = shallowMount(Component, { @@ -88,6 +90,7 @@ describe('registry_header', () => { }); const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(true); expect(text.props()).toMatchObject({ text: EXPIRATION_POLICY_DISABLED_TEXT, @@ -100,12 +103,17 @@ describe('registry_header', () => { await mountComponent({ expirationPolicy: { enabled: true }, expirationPolicyHelpPagePath: 'foo', + showCleanupPolicyLink: true, imagesCount: 1, }); const text = findExpirationPolicySubHeader(); + const cleanupLink = findSetupCleanUpLink(); + expect(text.exists()).toBe(true); expect(text.props('text')).toBe('Expiration policy will run in '); + expect(cleanupLink.exists()).toBe(true); + expect(cleanupLink.text()).toBe(SET_UP_CLEANUP); }); it('when the expiration policy is completely disabled', async () => { await mountComponent({ diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js index fda1db4b7e1..7e6f88fe5bc 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js @@ -5,6 +5,7 @@ export const imagesListResponse = [ name: 'rails-12009', path: 'gitlab-org/gitlab-test/rails-12009', status: null, + migrationState: 'default', location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009', canDelete: true, createdAt: '2020-11-03T13:29:21Z', @@ -17,6 +18,7 @@ export const imagesListResponse = [ name: 'rails-20572', path: 'gitlab-org/gitlab-test/rails-20572', status: null, + migrationState: 'default', location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572', canDelete: true, createdAt: '2020-09-21T06:57:43Z', diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js index da4bfcde217..79403d29d18 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js @@ -6,7 +6,6 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; -import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; import CliCommands from '~/packages_and_registries/shared/components/cli_commands.vue'; @@ -58,7 +57,6 @@ describe('List Page', () => { const findPersistedSearch = () => wrapper.findComponent(PersistedSearch); const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); const findDeleteImage = () => wrapper.findComponent(DeleteImage); - const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); const fireFirstSortUpdate = () => { findPersistedSearch().vm.$emit('update', { sort: 'UPDATED_DESC', filters: [] }); @@ -511,33 +509,4 @@ describe('List Page', () => { testTrackingCall('confirm_delete'); }); }); - - describe('cleanup is on alert', () => { - it('exist when showCleanupPolicyOnAlert is true and has the correct props', async () => { - mountComponent({ - config: { - showCleanupPolicyOnAlert: true, - projectPath: 'foo', - isGroupPage: false, - cleanupPoliciesSettingsPath: 'bar', - }, - }); - - await waitForApolloRequestRender(); - - expect(findCleanupAlert().exists()).toBe(true); - expect(findCleanupAlert().props()).toMatchObject({ - projectPath: 'foo', - cleanupPoliciesSettingsPath: 'bar', - }); - }); - - it('is hidden when showCleanupPolicyOnAlert is false', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findCleanupAlert().exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js index 79894e25889..dbe9793fb8c 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -1,19 +1,26 @@ import { + GlAlert, + GlDropdown, + GlDropdownItem, GlFormInputGroup, GlFormGroup, + GlModal, GlSkeletonLoader, GlSprintf, GlEmptyState, } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import MockAdapter from 'axios-mock-adapter'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { stripTypenames } from 'helpers/graphql_helpers'; import waitForPromises from 'helpers/wait_for_promises'; import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants'; +import axios from '~/lib/utils/axios_utils'; import DependencyProxyApp from '~/packages_and_registries/dependency_proxy/app.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; @@ -21,13 +28,25 @@ import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency import { proxyDetailsQuery, proxyData, pagination, proxyManifests } from './mock_data'; +const dummyApiVersion = 'v3000'; +const dummyGrouptId = 1; +const dummyUrlRoot = '/gitlab'; +const dummyGon = { + api_version: dummyApiVersion, + relative_url_root: dummyUrlRoot, +}; +let originalGon; +const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${dummyGrouptId}/dependency_proxy/cache`; + describe('DependencyProxyApp', () => { let wrapper; let apolloProvider; let resolver; + let mock; const provideDefaults = { groupPath: 'gitlab-org', + groupId: dummyGrouptId, dependencyProxyAvailable: true, noManifestsIllustration: 'noManifestsIllustration', }; @@ -43,9 +62,14 @@ describe('DependencyProxyApp', () => { apolloProvider, provide, stubs: { + GlAlert, + GlDropdown, + GlDropdownItem, GlFormInputGroup, GlFormGroup, + GlModal, GlSprintf, + TitleArea, }, }); } @@ -59,13 +83,24 @@ describe('DependencyProxyApp', () => { const findProxyCountText = () => wrapper.findByTestId('proxy-count'); const findManifestList = () => wrapper.findComponent(ManifestsList); const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findClearCacheDropdownList = () => wrapper.findComponent(GlDropdown); + const findClearCacheModal = () => wrapper.findComponent(GlModal); + const findClearCacheAlert = () => wrapper.findComponent(GlAlert); beforeEach(() => { resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()); + + originalGon = window.gon; + window.gon = { ...dummyGon }; + + mock = new MockAdapter(axios); + mock.onDelete(expectedUrl).reply(202, {}); }); afterEach(() => { wrapper.destroy(); + window.gon = originalGon; + mock.restore(); }); describe('when the dependency proxy is not available', () => { @@ -95,6 +130,12 @@ describe('DependencyProxyApp', () => { expect(resolver).not.toHaveBeenCalled(); }); + + it('hides the clear cache dropdown list', () => { + createComponent(createComponentArguments); + + expect(findClearCacheDropdownList().exists()).toBe(false); + }); }); describe('when the dependency proxy is available', () => { @@ -165,6 +206,7 @@ describe('DependencyProxyApp', () => { }), ); createComponent(); + return waitForPromises(); }); @@ -214,6 +256,34 @@ describe('DependencyProxyApp', () => { fullPath: provideDefaults.groupPath, }); }); + + it('shows the clear cache dropdown list', () => { + expect(findClearCacheDropdownList().exists()).toBe(true); + + const clearCacheDropdownItem = findClearCacheDropdownList().findComponent( + GlDropdownItem, + ); + + expect(clearCacheDropdownItem.text()).toBe('Clear cache'); + }); + + it('shows the clear cache confirmation modal', () => { + const modal = findClearCacheModal(); + + expect(modal.find('.modal-title').text()).toContain('Clear 2 images from cache?'); + expect(modal.props('actionPrimary').text).toBe('Clear cache'); + }); + + it('submits the clear cache request', async () => { + findClearCacheModal().vm.$emit('primary', { preventDefault: jest.fn() }); + + await waitForPromises(); + + expect(findClearCacheAlert().exists()).toBe(true); + expect(findClearCacheAlert().text()).toBe( + 'All items in the cache are scheduled for removal.', + ); + }); }); }); }); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js new file mode 100644 index 00000000000..636f3eeb04a --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js @@ -0,0 +1,88 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import { + HARBOR_REGISTRY_TITLE, + LIST_INTRO_TEXT, +} from '~/packages_and_registries/harbor_registry/constants/index'; + +describe('harbor_list_header', () => { + let wrapper; + + const findTitleArea = () => wrapper.find(TitleArea); + const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]'); + const findImagesMetaDataItem = () => wrapper.find(MetadataItem); + + const mountComponent = async (propsData, slots) => { + wrapper = shallowMount(HarborListHeader, { + stubs: { + GlSprintf, + TitleArea, + }, + propsData, + slots, + }); + await nextTick(); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('header', () => { + it('has a title', () => { + mountComponent({ metadataLoading: true }); + + expect(findTitleArea().props()).toMatchObject({ + title: HARBOR_REGISTRY_TITLE, + metadataLoading: true, + }); + }); + + it('has a commands slot', () => { + mountComponent(null, { commands: '<div data-testid="commands-slot">baz</div>' }); + + expect(findCommandsSlot().text()).toBe('baz'); + }); + + describe('sub header parts', () => { + describe('images count', () => { + it('exists', async () => { + await mountComponent({ imagesCount: 1 }); + + expect(findImagesMetaDataItem().exists()).toBe(true); + }); + + it('when there is one image', async () => { + await mountComponent({ imagesCount: 1 }); + + expect(findImagesMetaDataItem().props()).toMatchObject({ + text: '1 Image repository', + icon: 'container-image', + }); + }); + + it('when there is more than one image', async () => { + await mountComponent({ imagesCount: 3 }); + + expect(findImagesMetaDataItem().props('text')).toBe('3 Image repositories'); + }); + }); + }); + }); + + describe('info messages', () => { + describe('default message', () => { + it('is correctly bound to title_area props', () => { + mountComponent({ helpPagePath: 'foo' }); + + expect(findTitleArea().props('infoMessages')).toEqual([ + { text: LIST_INTRO_TEXT, link: 'foo' }, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js new file mode 100644 index 00000000000..8560c4f78f7 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js @@ -0,0 +1,99 @@ +import { shallowMount, RouterLinkStub as RouterLink } from '@vue/test-utils'; +import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; + +import HarborListRow from '~/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { harborListResponse } from '../../mock_data'; + +describe('Harbor List Row', () => { + let wrapper; + const [item] = harborListResponse.repositories; + + const findDetailsLink = () => wrapper.find(RouterLink); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findTagsCount = () => wrapper.find('[data-testid="tags-count"]'); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const mountComponent = (props) => { + wrapper = shallowMount(HarborListRow, { + stubs: { + RouterLink, + GlSprintf, + ListItem, + }, + propsData: { + item, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('image title and path', () => { + it('contains a link to the details page', () => { + mountComponent(); + + const link = findDetailsLink(); + expect(link.text()).toBe(item.name); + expect(findDetailsLink().props('to')).toMatchObject({ + name: 'details', + params: { + id: item.id, + }, + }); + }); + + it('contains a clipboard button', () => { + mountComponent(); + const button = findClipboardButton(); + expect(button.exists()).toBe(true); + expect(button.props('text')).toBe(item.location); + expect(button.props('title')).toBe(item.location); + }); + }); + + describe('tags count', () => { + it('exists', () => { + mountComponent(); + expect(findTagsCount().exists()).toBe(true); + }); + + it('contains a tag icon', () => { + mountComponent(); + const icon = findTagsCount().find(GlIcon); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('tag'); + }); + + describe('loading state', () => { + it('shows a loader when metadataLoading is true', () => { + mountComponent({ metadataLoading: true }); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('hides the tags count while loading', () => { + mountComponent({ metadataLoading: true }); + + expect(findTagsCount().exists()).toBe(false); + }); + }); + + describe('tags count text', () => { + it('with one tag in the image', () => { + mountComponent({ item: { ...item, artifactCount: 1 } }); + + expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag'); + }); + it('with more than one tag in the image', () => { + mountComponent({ item: { ...item, artifactCount: 3 } }); + + expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags'); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js new file mode 100644 index 00000000000..f018eff58c9 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js @@ -0,0 +1,39 @@ +import { shallowMount } from '@vue/test-utils'; +import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue'; +import HarborListRow from '~/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import { harborListResponse } from '../../mock_data'; + +describe('Harbor List', () => { + let wrapper; + + const findHarborListRow = () => wrapper.findAll(HarborListRow); + + const mountComponent = (props) => { + wrapper = shallowMount(HarborList, { + stubs: { RegistryList }, + propsData: { + images: harborListResponse.repositories, + pageInfo: harborListResponse.pageInfo, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('list', () => { + it('contains one list element for each image', () => { + mountComponent(); + + expect(findHarborListRow().length).toBe(harborListResponse.repositories.length); + }); + + it('passes down the metadataLoading prop', () => { + mountComponent({ metadataLoading: true }); + expect(findHarborListRow().at(0).props('metadataLoading')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/mock_data.js b/spec/frontend/packages_and_registries/harbor_registry/mock_data.js new file mode 100644 index 00000000000..85399c22e79 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/mock_data.js @@ -0,0 +1,175 @@ +export const harborListResponse = { + repositories: [ + { + artifactCount: 1, + creationTime: '2022-03-02T06:35:53.205Z', + id: 25, + name: 'shao/flinkx', + projectId: 21, + pullCount: 0, + updateTime: '2022-03-02T06:35:53.205Z', + location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', + }, + { + artifactCount: 1, + creationTime: '2022-03-02T06:35:53.205Z', + id: 26, + name: 'shao/flinkx1', + projectId: 21, + pullCount: 0, + updateTime: '2022-03-02T06:35:53.205Z', + location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', + }, + { + artifactCount: 1, + creationTime: '2022-03-02T06:35:53.205Z', + id: 27, + name: 'shao/flinkx2', + projectId: 21, + pullCount: 0, + updateTime: '2022-03-02T06:35:53.205Z', + location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', + }, + ], + totalCount: 3, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, +}; + +export const harborTagsResponse = { + tags: [ + { + digest: 'sha256:7f386a1844faf341353e1c20f2f39f11f397604fedc475435d13f756eeb235d1', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', + name: '02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', + revision: 'f53bde3d44699e04e11cf15fb415046a0913e2623d878d89bc21adb2cbda5255', + shortRevision: 'f53bde3d4', + createdAt: '2022-03-02T23:59:05+00:00', + totalSize: '6623124', + }, + { + digest: 'sha256:4554416b84c4568fe93086620b637064ed029737aabe7308b96d50e3d9d92ed7', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', + name: '02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', + revision: 'e1fe52d8bab66d71bd54a6b8784d3b9edbc68adbd6ea87f5fa44d9974144ef9e', + shortRevision: 'e1fe52d8b', + createdAt: '2022-02-10T01:09:56+00:00', + totalSize: '920760', + }, + { + digest: 'sha256:14f37b60e52b9ce0e9f8f7094b311d265384798592f783487c30aaa3d58e6345', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', + name: '03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', + revision: 'c72770c6eb93c421bc496964b4bffc742b1ec2e642cdab876be7afda1856029f', + shortRevision: 'c72770c6e', + createdAt: '2021-12-22T04:48:48+00:00', + totalSize: '48609053', + }, + { + digest: 'sha256:e925e3b8277ea23f387ed5fba5e78280cfac7cfb261a78cf046becf7b6a3faae', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', + name: '03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', + revision: '1ac2a43194f4e15166abdf3f26e6ec92215240490b9cac834d63de1a3d87494a', + shortRevision: '1ac2a4319', + createdAt: '2022-03-09T11:02:27+00:00', + totalSize: '35141894', + }, + { + digest: 'sha256:7d8303fd5c077787a8c879f8f66b69e2b5605f48ccd3f286e236fb0749fcc1ca', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', + name: '05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', + revision: 'cf8fee086701016e1a84e6824f0c896951fef4cce9d4745459558b87eec3232c', + shortRevision: 'cf8fee086', + createdAt: '2022-01-21T11:31:43+00:00', + totalSize: '48716070', + }, + { + digest: 'sha256:b33611cefe20e4a41a6e0dce356a5d7ef3c177ea7536a58652f5b3a4f2f83549', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', + name: '093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', + revision: '1a4b48198b13d55242c5164e64d41c4e9f75b5d9506bc6e0efc1534dd0dd1f15', + shortRevision: '1a4b48198', + createdAt: '2022-01-21T11:31:51+00:00', + totalSize: '6623127', + }, + { + digest: 'sha256:d25c3c020e2dbd4711a67b9fe308f4cbb7b0bb21815e722a02f91c570dc5d519', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', + name: '09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', + revision: '03e2e2777dde01c30469ee8c710973dd08a7a4f70494d7dc1583c24b525d7f61', + shortRevision: '03e2e2777', + createdAt: '2022-03-02T23:58:20+00:00', + totalSize: '911377', + }, + { + digest: 'sha256:fb760e4d2184e9e8e39d6917534d4610fe01009734698a5653b2de1391ba28f4', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', + name: '09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', + revision: '350e78d60646bf6967244448c6aaa14d21ecb9a0c6cf87e9ff0361cbe59b9012', + shortRevision: '350e78d60', + createdAt: '2022-01-19T13:49:14+00:00', + totalSize: '48710241', + }, + { + digest: 'sha256:407250f380cea92729cbc038c420e74900f53b852e11edc6404fe75a0fd2c402', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', + name: '0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', + revision: '76038370b7f3904364891457c4a6a234897255e6b9f45d0a852bf3a7e5257e18', + shortRevision: '76038370b', + createdAt: '2022-01-24T12:56:22+00:00', + totalSize: '280065', + }, + { + digest: 'sha256:ada87f25218542951ce6720c27f3d0758e90c2540bd129f5cfb9e15b31e07b07', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', + name: '0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', + revision: '3d4b49a7bbb36c48bb721f4d0e76e7950bec3878ee29cdfdd6da39f575d6d37f', + shortRevision: '3d4b49a7b', + createdAt: '2022-02-17T17:37:52+00:00', + totalSize: '48655767', + }, + ], + totalCount: 100, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, +}; + +export const dockerCommands = { + dockerBuildCommand: 'foofoo', + dockerPushCommand: 'barbar', + dockerLoginCommand: 'bazbaz', +}; diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/index_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/index_spec.js new file mode 100644 index 00000000000..55fc8066f65 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/pages/index_spec.js @@ -0,0 +1,24 @@ +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/harbor_registry/pages/index.vue'; + +describe('List Page', () => { + let wrapper; + + const findRouterView = () => wrapper.find({ ref: 'router-view' }); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { + RouterView: true, + }, + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + it('has a router view', () => { + expect(findRouterView().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js new file mode 100644 index 00000000000..61ee36a2794 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js @@ -0,0 +1,140 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { GlSkeletonLoader } from '@gitlab/ui'; +import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue'; +import HarborRegistryList from '~/packages_and_registries/harbor_registry/pages/list.vue'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +// import { harborListResponse } from '~/packages_and_registries/harbor_registry/mock_api.js'; +import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue'; +import CliCommands from '~/packages_and_registries/shared/components/cli_commands.vue'; +import { SORT_FIELDS } from '~/packages_and_registries/harbor_registry/constants/index'; +import { harborListResponse, dockerCommands } from '../mock_data'; + +let mockHarborListResponse; +jest.mock('~/packages_and_registries/harbor_registry/mock_api.js', () => ({ + harborListResponse: () => mockHarborListResponse, +})); + +describe('Harbor List Page', () => { + let wrapper; + + const waitForHarborPageRequest = async () => { + await waitForPromises(); + await nextTick(); + }; + + beforeEach(() => { + mockHarborListResponse = Promise.resolve(harborListResponse); + }); + + const findHarborListHeader = () => wrapper.findComponent(HarborListHeader); + const findPersistedSearch = () => wrapper.findComponent(PersistedSearch); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findHarborList = () => wrapper.findComponent(HarborList); + const findCliCommands = () => wrapper.findComponent(CliCommands); + + const fireFirstSortUpdate = () => { + findPersistedSearch().vm.$emit('update', { sort: 'UPDATED_DESC', filters: [] }); + }; + + const mountComponent = ({ config = { isGroupPage: false } } = {}) => { + wrapper = shallowMount(HarborRegistryList, { + stubs: { + HarborListHeader, + }, + provide() { + return { + config, + ...dockerCommands, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains harbor registry header', async () => { + mountComponent(); + fireFirstSortUpdate(); + await waitForHarborPageRequest(); + await nextTick(); + + expect(findHarborListHeader().exists()).toBe(true); + expect(findHarborListHeader().props()).toMatchObject({ + imagesCount: 3, + metadataLoading: false, + }); + }); + + describe('isLoading is true', () => { + it('shows the skeleton loader', async () => { + mountComponent(); + fireFirstSortUpdate(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('harborList is not visible', () => { + mountComponent(); + + expect(findHarborList().exists()).toBe(false); + }); + + it('cli commands is not visible', () => { + mountComponent(); + + expect(findCliCommands().exists()).toBe(false); + }); + + it('title has the metadataLoading props set to true', async () => { + mountComponent(); + fireFirstSortUpdate(); + + expect(findHarborListHeader().props('metadataLoading')).toBe(true); + }); + }); + + describe('list is not empty', () => { + describe('unfiltered state', () => { + it('quick start is visible', async () => { + mountComponent(); + fireFirstSortUpdate(); + + await waitForHarborPageRequest(); + await nextTick(); + + expect(findCliCommands().exists()).toBe(true); + }); + + it('list component is visible', async () => { + mountComponent(); + fireFirstSortUpdate(); + + await waitForHarborPageRequest(); + await nextTick(); + + expect(findHarborList().exists()).toBe(true); + }); + }); + + describe('search and sorting', () => { + it('has a persisted search box element', async () => { + mountComponent(); + fireFirstSortUpdate(); + await waitForHarborPageRequest(); + await nextTick(); + + const harborRegistrySearch = findPersistedSearch(); + expect(harborRegistrySearch.exists()).toBe(true); + expect(harborRegistrySearch.props()).toMatchObject({ + defaultOrder: 'UPDATED', + defaultSort: 'desc', + sortableFields: SORT_FIELDS, + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js index b9383d6c38c..31ab108558c 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js @@ -20,10 +20,10 @@ jest.mock('~/api.js'); describe('Actions Package details store', () => { describe('fetchPackageVersions', () => { - it('should fetch the package versions', (done) => { + it('should fetch the package versions', async () => { Api.projectPackage = jest.fn().mockResolvedValue({ data: packageEntity }); - testAction( + await testAction( fetchPackageVersions, undefined, { packageEntity }, @@ -33,20 +33,14 @@ describe('Actions Package details store', () => { { type: types.SET_LOADING, payload: false }, ], [], - () => { - expect(Api.projectPackage).toHaveBeenCalledWith( - packageEntity.project_id, - packageEntity.id, - ); - done(); - }, ); + expect(Api.projectPackage).toHaveBeenCalledWith(packageEntity.project_id, packageEntity.id); }); - it("does not set the versions if they don't exist", (done) => { + it("does not set the versions if they don't exist", async () => { Api.projectPackage = jest.fn().mockResolvedValue({ data: { packageEntity, versions: null } }); - testAction( + await testAction( fetchPackageVersions, undefined, { packageEntity }, @@ -55,20 +49,14 @@ describe('Actions Package details store', () => { { type: types.SET_LOADING, payload: false }, ], [], - () => { - expect(Api.projectPackage).toHaveBeenCalledWith( - packageEntity.project_id, - packageEntity.id, - ); - done(); - }, ); + expect(Api.projectPackage).toHaveBeenCalledWith(packageEntity.project_id, packageEntity.id); }); - it('should create flash on API error', (done) => { + it('should create flash on API error', async () => { Api.projectPackage = jest.fn().mockRejectedValue(); - testAction( + await testAction( fetchPackageVersions, undefined, { packageEntity }, @@ -77,41 +65,31 @@ describe('Actions Package details store', () => { { type: types.SET_LOADING, payload: false }, ], [], - () => { - expect(Api.projectPackage).toHaveBeenCalledWith( - packageEntity.project_id, - packageEntity.id, - ); - expect(createFlash).toHaveBeenCalledWith({ - message: FETCH_PACKAGE_VERSIONS_ERROR, - type: 'warning', - }); - done(); - }, ); + expect(Api.projectPackage).toHaveBeenCalledWith(packageEntity.project_id, packageEntity.id); + expect(createFlash).toHaveBeenCalledWith({ + message: FETCH_PACKAGE_VERSIONS_ERROR, + type: 'warning', + }); }); }); describe('deletePackage', () => { - it('should call Api.deleteProjectPackage', (done) => { + it('should call Api.deleteProjectPackage', async () => { Api.deleteProjectPackage = jest.fn().mockResolvedValue(); - testAction(deletePackage, undefined, { packageEntity }, [], [], () => { - expect(Api.deleteProjectPackage).toHaveBeenCalledWith( - packageEntity.project_id, - packageEntity.id, - ); - done(); - }); + await testAction(deletePackage, undefined, { packageEntity }, [], []); + expect(Api.deleteProjectPackage).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + ); }); - it('should create flash on API error', (done) => { + it('should create flash on API error', async () => { Api.deleteProjectPackage = jest.fn().mockRejectedValue(); - testAction(deletePackage, undefined, { packageEntity }, [], [], () => { - expect(createFlash).toHaveBeenCalledWith({ - message: DELETE_PACKAGE_ERROR_MESSAGE, - type: 'warning', - }); - done(); + await testAction(deletePackage, undefined, { packageEntity }, [], []); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + type: 'warning', }); }); }); @@ -119,37 +97,33 @@ describe('Actions Package details store', () => { describe('deletePackageFile', () => { const fileId = 'a_file_id'; - it('should call Api.deleteProjectPackageFile and commit the right data', (done) => { + it('should call Api.deleteProjectPackageFile and commit the right data', async () => { const packageFiles = [{ id: 'foo' }, { id: fileId }]; Api.deleteProjectPackageFile = jest.fn().mockResolvedValue(); - testAction( + await testAction( deletePackageFile, fileId, { packageEntity, packageFiles }, [{ type: types.UPDATE_PACKAGE_FILES, payload: [{ id: 'foo' }] }], [], - () => { - expect(Api.deleteProjectPackageFile).toHaveBeenCalledWith( - packageEntity.project_id, - packageEntity.id, - fileId, - ); - expect(createFlash).toHaveBeenCalledWith({ - message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, - type: 'success', - }); - done(); - }, ); + expect(Api.deleteProjectPackageFile).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + fileId, + ); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + type: 'success', + }); }); - it('should create flash on API error', (done) => { + + it('should create flash on API error', async () => { Api.deleteProjectPackageFile = jest.fn().mockRejectedValue(); - testAction(deletePackageFile, fileId, { packageEntity }, [], [], () => { - expect(createFlash).toHaveBeenCalledWith({ - message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, - type: 'warning', - }); - done(); + await testAction(deletePackageFile, fileId, { packageEntity }, [], []); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, + type: 'warning', }); }); }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap index d82af8f9e63..a33528d2d91 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -21,7 +21,7 @@ exports[`packages_list_app renders 1`] = ` > <img alt="" - class="gl-max-w-full" + class="gl-max-w-full gl-dark-invert-keep-hue" role="img" src="helpSvg" /> diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js index 3fbfe1060dc..d596f2dae33 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js @@ -32,8 +32,8 @@ describe('Actions Package list store', () => { }; const filter = []; - it('should fetch the project packages list when isGroupPage is false', (done) => { - testAction( + it('should fetch the project packages list when isGroupPage is false', async () => { + await testAction( actions.requestPackagesList, undefined, { config: { isGroupPage: false, resourceId: 1 }, sorting, filter }, @@ -43,17 +43,14 @@ describe('Actions Package list store', () => { { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, { type: 'setLoading', payload: false }, ], - () => { - expect(Api.projectPackages).toHaveBeenCalledWith(1, { - params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, - }); - done(); - }, ); + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, + }); }); - it('should fetch the group packages list when isGroupPage is true', (done) => { - testAction( + it('should fetch the group packages list when isGroupPage is true', async () => { + await testAction( actions.requestPackagesList, undefined, { config: { isGroupPage: true, resourceId: 2 }, sorting, filter }, @@ -63,19 +60,16 @@ describe('Actions Package list store', () => { { type: 'receivePackagesListSuccess', payload: { data: 'baz', headers } }, { type: 'setLoading', payload: false }, ], - () => { - expect(Api.groupPackages).toHaveBeenCalledWith(2, { - params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, - }); - done(); - }, ); + expect(Api.groupPackages).toHaveBeenCalledWith(2, { + params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, + }); }); - it('should fetch packages of a certain type when a filter with a type is present', (done) => { + it('should fetch packages of a certain type when a filter with a type is present', async () => { const packageType = 'maven'; - testAction( + await testAction( actions.requestPackagesList, undefined, { @@ -89,24 +83,21 @@ describe('Actions Package list store', () => { { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, { type: 'setLoading', payload: false }, ], - () => { - expect(Api.projectPackages).toHaveBeenCalledWith(1, { - params: { - page: 1, - per_page: 20, - sort: sorting.sort, - order_by: sorting.orderBy, - package_type: packageType, - }, - }); - done(); - }, ); + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { + page: 1, + per_page: 20, + sort: sorting.sort, + order_by: sorting.orderBy, + package_type: packageType, + }, + }); }); - it('should create flash on API error', (done) => { + it('should create flash on API error', async () => { Api.projectPackages = jest.fn().mockRejectedValue(); - testAction( + await testAction( actions.requestPackagesList, undefined, { config: { isGroupPage: false, resourceId: 2 }, sorting, filter }, @@ -115,15 +106,12 @@ describe('Actions Package list store', () => { { type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); - it('should force the terraform_module type when forceTerraform is true', (done) => { - testAction( + it('should force the terraform_module type when forceTerraform is true', async () => { + await testAction( actions.requestPackagesList, undefined, { config: { isGroupPage: false, resourceId: 1, forceTerraform: true }, sorting, filter }, @@ -133,27 +121,24 @@ describe('Actions Package list store', () => { { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, { type: 'setLoading', payload: false }, ], - () => { - expect(Api.projectPackages).toHaveBeenCalledWith(1, { - params: { - page: 1, - per_page: 20, - sort: sorting.sort, - order_by: sorting.orderBy, - package_type: 'terraform_module', - }, - }); - done(); - }, ); + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { + page: 1, + per_page: 20, + sort: sorting.sort, + order_by: sorting.orderBy, + package_type: 'terraform_module', + }, + }); }); }); describe('receivePackagesListSuccess', () => { - it('should set received packages', (done) => { + it('should set received packages', () => { const data = 'foo'; - testAction( + return testAction( actions.receivePackagesListSuccess, { data, headers }, null, @@ -162,33 +147,30 @@ describe('Actions Package list store', () => { { type: types.SET_PAGINATION, payload: headers }, ], [], - done, ); }); }); describe('setInitialState', () => { - it('should commit setInitialState', (done) => { - testAction( + it('should commit setInitialState', () => { + return testAction( actions.setInitialState, '1', null, [{ type: types.SET_INITIAL_STATE, payload: '1' }], [], - done, ); }); }); describe('setLoading', () => { - it('should commit set main loading', (done) => { - testAction( + it('should commit set main loading', () => { + return testAction( actions.setLoading, true, null, [{ type: types.SET_MAIN_LOADING, payload: true }], [], - done, ); }); }); @@ -199,11 +181,11 @@ describe('Actions Package list store', () => { delete_api_path: 'foo', }, }; - it('should perform a delete operation on _links.delete_api_path', (done) => { + it('should perform a delete operation on _links.delete_api_path', () => { mock.onDelete(payload._links.delete_api_path).replyOnce(200); Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo' }); - testAction( + return testAction( actions.requestDeletePackage, payload, { pagination: { page: 1 } }, @@ -212,13 +194,12 @@ describe('Actions Package list store', () => { { type: 'setLoading', payload: true }, { type: 'requestPackagesList', payload: { page: 1 } }, ], - done, ); }); - it('should stop the loading and call create flash on api error', (done) => { + it('should stop the loading and call create flash on api error', async () => { mock.onDelete(payload._links.delete_api_path).replyOnce(400); - testAction( + await testAction( actions.requestDeletePackage, payload, null, @@ -227,50 +208,44 @@ describe('Actions Package list store', () => { { type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); it.each` property | actionPayload ${'_links'} | ${{}} ${'delete_api_path'} | ${{ _links: {} }} - `('should reject and createFlash when $property is missing', ({ actionPayload }, done) => { - testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch((e) => { + `('should reject and createFlash when $property is missing', ({ actionPayload }) => { + return testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch((e) => { expect(e).toEqual(new Error(MISSING_DELETE_PATH_ERROR)); expect(createFlash).toHaveBeenCalledWith({ message: DELETE_PACKAGE_ERROR_MESSAGE, }); - done(); }); }); }); describe('setSorting', () => { - it('should commit SET_SORTING', (done) => { - testAction( + it('should commit SET_SORTING', () => { + return testAction( actions.setSorting, 'foo', null, [{ type: types.SET_SORTING, payload: 'foo' }], [], - done, ); }); }); describe('setFilter', () => { - it('should commit SET_FILTER', (done) => { - testAction( + it('should commit SET_FILTER', () => { + return testAction( actions.setFilter, 'foo', null, [{ type: types.SET_FILTER, payload: 'foo' }], [], - done, ); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js index 9e91b15bc6e..3670cfca8ea 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -73,7 +73,6 @@ describe('Package Search', () => { mountComponent(); expect(findLocalStorageSync().props()).toMatchObject({ - asJson: true, storageKey: 'package_registry_list_sorting', value: { orderBy: LIST_KEY_CREATED_AT, diff --git a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap index 0154486e224..17905a8db2d 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap @@ -21,7 +21,7 @@ exports[`PackagesListApp renders 1`] = ` > <img alt="" - class="gl-max-w-full" + class="gl-max-w-full gl-dark-invert-keep-hue" role="img" src="emptyListIllustration" /> diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js index a6c929844b1..0a72f0269ee 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -12,7 +12,6 @@ import { UNAVAILABLE_USER_FEATURE_TEXT, } from '~/packages_and_registries/settings/project/constants'; import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; -import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; import { @@ -31,12 +30,11 @@ describe('Registry Settings App', () => { adminSettingsPath: 'settingsPath', enableHistoricEntries: false, helpPagePath: 'helpPagePath', - showCleanupPolicyOnAlert: false, + showCleanupPolicyLink: false, }; const findSettingsComponent = () => wrapper.find(SettingsForm); const findAlert = () => wrapper.find(GlAlert); - const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); const mountComponent = (provide = defaultProvidedValues, config) => { wrapper = shallowMount(component, { @@ -69,26 +67,6 @@ describe('Registry Settings App', () => { wrapper.destroy(); }); - describe('cleanup is on alert', () => { - it('exist when showCleanupPolicyOnAlert is true and has the correct props', () => { - mountComponent({ - ...defaultProvidedValues, - showCleanupPolicyOnAlert: true, - }); - - expect(findCleanupAlert().exists()).toBe(true); - expect(findCleanupAlert().props()).toMatchObject({ - projectPath: 'path', - }); - }); - - it('is hidden when showCleanupPolicyOnAlert is false', async () => { - mountComponent(); - - expect(findCleanupAlert().exists()).toBe(false); - }); - }); - describe('isEdited status', () => { it.each` description | apiResponse | workingCopy | result diff --git a/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap deleted file mode 100644 index 2cded2ead2e..00000000000 --- a/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CleanupPolicyEnabledAlert renders 1`] = ` -<gl-alert-stub - class="gl-mt-2" - dismissible="true" - dismisslabel="Dismiss" - primarybuttonlink="" - primarybuttontext="" - secondarybuttonlink="" - secondarybuttontext="" - title="" - variant="info" -> - <gl-sprintf-stub - message="Cleanup policies are now available for this project. %{linkStart}Click here to get started.%{linkEnd}" - /> -</gl-alert-stub> -`; diff --git a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap index ceae8eebaef..3dd6023140f 100644 --- a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap +++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap @@ -10,11 +10,10 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` class="breadcrumb gl-breadcrumb-list" > <li - class="breadcrumb-item gl-breadcrumb-item" + class="gl-breadcrumb-item" > <a class="" - href="/" target="_self" > <span> @@ -45,9 +44,10 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` <!----> <li - class="breadcrumb-item gl-breadcrumb-item" + class="gl-breadcrumb-item" > <a + aria-current="page" class="" href="#" target="_self" @@ -75,11 +75,11 @@ exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` class="breadcrumb gl-breadcrumb-list" > <li - class="breadcrumb-item gl-breadcrumb-item" + class="gl-breadcrumb-item" > <a + aria-current="page" class="" - href="/" target="_self" > <span> diff --git a/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js b/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js deleted file mode 100644 index 269e087f5ac..00000000000 --- a/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js +++ /dev/null @@ -1,49 +0,0 @@ -import { GlAlert } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import component from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; - -describe('CleanupPolicyEnabledAlert', () => { - let wrapper; - - const defaultProps = { - projectPath: 'foo', - cleanupPoliciesSettingsPath: 'label-bar', - }; - - const findAlert = () => wrapper.findComponent(GlAlert); - - const mountComponent = (props) => { - wrapper = shallowMount(component, { - stubs: { - LocalStorageSync, - }, - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders', () => { - mountComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('when dismissed is not visible', async () => { - mountComponent(); - - expect(findAlert().exists()).toBe(true); - findAlert().vm.$emit('dismiss'); - - await nextTick(); - - expect(findAlert().exists()).toBe(false); - }); -}); diff --git a/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js index 6dfe116c285..15db454ac68 100644 --- a/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { mount, RouterLinkStub } from '@vue/test-utils'; import component from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; @@ -21,6 +21,9 @@ describe('Registry Breadcrumb', () => { }, }, }, + stubs: { + RouterLink: RouterLinkStub, + }, }); }; @@ -30,7 +33,6 @@ describe('Registry Breadcrumb', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('when is rootRoute', () => { @@ -46,7 +48,6 @@ describe('Registry Breadcrumb', () => { const links = wrapper.findAll('a'); expect(links).toHaveLength(1); - expect(links.at(0).attributes('href')).toBe('/'); }); it('the link text is calculated by nameGenerator', () => { @@ -67,7 +68,6 @@ describe('Registry Breadcrumb', () => { const links = wrapper.findAll('a'); expect(links).toHaveLength(2); - expect(links.at(0).attributes('href')).toBe('/'); expect(links.at(1).attributes('href')).toBe('#'); }); diff --git a/spec/frontend/pager_spec.js b/spec/frontend/pager_spec.js index 043ea470436..9df69124d66 100644 --- a/spec/frontend/pager_spec.js +++ b/spec/frontend/pager_spec.js @@ -68,34 +68,34 @@ describe('pager', () => { it('shows loader while loading next page', async () => { mockSuccess(); - jest.spyOn(Pager.loading, 'show').mockImplementation(() => {}); + jest.spyOn(Pager.$loading, 'show').mockImplementation(() => {}); Pager.getOld(); await waitForPromises(); - expect(Pager.loading.show).toHaveBeenCalled(); + expect(Pager.$loading.show).toHaveBeenCalled(); }); it('hides loader on success', async () => { mockSuccess(); - jest.spyOn(Pager.loading, 'hide').mockImplementation(() => {}); + jest.spyOn(Pager.$loading, 'hide').mockImplementation(() => {}); Pager.getOld(); await waitForPromises(); - expect(Pager.loading.hide).toHaveBeenCalled(); + expect(Pager.$loading.hide).toHaveBeenCalled(); }); it('hides loader on error', async () => { mockError(); - jest.spyOn(Pager.loading, 'hide').mockImplementation(() => {}); + jest.spyOn(Pager.$loading, 'hide').mockImplementation(() => {}); Pager.getOld(); await waitForPromises(); - expect(Pager.loading.hide).toHaveBeenCalled(); + expect(Pager.$loading.hide).toHaveBeenCalled(); }); it('sends request to url with offset and limit params', async () => { @@ -122,12 +122,12 @@ describe('pager', () => { Pager.limit = 20; mockSuccess(1); - jest.spyOn(Pager.loading, 'hide').mockImplementation(() => {}); + jest.spyOn(Pager.$loading, 'hide').mockImplementation(() => {}); Pager.getOld(); await waitForPromises(); - expect(Pager.loading.hide).toHaveBeenCalled(); + expect(Pager.$loading.hide).toHaveBeenCalled(); expect(Pager.disable).toBe(true); }); @@ -175,5 +175,46 @@ describe('pager', () => { expect(axios.get).toHaveBeenCalledWith(href, expect.any(Object)); }); }); + + describe('when `container` is passed', () => { + const href = '/some_list'; + const container = '#js-pager'; + let endlessScrollCallback; + + beforeEach(() => { + jest.spyOn(axios, 'get'); + jest.spyOn($.fn, 'endlessScroll').mockImplementation(({ callback }) => { + endlessScrollCallback = callback; + }); + }); + + describe('when `container` is visible', () => { + it('makes API request', () => { + setFixtures( + `<div id="js-pager"><div class="content_list" data-href="${href}"></div></div>`, + ); + + Pager.init({ container }); + + endlessScrollCallback(); + + expect(axios.get).toHaveBeenCalledWith(href, expect.any(Object)); + }); + }); + + describe('when `container` is not visible', () => { + it('does not make API request', () => { + setFixtures( + `<div id="js-pager" style="display: none;"><div class="content_list" data-href="${href}"></div></div>`, + ); + + Pager.init({ container }); + + endlessScrollCallback(); + + expect(axios.get).not.toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js index 9f326dc33c0..3a4f93d4464 100644 --- a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js +++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js @@ -23,13 +23,12 @@ describe('AccountAndLimits', () => { expect($userInternalRegex.readOnly).toBeTruthy(); }); - it('is checked', (done) => { + it('is checked', () => { if (!$userDefaultExternal.prop('checked')) $userDefaultExternal.click(); expect($userDefaultExternal.prop('checked')).toBeTruthy(); expect($userInternalRegex.placeholder).toEqual(PLACEHOLDER_USER_EXTERNAL_DEFAULT_TRUE); expect($userInternalRegex.readOnly).toBeFalsy(); - done(); }); }); }); diff --git a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js index 52648d3ce00..ebf21c01324 100644 --- a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js +++ b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js @@ -26,7 +26,7 @@ describe('stop_jobs_modal.vue', () => { }); describe('onSubmit', () => { - it('stops jobs and redirects to overview page', (done) => { + it('stops jobs and redirects to overview page', async () => { const responseURL = `${TEST_HOST}/stop_jobs_modal.vue/jobs`; jest.spyOn(axios, 'post').mockImplementation((url) => { expect(url).toBe(props.url); @@ -37,29 +37,19 @@ describe('stop_jobs_modal.vue', () => { }); }); - vm.onSubmit() - .then(() => { - expect(redirectTo).toHaveBeenCalledWith(responseURL); - }) - .then(done) - .catch(done.fail); + await vm.onSubmit(); + expect(redirectTo).toHaveBeenCalledWith(responseURL); }); - it('displays error if stopping jobs failed', (done) => { + it('displays error if stopping jobs failed', async () => { const dummyError = new Error('stopping jobs failed'); jest.spyOn(axios, 'post').mockImplementation((url) => { expect(url).toBe(props.url); return Promise.reject(dummyError); }); - vm.onSubmit() - .then(done.fail) - .catch((error) => { - expect(error).toBe(dummyError); - expect(redirectTo).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await expect(vm.onSubmit()).rejects.toEqual(dummyError); + expect(redirectTo).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index ef295e7d1ba..ae53afa7fba 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -31,15 +31,17 @@ describe('Todos', () => { }); describe('goToTodoUrl', () => { - it('opens the todo url', (done) => { + it('opens the todo url', () => { const todoLink = todoItem.dataset.url; + let expectedUrl = null; visitUrl.mockImplementation((url) => { - expect(url).toEqual(todoLink); - done(); + expectedUrl = url; }); todoItem.click(); + + expect(expectedUrl).toEqual(todoLink); }); describe('meta click', () => { diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js index 6fb03fa28fe..43c48617800 100644 --- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js +++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js @@ -137,6 +137,16 @@ describe('BulkImportsHistoryApp', () => { ); }); + it('renders correct url for destination group when relative_url is empty', async () => { + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent({ shallow: false }); + await axios.waitForAll(); + + expect(wrapper.find('tbody tr a').attributes().href).toBe( + `/${DUMMY_RESPONSE[0].destination_namespace}/${DUMMY_RESPONSE[0].destination_name}`, + ); + }); + describe('details button', () => { beforeEach(() => { mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); diff --git a/spec/frontend/pages/import/history/components/import_error_details_spec.js b/spec/frontend/pages/import/history/components/import_error_details_spec.js new file mode 100644 index 00000000000..4ff3f0361cf --- /dev/null +++ b/spec/frontend/pages/import/history/components/import_error_details_spec.js @@ -0,0 +1,66 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import ImportErrorDetails from '~/pages/import/history/components/import_error_details.vue'; + +describe('ImportErrorDetails', () => { + const FAKE_ID = 5; + const API_URL = `/api/v4/projects/${FAKE_ID}`; + + let wrapper; + let mock; + + function createComponent({ shallow = true } = {}) { + const mountFn = shallow ? shallowMount : mount; + wrapper = mountFn(ImportErrorDetails, { + propsData: { + id: FAKE_ID, + }, + }); + } + + const originalApiVersion = gon.api_version; + beforeAll(() => { + gon.api_version = 'v4'; + }); + + afterAll(() => { + gon.api_version = originalApiVersion; + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('general behavior', () => { + it('renders loading state when loading', () => { + createComponent(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders import_error if it is available', async () => { + const FAKE_IMPORT_ERROR = 'IMPORT ERROR'; + mock.onGet(API_URL).reply(200, { import_error: FAKE_IMPORT_ERROR }); + createComponent(); + await axios.waitForAll(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find('pre').text()).toBe(FAKE_IMPORT_ERROR); + }); + + it('renders default text if error is not available', async () => { + mock.onGet(API_URL).reply(200, { import_error: null }); + createComponent(); + await axios.waitForAll(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find('pre').text()).toBe('No additional information provided.'); + }); + }); +}); diff --git a/spec/frontend/pages/import/history/components/import_history_app_spec.js b/spec/frontend/pages/import/history/components/import_history_app_spec.js new file mode 100644 index 00000000000..0d821b114cf --- /dev/null +++ b/spec/frontend/pages/import/history/components/import_history_app_spec.js @@ -0,0 +1,205 @@ +import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import ImportErrorDetails from '~/pages/import/history/components/import_error_details.vue'; +import ImportHistoryApp from '~/pages/import/history/components/import_history_app.vue'; +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; + +describe('ImportHistoryApp', () => { + const API_URL = '/api/v4/projects.json'; + + const DEFAULT_HEADERS = { + 'x-page': 1, + 'x-per-page': 20, + 'x-next-page': 2, + 'x-total': 22, + 'x-total-pages': 2, + 'x-prev-page': null, + }; + const DUMMY_RESPONSE = [ + { + id: 1, + path_with_namespace: 'root/imported', + created_at: '2022-03-10T15:10:03.172Z', + import_url: null, + import_type: 'gitlab_project', + import_status: 'finished', + }, + { + id: 2, + name_with_namespace: 'Administrator / Dummy', + path_with_namespace: 'root/dummy', + created_at: '2022-03-09T11:23:04.974Z', + import_url: 'https://dummy.github/url', + import_type: 'github', + import_status: 'failed', + }, + { + id: 3, + name_with_namespace: 'Administrator / Dummy', + path_with_namespace: 'root/dummy2', + created_at: '2022-03-09T11:23:04.974Z', + import_url: 'git://non-http.url', + import_type: 'gi', + import_status: 'finished', + }, + ]; + let wrapper; + let mock; + + function createComponent({ shallow = true } = {}) { + const mountFn = shallow ? shallowMount : mount; + wrapper = mountFn(ImportHistoryApp, { + provide: { assets: { gitlabLogo: 'http://dummy.host' } }, + stubs: shallow ? { GlTable: { ...stubComponent(GlTable), props: ['items'] } } : {}, + }); + } + + const originalApiVersion = gon.api_version; + beforeAll(() => { + gon.api_version = 'v4'; + }); + + afterAll(() => { + gon.api_version = originalApiVersion; + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('general behavior', () => { + it('renders loading state when loading', () => { + createComponent(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders empty state when no data is available', async () => { + mock.onGet(API_URL).reply(200, [], DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(GlEmptyState).exists()).toBe(true); + }); + + it('renders table with data when history is available', async () => { + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + + const table = wrapper.find(GlTable); + expect(table.exists()).toBe(true); + expect(table.props().items).toStrictEqual(DUMMY_RESPONSE); + }); + + it('changes page when requested by pagination bar', async () => { + const NEW_PAGE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + mock.resetHistory(); + + const FAKE_NEXT_PAGE_REPLY = [ + { + id: 4, + path_with_namespace: 'root/some_other_project', + created_at: '2022-03-10T15:10:03.172Z', + import_url: null, + import_type: 'gitlab_project', + import_status: 'finished', + }, + ]; + + mock.onGet(API_URL).reply(200, FAKE_NEXT_PAGE_REPLY, DEFAULT_HEADERS); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page', NEW_PAGE); + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].params).toStrictEqual(expect.objectContaining({ page: NEW_PAGE })); + expect(wrapper.find(GlTable).props().items).toStrictEqual(FAKE_NEXT_PAGE_REPLY); + }); + }); + + it('changes page size when requested by pagination bar', async () => { + const NEW_PAGE_SIZE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + mock.resetHistory(); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].params).toStrictEqual( + expect.objectContaining({ per_page: NEW_PAGE_SIZE }), + ); + }); + + it('resets page to 1 when page size is changed', async () => { + const NEW_PAGE_SIZE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + wrapper.findComponent(PaginationBar).vm.$emit('set-page', 2); + await axios.waitForAll(); + mock.resetHistory(); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].params).toStrictEqual( + expect.objectContaining({ per_page: NEW_PAGE_SIZE, page: 1 }), + ); + }); + + describe('details button', () => { + beforeEach(() => { + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent({ shallow: false }); + return axios.waitForAll(); + }); + + it('renders details button if relevant item has failed', async () => { + expect( + extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(), + ).toBe(true); + }); + + it('does not render details button if relevant item does not failed', () => { + expect( + extendedWrapper(wrapper.find('tbody').findAll('tr').at(0)).findByText('Details').exists(), + ).toBe(false); + }); + + it('expands details when details button is clicked', async () => { + const ORIGINAL_ROW_INDEX = 1; + await extendedWrapper(wrapper.find('tbody').findAll('tr').at(ORIGINAL_ROW_INDEX)) + .findByText('Details') + .trigger('click'); + + const detailsRowContent = wrapper + .find('tbody') + .findAll('tr') + .at(ORIGINAL_ROW_INDEX + 1) + .findComponent(ImportErrorDetails); + + expect(detailsRowContent.exists()).toBe(true); + expect(detailsRowContent.props().id).toBe(DUMMY_RESPONSE[1].id); + }); + }); +}); diff --git a/spec/frontend/pages/profiles/show/emoji_menu_spec.js b/spec/frontend/pages/profiles/show/emoji_menu_spec.js index f35fb57aec7..fa6e7e51a60 100644 --- a/spec/frontend/pages/profiles/show/emoji_menu_spec.js +++ b/spec/frontend/pages/profiles/show/emoji_menu_spec.js @@ -46,22 +46,18 @@ describe('EmojiMenu', () => { const dummyEmoji = 'tropical_fish'; const dummyVotesBlock = () => $('<div />'); - it('calls selectEmojiCallback', (done) => { + it('calls selectEmojiCallback', async () => { expect(dummySelectEmojiCallback).not.toHaveBeenCalled(); - emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => { - expect(dummySelectEmojiCallback).toHaveBeenCalledWith(dummyEmoji, dummyEmojiTag); - done(); - }); + await emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false); + expect(dummySelectEmojiCallback).toHaveBeenCalledWith(dummyEmoji, dummyEmojiTag); }); - it('does not make an axios request', (done) => { + it('does not make an axios request', async () => { jest.spyOn(axios, 'request').mockReturnValue(); - emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => { - expect(axios.request).not.toHaveBeenCalled(); - done(); - }); + await emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false); + expect(axios.request).not.toHaveBeenCalled(); }); }); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap index 9e00ace761c..83feb621478 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap @@ -2,31 +2,26 @@ exports[`Learn GitLab Section Card renders correctly 1`] = ` <gl-card-stub - bodyclass="" - class="gl-pt-0 learn-gitlab-section-card" + bodyclass="gl-pt-0" + class="gl-pt-0 h-100" footerclass="" - headerclass="" + headerclass="gl-bg-white gl-border-0 gl-pb-0" > - <div - class="learn-gitlab-section-card-header" + <img + src="workspace.svg" + /> + + <h2 + class="gl-font-lg gl-mb-3" > - <img - src="workspace.svg" - /> - - <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> + 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> <learn-gitlab-section-link-stub action="userAdded" value="[object Object]" diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap index 62cf769cffd..269c7467c8b 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap @@ -51,170 +51,204 @@ exports[`Learn GitLab renders correctly 1`] = ` </div> <div - class="row row-cols-1 row-cols-md-3 gl-mt-5" + class="row" > <div - class="col gl-mb-6" + class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4" > <div - class="gl-card gl-pt-0 learn-gitlab-section-card" + class="gl-card gl-pt-0 h-100" > - <!----> - <div - class="gl-card-body" + class="gl-card-header gl-bg-white gl-border-0 gl-pb-0" > - <div - class="learn-gitlab-section-card-header" + <img + src="workspace.svg" + /> + + <h2 + class="gl-font-lg gl-mb-3" > - <img - src="workspace.svg" - /> - - <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> + 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> + + <div + class="gl-card-body gl-pt-0" + > <div class="gl-mb-4" > - <span - class="gl-text-green-500" + <!----> + + <div + class="flex align-items-center" > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="completed-icon" - role="img" + <span + class="gl-text-green-500" > - <use - href="#check-circle-filled" - /> - </svg> - - Invite your colleagues - - </span> - - <!----> + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="completed-icon" + role="img" + > + <use + href="#check-circle-filled" + /> + </svg> + + Invite your colleagues + + </span> + + <!----> + </div> </div> <div class="gl-mb-4" > - <span - class="gl-text-green-500" + <!----> + + <div + class="flex align-items-center" > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="completed-icon" - role="img" + <span + class="gl-text-green-500" > - <use - href="#check-circle-filled" - /> - </svg> - - Create or import a repository - - </span> - - <!----> + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="completed-icon" + role="img" + > + <use + href="#check-circle-filled" + /> + </svg> + + Create or import a repository + + </span> + + <!----> + </div> </div> <div class="gl-mb-4" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Set up CI/CD" - href="http://example.com/" - target="_self" - > - - Set up CI/CD - - </a> - <!----> + + <div + class="flex align-items-center" + > + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="Set up CI/CD" + href="http://example.com/" + target="_self" + > + + Set up CI/CD + + </a> + + <!----> + </div> </div> <div class="gl-mb-4" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Start a free Ultimate trial" - href="http://example.com/" - target="_self" - > - - Start a free Ultimate trial - - </a> - <!----> + + <div + class="flex align-items-center" + > + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="Start a free Ultimate trial" + href="http://example.com/" + target="_self" + > + + Start a free Ultimate trial + + </a> + + <!----> + </div> </div> <div class="gl-mb-4" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Add code owners" - href="http://example.com/" - target="_self" - > - - Add code owners - - </a> - - <span + <div class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only" > - - Trial only + Trial only - </span> + </div> + + <div + class="flex align-items-center" + > + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="Add code owners" + href="http://example.com/" + target="_self" + > + + Add code owners + + </a> + + <!----> + </div> </div> <div class="gl-mb-4" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Add merge request approval" - href="http://example.com/" - target="_self" - > - - Add merge request approval - - </a> - - <span + <div class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only" > - - Trial only + Trial only - </span> + </div> + + <div + class="flex align-items-center" + > + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="Add merge request approval" + href="http://example.com/" + target="_self" + > + + Add merge request approval + + </a> + + <!----> + </div> </div> </div> @@ -222,71 +256,81 @@ exports[`Learn GitLab renders correctly 1`] = ` </div> </div> <div - class="col gl-mb-6" + class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4" > <div - class="gl-card gl-pt-0 learn-gitlab-section-card" + class="gl-card gl-pt-0 h-100" > - <!----> - <div - class="gl-card-body" + class="gl-card-header gl-bg-white gl-border-0 gl-pb-0" > - <div - class="learn-gitlab-section-card-header" + <img + src="plan.svg" + /> + + <h2 + class="gl-font-lg gl-mb-3" > - <img - src="plan.svg" - /> - - <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> + 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> + + <div + class="gl-card-body gl-pt-0" + > <div class="gl-mb-4" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Create an issue" - href="http://example.com/" - target="_self" - > - - Create an issue - - </a> - <!----> + + <div + class="flex align-items-center" + > + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="Create an issue" + href="http://example.com/" + target="_self" + > + + Create an issue + + </a> + + <!----> + </div> </div> <div class="gl-mb-4" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Submit a merge request" - href="http://example.com/" - target="_self" - > - - Submit a merge request - - </a> - <!----> + + <div + class="flex align-items-center" + > + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="Submit a merge request" + href="http://example.com/" + target="_self" + > + + Submit a merge request + + </a> + + <!----> + </div> </div> </div> @@ -294,54 +338,58 @@ exports[`Learn GitLab renders correctly 1`] = ` </div> </div> <div - class="col gl-mb-6" + class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4" > <div - class="gl-card gl-pt-0 learn-gitlab-section-card" + class="gl-card gl-pt-0 h-100" > - <!----> - <div - class="gl-card-body" + class="gl-card-header gl-bg-white gl-border-0 gl-pb-0" > - <div - class="learn-gitlab-section-card-header" + <img + src="deploy.svg" + /> + + <h2 + class="gl-font-lg gl-mb-3" > - <img - src="deploy.svg" - /> - - <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> + 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> + + <div + class="gl-card-body gl-pt-0" + > <div class="gl-mb-4" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Run a Security scan using CI/CD" - href="https://docs.gitlab.com/ee/foobar/" - rel="noopener noreferrer" - target="_blank" - > - - Run a Security scan using CI/CD - - </a> - <!----> + + <div + class="flex align-items-center" + > + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="Run a Security scan using CI/CD" + href="https://docs.gitlab.com/ee/foobar/" + rel="noopener noreferrer" + target="_blank" + > + + Run a Security scan using CI/CD + + </a> + + <!----> + </div> </div> </div> diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js index e21371123e8..b8ebf2a1430 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { stubExperiments } from 'helpers/experimentation_helper'; import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; import eventHub from '~/invite_members/event_hub'; @@ -26,7 +26,7 @@ describe('Learn GitLab Section Link', () => { }); const createWrapper = (action = defaultAction, props = {}) => { - wrapper = shallowMount(LearnGitlabSectionLink, { + wrapper = mount(LearnGitlabSectionLink, { propsData: { action, value: { ...defaultProps, ...props } }, }); }; @@ -36,6 +36,8 @@ describe('Learn GitLab Section Link', () => { const findUncompletedLink = () => wrapper.find('[data-testid="uncompleted-learn-gitlab-link"]'); + const videoTutorialLink = () => wrapper.find('[data-testid="video-tutorial-link"]'); + it('renders no icon when not completed', () => { createWrapper(undefined, { completed: false }); @@ -130,4 +132,51 @@ describe('Learn GitLab Section Link', () => { unmockTracking(); }); }); + + describe('video_tutorials_continuous_onboarding experiment', () => { + describe('when control', () => { + beforeEach(() => { + stubExperiments({ video_tutorials_continuous_onboarding: 'control' }); + createWrapper('codeOwnersEnabled'); + }); + + it('renders no video link', () => { + expect(videoTutorialLink().exists()).toBe(false); + }); + }); + + describe('when candidate', () => { + beforeEach(() => { + stubExperiments({ video_tutorials_continuous_onboarding: 'candidate' }); + createWrapper('codeOwnersEnabled'); + }); + + it('renders video link with blank target', () => { + const videoLinkElement = videoTutorialLink(); + + expect(videoLinkElement.exists()).toBe(true); + expect(videoLinkElement.attributes('target')).toEqual('_blank'); + }); + + it('tracks the click', () => { + const trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + + videoTutorialLink().trigger('click'); + + expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_video_link', { + label: 'Add code owners', + property: 'Growth::Conversion::Experiment::LearnGitLab', + context: { + data: { + experiment: 'video_tutorials_continuous_onboarding', + variant: 'candidate', + }, + schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', + }, + }); + + unmockTracking(); + }); + }); + }); }); diff --git a/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js index 0fffcf433a3..5771e1b88e8 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js @@ -3,15 +3,17 @@ import { shallowMount } from '@vue/test-utils'; import ProjectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue'; describe('Project Feature Settings', () => { + const defaultOptions = [ + [1, 1], + [2, 2], + [3, 3], + [4, 4], + [5, 5], + ]; + const defaultProps = { name: 'Test', - options: [ - [1, 1], - [2, 2], - [3, 3], - [4, 4], - [5, 5], - ], + options: defaultOptions, value: 1, disabledInput: false, showToggle: true, @@ -110,15 +112,25 @@ describe('Project Feature Settings', () => { }, ); - it('should emit the change when a new option is selected', () => { + it('should emit the change when a new option is selected', async () => { wrapper = mountComponent(); expect(wrapper.emitted('change')).toBeUndefined(); - wrapper.findAll('option').at(1).trigger('change'); + await wrapper.findAll('option').at(1).setSelected(); expect(wrapper.emitted('change')).toHaveLength(1); expect(wrapper.emitted('change')[0]).toEqual([2]); }); + + it('value of select matches prop `value` if options are modified', async () => { + wrapper = mountComponent(); + + await wrapper.setProps({ value: 0, options: [[0, 0]] }); + expect(wrapper.find('select').element.selectedIndex).toBe(0); + + await wrapper.setProps({ value: 2, options: defaultOptions }); + expect(wrapper.find('select').element.selectedIndex).toBe(1); + }); }); }); diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index 305dce51971..30d5f89d2f6 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -1,6 +1,6 @@ import { GlSprintf, GlToggle } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; -import projectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue'; +import ProjectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue'; import settingsPanel from '~/pages/projects/shared/permissions/components/settings_panel.vue'; import { featureAccessLevel, @@ -21,6 +21,7 @@ const defaultProps = { wikiAccessLevel: 20, snippetsAccessLevel: 20, operationsAccessLevel: 20, + metricsDashboardAccessLevel: 20, pagesAccessLevel: 10, analyticsAccessLevel: 20, containerRegistryAccessLevel: 20, @@ -75,7 +76,7 @@ describe('Settings Panel', () => { const findLFSFeatureToggle = () => findLFSSettingsRow().find(GlToggle); const findRepositoryFeatureProjectRow = () => wrapper.find({ ref: 'repository-settings' }); const findRepositoryFeatureSetting = () => - findRepositoryFeatureProjectRow().find(projectFeatureSetting); + findRepositoryFeatureProjectRow().find(ProjectFeatureSetting); const findProjectVisibilitySettings = () => wrapper.find({ ref: 'project-visibility-settings' }); const findIssuesSettingsRow = () => wrapper.find({ ref: 'issues-settings' }); const findAnalyticsRow = () => wrapper.find({ ref: 'analytics-settings' }); @@ -106,7 +107,11 @@ describe('Settings Panel', () => { 'input[name="project[project_setting_attributes][warn_about_potentially_unwanted_characters]"]', ); const findMetricsVisibilitySettings = () => wrapper.find({ ref: 'metrics-visibility-settings' }); + const findMetricsVisibilityInput = () => + findMetricsVisibilitySettings().findComponent(ProjectFeatureSetting); const findOperationsSettings = () => wrapper.find({ ref: 'operations-settings' }); + const findOperationsVisibilityInput = () => + findOperationsSettings().findComponent(ProjectFeatureSetting); const findConfirmDangerButton = () => wrapper.findComponent(ConfirmDanger); afterEach(() => { @@ -595,7 +600,7 @@ describe('Settings Panel', () => { }); describe('Metrics dashboard', () => { - it('should show the metrics dashboard access toggle', () => { + it('should show the metrics dashboard access select', () => { wrapper = mountComponent(); expect(findMetricsVisibilitySettings().exists()).toBe(true); @@ -610,23 +615,51 @@ describe('Settings Panel', () => { }); it.each` - scenario | selectedOption | selectedOptionLabel - ${{ currentSettings: { visibilityLevel: visibilityOptions.PRIVATE } }} | ${String(featureAccessLevel.PROJECT_MEMBERS)} | ${'Only Project Members'} - ${{ currentSettings: { operationsAccessLevel: featureAccessLevel.NOT_ENABLED } }} | ${String(featureAccessLevel.NOT_ENABLED)} | ${'Enable feature to choose access level'} + before | after + ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.EVERYONE} + ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.PROJECT_MEMBERS} + ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.PROJECT_MEMBERS} + ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.NOT_ENABLED} + ${featureAccessLevel.PROJECT_MEMBERS} | ${featureAccessLevel.NOT_ENABLED} `( - 'should disable the metrics visibility dropdown when #scenario', - ({ scenario, selectedOption, selectedOptionLabel }) => { - wrapper = mountComponent(scenario, mount); + 'when updating Operations Settings access level from `$before` to `$after`, Metric Dashboard access is updated to `$after` as well', + async ({ before, after }) => { + wrapper = mountComponent({ + currentSettings: { operationsAccessLevel: before, metricsDashboardAccessLevel: before }, + }); - const select = findMetricsVisibilitySettings().find('select'); - const option = select.find('option'); + await findOperationsVisibilityInput().vm.$emit('change', after); - expect(select.attributes('disabled')).toBe('disabled'); - expect(select.element.value).toBe(selectedOption); - expect(option.attributes('value')).toBe(selectedOption); - expect(option.text()).toBe(selectedOptionLabel); + expect(findMetricsVisibilityInput().props('value')).toBe(after); }, ); + + it('when updating Operations Settings access level from `10` to `20`, Metric Dashboard access is not increased', async () => { + wrapper = mountComponent({ + currentSettings: { + operationsAccessLevel: featureAccessLevel.PROJECT_MEMBERS, + metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS, + }, + }); + + await findOperationsVisibilityInput().vm.$emit('change', featureAccessLevel.EVERYONE); + + expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.PROJECT_MEMBERS); + }); + + it('should reduce Metrics visibility level when visibility is set to private', async () => { + wrapper = mountComponent({ + currentSettings: { + visibilityLevel: visibilityOptions.PUBLIC, + operationsAccessLevel: featureAccessLevel.EVERYONE, + metricsDashboardAccessLevel: featureAccessLevel.EVERYONE, + }, + }); + + await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE); + + expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.PROJECT_MEMBERS); + }); }); describe('Analytics', () => { diff --git a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js new file mode 100644 index 00000000000..365bb878485 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js @@ -0,0 +1,97 @@ +import { GlSkeletonLoader, GlAlert } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import WikiContent from '~/pages/shared/wikis/components/wiki_content.vue'; +import { renderGFM } from '~/pages/shared/wikis/render_gfm_facade'; +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; +import waitForPromises from 'helpers/wait_for_promises'; + +jest.mock('~/pages/shared/wikis/render_gfm_facade'); + +describe('pages/shared/wikis/components/wiki_content', () => { + const PATH = '/test'; + let wrapper; + let mock; + + function buildWrapper(propsData = {}) { + wrapper = shallowMount(WikiContent, { + propsData: { getWikiContentUrl: PATH, ...propsData }, + stubs: { + GlSkeletonLoader, + GlAlert, + }, + }); + } + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlAlert = () => wrapper.findComponent(GlAlert); + const findGlSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findContent = () => wrapper.find('[data-testid="wiki_page_content"]'); + + describe('when loading content', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('renders skeleton loader', () => { + expect(findGlSkeletonLoader().exists()).toBe(true); + }); + + it('does not render content container or error alert', () => { + expect(findGlAlert().exists()).toBe(false); + expect(findContent().exists()).toBe(false); + }); + }); + + describe('when content loads successfully', () => { + const content = 'content'; + + beforeEach(() => { + mock.onGet(PATH, { params: { render_html: true } }).replyOnce(httpStatus.OK, { content }); + buildWrapper(); + return waitForPromises(); + }); + + it('renders content container', () => { + expect(findContent().text()).toBe(content); + }); + + it('does not render skeleton loader or error alert', () => { + expect(findGlAlert().exists()).toBe(false); + expect(findGlSkeletonLoader().exists()).toBe(false); + }); + + it('calls renderGFM after nextTick', async () => { + await nextTick(); + + expect(renderGFM).toHaveBeenCalledWith(wrapper.element); + }); + }); + + describe('when loading content fails', () => { + beforeEach(() => { + mock.onGet(PATH).replyOnce(httpStatus.INTERNAL_SERVER_ERROR, ''); + buildWrapper(); + return waitForPromises(); + }); + + it('renders error alert', () => { + expect(findGlAlert().exists()).toBe(true); + }); + + it('does not render skeleton loader or content container', () => { + expect(findContent().exists()).toBe(false); + expect(findGlSkeletonLoader().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index e118a35804f..d7f8dc3c98e 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -1,5 +1,5 @@ import { nextTick } from 'vue'; -import { GlLoadingIcon, GlModal, GlAlert, GlButton } from '@gitlab/ui'; +import { GlAlert, GlButton } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -32,11 +32,7 @@ describe('WikiForm', () => { const findMessage = () => wrapper.find('#wiki_message'); const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button'); const findCancelButton = () => wrapper.findByTestId('wiki-cancel-button'); - const findUseNewEditorButton = () => wrapper.findByText('Use the new editor'); const findToggleEditingModeButton = () => wrapper.findByTestId('toggle-editing-mode-button'); - const findDismissContentEditorAlertButton = () => wrapper.findByText('Try this later'); - const findSwitchToOldEditorButton = () => - wrapper.findByRole('button', { name: 'Switch me back to the classic editor.' }); const findTitleHelpLink = () => wrapper.findByText('Learn more.'); const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link'); const findContentEditor = () => wrapper.findComponent(ContentEditor); @@ -293,27 +289,21 @@ describe('WikiForm', () => { ); }); - describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is not enabled', () => { + describe('toggle editing mode control', () => { beforeEach(() => { - createWrapper({ - glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: false }, - }); - }); - - it('hides toggle editing mode button', () => { - expect(findToggleEditingModeButton().exists()).toBe(false); - }); - }); - - describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is enabled', () => { - beforeEach(() => { - createWrapper({ - glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: true }, - }); + createWrapper(); }); - it('hides gl-alert containing "use new editor" button', () => { - expect(findUseNewEditorButton().exists()).toBe(false); + it.each` + format | enabled | action + ${'markdown'} | ${true} | ${'displays'} + ${'rdoc'} | ${false} | ${'hides'} + ${'asciidoc'} | ${false} | ${'hides'} + ${'org'} | ${false} | ${'hides'} + `('$action toggle editing mode button when format is $format', async ({ format, enabled }) => { + await setFormat(format); + + expect(findToggleEditingModeButton().exists()).toBe(enabled); }); it('displays toggle editing mode button', () => { @@ -326,8 +316,8 @@ describe('WikiForm', () => { }); describe('when clicking the toggle editing mode button', () => { - beforeEach(() => { - findToggleEditingModeButton().vm.$emit('click'); + beforeEach(async () => { + await findToggleEditingModeButton().trigger('click'); }); it('hides the classic editor', () => { @@ -343,17 +333,13 @@ describe('WikiForm', () => { describe('when content editor is active', () => { let mockContentEditor; - beforeEach(() => { + beforeEach(async () => { mockContentEditor = { getSerializedContent: jest.fn(), setSerializedContent: jest.fn(), }; - findToggleEditingModeButton().vm.$emit('click'); - }); - - it('hides switch to old editor button', () => { - expect(findSwitchToOldEditorButton().exists()).toBe(false); + await findToggleEditingModeButton().trigger('click'); }); it('displays "Edit source" label in the toggle editing mode button', () => { @@ -363,13 +349,13 @@ describe('WikiForm', () => { describe('when clicking the toggle editing mode button', () => { const contentEditorFakeSerializedContent = 'fake content'; - beforeEach(() => { + beforeEach(async () => { mockContentEditor.getSerializedContent.mockReturnValueOnce( contentEditorFakeSerializedContent, ); findContentEditor().vm.$emit('initialized', mockContentEditor); - findToggleEditingModeButton().vm.$emit('click'); + await findToggleEditingModeButton().trigger('click'); }); it('hides the content editor', () => { @@ -388,75 +374,12 @@ describe('WikiForm', () => { }); describe('wiki content editor', () => { - it.each` - format | buttonExists - ${'markdown'} | ${true} - ${'rdoc'} | ${false} - `( - 'gl-alert containing "use new editor" button exists: $buttonExists if format is $format', - async ({ format, buttonExists }) => { - createWrapper(); - - await setFormat(format); - - expect(findUseNewEditorButton().exists()).toBe(buttonExists); - }, - ); - - it('gl-alert containing "use new editor" button is dismissed on clicking dismiss button', async () => { - createWrapper(); - - await findDismissContentEditorAlertButton().trigger('click'); - - expect(findUseNewEditorButton().exists()).toBe(false); - }); - - const assertOldEditorIsVisible = () => { - expect(findContentEditor().exists()).toBe(false); - expect(findClassicEditor().exists()).toBe(true); - expect(findSubmitButton().props('disabled')).toBe(false); - - expect(wrapper.text()).not.toContain( - "Switching will discard any changes you've made in the new editor.", - ); - expect(wrapper.text()).not.toContain( - "This editor is in beta and may not display the page's contents properly.", - ); - }; - - it('shows classic editor by default', () => { - createWrapper({ persisted: true }); - - assertOldEditorIsVisible(); - }); - - describe('switch format to rdoc', () => { - beforeEach(async () => { - createWrapper({ persisted: true }); - - await setFormat('rdoc'); - }); - - it('continues to show the classic editor', assertOldEditorIsVisible); - - describe('switch format back to markdown', () => { - beforeEach(async () => { - await setFormat('markdown'); - }); - - it( - 'still shows the classic editor and does not automatically switch to the content editor ', - assertOldEditorIsVisible, - ); - }); - }); - describe('clicking "use new editor": editor fails to load', () => { beforeEach(async () => { createWrapper({ mountFn: mount }); mock.onPost(/preview-markdown/).reply(400); - await findUseNewEditorButton().trigger('click'); + await findToggleEditingModeButton().trigger('click'); // try waiting for content editor to load (but it will never actually load) await waitForPromises(); @@ -466,14 +389,14 @@ describe('WikiForm', () => { expect(findSubmitButton().props('disabled')).toBe(true); }); - describe('clicking "switch to classic editor"', () => { + describe('toggling editing modes to the classic editor', () => { beforeEach(() => { - return findSwitchToOldEditorButton().trigger('click'); + return findToggleEditingModeButton().trigger('click'); }); - it('switches to classic editor directly without showing a modal', () => { - expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); - expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); + it('switches to classic editor', () => { + expect(findContentEditor().exists()).toBe(false); + expect(findClassicEditor().exists()).toBe(true); }); }); }); @@ -484,31 +407,15 @@ describe('WikiForm', () => { mock.onPost(/preview-markdown/).reply(200, { body: '<p>hello <strong>world</strong></p>' }); - await findUseNewEditorButton().trigger('click'); - }); - - it('shows a tip to send feedback', () => { - expect(wrapper.text()).toContain('Tell us your experiences with the new Markdown editor'); - }); - - it('shows warnings that the rich text editor is in beta and may not work properly', () => { - expect(wrapper.text()).toContain( - "This editor is in beta and may not display the page's contents properly.", - ); + await findToggleEditingModeButton().trigger('click'); + await waitForPromises(); }); it('shows the rich text editor when loading finishes', async () => { - // wait for content editor to load - await waitForPromises(); - - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.findComponent(ContentEditor).exists()).toBe(true); + expect(findContentEditor().exists()).toBe(true); }); it('sends tracking event when editor loads', async () => { - // wait for content editor to load - await waitForPromises(); - expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, { label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, }); @@ -564,49 +471,6 @@ describe('WikiForm', () => { expect(findContent().element.value).toBe('hello **world**'); }); }); - - describe('clicking "switch to classic editor"', () => { - let modal; - - beforeEach(async () => { - modal = wrapper.findComponent(GlModal); - jest.spyOn(modal.vm, 'show'); - - findSwitchToOldEditorButton().trigger('click'); - }); - - it('shows a modal confirming the change', () => { - expect(modal.vm.show).toHaveBeenCalled(); - }); - - describe('confirming "switch to classic editor" in the modal', () => { - beforeEach(async () => { - wrapper.vm.contentEditor.tiptapEditor.commands.setContent( - '<p>hello __world__ from content editor</p>', - true, - ); - - wrapper.findComponent(GlModal).vm.$emit('primary'); - - await nextTick(); - }); - - it('switches to classic editor', () => { - expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); - expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); - }); - - it('does not show a warning about content editor', () => { - expect(wrapper.text()).not.toContain( - "This editor is in beta and may not display the page's contents properly.", - ); - }); - - it('the classic editor retains its old value and does not use the content from the content editor', () => { - expect(findContent().element.value).toBe(' My page content '); - }); - }); - }); }); }); }); diff --git a/spec/frontend/pdf/page_spec.js b/spec/frontend/pdf/page_spec.js index 4e0a6f78b63..07a7f1bb2ff 100644 --- a/spec/frontend/pdf/page_spec.js +++ b/spec/frontend/pdf/page_spec.js @@ -1,4 +1,4 @@ -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import PageComponent from '~/pdf/page/index.vue'; @@ -14,8 +14,7 @@ describe('Page component', () => { vm.$destroy(); }); - it('renders the page when mounting', (done) => { - const promise = Promise.resolve(); + it('renders the page when mounting', async () => { const testPage = { render: jest.fn().mockReturnValue({ promise: Promise.resolve() }), getViewport: jest.fn().mockReturnValue({}), @@ -28,12 +27,9 @@ describe('Page component', () => { expect(vm.rendering).toBe(true); - promise - .then(() => { - expect(testPage.render).toHaveBeenCalledWith(vm.renderContext); - expect(vm.rendering).toBe(false); - }) - .then(done) - .catch(done.fail); + await nextTick(); + + expect(testPage.render).toHaveBeenCalledWith(vm.renderContext); + expect(vm.rendering).toBe(false); }); }); diff --git a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js index ba06f113120..33b53bf6a56 100644 --- a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js +++ b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js @@ -1,146 +1,27 @@ -import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; -import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; -import PipelineConfigReferenceCard from '~/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue'; -import VisualizeAndLintCard from '~/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue'; +import { GlDrawer } from '@gitlab/ui'; import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; -import { DRAWER_EXPANDED_KEY } from '~/pipeline_editor/constants'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; describe('Pipeline editor drawer', () => { - useLocalStorageSpy(); - let wrapper; + const findDrawer = () => wrapper.findComponent(GlDrawer); + const createComponent = () => { - wrapper = shallowMount(PipelineEditorDrawer, { - stubs: { LocalStorageSync }, - }); + wrapper = shallowMount(PipelineEditorDrawer); }; - const findFirstPipelineCard = () => wrapper.findComponent(FirstPipelineCard); - const findGettingStartedCard = () => wrapper.findComponent(GettingStartedCard); - const findPipelineConfigReferenceCard = () => wrapper.findComponent(PipelineConfigReferenceCard); - const findToggleBtn = () => wrapper.findComponent(GlButton); - const findVisualizeAndLintCard = () => wrapper.findComponent(VisualizeAndLintCard); - - const findArrowIcon = () => wrapper.find('[data-testid="toggle-icon"]'); - const findCollapseText = () => wrapper.find('[data-testid="collapse-text"]'); - const findDrawerContent = () => wrapper.find('[data-testid="drawer-content"]'); - - const clickToggleBtn = async () => findToggleBtn().vm.$emit('click'); - - const originalObjects = []; - - beforeEach(() => { - originalObjects.push(window.gon, window.gl); - }); - afterEach(() => { wrapper.destroy(); - localStorage.clear(); - [window.gon, window.gl] = originalObjects; - }); - - describe('default expanded state', () => { - it('sets the drawer to be closed by default', async () => { - createComponent(); - expect(findDrawerContent().exists()).toBe(false); - }); - }); - - describe('when the drawer is collapsed', () => { - beforeEach(async () => { - createComponent(); - }); - - it('shows the left facing arrow icon', () => { - expect(findArrowIcon().props('name')).toBe('chevron-double-lg-left'); - }); - - it('does not show the collapse text', () => { - expect(findCollapseText().exists()).toBe(false); - }); - - it('does not show the drawer content', () => { - expect(findDrawerContent().exists()).toBe(false); - }); - - it('can open the drawer by clicking on the toggle button', async () => { - expect(findDrawerContent().exists()).toBe(false); - - await clickToggleBtn(); - - expect(findDrawerContent().exists()).toBe(true); - }); - }); - - describe('when the drawer is expanded', () => { - beforeEach(async () => { - createComponent(); - await clickToggleBtn(); - }); - - it('shows the right facing arrow icon', () => { - expect(findArrowIcon().props('name')).toBe('chevron-double-lg-right'); - }); - - it('shows the collapse text', () => { - expect(findCollapseText().exists()).toBe(true); - }); - - it('shows the drawer content', () => { - expect(findDrawerContent().exists()).toBe(true); - }); - - it('shows all the introduction cards', () => { - expect(findFirstPipelineCard().exists()).toBe(true); - expect(findGettingStartedCard().exists()).toBe(true); - expect(findPipelineConfigReferenceCard().exists()).toBe(true); - expect(findVisualizeAndLintCard().exists()).toBe(true); - }); - - it('can close the drawer by clicking on the toggle button', async () => { - expect(findDrawerContent().exists()).toBe(true); - - await clickToggleBtn(); - - expect(findDrawerContent().exists()).toBe(false); - }); }); - describe('local storage', () => { - it('saves the drawer expanded value to local storage', async () => { - localStorage.setItem(DRAWER_EXPANDED_KEY, 'false'); - - createComponent(); - await clickToggleBtn(); - - expect(localStorage.setItem.mock.calls).toEqual([ - [DRAWER_EXPANDED_KEY, 'false'], - [DRAWER_EXPANDED_KEY, 'true'], - ]); - }); - - it('loads the drawer collapsed when local storage is set to `false`, ', async () => { - localStorage.setItem(DRAWER_EXPANDED_KEY, false); - createComponent(); - - await nextTick(); - - expect(findDrawerContent().exists()).toBe(false); - }); + it('emits close event when closing the drawer', () => { + createComponent(); - it('loads the drawer expanded when local storage is set to `true`, ', async () => { - localStorage.setItem(DRAWER_EXPANDED_KEY, true); - createComponent(); + expect(wrapper.emitted('close-drawer')).toBeUndefined(); - await nextTick(); + findDrawer().vm.$emit('close'); - expect(findDrawerContent().exists()).toBe(true); - }); + expect(wrapper.emitted('close-drawer')).toHaveLength(1); }); }); diff --git a/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js index 3ee53d4a055..8f50325295e 100644 --- a/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js @@ -1,5 +1,5 @@ -import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header.vue'; import { @@ -11,11 +11,18 @@ describe('CI Editor Header', () => { let wrapper; let trackingSpy = null; - const createComponent = () => { - wrapper = shallowMount(CiEditorHeader, {}); + const createComponent = ({ showDrawer = false } = {}) => { + wrapper = extendedWrapper( + shallowMount(CiEditorHeader, { + propsData: { + showDrawer, + }, + }), + ); }; - const findLinkBtn = () => wrapper.findComponent(GlButton); + const findLinkBtn = () => wrapper.findByTestId('template-repo-link'); + const findHelpBtn = () => wrapper.findByTestId('drawer-toggle'); afterEach(() => { wrapper.destroy(); @@ -50,4 +57,42 @@ describe('CI Editor Header', () => { }); }); }); + + describe('help button', () => { + beforeEach(() => { + createComponent(); + }); + + it('finds the help button', () => { + expect(findHelpBtn().exists()).toBe(true); + }); + + it('has the information-o icon', () => { + expect(findHelpBtn().props('icon')).toBe('information-o'); + }); + + describe('when pipeline editor drawer is closed', () => { + it('emits open drawer event when clicked', () => { + createComponent({ showDrawer: false }); + + expect(wrapper.emitted('open-drawer')).toBeUndefined(); + + findHelpBtn().vm.$emit('click'); + + expect(wrapper.emitted('open-drawer')).toHaveLength(1); + }); + }); + + describe('when pipeline editor drawer is open', () => { + it('emits close drawer event when clicked', () => { + createComponent({ showDrawer: true }); + + expect(wrapper.emitted('close-drawer')).toBeUndefined(); + + findHelpBtn().vm.$emit('click'); + + expect(wrapper.emitted('close-drawer')).toHaveLength(1); + }); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js index fee52db9b64..6dffb7e5470 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -40,6 +40,7 @@ describe('Pipeline editor tabs component', () => { ciConfigData: mockLintResponse, ciFileContent: mockCiYml, isNewCiConfigFile: true, + showDrawer: false, ...props, }, data() { diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js index 6f969546171..98e2c17967c 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js @@ -1,6 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import { GlModal } from '@gitlab/ui'; +import { GlButton, GlDrawer, GlModal } from '@gitlab/ui'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header.vue'; import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; @@ -18,24 +20,26 @@ describe('Pipeline editor home wrapper', () => { let wrapper; const createComponent = ({ props = {}, glFeatures = {}, data = {}, stubs = {} } = {}) => { - wrapper = shallowMount(PipelineEditorHome, { - data: () => data, - propsData: { - ciConfigData: mockLintResponse, - ciFileContent: mockCiYml, - isCiConfigDataLoading: false, - isNewCiConfigFile: false, - ...props, - }, - provide: { - projectFullPath: '', - totalBranches: 19, - glFeatures: { - ...glFeatures, + wrapper = extendedWrapper( + shallowMount(PipelineEditorHome, { + data: () => data, + propsData: { + ciConfigData: mockLintResponse, + ciFileContent: mockCiYml, + isCiConfigDataLoading: false, + isNewCiConfigFile: false, + ...props, }, - }, - stubs, - }); + provide: { + projectFullPath: '', + totalBranches: 19, + glFeatures: { + ...glFeatures, + }, + }, + stubs, + }), + ); }; const findBranchSwitcher = () => wrapper.findComponent(BranchSwitcher); @@ -45,6 +49,7 @@ describe('Pipeline editor home wrapper', () => { const findPipelineEditorDrawer = () => wrapper.findComponent(PipelineEditorDrawer); const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader); const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs); + const findHelpBtn = () => wrapper.findByTestId('drawer-toggle'); afterEach(() => { wrapper.destroy(); @@ -70,10 +75,6 @@ describe('Pipeline editor home wrapper', () => { it('shows the commit section by default', () => { expect(findCommitSection().exists()).toBe(true); }); - - it('show the pipeline drawer', () => { - expect(findPipelineEditorDrawer().exists()).toBe(true); - }); }); describe('modal when switching branch', () => { @@ -175,4 +176,58 @@ describe('Pipeline editor home wrapper', () => { }); }); }); + + describe('help drawer', () => { + const clickHelpBtn = async () => { + findHelpBtn().vm.$emit('click'); + await nextTick(); + }; + + it('hides the drawer by default', () => { + createComponent(); + + expect(findPipelineEditorDrawer().props('isVisible')).toBe(false); + }); + + it('toggles the drawer on button click', async () => { + createComponent({ + stubs: { + CiEditorHeader, + GlButton, + GlDrawer, + PipelineEditorTabs, + PipelineEditorDrawer, + }, + }); + + await clickHelpBtn(); + + expect(findPipelineEditorDrawer().props('isVisible')).toBe(true); + + await clickHelpBtn(); + + expect(findPipelineEditorDrawer().props('isVisible')).toBe(false); + }); + + it("closes the drawer through the drawer's close button", async () => { + createComponent({ + stubs: { + CiEditorHeader, + GlButton, + GlDrawer, + PipelineEditorTabs, + PipelineEditorDrawer, + }, + }); + + await clickHelpBtn(); + + expect(findPipelineEditorDrawer().props('isVisible')).toBe(true); + + findPipelineEditorDrawer().find(GlDrawer).vm.$emit('close'); + await nextTick(); + + expect(findPipelineEditorDrawer().props('isVisible')).toBe(false); + }); + }); }); diff --git a/spec/frontend/pipeline_wizard/components/wrapper_spec.js b/spec/frontend/pipeline_wizard/components/wrapper_spec.js index bd1679baf48..357a9d21723 100644 --- a/spec/frontend/pipeline_wizard/components/wrapper_spec.js +++ b/spec/frontend/pipeline_wizard/components/wrapper_spec.js @@ -1,21 +1,26 @@ import { Document, parseDocument } from 'yaml'; import { GlProgressBar } from '@gitlab/ui'; import { nextTick } from 'vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import PipelineWizardWrapper, { i18n } from '~/pipeline_wizard/components/wrapper.vue'; import WizardStep from '~/pipeline_wizard/components/step.vue'; import CommitStep from '~/pipeline_wizard/components/commit.vue'; import YamlEditor from '~/pipeline_wizard/components/editor.vue'; import { sprintf } from '~/locale'; -import { steps as stepsYaml } from '../mock/yaml'; +import { + steps as stepsYaml, + compiledScenario1, + compiledScenario2, + compiledScenario3, +} from '../mock/yaml'; describe('Pipeline Wizard - wrapper.vue', () => { let wrapper; const steps = parseDocument(stepsYaml).toJS(); const getAsYamlNode = (value) => new Document(value).contents; - const createComponent = (props = {}) => { - wrapper = shallowMountExtended(PipelineWizardWrapper, { + const createComponent = (props = {}, mountFn = shallowMountExtended) => { + wrapper = mountFn(PipelineWizardWrapper, { propsData: { projectPath: '/user/repo', defaultBranch: 'main', @@ -23,13 +28,21 @@ describe('Pipeline Wizard - wrapper.vue', () => { steps: getAsYamlNode(steps), ...props, }, + stubs: { + CommitStep: true, + }, }); }; const getEditorContent = () => { - return wrapper.getComponent(YamlEditor).attributes().doc.toString(); + return wrapper.getComponent(YamlEditor).props().doc.toString(); }; - const getStepWrapper = () => wrapper.getComponent(WizardStep); + const getStepWrapper = () => + wrapper.findAllComponents(WizardStep).wrappers.find((w) => w.isVisible()); const getGlProgressBarWrapper = () => wrapper.getComponent(GlProgressBar); + const findFirstVisibleStep = () => + wrapper.findAllComponents('[data-testid="step"]').wrappers.find((w) => w.isVisible()); + const findFirstInputFieldForTarget = (target) => + wrapper.find(`[data-input-target="${target}"]`).find('input'); describe('display', () => { afterEach(() => { @@ -118,8 +131,9 @@ describe('Pipeline Wizard - wrapper.vue', () => { }) => { beforeAll(async () => { createComponent(); + for (const emittedValue of navigationEventChain) { - wrapper.findComponent({ ref: 'step' }).vm.$emit(emittedValue); + findFirstVisibleStep().vm.$emit(emittedValue); // We have to wait for the next step to be mounted // before we can emit the next event, so we have to await // inside the loop. @@ -134,11 +148,11 @@ describe('Pipeline Wizard - wrapper.vue', () => { if (expectCommitStepShown) { it('does not show the step wrapper', async () => { - expect(wrapper.findComponent(WizardStep).exists()).toBe(false); + expect(wrapper.findComponent(WizardStep).isVisible()).toBe(false); }); it('shows the commit step page', () => { - expect(wrapper.findComponent(CommitStep).exists()).toBe(true); + expect(wrapper.findComponent(CommitStep).isVisible()).toBe(true); }); } else { it('passes the correct step config to the step component', async () => { @@ -146,7 +160,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { }); it('does not show the commit step page', () => { - expect(wrapper.findComponent(CommitStep).exists()).toBe(false); + expect(wrapper.findComponent(CommitStep).isVisible()).toBe(false); }); } @@ -247,4 +261,54 @@ describe('Pipeline Wizard - wrapper.vue', () => { expect(wrapper.getComponent(YamlEditor).props('highlight')).toBe(null); }); }); + + describe('integration test', () => { + beforeAll(async () => { + createComponent({}, mountExtended); + }); + + it('updates the editor content after input on step 1', async () => { + findFirstInputFieldForTarget('$FOO').setValue('fooVal'); + await nextTick(); + + expect(getEditorContent()).toBe(compiledScenario1); + }); + + it('updates the editor content after input on step 2', async () => { + findFirstVisibleStep().vm.$emit('next'); + await nextTick(); + + findFirstInputFieldForTarget('$BAR').setValue('barVal'); + await nextTick(); + + expect(getEditorContent()).toBe(compiledScenario2); + }); + + describe('navigating back', () => { + let inputField; + + beforeAll(async () => { + findFirstVisibleStep().vm.$emit('back'); + await nextTick(); + + inputField = findFirstInputFieldForTarget('$FOO'); + }); + + afterAll(() => { + wrapper.destroy(); + inputField = undefined; + }); + + it('still shows the input values from the former visit', () => { + expect(inputField.element.value).toBe('fooVal'); + }); + + it('updates the editor content without modifying input that came from a later step', async () => { + inputField.setValue('newFooVal'); + await nextTick(); + + expect(getEditorContent()).toBe(compiledScenario3); + }); + }); + }); }); diff --git a/spec/frontend/pipeline_wizard/mock/yaml.js b/spec/frontend/pipeline_wizard/mock/yaml.js index 5eaeaa32a8c..e7087b59ce7 100644 --- a/spec/frontend/pipeline_wizard/mock/yaml.js +++ b/spec/frontend/pipeline_wizard/mock/yaml.js @@ -59,6 +59,17 @@ export const steps = ` bar: $BAR `; +export const compiledScenario1 = `foo: fooVal +`; + +export const compiledScenario2 = `foo: fooVal +bar: barVal +`; + +export const compiledScenario3 = `foo: newFooVal +bar: barVal +`; + export const fullTemplate = ` title: some title description: some description diff --git a/spec/frontend/pipelines/components/pipeline_tabs_spec.js b/spec/frontend/pipelines/components/pipeline_tabs_spec.js new file mode 100644 index 00000000000..e18c3edbad9 --- /dev/null +++ b/spec/frontend/pipelines/components/pipeline_tabs_spec.js @@ -0,0 +1,61 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import PipelineTabs from '~/pipelines/components/pipeline_tabs.vue'; +import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; +import Dag from '~/pipelines/components/dag/dag.vue'; +import JobsApp from '~/pipelines/components/jobs/jobs_app.vue'; +import TestReports from '~/pipelines/components/test_reports/test_reports.vue'; + +describe('The Pipeline Tabs', () => { + let wrapper; + + const findDagTab = () => wrapper.findByTestId('dag-tab'); + const findFailedJobsTab = () => wrapper.findByTestId('failed-jobs-tab'); + const findJobsTab = () => wrapper.findByTestId('jobs-tab'); + const findPipelineTab = () => wrapper.findByTestId('pipeline-tab'); + const findTestsTab = () => wrapper.findByTestId('tests-tab'); + + const findDagApp = () => wrapper.findComponent(Dag); + const findFailedJobsApp = () => wrapper.findComponent(JobsApp); + const findJobsApp = () => wrapper.findComponent(JobsApp); + const findPipelineApp = () => wrapper.findComponent(PipelineGraphWrapper); + const findTestsApp = () => wrapper.findComponent(TestReports); + + const createComponent = (propsData = {}) => { + wrapper = extendedWrapper( + shallowMount(PipelineTabs, { + propsData, + stubs: { + Dag: { template: '<div id="dag"/>' }, + JobsApp: { template: '<div class="jobs" />' }, + PipelineGraph: { template: '<div id="graph" />' }, + TestReports: { template: '<div id="tests" />' }, + }, + }), + ); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + // The failed jobs MUST be removed from here and tested individually once + // the logic for the tab is implemented. + describe('Tabs', () => { + it.each` + tabName | tabComponent | appComponent + ${'Pipeline'} | ${findPipelineTab} | ${findPipelineApp} + ${'Dag'} | ${findDagTab} | ${findDagApp} + ${'Jobs'} | ${findJobsTab} | ${findJobsApp} + ${'Failed Jobs'} | ${findFailedJobsTab} | ${findFailedJobsApp} + ${'Tests'} | ${findTestsTab} | ${findTestsApp} + `('shows $tabName tab and its associated component', ({ appComponent, tabComponent }) => { + expect(tabComponent().exists()).toBe(true); + expect(appComponent().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index 0822b293f75..6c743f92116 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -173,7 +173,7 @@ describe('Pipelines filtered search', () => { { type: 'filtered-search-term', value: { data: '' } }, ]; - expect(findFilteredSearch().props('value')).toEqual(expectedValueProp); + expect(findFilteredSearch().props('value')).toMatchObject(expectedValueProp); expect(findFilteredSearch().props('value')).toHaveLength(expectedValueProp.length); }); }); diff --git a/spec/frontend/pipelines/empty_state/ci_templates_spec.js b/spec/frontend/pipelines/empty_state/ci_templates_spec.js new file mode 100644 index 00000000000..606fdc9cac1 --- /dev/null +++ b/spec/frontend/pipelines/empty_state/ci_templates_spec.js @@ -0,0 +1,85 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; +const suggestedCiTemplates = [ + { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, + { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, + { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, +]; + +describe('CI Templates', () => { + let wrapper; + let trackingSpy; + + const createWrapper = () => { + return shallowMountExtended(CiTemplates, { + provide: { + pipelineEditorPath, + suggestedCiTemplates, + }, + }); + }; + + const findTemplateDescription = () => wrapper.findByTestId('template-description'); + const findTemplateLink = () => wrapper.findByTestId('template-link'); + const findTemplateName = () => wrapper.findByTestId('template-name'); + const findTemplateLogo = () => wrapper.findByTestId('template-logo'); + + beforeEach(() => { + wrapper = createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('renders template list', () => { + it('renders all suggested templates', () => { + const content = wrapper.text(); + + expect(content).toContain('Android', 'Bash', 'C++'); + }); + + it('has the correct template name', () => { + expect(findTemplateName().text()).toBe('Android'); + }); + + it('links to the correct template', () => { + expect(findTemplateLink().attributes('href')).toBe( + pipelineEditorPath.concat('?template=Android'), + ); + }); + + it('has the description of the template', () => { + expect(findTemplateDescription().text()).toBe( + 'CI/CD template to test and deploy your Android project.', + ); + }); + + it('has the right logo of the template', () => { + expect(findTemplateLogo().attributes('src')).toBe('/assets/illustrations/logos/android.svg'); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends an event when template is clicked', () => { + findTemplateLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: 'Android', + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js index 7064f7448ec..14860f20317 100644 --- a/spec/frontend/pipelines/pipelines_ci_templates_spec.js +++ b/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js @@ -1,12 +1,12 @@ import '~/commons'; import { GlButton, GlSprintf } from '@gitlab/ui'; -import { sprintf } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockTracking } from 'helpers/tracking_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { stubExperiments } from 'helpers/experimentation_helper'; import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import ExperimentTracking from '~/experimentation/experiment_tracking'; -import PipelinesCiTemplate from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue'; +import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; +import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; import { RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, RUNNERS_SETTINGS_LINK_CLICKED_EVENT, @@ -16,11 +16,6 @@ import { } from '~/pipeline_editor/constants'; const pipelineEditorPath = '/-/ci/editor'; -const suggestedCiTemplates = [ - { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, - { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, - { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, -]; jest.mock('~/experimentation/experiment_tracking'); @@ -29,21 +24,17 @@ describe('Pipelines CI Templates', () => { let trackingSpy; const createWrapper = (propsData = {}, stubs = {}) => { - return shallowMountExtended(PipelinesCiTemplate, { + return shallowMountExtended(PipelinesCiTemplates, { provide: { pipelineEditorPath, - suggestedCiTemplates, }, propsData, stubs, }); }; - const findTestTemplateLinks = () => wrapper.findAll('[data-testid="test-template-link"]'); - const findTemplateDescriptions = () => wrapper.findAll('[data-testid="template-description"]'); - const findTemplateLinks = () => wrapper.findAll('[data-testid="template-link"]'); - const findTemplateNames = () => wrapper.findAll('[data-testid="template-name"]'); - const findTemplateLogos = () => wrapper.findAll('[data-testid="template-logo"]'); + const findTestTemplateLink = () => wrapper.findByTestId('test-template-link'); + const findCiTemplates = () => wrapper.findComponent(CiTemplates); const findSettingsLink = () => wrapper.findByTestId('settings-link'); const findDocumentationLink = () => wrapper.findByTestId('documentation-link'); const findSettingsButton = () => wrapper.findByTestId('settings-button'); @@ -59,63 +50,24 @@ describe('Pipelines CI Templates', () => { }); it('links to the getting started template', () => { - expect(findTestTemplateLinks().at(0).attributes('href')).toBe( + expect(findTestTemplateLink().attributes('href')).toBe( pipelineEditorPath.concat('?template=Getting-Started'), ); }); }); - describe('renders template list', () => { - beforeEach(() => { - wrapper = createWrapper(); - }); - - it('renders all suggested templates', () => { - const content = wrapper.text(); - - expect(content).toContain('Android', 'Bash', 'C++'); - }); - - it('has the correct template name', () => { - expect(findTemplateNames().at(0).text()).toBe('Android'); - }); - - it('links to the correct template', () => { - expect(findTemplateLinks().at(0).attributes('href')).toBe( - pipelineEditorPath.concat('?template=Android'), - ); - }); - - it('has the description of the template', () => { - expect(findTemplateDescriptions().at(0).text()).toBe( - sprintf(I18N.templates.description, { name: 'Android' }), - ); - }); - - it('has the right logo of the template', () => { - expect(findTemplateLogos().at(0).attributes('src')).toBe( - '/assets/illustrations/logos/android.svg', - ); - }); - }); - describe('tracking', () => { beforeEach(() => { wrapper = createWrapper(); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); - it('sends an event when template is clicked', () => { - findTemplateLinks().at(0).vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { - label: 'Android', - }); + afterEach(() => { + unmockTracking(); }); it('sends an event when Getting-Started template is clicked', () => { - findTestTemplateLinks().at(0).vm.$emit('click'); + findTestTemplateLink().vm.$emit('click'); expect(trackingSpy).toHaveBeenCalledTimes(1); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { @@ -198,8 +150,8 @@ describe('Pipelines CI Templates', () => { }); it(`renders the templates: ${templatesRendered}`, () => { - expect(findTestTemplateLinks().exists()).toBe(templatesRendered); - expect(findTemplateLinks().exists()).toBe(templatesRendered); + expect(findTestTemplateLink().exists()).toBe(templatesRendered); + expect(findCiTemplates().exists()).toBe(templatesRendered); }); }, ); diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index 31b74a06efd..46dad4a035c 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -1,7 +1,7 @@ import '~/commons'; import { mount } from '@vue/test-utils'; import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; -import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue'; +import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; describe('Pipelines Empty State', () => { let wrapper; diff --git a/spec/frontend/pipelines/graph/action_component_spec.js b/spec/frontend/pipelines/graph/action_component_spec.js index fab6e6887b7..6e5aa572ec0 100644 --- a/spec/frontend/pipelines/graph/action_component_spec.js +++ b/spec/frontend/pipelines/graph/action_component_spec.js @@ -48,17 +48,14 @@ describe('pipeline graph action component', () => { }); describe('on click', () => { - it('emits `pipelineActionRequestComplete` after a successful request', (done) => { + it('emits `pipelineActionRequestComplete` after a successful request', async () => { jest.spyOn(wrapper.vm, '$emit'); findButton().trigger('click'); - waitForPromises() - .then(() => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete'); - done(); - }) - .catch(done.fail); + await waitForPromises(); + + expect(wrapper.vm.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete'); }); it('renders a loading icon while waiting for request', async () => { diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 8bc6c086b9d..cb7073fb5f5 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -30,6 +30,7 @@ import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import * as parsingUtils from '~/pipelines/components/parsing_utils'; import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql'; import * as sentryUtils from '~/pipelines/utils'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { mockRunningPipelineHeaderData } from '../mock_data'; import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data'; @@ -55,6 +56,7 @@ describe('Pipeline graph wrapper', () => { wrapper.find(StageColumnComponent).findAll('[data-testid="stage-column-group"]'); const getViewSelector = () => wrapper.find(GraphViewSelector); const getViewSelectorTrip = () => getViewSelector().findComponent(GlAlert); + const getLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); const createComponent = ({ apolloProvider, @@ -376,6 +378,10 @@ describe('Pipeline graph wrapper', () => { localStorage.clear(); }); + it('sets the asString prop on the LocalStorageSync component', () => { + expect(getLocalStorageSync().props('asString')).toBe(true); + }); + it('reads the view type from localStorage when available', () => { const viewSelectorNeedsSegment = wrapper .find(GlButtonGroup) diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js index 701b1691c7b..58bfb68e85c 100644 --- a/spec/frontend/pipelines/pipeline_triggerer_spec.js +++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js @@ -1,17 +1,11 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import pipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_triggerer.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; describe('Pipelines Triggerer', () => { let wrapper; - const expectComponentWithProps = (Component, props = {}) => { - const componentWrapper = wrapper.find(Component); - expect(componentWrapper.isVisible()).toBe(true); - expect(componentWrapper.props()).toEqual(expect.objectContaining(props)); - }; - const mockData = { pipeline: { user: { @@ -22,40 +16,65 @@ describe('Pipelines Triggerer', () => { }, }; - const createComponent = () => { - wrapper = shallowMount(pipelineTriggerer, { - propsData: mockData, + const createComponent = (props) => { + wrapper = shallowMountExtended(pipelineTriggerer, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); - it('should render pipeline triggerer table cell', () => { - expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true); - }); + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findTriggerer = () => wrapper.findByText('API'); + + describe('when user was a triggerer', () => { + beforeEach(() => { + createComponent(mockData); + }); + + it('should render pipeline triggerer table cell', () => { + expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true); + }); + + it('should render only user avatar', () => { + expect(findAvatarLink().exists()).toBe(true); + expect(findTriggerer().exists()).toBe(false); + }); + + it('should set correct props on avatar link component', () => { + expect(findAvatarLink().attributes()).toMatchObject({ + title: mockData.pipeline.user.name, + href: mockData.pipeline.user.path, + }); + }); - it('should pass triggerer information when triggerer is provided', () => { - expectComponentWithProps(UserAvatarLink, { - linkHref: mockData.pipeline.user.path, - tooltipText: mockData.pipeline.user.name, - imgSrc: mockData.pipeline.user.avatar_url, + it('should add tooltip to avatar link', () => { + const tooltip = getBinding(findAvatarLink().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + }); + + it('should set correct props on avatar component', () => { + expect(findAvatar().attributes().src).toBe(mockData.pipeline.user.avatar_url); }); }); - it('should render "API" when no triggerer is provided', async () => { - wrapper.setProps({ - pipeline: { - user: null, - }, + describe('when API was a triggerer', () => { + beforeEach(() => { + createComponent({ pipeline: {} }); }); - await nextTick(); - expect(wrapper.find('.js-pipeline-url-api').text()).toEqual('API'); + it('should render label only', () => { + expect(findAvatarLink().exists()).toBe(false); + expect(findTriggerer().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index 2a0aeed917c..c6104a13216 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -1,5 +1,6 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PipelineUrlComponent from '~/pipelines/components/pipelines_list/pipeline_url.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { mockPipeline, mockPipelineBranch, mockPipelineTag } from './mock_data'; const projectPath = 'test/test'; @@ -57,6 +58,30 @@ describe('Pipeline Url Component', () => { expect(findCommitShortSha().exists()).toBe(true); }); + describe('commit user avatar', () => { + it('renders when commit author exists', () => { + const pipelineBranch = mockPipelineBranch(); + const { avatar_url, name, path } = pipelineBranch.pipeline.commit.author; + createComponent(pipelineBranch); + + const component = wrapper.findComponent(UserAvatarLink); + expect(component.exists()).toBe(true); + expect(component.props()).toMatchObject({ + imgSize: 16, + imgSrc: avatar_url, + imgAlt: name, + linkHref: path, + tooltipText: name, + }); + }); + + it('does not render when commit author does not exist', () => { + createComponent(); + + expect(wrapper.findComponent(UserAvatarLink).exists()).toBe(false); + }); + }); + it('should render commit icon tooltip', () => { createComponent({}, true); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 20ed12cd1f5..d2b30c93746 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -14,7 +14,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; -import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue'; +import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; import { RAW_TEXT_WARNING } from '~/pipelines/constants'; import Store from '~/pipelines/stores/pipelines_store'; diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index 84a9f4776b9..d5acb115bc1 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -38,29 +38,25 @@ describe('Actions TestReports Store', () => { mock.onGet(summaryEndpoint).replyOnce(200, summary, {}); }); - it('sets testReports and shows tests', (done) => { - testAction( + it('sets testReports and shows tests', () => { + return testAction( actions.fetchSummary, null, state, [{ type: types.SET_SUMMARY, payload: summary }], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - done, ); }); - it('should create flash on API error', (done) => { - testAction( + it('should create flash on API error', async () => { + await testAction( actions.fetchSummary, null, { summaryEndpoint: null }, [], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); }); @@ -73,87 +69,80 @@ describe('Actions TestReports Store', () => { .replyOnce(200, testReports.test_suites[0], {}); }); - it('sets test suite and shows tests', (done) => { + it('sets test suite and shows tests', () => { const suite = testReports.test_suites[0]; const index = 0; - testAction( + return testAction( actions.fetchTestSuite, index, { ...state, testReports }, [{ type: types.SET_SUITE, payload: { suite, index } }], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - done, ); }); - it('should create flash on API error', (done) => { + it('should create flash on API error', async () => { const index = 0; - testAction( + await testAction( actions.fetchTestSuite, index, { ...state, testReports, suiteEndpoint: null }, [], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); describe('when we already have the suite data', () => { - it('should not fetch suite', (done) => { + it('should not fetch suite', () => { const index = 0; testReports.test_suites[0].hasFullSuite = true; - testAction(actions.fetchTestSuite, index, { ...state, testReports }, [], [], done); + return testAction(actions.fetchTestSuite, index, { ...state, testReports }, [], []); }); }); }); describe('set selected suite index', () => { - it('sets selectedSuiteIndex', (done) => { + it('sets selectedSuiteIndex', () => { const selectedSuiteIndex = 0; - testAction( + return testAction( actions.setSelectedSuiteIndex, selectedSuiteIndex, { ...state, hasFullReport: true }, [{ type: types.SET_SELECTED_SUITE_INDEX, payload: selectedSuiteIndex }], [], - done, ); }); }); describe('remove selected suite index', () => { - it('sets selectedSuiteIndex to null', (done) => { - testAction( + it('sets selectedSuiteIndex to null', () => { + return testAction( actions.removeSelectedSuiteIndex, {}, state, [{ type: types.SET_SELECTED_SUITE_INDEX, payload: null }], [], - done, ); }); }); describe('toggles loading', () => { - it('sets isLoading to true', (done) => { - testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], [], done); + it('sets isLoading to true', () => { + return testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], []); }); - it('toggles isLoading to false', (done) => { - testAction( + it('toggles isLoading to false', () => { + return testAction( actions.toggleLoading, {}, { ...state, isLoading: true }, [{ type: types.TOGGLE_LOADING }], [], - done, ); }); }); diff --git a/spec/frontend/profile/add_ssh_key_validation_spec.js b/spec/frontend/profile/add_ssh_key_validation_spec.js index a6bcca0ccb3..730a94592a7 100644 --- a/spec/frontend/profile/add_ssh_key_validation_spec.js +++ b/spec/frontend/profile/add_ssh_key_validation_spec.js @@ -1,4 +1,4 @@ -import AddSshKeyValidation from '../../../app/assets/javascripts/profile/add_ssh_key_validation'; +import AddSshKeyValidation from '~/profile/add_ssh_key_validation'; describe('AddSshKeyValidation', () => { describe('submit', () => { diff --git a/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap b/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap new file mode 100644 index 00000000000..3025a2f87ae --- /dev/null +++ b/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap @@ -0,0 +1,915 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` +<div + class="form-group" +> + <label> + Preview + </label> + + <table + class="code" + > + <tbody> + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="1" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span + class="c1" + > + # + <span + class="idiff deletion" + > + Removed + </span> + content + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="1" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span + class="c1" + > + # + <span + class="idiff addition" + > + Added + </span> + content + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="2" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span + class="n" + > + v + </span> + + <span + class="o" + > + = + </span> + + <span + class="mi" + > + 1 + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="2" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span + class="n" + > + v + </span> + + <span + class="o" + > + = + </span> + + <span + class="mi" + > + 1 + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="3" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span + class="n" + > + s + </span> + + <span + class="o" + > + = + </span> + + <span + class="s" + > + "string" + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="3" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span + class="n" + > + s + </span> + + <span + class="o" + > + = + </span> + + <span + class="s" + > + "string" + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="4" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span /> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="4" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span /> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="5" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span + class="k" + > + for + </span> + + <span + class="n" + > + i + </span> + + <span + class="ow" + > + in + </span> + + <span + class="nb" + > + range + </span> + <span + class="p" + > + ( + </span> + <span + class="o" + > + - + </span> + <span + class="mi" + > + 10 + </span> + <span + class="p" + > + , + </span> + + <span + class="mi" + > + 10 + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="5" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span + class="k" + > + for + </span> + + <span + class="n" + > + i + </span> + + <span + class="ow" + > + in + </span> + + <span + class="nb" + > + range + </span> + <span + class="p" + > + ( + </span> + <span + class="o" + > + - + </span> + <span + class="mi" + > + 10 + </span> + <span + class="p" + > + , + </span> + + <span + class="mi" + > + 10 + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="6" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span> + + </span> + + <span + class="k" + > + print + </span> + <span + class="p" + > + ( + </span> + <span + class="n" + > + i + </span> + + <span + class="o" + > + + + </span> + + <span + class="mi" + > + 1 + </span> + <span + class="p" + > + ) + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="6" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span> + + </span> + + <span + class="k" + > + print + </span> + <span + class="p" + > + ( + </span> + <span + class="n" + > + i + </span> + + <span + class="o" + > + + + </span> + + <span + class="mi" + > + 1 + </span> + <span + class="p" + > + ) + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="7" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span /> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="7" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span /> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="8" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span + class="k" + > + class + </span> + + <span + class="nc" + > + LinkedList + </span> + <span + class="p" + > + ( + </span> + <span + class="nb" + > + object + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="8" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span + class="k" + > + class + </span> + + <span + class="nc" + > + LinkedList + </span> + <span + class="p" + > + ( + </span> + <span + class="nb" + > + object + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="9" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span> + + </span> + + <span + class="k" + > + def + </span> + + <span + class="nf" + > + __init__ + </span> + <span + class="p" + > + ( + </span> + <span + class="bp" + > + self + </span> + <span + class="p" + > + , + </span> + + <span + class="n" + > + x + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="9" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span> + + </span> + + <span + class="k" + > + def + </span> + + <span + class="nf" + > + __init__ + </span> + <span + class="p" + > + ( + </span> + <span + class="bp" + > + self + </span> + <span + class="p" + > + , + </span> + + <span + class="n" + > + x + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="10" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span> + + </span> + + <span + class="bp" + > + self + </span> + <span + class="p" + > + . + </span> + <span + class="n" + > + val + </span> + + <span + class="o" + > + = + </span> + + <span + class="n" + > + x + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="10" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span> + + </span> + + <span + class="bp" + > + self + </span> + <span + class="p" + > + . + </span> + <span + class="n" + > + val + </span> + + <span + class="o" + > + = + </span> + + <span + class="n" + > + x + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="11" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span> + + </span> + + <span + class="bp" + > + self + </span> + <span + class="p" + > + . + </span> + <span + class="nb" + > + next + </span> + + <span + class="o" + > + = + </span> + + <span + class="bp" + > + None + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="11" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span> + + </span> + + <span + class="bp" + > + self + </span> + <span + class="p" + > + . + </span> + <span + class="nb" + > + next + </span> + + <span + class="o" + > + = + </span> + + <span + class="bp" + > + None + </span> + </span> + </td> + </tr> + </tbody> + </table> +</div> +`; diff --git a/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js b/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js new file mode 100644 index 00000000000..e60602ab336 --- /dev/null +++ b/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js @@ -0,0 +1,23 @@ +import { shallowMount } from '@vue/test-utils'; +import DiffsColorsPreview from '~/profile/preferences/components/diffs_colors_preview.vue'; + +describe('DiffsColorsPreview component', () => { + let wrapper; + + function createComponent() { + wrapper = shallowMount(DiffsColorsPreview); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders diff colors preview', () => { + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/profile/preferences/components/diffs_colors_spec.js b/spec/frontend/profile/preferences/components/diffs_colors_spec.js new file mode 100644 index 00000000000..02f501a0b06 --- /dev/null +++ b/spec/frontend/profile/preferences/components/diffs_colors_spec.js @@ -0,0 +1,153 @@ +import { shallowMount } from '@vue/test-utils'; +import { s__ } from '~/locale'; +import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue'; +import DiffsColors from '~/profile/preferences/components/diffs_colors.vue'; +import DiffsColorsPreview from '~/profile/preferences/components/diffs_colors_preview.vue'; +import * as CssUtils from '~/lib/utils/css_utils'; + +describe('DiffsColors component', () => { + let wrapper; + + const defaultInjectedProps = { + addition: '#00ff00', + deletion: '#ff0000', + }; + + const initialSuggestedColors = { + '#d99530': s__('SuggestedColors|Orange'), + '#1f75cb': s__('SuggestedColors|Blue'), + }; + + const findColorPickers = () => wrapper.findAllComponents(ColorPicker); + + function createComponent(provide = {}) { + wrapper = shallowMount(DiffsColors, { + provide: { + ...defaultInjectedProps, + ...provide, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('mounts', () => { + createComponent(); + + expect(wrapper.exists()).toBe(true); + }); + + describe('preview', () => { + it('should render preview', () => { + createComponent(); + + expect(wrapper.findComponent(DiffsColorsPreview).exists()).toBe(true); + }); + + it('should set preview classes', () => { + createComponent(); + + expect(wrapper.attributes('class')).toBe( + 'diff-custom-addition-color diff-custom-deletion-color', + ); + }); + + it.each([ + [{ addition: null }, 'diff-custom-deletion-color'], + [{ deletion: null }, 'diff-custom-addition-color'], + ])('should not set preview class if color not set', (provide, expectedClass) => { + createComponent(provide); + + expect(wrapper.attributes('class')).toBe(expectedClass); + }); + + it.each([ + [{}, '--diff-deletion-color: rgba(255,0,0,0.2); --diff-addition-color: rgba(0,255,0,0.2);'], + [{ addition: null }, '--diff-deletion-color: rgba(255,0,0,0.2);'], + [{ deletion: null }, '--diff-addition-color: rgba(0,255,0,0.2);'], + ])('should set correct CSS variables', (provide, expectedStyle) => { + createComponent(provide); + + expect(wrapper.attributes('style')).toBe(expectedStyle); + }); + }); + + describe('color pickers', () => { + it('should render both color pickers', () => { + createComponent(); + + const colorPickers = findColorPickers(); + + expect(colorPickers.length).toBe(2); + expect(colorPickers.at(0).props()).toMatchObject({ + label: s__('Preferences|Color for removed lines'), + value: '#ff0000', + state: true, + }); + expect(colorPickers.at(1).props()).toMatchObject({ + label: s__('Preferences|Color for added lines'), + value: '#00ff00', + state: true, + }); + }); + + describe('suggested colors', () => { + const suggestedColors = () => findColorPickers().at(0).props('suggestedColors'); + + it('contains initial suggested colors', () => { + createComponent(); + + expect(suggestedColors()).toMatchObject(initialSuggestedColors); + }); + + it('contains default diff colors of theme', () => { + jest.spyOn(CssUtils, 'getCssVariable').mockImplementation((variable) => { + if (variable === '--default-diff-color-addition') return '#111111'; + if (variable === '--default-diff-color-deletion') return '#222222'; + return '#000000'; + }); + + createComponent(); + + expect(suggestedColors()).toMatchObject({ + '#111111': s__('SuggestedColors|Default addition color'), + '#222222': s__('SuggestedColors|Default removal color'), + }); + }); + + it('contains current diff colors if set', () => { + createComponent(); + + expect(suggestedColors()).toMatchObject({ + [defaultInjectedProps.addition]: s__('SuggestedColors|Current addition color'), + [defaultInjectedProps.deletion]: s__('SuggestedColors|Current removal color'), + }); + }); + + it.each([ + [ + { addition: null }, + s__('SuggestedColors|Current removal color'), + s__('SuggestedColors|Current addition color'), + ], + [ + { deletion: null }, + s__('SuggestedColors|Current addition color'), + s__('SuggestedColors|Current removal color'), + ], + ])( + 'does not contain current diff color if not set %p', + (provide, expectedToContain, expectNotToContain) => { + createComponent(provide); + + const suggestedColorsLabels = Object.values(suggestedColors()); + expect(suggestedColorsLabels).toContain(expectedToContain); + expect(suggestedColorsLabels).not.toContain(expectNotToContain); + }, + ); + }); + }); +}); diff --git a/spec/frontend/profile/preferences/components/integration_view_spec.js b/spec/frontend/profile/preferences/components/integration_view_spec.js index 6ab0c70298c..92c53b8c91b 100644 --- a/spec/frontend/profile/preferences/components/integration_view_spec.js +++ b/spec/frontend/profile/preferences/components/integration_view_spec.js @@ -1,5 +1,5 @@ -import { GlFormText } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlFormGroup } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import IntegrationView from '~/profile/preferences/components/integration_view.vue'; @@ -21,7 +21,7 @@ describe('IntegrationView component', () => { function createComponent(options = {}) { const { props = {}, provide = {} } = options; - return shallowMount(IntegrationView, { + return mountExtended(IntegrationView, { provide: { userFields, ...provide, @@ -33,28 +33,20 @@ describe('IntegrationView component', () => { }); } - function findCheckbox() { - return wrapper.find('[data-testid="profile-preferences-integration-checkbox"]'); - } - function findFormGroup() { - return wrapper.find('[data-testid="profile-preferences-integration-form-group"]'); - } - function findHiddenField() { - return wrapper.find('[data-testid="profile-preferences-integration-hidden-field"]'); - } - function findFormGroupLabel() { - return wrapper.find('[data-testid="profile-preferences-integration-form-group"] label'); - } + const findCheckbox = () => wrapper.findByLabelText(new RegExp(defaultProps.config.label)); + const findFormGroup = () => wrapper.findComponent(GlFormGroup); + const findHiddenField = () => + wrapper.findByTestId('profile-preferences-integration-hidden-field'); afterEach(() => { wrapper.destroy(); wrapper = null; }); - it('should render the title correctly', () => { + it('should render the form group legend correctly', () => { wrapper = createComponent(); - expect(wrapper.find('label.label-bold').text()).toBe('Foo'); + expect(wrapper.findByText(defaultProps.config.title).exists()).toBe(true); }); it('should render the form correctly', () => { @@ -106,13 +98,6 @@ describe('IntegrationView component', () => { it('should render the help text', () => { wrapper = createComponent(); - expect(wrapper.find(GlFormText).exists()).toBe(true); expect(wrapper.find(IntegrationHelpText).exists()).toBe(true); }); - - it('should render the label correctly', () => { - wrapper = createComponent(); - - expect(findFormGroupLabel().text()).toBe('Enable foo'); - }); }); diff --git a/spec/frontend/projects/commit/store/actions_spec.js b/spec/frontend/projects/commit/store/actions_spec.js index 305257c9ca5..56dffcbd48e 100644 --- a/spec/frontend/projects/commit/store/actions_spec.js +++ b/spec/frontend/projects/commit/store/actions_spec.js @@ -44,12 +44,12 @@ describe('Commit form modal store actions', () => { }); describe('fetchBranches', () => { - it('dispatch correct actions on fetchBranches', (done) => { + it('dispatch correct actions on fetchBranches', () => { jest .spyOn(axios, 'get') .mockImplementation(() => Promise.resolve({ data: { Branches: mockData.mockBranches } })); - testAction( + return testAction( actions.fetchBranches, {}, state, @@ -60,19 +60,15 @@ describe('Commit form modal store actions', () => { }, ], [{ type: 'requestBranches' }], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on fetchBranches failure', (done) => { + it('should show flash error and set error in state on fetchBranches failure', async () => { jest.spyOn(axios, 'get').mockRejectedValue(); - testAction(actions.fetchBranches, {}, state, [], [{ type: 'requestBranches' }], () => { - expect(createFlash).toHaveBeenCalledWith({ message: PROJECT_BRANCHES_ERROR }); - done(); - }); + await testAction(actions.fetchBranches, {}, state, [], [{ type: 'requestBranches' }]); + + expect(createFlash).toHaveBeenCalledWith({ message: PROJECT_BRANCHES_ERROR }); }); }); diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap index b8f9951bbfc..26a3b27d958 100644 --- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap +++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap @@ -36,7 +36,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` modalclass="" modalid="fakeUniqueId" ok-variant="danger" - size="sm" + size="md" title-class="gl-text-red-500" titletag="h4" > diff --git a/spec/frontend/projects/new/components/deployment_target_select_spec.js b/spec/frontend/projects/new/components/deployment_target_select_spec.js index 8fe4c5f1230..1c443879dc3 100644 --- a/spec/frontend/projects/new/components/deployment_target_select_spec.js +++ b/spec/frontend/projects/new/components/deployment_target_select_spec.js @@ -1,4 +1,5 @@ -import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { GlFormGroup, GlFormSelect, GlFormText, GlLink, GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import { mockTracking } from 'helpers/tracking_helper'; import DeploymentTargetSelect from '~/projects/new/components/deployment_target_select.vue'; @@ -6,7 +7,9 @@ import { DEPLOYMENT_TARGET_SELECTIONS, DEPLOYMENT_TARGET_LABEL, DEPLOYMENT_TARGET_EVENT, + VISIT_DOCS_EVENT, NEW_PROJECT_FORM, + K8S_OPTION, } from '~/projects/new/constants'; describe('Deployment target select', () => { @@ -15,11 +18,15 @@ describe('Deployment target select', () => { const findFormGroup = () => wrapper.findComponent(GlFormGroup); const findSelect = () => wrapper.findComponent(GlFormSelect); + const findText = () => wrapper.findComponent(GlFormText); + const findLink = () => wrapper.findComponent(GlLink); const createdWrapper = () => { wrapper = shallowMount(DeploymentTargetSelect, { stubs: { GlFormSelect, + GlFormText, + GlSprintf, }, }); }; @@ -79,4 +86,34 @@ describe('Deployment target select', () => { }); } }); + + describe.each` + selectedTarget | isTextShown + ${null} | ${false} + ${DEPLOYMENT_TARGET_SELECTIONS[0]} | ${true} + ${DEPLOYMENT_TARGET_SELECTIONS[1]} | ${false} + `('K8s education text', ({ selectedTarget, isTextShown }) => { + beforeEach(() => { + findSelect().vm.$emit('input', selectedTarget); + }); + + it(`is ${!isTextShown ? 'not ' : ''}shown when selected option is ${selectedTarget}`, () => { + expect(findText().exists()).toBe(isTextShown); + }); + }); + + describe('when user clicks on the docs link', () => { + beforeEach(async () => { + findSelect().vm.$emit('input', K8S_OPTION); + await nextTick(); + + findLink().trigger('click'); + }); + + it('sends the snowplow tracking event', () => { + expect(trackingSpy).toHaveBeenCalledWith('_category_', VISIT_DOCS_EVENT, { + label: DEPLOYMENT_TARGET_LABEL, + }); + }); + }); }); diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js index 921f5b74278..ba22622e1f7 100644 --- a/spec/frontend/projects/new/components/new_project_url_select_spec.js +++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js @@ -15,6 +15,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import eventHub from '~/projects/new/event_hub'; import NewProjectUrlSelect from '~/projects/new/components/new_project_url_select.vue'; import searchQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; +import { s__ } from '~/locale'; describe('NewProjectUrlSelect component', () => { let wrapper; @@ -61,7 +62,6 @@ describe('NewProjectUrlSelect component', () => { namespaceId: '28', rootUrl: 'https://gitlab.com/', trackLabel: 'blank_project', - userNamespaceFullPath: 'root', userNamespaceId: '1', }; @@ -91,7 +91,10 @@ describe('NewProjectUrlSelect component', () => { const findButtonLabel = () => wrapper.findComponent(GlButton); const findDropdown = () => wrapper.findComponent(GlDropdown); const findInput = () => wrapper.findComponent(GlSearchBoxByType); - const findHiddenInput = () => wrapper.find('[name="project[namespace_id]"]'); + const findHiddenNamespaceInput = () => wrapper.find('[name="project[namespace_id]"]'); + + const findHiddenSelectedNamespaceInput = () => + wrapper.find('[name="project[selected_namespace_id]"]'); const clickDropdownItem = async () => { wrapper.findComponent(GlDropdownItem).vm.$emit('click'); @@ -122,11 +125,20 @@ describe('NewProjectUrlSelect component', () => { }); it('renders a dropdown with the given namespace full path as the text', () => { - expect(findDropdown().props('text')).toBe(defaultProvide.namespaceFullPath); + const dropdownProps = findDropdown().props(); + + expect(dropdownProps.text).toBe(defaultProvide.namespaceFullPath); + expect(dropdownProps.toggleClass).not.toContain('gl-text-gray-500!'); + }); + + it('renders a hidden input with the given namespace id', () => { + expect(findHiddenNamespaceInput().attributes('value')).toBe(defaultProvide.namespaceId); }); - it('renders a dropdown with the given namespace id in the hidden input', () => { - expect(findHiddenInput().attributes('value')).toBe(defaultProvide.namespaceId); + it('renders a hidden input with the selected namespace id', () => { + expect(findHiddenSelectedNamespaceInput().attributes('value')).toBe( + defaultProvide.namespaceId, + ); }); }); @@ -142,11 +154,18 @@ describe('NewProjectUrlSelect component', () => { }); it("renders a dropdown with the user's namespace full path as the text", () => { - expect(findDropdown().props('text')).toBe(defaultProvide.userNamespaceFullPath); + const dropdownProps = findDropdown().props(); + + expect(dropdownProps.text).toBe(s__('ProjectsNew|Pick a group or namespace')); + expect(dropdownProps.toggleClass).toContain('gl-text-gray-500!'); + }); + + it("renders a hidden input with the user's namespace id", () => { + expect(findHiddenNamespaceInput().attributes('value')).toBe(defaultProvide.userNamespaceId); }); - it("renders a dropdown with the user's namespace id in the hidden input", () => { - expect(findHiddenInput().attributes('value')).toBe(defaultProvide.userNamespaceId); + it('renders a hidden input with the selected namespace id', () => { + expect(findHiddenSelectedNamespaceInput().attributes('value')).toBe(undefined); }); }); @@ -270,7 +289,7 @@ describe('NewProjectUrlSelect component', () => { await clickDropdownItem(); - expect(findHiddenInput().attributes('value')).toBe( + expect(findHiddenNamespaceInput().attributes('value')).toBe( getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(), ); }); diff --git a/spec/frontend/releases/components/app_index_apollo_client_spec.js b/spec/frontend/releases/components/app_index_apollo_client_spec.js deleted file mode 100644 index 9881ef9bc9f..00000000000 --- a/spec/frontend/releases/components/app_index_apollo_client_spec.js +++ /dev/null @@ -1,398 +0,0 @@ -import { cloneDeep } from 'lodash'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql'; -import createFlash from '~/flash'; -import { historyPushState } from '~/lib/utils/common_utils'; -import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo_client.vue'; -import ReleaseBlock from '~/releases/components/release_block.vue'; -import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; -import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; -import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue'; -import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue'; -import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants'; - -Vue.use(VueApollo); - -jest.mock('~/flash'); - -let mockQueryParams; -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), - historyPushState: jest.fn(), -})); - -jest.mock('~/lib/utils/url_utility', () => ({ - ...jest.requireActual('~/lib/utils/url_utility'), - getParameterByName: jest - .fn() - .mockImplementation((parameterName) => mockQueryParams[parameterName]), -})); - -describe('app_index_apollo_client.vue', () => { - const projectPath = 'project/path'; - const newReleasePath = 'path/to/new/release/page'; - const before = 'beforeCursor'; - const after = 'afterCursor'; - - let wrapper; - let allReleases; - let singleRelease; - let noReleases; - let queryMock; - - const createComponent = ({ - singleResponse = Promise.resolve(singleRelease), - fullResponse = Promise.resolve(allReleases), - } = {}) => { - const apolloProvider = createMockApollo([ - [ - allReleasesQuery, - queryMock.mockImplementation((vars) => { - return vars.first === 1 ? singleResponse : fullResponse; - }), - ], - ]); - - wrapper = shallowMountExtended(ReleasesIndexApolloClientApp, { - apolloProvider, - provide: { - newReleasePath, - projectPath, - }, - }); - }; - - beforeEach(() => { - mockQueryParams = {}; - - allReleases = cloneDeep(originalAllReleasesQueryResponse); - - singleRelease = cloneDeep(originalAllReleasesQueryResponse); - singleRelease.data.project.releases.nodes.splice( - 1, - singleRelease.data.project.releases.nodes.length, - ); - - noReleases = cloneDeep(originalAllReleasesQueryResponse); - noReleases.data.project.releases.nodes = []; - - queryMock = jest.fn(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - // Finders - const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader); - const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState); - const findNewReleaseButton = () => - wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease); - const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock); - const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient); - const findSort = () => wrapper.findComponent(ReleasesSortApolloClient); - - // Tests - describe('component states', () => { - // These need to be defined as functions, since `singleRelease` and - // `allReleases` are generated in a `beforeEach`, and therefore - // aren't available at test definition time. - const getInProgressResponse = () => new Promise(() => {}); - const getErrorResponse = () => Promise.reject(new Error('Oops!')); - const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease); - const getFullRequestLoadedResponse = () => Promise.resolve(allReleases); - const getLoadedEmptyResponse = () => Promise.resolve(noReleases); - - const toDescription = (bool) => (bool ? 'does' : 'does not'); - - describe.each` - description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination - ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} - ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false} - ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} - ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} - ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} - ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false} - ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false} - ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false} - ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} - ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} - ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} - ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} - `( - '$description', - ({ - singleResponseFn, - fullResponseFn, - loadingIndicator, - emptyState, - flashMessage, - releaseCount, - pagination, - }) => { - beforeEach(() => { - createComponent({ - singleResponse: singleResponseFn(), - fullResponse: fullResponseFn(), - }); - }); - - it(`${toDescription(loadingIndicator)} render a loading indicator`, async () => { - await waitForPromises(); - expect(findLoadingIndicator().exists()).toBe(loadingIndicator); - }); - - it(`${toDescription(emptyState)} render an empty state`, () => { - expect(findEmptyState().exists()).toBe(emptyState); - }); - - it(`${toDescription(flashMessage)} show a flash message`, () => { - if (flashMessage) { - expect(createFlash).toHaveBeenCalledWith({ - message: ReleasesIndexApolloClientApp.i18n.errorMessage, - captureError: true, - error: expect.any(Error), - }); - } else { - expect(createFlash).not.toHaveBeenCalled(); - } - }); - - it(`renders ${releaseCount} release(s)`, () => { - expect(findAllReleaseBlocks()).toHaveLength(releaseCount); - }); - - it(`${toDescription(pagination)} render the pagination controls`, () => { - expect(findPagination().exists()).toBe(pagination); - }); - - it('does render the "New release" button', () => { - expect(findNewReleaseButton().exists()).toBe(true); - }); - - it('does render the sort controls', () => { - expect(findSort().exists()).toBe(true); - }); - }, - ); - }); - - describe('URL parameters', () => { - describe('when the URL contains no query parameters', () => { - beforeEach(() => { - createComponent(); - }); - - it('makes a request with the correct GraphQL query parameters', () => { - expect(queryMock).toHaveBeenCalledTimes(2); - - expect(queryMock).toHaveBeenCalledWith({ - first: 1, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - - expect(queryMock).toHaveBeenCalledWith({ - first: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - - describe('when the URL contains a "before" query parameter', () => { - beforeEach(() => { - mockQueryParams = { before }; - createComponent(); - }); - - it('makes a request with the correct GraphQL query parameters', () => { - expect(queryMock).toHaveBeenCalledTimes(1); - - expect(queryMock).toHaveBeenCalledWith({ - before, - last: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - - describe('when the URL contains an "after" query parameter', () => { - beforeEach(() => { - mockQueryParams = { after }; - createComponent(); - }); - - it('makes a request with the correct GraphQL query parameters', () => { - expect(queryMock).toHaveBeenCalledTimes(2); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: 1, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - - describe('when the URL contains both "before" and "after" query parameters', () => { - beforeEach(() => { - mockQueryParams = { before, after }; - createComponent(); - }); - - it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => { - expect(queryMock).toHaveBeenCalledTimes(2); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: 1, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - }); - - describe('New release button', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the new release button with the correct href', () => { - expect(findNewReleaseButton().attributes().href).toBe(newReleasePath); - }); - }); - - describe('pagination', () => { - beforeEach(() => { - mockQueryParams = { before }; - createComponent(); - }); - - it('requeries the GraphQL endpoint when a pagination button is clicked', async () => { - expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]); - - mockQueryParams = { after }; - findPagination().vm.$emit('next', after); - - await nextTick(); - - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ before })], - [expect.objectContaining({ after })], - [expect.objectContaining({ after })], - ]); - }); - }); - - describe('sorting', () => { - beforeEach(() => { - createComponent(); - }); - - it(`sorts by ${DEFAULT_SORT} by default`, () => { - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: DEFAULT_SORT })], - ]); - }); - - it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => { - findSort().vm.$emit('input', CREATED_ASC); - - await nextTick(); - - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: CREATED_ASC })], - [expect.objectContaining({ sort: CREATED_ASC })], - ]); - - // URL manipulation is tested in more detail in the `describe` block below - expect(historyPushState).toHaveBeenCalled(); - }); - - it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => { - findSort().vm.$emit('input', DEFAULT_SORT); - - await nextTick(); - - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: DEFAULT_SORT })], - ]); - - expect(historyPushState).not.toHaveBeenCalled(); - }); - }); - - describe('sorting + pagination interaction', () => { - const nonPaginationQueryParam = 'nonPaginationQueryParam'; - - beforeEach(() => { - historyPushState.mockImplementation((newUrl) => { - mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams); - }); - }); - - describe.each` - queryParamsBefore | paramName | paramInitialValue - ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before} - ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after} - `( - 'when the URL contains a "$paramName" pagination cursor', - ({ queryParamsBefore, paramName, paramInitialValue }) => { - beforeEach(async () => { - mockQueryParams = queryParamsBefore; - createComponent(); - - findSort().vm.$emit('input', CREATED_ASC); - - await nextTick(); - }); - - it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => { - const firstRequestVariables = queryMock.mock.calls[0][0]; - // Might be request #2 or #3, depending on the pagination direction - const mostRecentRequestVariables = - queryMock.mock.calls[queryMock.mock.calls.length - 1][0]; - - expect(firstRequestVariables[paramName]).toBe(paramInitialValue); - expect(mostRecentRequestVariables[paramName]).toBeUndefined(); - }); - - it(`updates the URL to not include the "${paramName}" URL query parameter`, () => { - expect(historyPushState).toHaveBeenCalledTimes(1); - - const updatedUrlQueryParams = Object.fromEntries( - new URL(historyPushState.mock.calls[0][0]).searchParams, - ); - - expect(updatedUrlQueryParams[paramName]).toBeUndefined(); - }); - }, - ); - }); -}); diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index 43e88650ae3..63ce4c8bb17 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -1,50 +1,87 @@ -import { shallowMount } from '@vue/test-utils'; -import { merge } from 'lodash'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import { getParameterByName } from '~/lib/utils/url_utility'; -import AppIndex from '~/releases/components/app_index.vue'; +import { cloneDeep } from 'lodash'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; +import createFlash from '~/flash'; +import { historyPushState } from '~/lib/utils/common_utils'; +import ReleasesIndexApp from '~/releases/components/app_index.vue'; +import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; +import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; import ReleasesPagination from '~/releases/components/releases_pagination.vue'; import ReleasesSort from '~/releases/components/releases_sort.vue'; +import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants'; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +let mockQueryParams; +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + historyPushState: jest.fn(), +})); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), - getParameterByName: jest.fn(), + getParameterByName: jest + .fn() + .mockImplementation((parameterName) => mockQueryParams[parameterName]), })); -Vue.use(Vuex); - describe('app_index.vue', () => { + const projectPath = 'project/path'; + const newReleasePath = 'path/to/new/release/page'; + const before = 'beforeCursor'; + const after = 'afterCursor'; + let wrapper; - let fetchReleasesSpy; - let urlParams; - - const createComponent = (storeUpdates) => { - wrapper = shallowMount(AppIndex, { - store: new Vuex.Store({ - modules: { - index: merge( - { - namespaced: true, - actions: { - fetchReleases: fetchReleasesSpy, - }, - state: { - isLoading: true, - releases: [], - }, - }, - storeUpdates, - ), - }, - }), + let allReleases; + let singleRelease; + let noReleases; + let queryMock; + + const createComponent = ({ + singleResponse = Promise.resolve(singleRelease), + fullResponse = Promise.resolve(allReleases), + } = {}) => { + const apolloProvider = createMockApollo([ + [ + allReleasesQuery, + queryMock.mockImplementation((vars) => { + return vars.first === 1 ? singleResponse : fullResponse; + }), + ], + ]); + + wrapper = shallowMountExtended(ReleasesIndexApp, { + apolloProvider, + provide: { + newReleasePath, + projectPath, + }, }); }; beforeEach(() => { - fetchReleasesSpy = jest.fn(); - getParameterByName.mockImplementation((paramName) => urlParams[paramName]); + mockQueryParams = {}; + + allReleases = cloneDeep(originalAllReleasesQueryResponse); + + singleRelease = cloneDeep(originalAllReleasesQueryResponse); + singleRelease.data.project.releases.nodes.splice( + 1, + singleRelease.data.project.releases.nodes.length, + ); + + noReleases = cloneDeep(originalAllReleasesQueryResponse); + noReleases.data.project.releases.nodes = []; + + queryMock = jest.fn(); }); afterEach(() => { @@ -52,120 +89,221 @@ describe('app_index.vue', () => { }); // Finders - const findLoadingIndicator = () => wrapper.find(ReleaseSkeletonLoader); - const findEmptyState = () => wrapper.find('[data-testid="empty-state"]'); - const findSuccessState = () => wrapper.find('[data-testid="success-state"]'); - const findPagination = () => wrapper.find(ReleasesPagination); - const findSortControls = () => wrapper.find(ReleasesSort); - const findNewReleaseButton = () => wrapper.find('[data-testid="new-release-button"]'); - - // Expectations - const expectLoadingIndicator = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} a loading indicator`, () => { - expect(findLoadingIndicator().exists()).toBe(shouldExist); - }); - }; + const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader); + const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState); + const findNewReleaseButton = () => wrapper.findByText(ReleasesIndexApp.i18n.newRelease); + const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock); + const findPagination = () => wrapper.findComponent(ReleasesPagination); + const findSort = () => wrapper.findComponent(ReleasesSort); - const expectEmptyState = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} an empty state`, () => { - expect(findEmptyState().exists()).toBe(shouldExist); - }); - }; + // Tests + describe('component states', () => { + // These need to be defined as functions, since `singleRelease` and + // `allReleases` are generated in a `beforeEach`, and therefore + // aren't available at test definition time. + const getInProgressResponse = () => new Promise(() => {}); + const getErrorResponse = () => Promise.reject(new Error('Oops!')); + const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease); + const getFullRequestLoadedResponse = () => Promise.resolve(allReleases); + const getLoadedEmptyResponse = () => Promise.resolve(noReleases); + + const toDescription = (bool) => (bool ? 'does' : 'does not'); + + describe.each` + description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination + ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false} + ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} + ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false} + ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false} + ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false} + ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} + `( + '$description', + ({ + singleResponseFn, + fullResponseFn, + loadingIndicator, + emptyState, + flashMessage, + releaseCount, + pagination, + }) => { + beforeEach(() => { + createComponent({ + singleResponse: singleResponseFn(), + fullResponse: fullResponseFn(), + }); + }); + + it(`${toDescription(loadingIndicator)} render a loading indicator`, async () => { + await waitForPromises(); + expect(findLoadingIndicator().exists()).toBe(loadingIndicator); + }); + + it(`${toDescription(emptyState)} render an empty state`, () => { + expect(findEmptyState().exists()).toBe(emptyState); + }); + + it(`${toDescription(flashMessage)} show a flash message`, async () => { + await waitForPromises(); + if (flashMessage) { + expect(createFlash).toHaveBeenCalledWith({ + message: ReleasesIndexApp.i18n.errorMessage, + captureError: true, + error: expect.any(Error), + }); + } else { + expect(createFlash).not.toHaveBeenCalled(); + } + }); + + it(`renders ${releaseCount} release(s)`, () => { + expect(findAllReleaseBlocks()).toHaveLength(releaseCount); + }); + + it(`${toDescription(pagination)} render the pagination controls`, () => { + expect(findPagination().exists()).toBe(pagination); + }); + + it('does render the "New release" button', () => { + expect(findNewReleaseButton().exists()).toBe(true); + }); + + it('does render the sort controls', () => { + expect(findSort().exists()).toBe(true); + }); + }, + ); + }); - const expectSuccessState = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} the success state`, () => { - expect(findSuccessState().exists()).toBe(shouldExist); - }); - }; + describe('URL parameters', () => { + describe('when the URL contains no query parameters', () => { + beforeEach(() => { + createComponent(); + }); - const expectPagination = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} the pagination controls`, () => { - expect(findPagination().exists()).toBe(shouldExist); - }); - }; + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(2); - const expectNewReleaseButton = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} the "New release" button`, () => { - expect(findNewReleaseButton().exists()).toBe(shouldExist); - }); - }; + expect(queryMock).toHaveBeenCalledWith({ + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); - // Tests - describe('on startup', () => { - it.each` - before | after - ${null} | ${null} - ${'before_param_value'} | ${null} - ${null} | ${'after_param_value'} - `( - 'calls fetchRelease with the correct parameters based on the curent query parameters: before: $before, after: $after', - ({ before, after }) => { - urlParams = { before, after }; + expect(queryMock).toHaveBeenCalledWith({ + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); + describe('when the URL contains a "before" query parameter', () => { + beforeEach(() => { + mockQueryParams = { before }; createComponent(); + }); - expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); - expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams); - }, - ); - }); + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(1); - describe('when the request to fetch releases has not yet completed', () => { - beforeEach(() => { - createComponent(); + expect(queryMock).toHaveBeenCalledWith({ + before, + last: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); }); - expectLoadingIndicator(true); - expectEmptyState(false); - expectSuccessState(false); - expectPagination(false); - }); + describe('when the URL contains an "after" query parameter', () => { + beforeEach(() => { + mockQueryParams = { after }; + createComponent(); + }); - describe('when the request fails', () => { - beforeEach(() => { - createComponent({ - state: { - isLoading: false, - hasError: true, - }, + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); }); }); - expectLoadingIndicator(false); - expectEmptyState(false); - expectSuccessState(false); - expectPagination(true); + describe('when the URL contains both "before" and "after" query parameters', () => { + beforeEach(() => { + mockQueryParams = { before, after }; + createComponent(); + }); + + it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); }); - describe('when the request succeeds but returns no releases', () => { + describe('New release button', () => { beforeEach(() => { - createComponent({ - state: { - isLoading: false, - }, - }); + createComponent(); }); - expectLoadingIndicator(false); - expectEmptyState(true); - expectSuccessState(false); - expectPagination(true); + it('renders the new release button with the correct href', () => { + expect(findNewReleaseButton().attributes().href).toBe(newReleasePath); + }); }); - describe('when the request succeeds and includes at least one release', () => { + describe('pagination', () => { beforeEach(() => { - createComponent({ - state: { - isLoading: false, - releases: [{}], - }, - }); + mockQueryParams = { before }; + createComponent(); }); - expectLoadingIndicator(false); - expectEmptyState(false); - expectSuccessState(true); - expectPagination(true); + it('requeries the GraphQL endpoint when a pagination button is clicked', async () => { + expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]); + + mockQueryParams = { after }; + findPagination().vm.$emit('next', after); + + await nextTick(); + + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ before })], + [expect.objectContaining({ after })], + [expect.objectContaining({ after })], + ]); + }); }); describe('sorting', () => { @@ -173,59 +311,88 @@ describe('app_index.vue', () => { createComponent(); }); - it('renders the sort controls', () => { - expect(findSortControls().exists()).toBe(true); + it(`sorts by ${DEFAULT_SORT} by default`, () => { + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + ]); }); - it('calls the fetchReleases store method when the sort is updated', () => { - fetchReleasesSpy.mockClear(); + it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => { + findSort().vm.$emit('input', CREATED_ASC); + + await nextTick(); - findSortControls().vm.$emit('sort:changed'); + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: CREATED_ASC })], + [expect.objectContaining({ sort: CREATED_ASC })], + ]); - expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); + // URL manipulation is tested in more detail in the `describe` block below + expect(historyPushState).toHaveBeenCalled(); }); - }); - describe('"New release" button', () => { - describe('when the user is allowed to create releases', () => { - const newReleasePath = 'path/to/new/release/page'; + it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => { + findSort().vm.$emit('input', DEFAULT_SORT); - beforeEach(() => { - createComponent({ state: { newReleasePath } }); - }); + await nextTick(); - expectNewReleaseButton(true); + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + ]); - it('renders the button with the correct href', () => { - expect(findNewReleaseButton().attributes('href')).toBe(newReleasePath); - }); + expect(historyPushState).not.toHaveBeenCalled(); }); + }); - describe('when the user is not allowed to create releases', () => { - beforeEach(() => { - createComponent(); - }); + describe('sorting + pagination interaction', () => { + const nonPaginationQueryParam = 'nonPaginationQueryParam'; - expectNewReleaseButton(false); + beforeEach(() => { + historyPushState.mockImplementation((newUrl) => { + mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams); + }); }); - }); - describe("when the browser's back button is pressed", () => { - beforeEach(() => { - urlParams = { - before: 'before_param_value', - }; + describe.each` + queryParamsBefore | paramName | paramInitialValue + ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before} + ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after} + `( + 'when the URL contains a "$paramName" pagination cursor', + ({ queryParamsBefore, paramName, paramInitialValue }) => { + beforeEach(async () => { + mockQueryParams = queryParamsBefore; + createComponent(); - createComponent(); + findSort().vm.$emit('input', CREATED_ASC); - fetchReleasesSpy.mockClear(); + await nextTick(); + }); - window.dispatchEvent(new PopStateEvent('popstate')); - }); + it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => { + const firstRequestVariables = queryMock.mock.calls[0][0]; + // Might be request #2 or #3, depending on the pagination direction + const mostRecentRequestVariables = + queryMock.mock.calls[queryMock.mock.calls.length - 1][0]; - it('calls the fetchRelease store method with the parameters from the URL query', () => { - expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); - expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams); - }); + expect(firstRequestVariables[paramName]).toBe(paramInitialValue); + expect(mostRecentRequestVariables[paramName]).toBeUndefined(); + }); + + it(`updates the URL to not include the "${paramName}" URL query parameter`, () => { + expect(historyPushState).toHaveBeenCalledTimes(1); + + const updatedUrlQueryParams = Object.fromEntries( + new URL(historyPushState.mock.calls[0][0]).searchParams, + ); + + expect(updatedUrlQueryParams[paramName]).toBeUndefined(); + }); + }, + ); }); }); diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index 41c9746a363..c2ea6900d6e 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -143,6 +143,12 @@ describe('Release show component', () => { describe('when the request succeeded, but the returned "project.release" key was null', () => { beforeEach(async () => { + // As we return a release as `null`, Apollo also throws an error to the console + // about the missing field. We need to suppress console.error in order to check + // that flash message was called + + // eslint-disable-next-line no-console + console.error = jest.fn(); const apolloProvider = createMockApollo([ [ oneReleaseQuery, diff --git a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js b/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js deleted file mode 100644 index a538afd5d38..00000000000 --- a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js +++ /dev/null @@ -1,126 +0,0 @@ -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { historyPushState } from '~/lib/utils/common_utils'; -import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue'; - -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), - historyPushState: jest.fn(), -})); - -describe('releases_pagination_apollo_client.vue', () => { - const startCursor = 'startCursor'; - const endCursor = 'endCursor'; - let wrapper; - let onPrev; - let onNext; - - const createComponent = (pageInfo) => { - onPrev = jest.fn(); - onNext = jest.fn(); - - wrapper = mountExtended(ReleasesPaginationApolloClient, { - propsData: { - pageInfo, - }, - listeners: { - prev: onPrev, - next: onNext, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const singlePageInfo = { - hasPreviousPage: false, - hasNextPage: false, - startCursor, - endCursor, - }; - - const onlyNextPageInfo = { - hasPreviousPage: false, - hasNextPage: true, - startCursor, - endCursor, - }; - - const onlyPrevPageInfo = { - hasPreviousPage: true, - hasNextPage: false, - startCursor, - endCursor, - }; - - const prevAndNextPageInfo = { - hasPreviousPage: true, - hasNextPage: true, - startCursor, - endCursor, - }; - - const findPrevButton = () => wrapper.findByTestId('prevButton'); - const findNextButton = () => wrapper.findByTestId('nextButton'); - - describe.each` - description | pageInfo | prevEnabled | nextEnabled - ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false} - ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true} - ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false} - ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true} - `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => { - describe(description, () => { - beforeEach(() => { - createComponent(pageInfo); - }); - - it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => { - expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled'); - }); - - it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => { - expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled'); - }); - }); - }); - - describe('button behavior', () => { - beforeEach(() => { - createComponent(prevAndNextPageInfo); - }); - - describe('next button behavior', () => { - beforeEach(() => { - findNextButton().trigger('click'); - }); - - it('emits an "next" event with the "after" cursor', () => { - expect(onNext.mock.calls).toEqual([[endCursor]]); - }); - - it('calls historyPushState with the new URL', () => { - expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?after=${endCursor}`)], - ]); - }); - }); - - describe('prev button behavior', () => { - beforeEach(() => { - findPrevButton().trigger('click'); - }); - - it('emits an "prev" event with the "before" cursor', () => { - expect(onPrev.mock.calls).toEqual([[startCursor]]); - }); - - it('calls historyPushState with the new URL', () => { - expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?before=${startCursor}`)], - ]); - }); - }); - }); -}); diff --git a/spec/frontend/releases/components/releases_pagination_spec.js b/spec/frontend/releases/components/releases_pagination_spec.js index b8c69b0ea70..59be808c802 100644 --- a/spec/frontend/releases/components/releases_pagination_spec.js +++ b/spec/frontend/releases/components/releases_pagination_spec.js @@ -1,140 +1,94 @@ -import { GlKeysetPagination } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { historyPushState } from '~/lib/utils/common_utils'; import ReleasesPagination from '~/releases/components/releases_pagination.vue'; -import createStore from '~/releases/stores'; -import createIndexModule from '~/releases/stores/modules/index'; jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), historyPushState: jest.fn(), })); -Vue.use(Vuex); - -describe('~/releases/components/releases_pagination.vue', () => { +describe('releases_pagination.vue', () => { + const startCursor = 'startCursor'; + const endCursor = 'endCursor'; let wrapper; - let indexModule; - - const cursors = { - startCursor: 'startCursor', - endCursor: 'endCursor', - }; - - const projectPath = 'my/project'; + let onPrev; + let onNext; const createComponent = (pageInfo) => { - indexModule = createIndexModule({ projectPath }); - - indexModule.state.pageInfo = pageInfo; - - indexModule.actions.fetchReleases = jest.fn(); - - wrapper = mount(ReleasesPagination, { - store: createStore({ - modules: { - index: indexModule, - }, - featureFlags: {}, - }), + onPrev = jest.fn(); + onNext = jest.fn(); + + wrapper = mountExtended(ReleasesPagination, { + propsData: { + pageInfo, + }, + listeners: { + prev: onPrev, + next: onNext, + }, }); }; afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); - const findPrevButton = () => findGlKeysetPagination().find('[data-testid="prevButton"]'); - const findNextButton = () => findGlKeysetPagination().find('[data-testid="nextButton"]'); - - const expectDisabledPrev = () => { - expect(findPrevButton().attributes().disabled).toBe('disabled'); + const singlePageInfo = { + hasPreviousPage: false, + hasNextPage: false, + startCursor, + endCursor, }; - const expectEnabledPrev = () => { - expect(findPrevButton().attributes().disabled).toBe(undefined); + + const onlyNextPageInfo = { + hasPreviousPage: false, + hasNextPage: true, + startCursor, + endCursor, }; - const expectDisabledNext = () => { - expect(findNextButton().attributes().disabled).toBe('disabled'); + + const onlyPrevPageInfo = { + hasPreviousPage: true, + hasNextPage: false, + startCursor, + endCursor, }; - const expectEnabledNext = () => { - expect(findNextButton().attributes().disabled).toBe(undefined); + + const prevAndNextPageInfo = { + hasPreviousPage: true, + hasNextPage: true, + startCursor, + endCursor, }; - describe('when there is only one page of results', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: false, - hasNextPage: false, + const findPrevButton = () => wrapper.findByTestId('prevButton'); + const findNextButton = () => wrapper.findByTestId('nextButton'); + + describe.each` + description | pageInfo | prevEnabled | nextEnabled + ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false} + ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true} + ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false} + ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true} + `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => { + describe(description, () => { + beforeEach(() => { + createComponent(pageInfo); }); - }); - - it('does not render a GlKeysetPagination', () => { - expect(findGlKeysetPagination().exists()).toBe(false); - }); - }); - describe('when there is a next page, but not a previous page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: false, - hasNextPage: true, + it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => { + expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled'); }); - }); - - it('renders a disabled "Prev" button', () => { - expectDisabledPrev(); - }); - it('renders an enabled "Next" button', () => { - expectEnabledNext(); - }); - }); - - describe('when there is a previous page, but not a next page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: false, - }); - }); - - it('renders a enabled "Prev" button', () => { - expectEnabledPrev(); - }); - - it('renders an disabled "Next" button', () => { - expectDisabledNext(); - }); - }); - - describe('when there is both a previous page and a next page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: true, + it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => { + expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled'); }); }); - - it('renders a enabled "Prev" button', () => { - expectEnabledPrev(); - }); - - it('renders an enabled "Next" button', () => { - expectEnabledNext(); - }); }); describe('button behavior', () => { beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: true, - ...cursors, - }); + createComponent(prevAndNextPageInfo); }); describe('next button behavior', () => { @@ -142,33 +96,29 @@ describe('~/releases/components/releases_pagination.vue', () => { findNextButton().trigger('click'); }); - it('calls fetchReleases with the correct after cursor', () => { - expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ - [expect.anything(), { after: cursors.endCursor }], - ]); + it('emits an "next" event with the "after" cursor', () => { + expect(onNext.mock.calls).toEqual([[endCursor]]); }); it('calls historyPushState with the new URL', () => { expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?after=${cursors.endCursor}`)], + [expect.stringContaining(`?after=${endCursor}`)], ]); }); }); - describe('previous button behavior', () => { + describe('prev button behavior', () => { beforeEach(() => { findPrevButton().trigger('click'); }); - it('calls fetchReleases with the correct before cursor', () => { - expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ - [expect.anything(), { before: cursors.startCursor }], - ]); + it('emits an "prev" event with the "before" cursor', () => { + expect(onPrev.mock.calls).toEqual([[startCursor]]); }); it('calls historyPushState with the new URL', () => { expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?before=${cursors.startCursor}`)], + [expect.stringContaining(`?before=${startCursor}`)], ]); }); }); diff --git a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js b/spec/frontend/releases/components/releases_sort_apollo_client_spec.js deleted file mode 100644 index d93a932af01..00000000000 --- a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { GlSorting, GlSortingItem } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue'; -import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants'; - -describe('releases_sort_apollo_client.vue', () => { - let wrapper; - - const createComponent = (valueProp = RELEASED_AT_ASC) => { - wrapper = shallowMountExtended(ReleasesSortApolloClient, { - propsData: { - value: valueProp, - }, - stubs: { - GlSortingItem, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const findSorting = () => wrapper.findComponent(GlSorting); - const findSortingItems = () => wrapper.findAllComponents(GlSortingItem); - const findReleasedDateItem = () => - findSortingItems().wrappers.find((item) => item.text() === 'Released date'); - const findCreatedDateItem = () => - findSortingItems().wrappers.find((item) => item.text() === 'Created date'); - const getSortingItemsInfo = () => - findSortingItems().wrappers.map((item) => ({ - label: item.text(), - active: item.attributes().active === 'true', - })); - - describe.each` - valueProp | text | isAscending | items - ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} - ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} - ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} - ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} - `('component states', ({ valueProp, text, isAscending, items }) => { - beforeEach(() => { - createComponent(valueProp); - }); - - it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => { - expect(findSorting().props()).toEqual( - expect.objectContaining({ - text, - isAscending, - }), - ); - }); - - it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => { - expect(getSortingItemsInfo()).toEqual(items); - }); - }); - - const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click'); - const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click'); - const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange'); - - const releasedAtDropdownItemDescription = 'released at dropdown item'; - const createdAtDropdownItemDescription = 'created at dropdown item'; - const sortDirectionButtonDescription = 'sort direction button'; - - describe.each` - initialValueProp | itemClickFn | itemToClickDescription | emittedEvent - ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} - ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC} - ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC} - ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} - ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC} - ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC} - ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC} - ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} - ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC} - ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC} - ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} - ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC} - `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => { - beforeEach(() => { - createComponent(initialValueProp); - itemClickFn(); - }); - - it(`emits ${ - emittedEvent || 'nothing' - } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => { - expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent); - }); - }); - - describe('prop validation', () => { - it('validates that the `value` prop is one of the expected sort strings', () => { - expect(() => { - createComponent('not a valid value'); - }).toThrow('Invalid prop: custom validator check failed'); - }); - }); -}); diff --git a/spec/frontend/releases/components/releases_sort_spec.js b/spec/frontend/releases/components/releases_sort_spec.js index 7774532bc12..c6e1846d252 100644 --- a/spec/frontend/releases/components/releases_sort_spec.js +++ b/spec/frontend/releases/components/releases_sort_spec.js @@ -1,65 +1,103 @@ import { GlSorting, GlSortingItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ReleasesSort from '~/releases/components/releases_sort.vue'; -import createStore from '~/releases/stores'; -import createIndexModule from '~/releases/stores/modules/index'; +import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants'; -Vue.use(Vuex); - -describe('~/releases/components/releases_sort.vue', () => { +describe('releases_sort.vue', () => { let wrapper; - let store; - let indexModule; - const projectId = 8; - - const createComponent = () => { - indexModule = createIndexModule({ projectId }); - store = createStore({ - modules: { - index: indexModule, + const createComponent = (valueProp = RELEASED_AT_ASC) => { + wrapper = shallowMountExtended(ReleasesSort, { + propsData: { + value: valueProp, }, - }); - - store.dispatch = jest.fn(); - - wrapper = shallowMount(ReleasesSort, { - store, stubs: { GlSortingItem, }, }); }; - const findReleasesSorting = () => wrapper.find(GlSorting); - const findSortingItems = () => wrapper.findAll(GlSortingItem); - afterEach(() => { wrapper.destroy(); - wrapper = null; }); - beforeEach(() => { - createComponent(); - }); + const findSorting = () => wrapper.findComponent(GlSorting); + const findSortingItems = () => wrapper.findAllComponents(GlSortingItem); + const findReleasedDateItem = () => + findSortingItems().wrappers.find((item) => item.text() === 'Released date'); + const findCreatedDateItem = () => + findSortingItems().wrappers.find((item) => item.text() === 'Created date'); + const getSortingItemsInfo = () => + findSortingItems().wrappers.map((item) => ({ + label: item.text(), + active: item.attributes().active === 'true', + })); + + describe.each` + valueProp | text | isAscending | items + ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} + ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} + ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} + ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} + `('component states', ({ valueProp, text, isAscending, items }) => { + beforeEach(() => { + createComponent(valueProp); + }); - it('has all the sortable items', () => { - expect(findSortingItems()).toHaveLength(wrapper.vm.sortOptions.length); + it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => { + expect(findSorting().props()).toEqual( + expect.objectContaining({ + text, + isAscending, + }), + ); + }); + + it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => { + expect(getSortingItemsInfo()).toEqual(items); + }); }); - it('on sort change set sorting in vuex and emit event', () => { - findReleasesSorting().vm.$emit('sortDirectionChange'); - expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { sort: 'asc' }); - expect(wrapper.emitted('sort:changed')).toBeTruthy(); + const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click'); + const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click'); + const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange'); + + const releasedAtDropdownItemDescription = 'released at dropdown item'; + const createdAtDropdownItemDescription = 'created at dropdown item'; + const sortDirectionButtonDescription = 'sort direction button'; + + describe.each` + initialValueProp | itemClickFn | itemToClickDescription | emittedEvent + ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} + ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC} + ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC} + ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} + ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC} + ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC} + ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC} + ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} + ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC} + ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC} + ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} + ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC} + `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => { + beforeEach(() => { + createComponent(initialValueProp); + itemClickFn(); + }); + + it(`emits ${ + emittedEvent || 'nothing' + } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => { + expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent); + }); }); - it('on sort item click set sorting and emit event', () => { - const item = findSortingItems().at(0); - const { orderBy } = wrapper.vm.sortOptions[0]; - item.vm.$emit('click'); - expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { orderBy }); - expect(wrapper.emitted('sort:changed')).toBeTruthy(); + describe('prop validation', () => { + it('validates that the `value` prop is one of the expected sort strings', () => { + expect(() => { + createComponent('not a valid value'); + }).toThrow('Invalid prop: custom validator check failed'); + }); }); }); diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js deleted file mode 100644 index 91406f7e2f4..00000000000 --- a/spec/frontend/releases/stores/modules/list/actions_spec.js +++ /dev/null @@ -1,197 +0,0 @@ -import { cloneDeep } from 'lodash'; -import originalGraphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; -import testAction from 'helpers/vuex_action_helper'; -import { PAGE_SIZE } from '~/releases/constants'; -import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; -import { - fetchReleases, - receiveReleasesError, - setSorting, -} from '~/releases/stores/modules/index/actions'; -import * as types from '~/releases/stores/modules/index/mutation_types'; -import createState from '~/releases/stores/modules/index/state'; -import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util'; - -describe('Releases State actions', () => { - let mockedState; - let graphqlReleasesResponse; - - const projectPath = 'root/test-project'; - const projectId = 19; - const before = 'testBeforeCursor'; - const after = 'testAfterCursor'; - - beforeEach(() => { - mockedState = { - ...createState({ - projectId, - projectPath, - }), - }; - - graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse); - }); - - describe('fetchReleases', () => { - describe('GraphQL query variables', () => { - let vuexParams; - - beforeEach(() => { - jest.spyOn(gqClient, 'query'); - - vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState }; - }); - - describe('when neither a before nor an after parameter is provided', () => { - beforeEach(() => { - fetchReleases(vuexParams, { before: undefined, after: undefined }); - }); - - it('makes a GraphQl query with a first variable', () => { - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, first: PAGE_SIZE, sort: 'RELEASED_AT_DESC' }, - }); - }); - }); - - describe('when only a before parameter is provided', () => { - beforeEach(() => { - fetchReleases(vuexParams, { before, after: undefined }); - }); - - it('makes a GraphQl query with last and before variables', () => { - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, last: PAGE_SIZE, before, sort: 'RELEASED_AT_DESC' }, - }); - }); - }); - - describe('when only an after parameter is provided', () => { - beforeEach(() => { - fetchReleases(vuexParams, { before: undefined, after }); - }); - - it('makes a GraphQl query with first and after variables', () => { - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, first: PAGE_SIZE, after, sort: 'RELEASED_AT_DESC' }, - }); - }); - }); - - describe('when both before and after parameters are provided', () => { - it('throws an error', () => { - const callFetchReleases = () => { - fetchReleases(vuexParams, { before, after }); - }; - - expect(callFetchReleases).toThrowError( - 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.', - ); - }); - }); - - describe('when the sort parameters are provided', () => { - it.each` - sort | orderBy | ReleaseSort - ${'asc'} | ${'released_at'} | ${'RELEASED_AT_ASC'} - ${'desc'} | ${'released_at'} | ${'RELEASED_AT_DESC'} - ${'asc'} | ${'created_at'} | ${'CREATED_ASC'} - ${'desc'} | ${'created_at'} | ${'CREATED_DESC'} - `( - 'correctly sets $ReleaseSort based on $sort and $orderBy', - ({ sort, orderBy, ReleaseSort }) => { - mockedState.sorting.sort = sort; - mockedState.sorting.orderBy = orderBy; - - fetchReleases(vuexParams, { before: undefined, after: undefined }); - - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, first: PAGE_SIZE, sort: ReleaseSort }, - }); - }, - ); - }); - }); - - describe('when the request is successful', () => { - beforeEach(() => { - jest.spyOn(gqClient, 'query').mockResolvedValue(graphqlReleasesResponse); - }); - - it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => { - const convertedResponse = convertAllReleasesGraphQLResponse(graphqlReleasesResponse); - - return testAction( - fetchReleases, - {}, - mockedState, - [ - { - type: types.REQUEST_RELEASES, - }, - { - type: types.RECEIVE_RELEASES_SUCCESS, - payload: { - data: convertedResponse.data, - pageInfo: convertedResponse.paginationInfo, - }, - }, - ], - [], - ); - }); - }); - - describe('when the request fails', () => { - beforeEach(() => { - jest.spyOn(gqClient, 'query').mockRejectedValue(new Error('Something went wrong!')); - }); - - it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => { - return testAction( - fetchReleases, - {}, - mockedState, - [ - { - type: types.REQUEST_RELEASES, - }, - ], - [ - { - type: 'receiveReleasesError', - }, - ], - ); - }); - }); - }); - - describe('receiveReleasesError', () => { - it('should commit RECEIVE_RELEASES_ERROR mutation', () => { - return testAction( - receiveReleasesError, - null, - mockedState, - [{ type: types.RECEIVE_RELEASES_ERROR }], - [], - ); - }); - }); - - describe('setSorting', () => { - it('should commit SET_SORTING', () => { - return testAction( - setSorting, - { orderBy: 'released_at', sort: 'asc' }, - null, - [{ type: types.SET_SORTING, payload: { orderBy: 'released_at', sort: 'asc' } }], - [], - ); - }); - }); -}); diff --git a/spec/frontend/releases/stores/modules/list/helpers.js b/spec/frontend/releases/stores/modules/list/helpers.js deleted file mode 100644 index 6669f44aa95..00000000000 --- a/spec/frontend/releases/stores/modules/list/helpers.js +++ /dev/null @@ -1,5 +0,0 @@ -import state from '~/releases/stores/modules/index/state'; - -export const resetStore = (store) => { - store.replaceState(state()); -}; diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js deleted file mode 100644 index 49e324c28a5..00000000000 --- a/spec/frontend/releases/stores/modules/list/mutations_spec.js +++ /dev/null @@ -1,81 +0,0 @@ -import originalRelease from 'test_fixtures/api/releases/release.json'; -import graphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import * as types from '~/releases/stores/modules/index/mutation_types'; -import mutations from '~/releases/stores/modules/index/mutations'; -import createState from '~/releases/stores/modules/index/state'; -import { convertAllReleasesGraphQLResponse } from '~/releases/util'; - -const originalReleases = [originalRelease]; - -describe('Releases Store Mutations', () => { - let stateCopy; - let pageInfo; - let releases; - - beforeEach(() => { - stateCopy = createState({}); - pageInfo = convertAllReleasesGraphQLResponse(graphqlReleasesResponse).paginationInfo; - releases = convertObjectPropsToCamelCase(originalReleases, { deep: true }); - }); - - describe('REQUEST_RELEASES', () => { - it('sets isLoading to true', () => { - mutations[types.REQUEST_RELEASES](stateCopy); - - expect(stateCopy.isLoading).toEqual(true); - }); - }); - - describe('RECEIVE_RELEASES_SUCCESS', () => { - beforeEach(() => { - mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { - pageInfo, - data: releases, - }); - }); - - it('sets is loading to false', () => { - expect(stateCopy.isLoading).toEqual(false); - }); - - it('sets hasError to false', () => { - expect(stateCopy.hasError).toEqual(false); - }); - - it('sets data', () => { - expect(stateCopy.releases).toEqual(releases); - }); - - it('sets pageInfo', () => { - expect(stateCopy.pageInfo).toEqual(pageInfo); - }); - }); - - describe('RECEIVE_RELEASES_ERROR', () => { - it('resets data', () => { - mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { - pageInfo, - data: releases, - }); - - mutations[types.RECEIVE_RELEASES_ERROR](stateCopy); - - expect(stateCopy.isLoading).toEqual(false); - expect(stateCopy.releases).toEqual([]); - expect(stateCopy.pageInfo).toEqual({}); - }); - }); - - describe('SET_SORTING', () => { - it('should merge the sorting object with sort value', () => { - mutations[types.SET_SORTING](stateCopy, { sort: 'asc' }); - expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, sort: 'asc' }); - }); - - it('should merge the sorting object with order_by value', () => { - mutations[types.SET_SORTING](stateCopy, { orderBy: 'created_at' }); - expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, orderBy: 'created_at' }); - }); - }); -}); diff --git a/spec/frontend/reports/accessibility_report/store/actions_spec.js b/spec/frontend/reports/accessibility_report/store/actions_spec.js index 46dbe1ff7a1..bab6c4905a7 100644 --- a/spec/frontend/reports/accessibility_report/store/actions_spec.js +++ b/spec/frontend/reports/accessibility_report/store/actions_spec.js @@ -17,16 +17,15 @@ describe('Accessibility Reports actions', () => { }); describe('setEndpoints', () => { - it('should commit SET_ENDPOINTS mutation', (done) => { + it('should commit SET_ENDPOINTS mutation', () => { const endpoint = 'endpoint.json'; - testAction( + return testAction( actions.setEndpoint, endpoint, localState, [{ type: types.SET_ENDPOINT, payload: endpoint }], [], - done, ); }); }); @@ -46,11 +45,11 @@ describe('Accessibility Reports actions', () => { }); describe('success', () => { - it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', (done) => { + it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', () => { const data = { report: { summary: {} } }; mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, data); - testAction( + return testAction( actions.fetchReport, null, localState, @@ -61,60 +60,55 @@ describe('Accessibility Reports actions', () => { type: 'receiveReportSuccess', }, ], - done, ); }); }); describe('error', () => { - it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', (done) => { + it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); - testAction( + return testAction( actions.fetchReport, null, localState, [{ type: types.REQUEST_REPORT }], [{ type: 'receiveReportError' }], - done, ); }); }); }); describe('receiveReportSuccess', () => { - it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', (done) => { - testAction( + it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', () => { + return testAction( actions.receiveReportSuccess, { status: 200, data: mockReport }, localState, [{ type: types.RECEIVE_REPORT_SUCCESS, payload: mockReport }], [{ type: 'stopPolling' }], - done, ); }); - it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', (done) => { - testAction( + it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', () => { + return testAction( actions.receiveReportSuccess, { status: 204, data: mockReport }, localState, [], [], - done, ); }); }); describe('receiveReportError', () => { - it('should commit RECEIVE_REPORT_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_REPORT_ERROR mutation', () => { + return testAction( actions.receiveReportError, null, localState, [{ type: types.RECEIVE_REPORT_ERROR }], [{ type: 'stopPolling' }], - done, ); }); }); diff --git a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js index c548007a8a6..17f07ac2b8f 100644 --- a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js +++ b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js @@ -51,6 +51,7 @@ describe('code quality issue body issue body', () => { ${'blocker'} | ${'text-danger-800'} | ${'severity-critical'} ${'unknown'} | ${'text-secondary-400'} | ${'severity-unknown'} ${'invalid'} | ${'text-secondary-400'} | ${'severity-unknown'} + ${undefined} | ${'text-secondary-400'} | ${'severity-unknown'} `( 'renders correct icon for "$severity" severity rating', ({ severity, iconClass, iconName }) => { diff --git a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js index 1f923f41274..b61b65c2713 100644 --- a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js +++ b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js @@ -135,7 +135,7 @@ describe('Grouped code quality reports app', () => { }); it('does not render a help icon', () => { - expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(false); + expect(findWidget().find('[data-testid="question-o-icon"]').exists()).toBe(false); }); describe('when base report was not found', () => { @@ -144,7 +144,7 @@ describe('Grouped code quality reports app', () => { }); it('renders a help icon with more information', () => { - expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(true); + expect(findWidget().find('[data-testid="question-o-icon"]').exists()).toBe(true); }); }); }); diff --git a/spec/frontend/reports/codequality_report/store/actions_spec.js b/spec/frontend/reports/codequality_report/store/actions_spec.js index 1821390786b..71f1a0f4de0 100644 --- a/spec/frontend/reports/codequality_report/store/actions_spec.js +++ b/spec/frontend/reports/codequality_report/store/actions_spec.js @@ -23,7 +23,7 @@ describe('Codequality Reports actions', () => { }); describe('setPaths', () => { - it('should commit SET_PATHS mutation', (done) => { + it('should commit SET_PATHS mutation', () => { const paths = { baseBlobPath: 'baseBlobPath', headBlobPath: 'headBlobPath', @@ -31,13 +31,12 @@ describe('Codequality Reports actions', () => { helpPath: 'codequalityHelpPath', }; - testAction( + return testAction( actions.setPaths, paths, localState, [{ type: types.SET_PATHS, payload: paths }], [], - done, ); }); }); @@ -56,10 +55,10 @@ describe('Codequality Reports actions', () => { }); describe('on success', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', (done) => { + it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', () => { mock.onGet(endpoint).reply(200, reportIssues); - testAction( + return testAction( actions.fetchReports, null, localState, @@ -70,51 +69,48 @@ describe('Codequality Reports actions', () => { type: 'receiveReportsSuccess', }, ], - done, ); }); }); describe('on error', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => { + it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => { mock.onGet(endpoint).reply(500); - testAction( + return testAction( actions.fetchReports, null, localState, [{ type: types.REQUEST_REPORTS }], [{ type: 'receiveReportsError', payload: expect.any(Error) }], - done, ); }); }); describe('when base report is not found', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => { + it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => { const data = { status: STATUS_NOT_FOUND }; mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(200, data); - testAction( + return testAction( actions.fetchReports, null, localState, [{ type: types.REQUEST_REPORTS }], [{ type: 'receiveReportsError', payload: data }], - done, ); }); }); describe('while waiting for report results', () => { - it('continues polling until it receives data', (done) => { + it('continues polling until it receives data', () => { mock .onGet(endpoint) .replyOnce(204, undefined, pollIntervalHeader) .onGet(endpoint) .reply(200, reportIssues); - Promise.all([ + return Promise.all([ testAction( actions.fetchReports, null, @@ -126,7 +122,6 @@ describe('Codequality Reports actions', () => { type: 'receiveReportsSuccess', }, ], - done, ), axios // wait for initial NO_CONTENT response to be fulfilled @@ -134,24 +129,23 @@ describe('Codequality Reports actions', () => { .then(() => { jest.advanceTimersByTime(pollInterval); }), - ]).catch(done.fail); + ]); }); - it('continues polling until it receives an error', (done) => { + it('continues polling until it receives an error', () => { mock .onGet(endpoint) .replyOnce(204, undefined, pollIntervalHeader) .onGet(endpoint) .reply(500); - Promise.all([ + return Promise.all([ testAction( actions.fetchReports, null, localState, [{ type: types.REQUEST_REPORTS }], [{ type: 'receiveReportsError', payload: expect.any(Error) }], - done, ), axios // wait for initial NO_CONTENT response to be fulfilled @@ -159,35 +153,33 @@ describe('Codequality Reports actions', () => { .then(() => { jest.advanceTimersByTime(pollInterval); }), - ]).catch(done.fail); + ]); }); }); }); describe('receiveReportsSuccess', () => { - it('commits RECEIVE_REPORTS_SUCCESS', (done) => { + it('commits RECEIVE_REPORTS_SUCCESS', () => { const data = { issues: [] }; - testAction( + return testAction( actions.receiveReportsSuccess, data, localState, [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: data }], [], - done, ); }); }); describe('receiveReportsError', () => { - it('commits RECEIVE_REPORTS_ERROR', (done) => { - testAction( + it('commits RECEIVE_REPORTS_ERROR', () => { + return testAction( actions.receiveReportsError, null, localState, [{ type: types.RECEIVE_REPORTS_ERROR, payload: null }], [], - done, ); }); }); diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/reports/components/report_section_spec.js index f9eb6dd05f3..888b49f3e0c 100644 --- a/spec/frontend/reports/components/report_section_spec.js +++ b/spec/frontend/reports/components/report_section_spec.js @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import mountComponent, { mountComponentWithSlots } from 'helpers/vue_mount_component_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import reportSection from '~/reports/components/report_section.vue'; describe('Report section', () => { @@ -9,6 +10,7 @@ describe('Report section', () => { let wrapper; const ReportSection = Vue.extend(reportSection); const findCollapseButton = () => wrapper.findByTestId('report-section-expand-button'); + const findPopover = () => wrapper.findComponent(HelpPopover); const resolvedIssues = [ { @@ -269,4 +271,33 @@ describe('Report section', () => { expect(vm.$el.textContent.trim()).not.toContain('This is a success'); }); }); + + describe('help popover', () => { + describe('when popover options are defined', () => { + const options = { + title: 'foo', + content: 'bar', + }; + + beforeEach(() => { + createComponent({ + popoverOptions: options, + }); + }); + + it('popover is shown with options', () => { + expect(findPopover().props('options')).toEqual(options); + }); + }); + + describe('when popover options are not defined', () => { + beforeEach(() => { + createComponent({ popoverOptions: {} }); + }); + + it('popover is not shown', () => { + expect(findPopover().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/reports/components/summary_row_spec.js b/spec/frontend/reports/components/summary_row_spec.js index 04d9d10dcd2..778660d9e44 100644 --- a/spec/frontend/reports/components/summary_row_spec.js +++ b/spec/frontend/reports/components/summary_row_spec.js @@ -1,25 +1,26 @@ import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import SummaryRow from '~/reports/components/summary_row.vue'; describe('Summary row', () => { let wrapper; - const props = { - summary: 'SAST detected 1 new vulnerability and 1 fixed vulnerability', - popoverOptions: { - title: 'Static Application Security Testing (SAST)', - content: '<a>Learn more about SAST</a>', - }, - statusIcon: 'warning', + const summary = 'SAST detected 1 new vulnerability and 1 fixed vulnerability'; + const popoverOptions = { + title: 'Static Application Security Testing (SAST)', + content: '<a>Learn more about SAST</a>', }; + const statusIcon = 'warning'; - const createComponent = ({ propsData = {}, slots = {} } = {}) => { + const createComponent = ({ props = {}, slots = {} } = {}) => { wrapper = extendedWrapper( mount(SummaryRow, { propsData: { + summary, + popoverOptions, + statusIcon, ...props, - ...propsData, }, slots, }), @@ -28,6 +29,7 @@ describe('Summary row', () => { const findSummary = () => wrapper.findByTestId('summary-row-description'); const findStatusIcon = () => wrapper.findByTestId('summary-row-icon'); + const findHelpPopover = () => wrapper.findComponent(HelpPopover); afterEach(() => { wrapper.destroy(); @@ -36,7 +38,7 @@ describe('Summary row', () => { it('renders provided summary', () => { createComponent(); - expect(findSummary().text()).toContain(props.summary); + expect(findSummary().text()).toContain(summary); }); it('renders provided icon', () => { @@ -44,12 +46,22 @@ describe('Summary row', () => { expect(findStatusIcon().classes()).toContain('js-ci-status-icon-warning'); }); + it('renders help popover if popoverOptions are provided', () => { + createComponent(); + expect(findHelpPopover().props('options')).toEqual(popoverOptions); + }); + + it('does not render help popover if popoverOptions are not provided', () => { + createComponent({ props: { popoverOptions: null } }); + expect(findHelpPopover().exists()).toBe(false); + }); + describe('summary slot', () => { it('replaces the summary prop', () => { const summarySlotContent = 'Summary slot content'; createComponent({ slots: { summary: summarySlotContent } }); - expect(wrapper.text()).not.toContain(props.summary); + expect(wrapper.text()).not.toContain(summary); expect(findSummary().text()).toContain(summarySlotContent); }); }); diff --git a/spec/frontend/reports/grouped_test_report/store/actions_spec.js b/spec/frontend/reports/grouped_test_report/store/actions_spec.js index bbc3a5dbba5..5876827c548 100644 --- a/spec/frontend/reports/grouped_test_report/store/actions_spec.js +++ b/spec/frontend/reports/grouped_test_report/store/actions_spec.js @@ -24,8 +24,8 @@ describe('Reports Store Actions', () => { }); describe('setPaths', () => { - it('should commit SET_PATHS mutation', (done) => { - testAction( + it('should commit SET_PATHS mutation', () => { + return testAction( setPaths, { endpoint: 'endpoint.json', headBlobPath: '/blob/path' }, mockedState, @@ -36,14 +36,13 @@ describe('Reports Store Actions', () => { }, ], [], - done, ); }); }); describe('requestReports', () => { - it('should commit REQUEST_REPORTS mutation', (done) => { - testAction(requestReports, null, mockedState, [{ type: types.REQUEST_REPORTS }], [], done); + it('should commit REQUEST_REPORTS mutation', () => { + return testAction(requestReports, null, mockedState, [{ type: types.REQUEST_REPORTS }], []); }); }); @@ -62,12 +61,12 @@ describe('Reports Store Actions', () => { }); describe('success', () => { - it('dispatches requestReports and receiveReportsSuccess ', (done) => { + it('dispatches requestReports and receiveReportsSuccess ', () => { mock .onGet(`${TEST_HOST}/endpoint.json`) .replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] }); - testAction( + return testAction( fetchReports, null, mockedState, @@ -81,7 +80,6 @@ describe('Reports Store Actions', () => { type: 'receiveReportsSuccess', }, ], - done, ); }); }); @@ -91,8 +89,8 @@ describe('Reports Store Actions', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); }); - it('dispatches requestReports and receiveReportsError ', (done) => { - testAction( + it('dispatches requestReports and receiveReportsError ', () => { + return testAction( fetchReports, null, mockedState, @@ -105,71 +103,65 @@ describe('Reports Store Actions', () => { type: 'receiveReportsError', }, ], - done, ); }); }); }); describe('receiveReportsSuccess', () => { - it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', (done) => { - testAction( + it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', () => { + return testAction( receiveReportsSuccess, { data: { summary: {} }, status: 200 }, mockedState, [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: { summary: {} } }], [], - done, ); }); - it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', (done) => { - testAction( + it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', () => { + return testAction( receiveReportsSuccess, { data: { summary: {} }, status: 204 }, mockedState, [], [], - done, ); }); }); describe('receiveReportsError', () => { - it('should commit RECEIVE_REPORTS_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_REPORTS_ERROR mutation', () => { + return testAction( receiveReportsError, null, mockedState, [{ type: types.RECEIVE_REPORTS_ERROR }], [], - done, ); }); }); describe('openModal', () => { - it('should commit SET_ISSUE_MODAL_DATA', (done) => { - testAction( + it('should commit SET_ISSUE_MODAL_DATA', () => { + return testAction( openModal, { name: 'foo' }, mockedState, [{ type: types.SET_ISSUE_MODAL_DATA, payload: { name: 'foo' } }], [], - done, ); }); }); describe('closeModal', () => { - it('should commit RESET_ISSUE_MODAL_DATA', (done) => { - testAction( + it('should commit RESET_ISSUE_MODAL_DATA', () => { + return testAction( closeModal, {}, mockedState, [{ type: types.RESET_ISSUE_MODAL_DATA, payload: {} }], [], - done, ); }); }); diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index 7854325e4ed..fea937b905f 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -106,114 +106,3 @@ exports[`Repository last commit component renders commit widget 1`] = ` </div> </div> `; - -exports[`Repository last commit component renders the signature HTML as returned by the backend 1`] = ` -<div - class="well-segment commit gl-p-5 gl-w-full" -> - <user-avatar-link-stub - class="avatar-cell" - imgalt="" - imgcssclasses="" - imgsize="40" - imgsrc="https://test.com" - linkhref="/test" - tooltipplacement="top" - tooltiptext="" - username="" - /> - - <div - class="commit-detail flex-list" - > - <div - class="commit-content qa-commit-content" - > - <gl-link-stub - class="commit-row-message item-title" - href="/commit/123" - > - Commit title - </gl-link-stub> - - <!----> - - <div - class="committer" - > - <gl-link-stub - class="commit-author-link js-user-link" - href="/test" - > - - Test - </gl-link-stub> - - authored - - <timeago-tooltip-stub - cssclass="" - time="2019-01-01" - tooltipplacement="bottom" - /> - </div> - - <!----> - </div> - - <div - class="commit-actions flex-row" - > - <div> - <button> - Verified - </button> - </div> - - <div - class="ci-status-link" - > - <gl-link-stub - class="js-commit-pipeline" - href="https://test.com/pipeline" - title="Pipeline: failed" - > - <ci-icon-stub - aria-label="Pipeline: failed" - cssclasses="" - size="24" - status="[object Object]" - /> - </gl-link-stub> - </div> - - <gl-button-group-stub - class="gl-ml-4 js-commit-sha-group" - > - <gl-button-stub - buttontextclasses="" - category="primary" - class="gl-font-monospace" - data-testid="last-commit-id-label" - icon="" - label="true" - size="medium" - variant="default" - > - 12345678 - </gl-button-stub> - - <clipboard-button-stub - category="secondary" - class="input-group-text" - size="medium" - text="123456789" - title="Copy commit SHA" - tooltipplacement="top" - variant="default" - /> - </gl-button-group-stub> - </div> - </div> -</div> -`; diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 96c03419dd6..2f6de03b73d 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -25,6 +25,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import httpStatusCodes from '~/lib/utils/http_status'; +import LineHighlighter from '~/blob/line_highlighter'; import { simpleViewerMock, richViewerMock, @@ -39,6 +40,7 @@ import { jest.mock('~/repository/components/blob_viewers'); jest.mock('~/lib/utils/url_utility'); jest.mock('~/lib/utils/common_utils'); +jest.mock('~/blob/line_highlighter'); let wrapper; let mockResolver; @@ -173,20 +175,30 @@ describe('Blob content viewer component', () => { }); describe('legacy viewers', () => { + const legacyViewerUrl = 'some_file.js?format=json&viewer=simple'; + const fileType = 'text'; + const highlightJs = false; + it('loads a legacy viewer when a the fileType is text and the highlightJs feature is turned off', async () => { await createComponent({ - blob: { ...simpleViewerMock, fileType: 'text', highlightJs: false }, + blob: { ...simpleViewerMock, fileType, highlightJs }, }); expect(mockAxios.history.get).toHaveLength(1); - expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=simple'); + expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl); }); it('loads a legacy viewer when a viewer component is not available', async () => { await createComponent({ blob: { ...simpleViewerMock, fileType: 'unknown' } }); expect(mockAxios.history.get).toHaveLength(1); - expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=simple'); + expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl); + }); + + it('loads the LineHighlighter', async () => { + mockAxios.onGet(legacyViewerUrl).replyOnce(httpStatusCodes.OK, 'test'); + await createComponent({ blob: { ...simpleViewerMock, fileType, highlightJs } }); + expect(LineHighlighter).toHaveBeenCalled(); }); }); }); @@ -258,6 +270,7 @@ describe('Blob content viewer component', () => { codeNavigationPath: simpleViewerMock.codeNavigationPath, blobPath: simpleViewerMock.path, pathPrefix: simpleViewerMock.projectBlobPathRoot, + wrapTextNodes: true, }); }); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index 0e3e7075e99..eef66045573 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -12,7 +12,7 @@ const defaultMockRoute = { describe('Repository breadcrumbs component', () => { let wrapper; - const factory = (currentPath, extraProps = {}, mockRoute = {}, newDirModal = true) => { + const factory = (currentPath, extraProps = {}, mockRoute = {}) => { const $apollo = { queries: { userPermissions: { @@ -36,7 +36,6 @@ describe('Repository breadcrumbs component', () => { }, $apollo, }, - provide: { glFeatures: { newDirModal } }, }); }; @@ -147,37 +146,21 @@ describe('Repository breadcrumbs component', () => { }); describe('renders the new directory modal', () => { - describe('with the feature flag enabled', () => { - beforeEach(() => { - window.gon.features = { - newDirModal: true, - }; - factory('/', { canEditTree: true }); - }); - - it('does not render the modal while loading', () => { - expect(findNewDirectoryModal().exists()).toBe(false); - }); - - it('renders the modal once loaded', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); - - await nextTick(); - - expect(findNewDirectoryModal().exists()).toBe(true); - }); + beforeEach(() => { + factory('/', { canEditTree: true }); + }); + it('does not render the modal while loading', () => { + expect(findNewDirectoryModal().exists()).toBe(false); }); - describe('with the feature flag disabled', () => { - it('does not render the modal', () => { - window.gon.features = { - newDirModal: false, - }; - factory('/', { canEditTree: true }, {}, {}, false); - expect(findNewDirectoryModal().exists()).toBe(false); - }); + it('renders the modal once loaded', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); + + await nextTick(); + + expect(findNewDirectoryModal().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index bb710c3a96c..cfbf74e34aa 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -143,11 +143,30 @@ describe('Repository last commit component', () => { }); it('renders the signature HTML as returned by the backend', async () => { - factory(createCommitData({ signatureHtml: '<button>Verified</button>' })); + factory( + createCommitData({ + signatureHtml: `<a + class="btn gpg-status-box valid" + data-content="signature-content" + data-html="true" + data-placement="top" + data-title="signature-title" + data-toggle="popover" + role="button" + tabindex="0" + > + Verified + </a>`, + }), + ); await nextTick(); - expect(vm.element).toMatchSnapshot(); + expect(vm.find('.gpg-status-box').html()).toBe( + `<a class="btn gpg-status-box valid" data-content="signature-content" data-html="true" data-placement="top" data-title="signature-title" data-toggle="popover" role="button" tabindex="0"> + Verified +</a>`, + ); }); it('sets correct CSS class if the commit message is empty', async () => { 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 cdaec0a3a8b..2ef856c90ab 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -13,6 +13,7 @@ import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; +import { createLocalState } from '~/runner/graphql/list/local_state'; import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; @@ -30,9 +31,10 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, + PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG, - STATUS_ACTIVE, + STATUS_ONLINE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import adminRunnersQuery from '~/runner/graphql/list/admin_runners.query.graphql'; @@ -40,9 +42,16 @@ import adminRunnersCountQuery from '~/runner/graphql/list/admin_runners_count.qu import { captureException } from '~/runner/sentry_utils'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { runnersData, runnersCountData, runnersDataPaginated } from '../mock_data'; +import { + runnersData, + runnersCountData, + runnersDataPaginated, + onlineContactTimeoutSecs, + staleTimeoutSecs, +} from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +const mockRunners = runnersData.data.runners.nodes; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -58,6 +67,8 @@ describe('AdminRunnersApp', () => { let wrapper; let mockRunnersQuery; let mockRunnersCountQuery; + let cacheConfig; + let localMutations; const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); @@ -69,18 +80,32 @@ describe('AdminRunnersApp', () => { const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); - const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + const createComponent = ({ + props = {}, + mountFn = shallowMountExtended, + provide, + ...options + } = {}) => { + ({ cacheConfig, localMutations } = createLocalState()); + const handlers = [ [adminRunnersQuery, mockRunnersQuery], [adminRunnersCountQuery, mockRunnersCountQuery], ]; wrapper = mountFn(AdminRunnersApp, { - apolloProvider: createMockApollo(handlers), + apolloProvider: createMockApollo(handlers, {}, cacheConfig), propsData: { registrationToken: mockRegistrationToken, ...props, }, + provide: { + localMutations, + onlineContactTimeoutSecs, + staleTimeoutSecs, + ...provide, + }, + ...options, }); }; @@ -173,7 +198,7 @@ describe('AdminRunnersApp', () => { }); it('shows the runners list', () => { - expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes); + expect(findRunnerList().props('runners')).toEqual(mockRunners); }); it('runner item links to the runner admin page', async () => { @@ -181,7 +206,7 @@ describe('AdminRunnersApp', () => { await waitForPromises(); - const { id, shortSha } = runnersData.data.runners.nodes[0]; + const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink); @@ -197,7 +222,7 @@ describe('AdminRunnersApp', () => { const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell); - const runner = runnersData.data.runners.nodes[0]; + const runner = mockRunners[0]; expect(runnerActions.props()).toEqual({ runner, @@ -219,6 +244,10 @@ describe('AdminRunnersApp', () => { expect(findFilteredSearch().props('tokens')).toEqual([ expect.objectContaining({ + type: PARAM_KEY_PAUSED, + options: expect.any(Array), + }), + expect.objectContaining({ type: PARAM_KEY_STATUS, options: expect.any(Array), }), @@ -232,12 +261,13 @@ describe('AdminRunnersApp', () => { describe('Single runner row', () => { let showToast; - const mockRunner = runnersData.data.runners.nodes[0]; - const { id: graphqlId, shortSha } = mockRunner; + const { id: graphqlId, shortSha } = mockRunners[0]; const id = getIdFromGraphQLId(graphqlId); + const COUNT_QUERIES = 7; // Smart queries that display a filtered count of runners + const FILTERED_COUNT_QUERIES = 4; // Smart queries that display a count of runners in tabs beforeEach(async () => { - mockRunnersQuery.mockClear(); + mockRunnersCountQuery.mockClear(); createComponent({ mountFn: mountExtended }); showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); @@ -252,12 +282,18 @@ describe('AdminRunnersApp', () => { expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`); }); - it('When runner is deleted, data is refetched and a toast message is shown', async () => { - expect(mockRunnersQuery).toHaveBeenCalledTimes(1); + it('When runner is paused or unpaused, some data is refetched', async () => { + expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES); - findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); + findRunnerActionsCell().vm.$emit('toggledPaused'); - expect(mockRunnersQuery).toHaveBeenCalledTimes(2); + expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES + FILTERED_COUNT_QUERIES); + + expect(showToast).toHaveBeenCalledTimes(0); + }); + + it('When runner is deleted, data is refetched and a toast message is shown', async () => { + findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Runner deleted'); @@ -266,7 +302,7 @@ describe('AdminRunnersApp', () => { describe('when a filter is preselected', () => { beforeEach(async () => { - setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); + setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); createComponent(); await waitForPromises(); @@ -276,7 +312,7 @@ describe('AdminRunnersApp', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ runnerType: INSTANCE_TYPE, filters: [ - { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }, { type: 'tag', value: { data: 'tag1', operator: '=' } }, ], sort: 'CREATED_DESC', @@ -286,7 +322,7 @@ describe('AdminRunnersApp', () => { it('requests the runners with filter parameters', () => { expect(mockRunnersQuery).toHaveBeenLastCalledWith({ - status: STATUS_ACTIVE, + status: STATUS_ONLINE, type: INSTANCE_TYPE, tagList: ['tag1'], sort: DEFAULT_SORT, @@ -299,7 +335,7 @@ describe('AdminRunnersApp', () => { beforeEach(() => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, - filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); }); @@ -307,13 +343,13 @@ describe('AdminRunnersApp', () => { it('updates the browser url', () => { expect(updateHistory).toHaveBeenLastCalledWith({ title: expect.any(String), - url: 'http://test.host/admin/runners?status[]=ACTIVE&sort=CREATED_ASC', + url: 'http://test.host/admin/runners?status[]=ONLINE&sort=CREATED_ASC', }); }); it('requests the runners with filters', () => { expect(mockRunnersQuery).toHaveBeenLastCalledWith({ - status: STATUS_ACTIVE, + status: STATUS_ONLINE, sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); @@ -325,6 +361,41 @@ describe('AdminRunnersApp', () => { expect(findRunnerList().props('loading')).toBe(true); }); + describe('when bulk delete is enabled', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { adminRunnersBulkDelete: true }, + }, + }); + }); + + it('runner list is checkable', () => { + expect(findRunnerList().props('checkable')).toBe(true); + }); + + it('responds to checked items by updating the local cache', () => { + const setRunnerCheckedMock = jest + .spyOn(localMutations, 'setRunnerChecked') + .mockImplementation(() => {}); + + const runner = mockRunners[0]; + + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(0); + + findRunnerList().vm.$emit('checked', { + runner, + isChecked: true, + }); + + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1); + expect(setRunnerCheckedMock).toHaveBeenCalledWith({ + runner, + isChecked: true, + }); + }); + }); + describe('when no runners are found', () => { beforeEach(async () => { mockRunnersQuery = jest.fn().mockResolvedValue({ diff --git a/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap b/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap new file mode 100644 index 00000000000..80a04401760 --- /dev/null +++ b/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RunnerStatusPopover renders complete text 1`] = `"Never contacted: Runner has never contacted GitLab (when you register a runner, use gitlab-runner run to bring it online) Online: Runner has contacted GitLab within the last 2 hours Offline: Runner has not contacted GitLab in more than 2 hours Stale: Runner has not contacted GitLab in more than 2 months"`; diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js index 0d579106860..7a949cb6505 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -92,6 +92,24 @@ describe('RunnerActionsCell', () => { expect(findDeleteBtn().props('compact')).toBe(true); }); + it('Passes runner data to delete button', () => { + createComponent({ + runner: mockRunner, + }); + + expect(findDeleteBtn().props('runner')).toEqual(mockRunner); + }); + + it('Emits toggledPaused events', () => { + createComponent(); + + expect(wrapper.emitted('toggledPaused')).toBe(undefined); + + findRunnerPauseBtn().vm.$emit('toggledPaused'); + + expect(wrapper.emitted('toggledPaused')).toHaveLength(1); + }); + it('Emits delete events', () => { const value = { name: 'Runner' }; @@ -104,7 +122,7 @@ describe('RunnerActionsCell', () => { expect(wrapper.emitted('deleted')).toEqual([[value]]); }); - it('Does not render the runner delete button when user cannot delete', () => { + it('Renders the runner delete disabled button when user cannot delete', () => { createComponent({ runner: { userPermissions: { @@ -114,7 +132,7 @@ describe('RunnerActionsCell', () => { }, }); - expect(findDeleteBtn().exists()).toBe(false); + expect(findDeleteBtn().props('disabled')).toBe(true); }); }); }); diff --git a/spec/frontend/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js index b6d957d27ea..b2e8c5a3ad9 100644 --- a/spec/frontend/runner/components/cells/runner_summary_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js @@ -5,6 +5,7 @@ import { INSTANCE_TYPE, PROJECT_TYPE } from '~/runner/constants'; const mockId = '1'; const mockShortSha = '2P6oDVDm'; const mockDescription = 'runner-1'; +const mockIpAddress = '0.0.0.0'; describe('RunnerTypeCell', () => { let wrapper; @@ -18,6 +19,7 @@ describe('RunnerTypeCell', () => { id: `gid://gitlab/Ci::Runner/${mockId}`, shortSha: mockShortSha, description: mockDescription, + ipAddress: mockIpAddress, runnerType: INSTANCE_TYPE, ...runner, }, @@ -59,6 +61,10 @@ describe('RunnerTypeCell', () => { expect(wrapper.text()).toContain(mockDescription); }); + it('Displays the runner ip address', () => { + expect(wrapper.text()).toContain(mockIpAddress); + }); + it('Displays a custom slot', () => { const slotContent = 'My custom runner summary'; diff --git a/spec/frontend/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/runner/components/registration/registration_dropdown_spec.js index da8ef7c3af0..5cd93df9967 100644 --- a/spec/frontend/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/runner/components/registration/registration_dropdown_spec.js @@ -8,6 +8,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; +import RegistrationToken from '~/runner/components/registration/registration_token.vue'; import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; @@ -30,11 +31,11 @@ describe('RegistrationDropdown', () => { const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); + const findRegistrationToken = () => wrapper.findComponent(RegistrationToken); + const findRegistrationTokenInput = () => wrapper.findByTestId('token-value').find('input'); const findTokenResetDropdownItem = () => wrapper.findComponent(RegistrationTokenResetDropdownItem); - const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked'); - const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { wrapper = extendedWrapper( mountFn(RegistrationDropdown, { @@ -134,9 +135,7 @@ describe('RegistrationDropdown', () => { it('Displays masked value by default', () => { createComponent({}, mount); - expect(findTokenDropdownItem().text()).toMatchInterpolatedText( - `Registration token ${maskToken}`, - ); + expect(findRegistrationTokenInput().element.value).toBe(maskToken); }); }); @@ -155,16 +154,14 @@ describe('RegistrationDropdown', () => { }); it('Updates the token when it gets reset', async () => { + const newToken = 'mock1'; createComponent({}, mount); - const newToken = 'mock1'; + expect(findRegistrationTokenInput().props('value')).not.toBe(newToken); findTokenResetDropdownItem().vm.$emit('tokenReset', newToken); - findToggleMaskButton().vm.$emit('click', { stopPropagation: jest.fn() }); await nextTick(); - expect(findTokenDropdownItem().text()).toMatchInterpolatedText( - `Registration token ${newToken}`, - ); + expect(findRegistrationToken().props('value')).toBe(newToken); }); }); diff --git a/spec/frontend/runner/components/registration/registration_token_spec.js b/spec/frontend/runner/components/registration/registration_token_spec.js index 6b9708cc525..cb42c7c8493 100644 --- a/spec/frontend/runner/components/registration/registration_token_spec.js +++ b/spec/frontend/runner/components/registration/registration_token_spec.js @@ -1,20 +1,17 @@ -import { nextTick } from 'vue'; import { GlToast } from '@gitlab/ui'; import { createLocalVue } from '@vue/test-utils'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RegistrationToken from '~/runner/components/registration/registration_token.vue'; -import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; const mockToken = '01234567890'; const mockMasked = '***********'; describe('RegistrationToken', () => { let wrapper; - let stopPropagation; let showToast; - const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked'); - const findCopyButton = () => wrapper.findComponent(ModalCopyButton); + const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility); const vueWithGlToast = () => { const localVue = createLocalVue(); @@ -22,10 +19,14 @@ describe('RegistrationToken', () => { return localVue; }; - const createComponent = ({ props = {}, withGlToast = true } = {}) => { + const createComponent = ({ + props = {}, + withGlToast = true, + mountFn = shallowMountExtended, + } = {}) => { const localVue = withGlToast ? vueWithGlToast() : undefined; - wrapper = shallowMountExtended(RegistrationToken, { + wrapper = mountFn(RegistrationToken, { propsData: { value: mockToken, ...props, @@ -36,61 +37,33 @@ describe('RegistrationToken', () => { showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; }; - beforeEach(() => { - stopPropagation = jest.fn(); - - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); - it('Displays masked value by default', () => { - expect(wrapper.text()).toBe(mockMasked); - }); + it('Displays value and copy button', () => { + createComponent(); - it('Displays button to reveal token', () => { - expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal'); + expect(findInputCopyToggleVisibility().props('value')).toBe(mockToken); + expect(findInputCopyToggleVisibility().props('copyButtonTitle')).toBe( + 'Copy registration token', + ); }); - it('Can copy the original token value', () => { - expect(findCopyButton().props('text')).toBe(mockToken); + // Component integration test to ensure secure masking + it('Displays masked value by default', () => { + createComponent({ mountFn: mountExtended }); + + expect(wrapper.find('input').element.value).toBe(mockMasked); }); - describe('When the reveal icon is clicked', () => { + describe('When the copy to clipboard button is clicked', () => { beforeEach(() => { - findToggleMaskButton().vm.$emit('click', { stopPropagation }); - }); - - it('Click event is not propagated', async () => { - expect(stopPropagation).toHaveBeenCalledTimes(1); + createComponent(); }); - it('Displays the actual value', () => { - expect(wrapper.text()).toBe(mockToken); - }); - - it('Can copy the original token value', () => { - expect(findCopyButton().props('text')).toBe(mockToken); - }); - - it('Displays button to mask token', () => { - expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to hide'); - }); - - it('When user clicks again, displays masked value', async () => { - findToggleMaskButton().vm.$emit('click', { stopPropagation }); - await nextTick(); - - expect(wrapper.text()).toBe(mockMasked); - expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal'); - }); - }); - - describe('When the copy to clipboard button is clicked', () => { it('shows a copied message', () => { - findCopyButton().vm.$emit('success'); + findInputCopyToggleVisibility().vm.$emit('copy'); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Registration token copied!'); @@ -98,7 +71,7 @@ describe('RegistrationToken', () => { it('does not fail when toast is not defined', () => { createComponent({ withGlToast: false }); - findCopyButton().vm.$emit('success'); + findInputCopyToggleVisibility().vm.$emit('copy'); // This block also tests for unhandled errors expect(showToast).toBeNull(); diff --git a/spec/frontend/runner/components/runner_assigned_item_spec.js b/spec/frontend/runner/components/runner_assigned_item_spec.js index c6156c16d4a..1ff6983fbe7 100644 --- a/spec/frontend/runner/components/runner_assigned_item_spec.js +++ b/spec/frontend/runner/components/runner_assigned_item_spec.js @@ -1,6 +1,7 @@ import { GlAvatar } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; const mockHref = '/group/project'; const mockName = 'Project'; @@ -40,7 +41,7 @@ describe('RunnerAssignedItem', () => { alt: mockName, entityName: mockName, src: mockAvatarUrl, - shape: 'rect', + shape: AVATAR_SHAPE_OPTION_RECT, size: 48, }); }); diff --git a/spec/frontend/runner/components/runner_bulk_delete_spec.js b/spec/frontend/runner/components/runner_bulk_delete_spec.js new file mode 100644 index 00000000000..f5b56396cf1 --- /dev/null +++ b/spec/frontend/runner/components/runner_bulk_delete_spec.js @@ -0,0 +1,103 @@ +import Vue from 'vue'; +import { GlSprintf } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createLocalState } from '~/runner/graphql/list/local_state'; +import waitForPromises from 'helpers/wait_for_promises'; + +Vue.use(VueApollo); + +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +describe('RunnerBulkDelete', () => { + let wrapper; + let mockState; + let mockCheckedRunnerIds; + + const findClearBtn = () => wrapper.findByTestId('clear-btn'); + const findDeleteBtn = () => wrapper.findByTestId('delete-btn'); + + const createComponent = () => { + const { cacheConfig, localMutations } = mockState; + + wrapper = shallowMountExtended(RunnerBulkDelete, { + apolloProvider: createMockApollo(undefined, undefined, cacheConfig), + provide: { + localMutations, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + mockState = createLocalState(); + + jest + .spyOn(mockState.cacheConfig.typePolicies.Query.fields, 'checkedRunnerIds') + .mockImplementation(() => mockCheckedRunnerIds); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When no runners are checked', () => { + beforeEach(async () => { + mockCheckedRunnerIds = []; + + createComponent(); + + await waitForPromises(); + }); + + it('shows no contents', () => { + expect(wrapper.html()).toBe(''); + }); + }); + + describe.each` + count | ids | text + ${1} | ${['gid:Runner/1']} | ${'1 runner'} + ${2} | ${['gid:Runner/1', 'gid:Runner/2']} | ${'2 runners'} + `('When $count runner(s) are checked', ({ count, ids, text }) => { + beforeEach(() => { + mockCheckedRunnerIds = ids; + + createComponent(); + + jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {}); + }); + + it(`shows "${text}"`, () => { + expect(wrapper.text()).toContain(text); + }); + + it('clears selection', () => { + expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(0); + + findClearBtn().vm.$emit('click'); + + expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(1); + }); + + it('shows confirmation modal', () => { + expect(confirmAction).toHaveBeenCalledTimes(0); + + findDeleteBtn().vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalledTimes(1); + + const [, confirmOptions] = confirmAction.mock.calls[0]; + const { title, modalHtmlMessage, primaryBtnText } = confirmOptions; + + expect(title).toMatch(text); + expect(primaryBtnText).toMatch(text); + expect(modalHtmlMessage).toMatch(`<strong>${count}</strong>`); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js index 81c870f23cf..3eb257607b4 100644 --- a/spec/frontend/runner/components/runner_delete_button_spec.js +++ b/spec/frontend/runner/components/runner_delete_button_spec.js @@ -9,7 +9,11 @@ import waitForPromises from 'helpers/wait_for_promises'; import { captureException } from '~/runner/sentry_utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; -import { I18N_DELETE_RUNNER } from '~/runner/constants'; +import { + I18N_DELETE_RUNNER, + I18N_DELETE_DISABLED_MANY_PROJECTS, + I18N_DELETE_DISABLED_UNKNOWN_REASON, +} from '~/runner/constants'; import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue'; @@ -25,26 +29,32 @@ jest.mock('~/runner/sentry_utils'); describe('RunnerDeleteButton', () => { let wrapper; + let apolloProvider; + let apolloCache; let runnerDeleteHandler; - const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; - const getModal = () => getBinding(wrapper.element, 'gl-modal').value; const findBtn = () => wrapper.findComponent(GlButton); const findModal = () => wrapper.findComponent(RunnerDeleteModal); + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; + const getModal = () => getBinding(findBtn().element, 'gl-modal').value; + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { const { runner, ...propsData } = props; wrapper = mountFn(RunnerDeleteButton, { propsData: { runner: { + // We need typename so that cache.identify works + // eslint-disable-next-line no-underscore-dangle + __typename: mockRunner.__typename, id: mockRunner.id, shortSha: mockRunner.shortSha, ...runner, }, ...propsData, }, - apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]), + apolloProvider, directives: { GlTooltip: createMockDirective(), GlModal: createMockDirective(), @@ -67,6 +77,11 @@ describe('RunnerDeleteButton', () => { }, }); }); + apolloProvider = createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]); + apolloCache = apolloProvider.defaultClient.cache; + + jest.spyOn(apolloCache, 'evict'); + jest.spyOn(apolloCache, 'gc'); createComponent(); }); @@ -88,6 +103,10 @@ describe('RunnerDeleteButton', () => { expect(findModal().props('runnerName')).toBe(`#${mockRunnerId} (${mockRunner.shortSha})`); }); + it('Does not have tabindex when button is enabled', () => { + expect(wrapper.attributes('tabindex')).toBeUndefined(); + }); + it('Displays a modal when clicked', () => { const modalId = `delete-runner-modal-${mockRunnerId}`; @@ -140,6 +159,13 @@ describe('RunnerDeleteButton', () => { expect(deleted[0][0].message).toMatch(`#${mockRunnerId}`); expect(deleted[0][0].message).toMatch(`${mockRunner.shortSha}`); }); + + it('evicts runner from apollo cache', () => { + expect(apolloCache.evict).toHaveBeenCalledWith({ + id: apolloCache.identify(mockRunner), + }); + expect(apolloCache.gc).toHaveBeenCalled(); + }); }); describe('When update fails', () => { @@ -190,6 +216,11 @@ describe('RunnerDeleteButton', () => { it('error is shown to the user', () => { expect(createAlert).toHaveBeenCalledTimes(1); }); + + it('does not evict runner from apollo cache', () => { + expect(apolloCache.evict).not.toHaveBeenCalled(); + expect(apolloCache.gc).not.toHaveBeenCalled(); + }); }); }); @@ -230,4 +261,29 @@ describe('RunnerDeleteButton', () => { }); }); }); + + describe.each` + reason | runner | tooltip + ${'runner belongs to more than 1 project'} | ${{ projectCount: 2 }} | ${I18N_DELETE_DISABLED_MANY_PROJECTS} + ${'unknown reason'} | ${{}} | ${I18N_DELETE_DISABLED_UNKNOWN_REASON} + `('When button is disabled because $reason', ({ runner, tooltip }) => { + beforeEach(() => { + createComponent({ + props: { + disabled: true, + runner, + }, + }); + }); + + it('Displays a disabled delete button', () => { + expect(findBtn().props('disabled')).toBe(true); + }); + + it(`Tooltip "${tooltip}" is shown`, () => { + // tabindex is required for a11y + expect(wrapper.attributes('tabindex')).toBe('0'); + expect(getTooltip()).toBe(tooltip); + }); + }); }); 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 fda96e5918e..b1b436e5443 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -4,7 +4,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_ import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config'; import TagToken from '~/runner/components/search_tokens/tag_token.vue'; import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config'; -import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ACTIVE, INSTANCE_TYPE } from '~/runner/constants'; +import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ONLINE, INSTANCE_TYPE } 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'; @@ -18,7 +18,7 @@ describe('RunnerList', () => { const mockDefaultSort = 'CREATED_DESC'; const mockOtherSort = 'CONTACTED_DESC'; const mockFilters = [ - { type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, { type: 'filtered-search-term', value: { data: '' } }, ]; @@ -113,7 +113,7 @@ describe('RunnerList', () => { }); it('filter values are shown', () => { - expect(findGlFilteredSearch().props('value')).toEqual(mockFilters); + expect(findGlFilteredSearch().props('value')).toMatchObject(mockFilters); }); it('sort option is selected', () => { diff --git a/spec/frontend/runner/components/runner_jobs_spec.js b/spec/frontend/runner/components/runner_jobs_spec.js index 9abb2861005..9e40e911448 100644 --- a/spec/frontend/runner/components/runner_jobs_spec.js +++ b/spec/frontend/runner/components/runner_jobs_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index a0f42738d2c..872394430ae 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -6,7 +6,8 @@ import { } from 'helpers/vue_test_utils_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerList from '~/runner/components/runner_list.vue'; -import { runnersData } from '../mock_data'; +import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue'; +import { runnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; const mockRunners = runnersData.data.runners.nodes; const mockActiveRunnersCount = mockRunners.length; @@ -28,26 +29,38 @@ describe('RunnerList', () => { activeRunnersCount: mockActiveRunnersCount, ...props, }, + provide: { + onlineContactTimeoutSecs, + staleTimeoutSecs, + }, ...options, }); }; - beforeEach(() => { - createComponent({}, mountExtended); - }); - afterEach(() => { wrapper.destroy(); }); it('Displays headers', () => { + createComponent( + { + stubs: { + RunnerStatusPopover: { + template: '<div/>', + }, + }, + }, + mountExtended, + ); + const headerLabels = findHeaders().wrappers.map((w) => w.text()); + expect(findHeaders().at(0).findComponent(RunnerStatusPopover).exists()).toBe(true); + expect(headerLabels).toEqual([ 'Status', 'Runner', 'Version', - 'IP', 'Jobs', 'Tags', 'Last contact', @@ -56,19 +69,23 @@ describe('RunnerList', () => { }); it('Sets runner id as a row key', () => { - createComponent({}); + createComponent(); expect(findTable().attributes('primary-key')).toBe('id'); }); it('Displays a list of runners', () => { + createComponent({}, mountExtended); + expect(findRows()).toHaveLength(4); expect(findSkeletonLoader().exists()).toBe(false); }); it('Displays details of a runner', () => { - const { id, description, version, ipAddress, shortSha } = mockRunners[0]; + const { id, description, version, shortSha } = mockRunners[0]; + + createComponent({}, mountExtended); // Badges expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText( @@ -83,7 +100,6 @@ describe('RunnerList', () => { // Other fields expect(findCell({ fieldKey: 'version' }).text()).toBe(version); - expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress); expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('0'); expect(findCell({ fieldKey: 'tagList' }).text()).toBe(''); expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String)); @@ -92,6 +108,35 @@ describe('RunnerList', () => { expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true); }); + describe('When the list is checkable', () => { + beforeEach(() => { + createComponent( + { + props: { + checkable: true, + }, + }, + mountExtended, + ); + }); + + it('Displays a checkbox field', () => { + expect(findCell({ fieldKey: 'checkbox' }).find('input').exists()).toBe(true); + }); + + it('Emits a checked event', () => { + const checkbox = findCell({ fieldKey: 'checkbox' }).find('input'); + + checkbox.setChecked(); + + expect(wrapper.emitted('checked')).toHaveLength(1); + expect(wrapper.emitted('checked')[0][0]).toEqual({ + isChecked: true, + runner: mockRunners[0], + }); + }); + }); + describe('Scoped cell slots', () => { it('Render #runner-name slot in "summary" cell', () => { createComponent( @@ -156,6 +201,8 @@ describe('RunnerList', () => { const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); + createComponent({}, mountExtended); + expect(findCell({ fieldKey: 'summary' }).text()).toContain(`#${numericId} (${shortSha})`); }); diff --git a/spec/frontend/runner/components/runner_pause_button_spec.js b/spec/frontend/runner/components/runner_pause_button_spec.js index 3d9df03977e..9ebb30b6ed7 100644 --- a/spec/frontend/runner/components/runner_pause_button_spec.js +++ b/spec/frontend/runner/components/runner_pause_button_spec.js @@ -146,6 +146,10 @@ describe('RunnerPauseButton', () => { it('The button does not have a loading state', () => { expect(findBtn().props('loading')).toBe(false); }); + + it('The button emits toggledPaused', () => { + expect(wrapper.emitted('toggledPaused')).toHaveLength(1); + }); }); describe('When update fails', () => { diff --git a/spec/frontend/runner/components/runner_projects_spec.js b/spec/frontend/runner/components/runner_projects_spec.js index 96de8d11bca..62ebc6539e2 100644 --- a/spec/frontend/runner/components/runner_projects_spec.js +++ b/spec/frontend/runner/components/runner_projects_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; diff --git a/spec/frontend/runner/components/runner_status_badge_spec.js b/spec/frontend/runner/components/runner_status_badge_spec.js index c470c6bb989..bb833bd7d5a 100644 --- a/spec/frontend/runner/components/runner_status_badge_spec.js +++ b/spec/frontend/runner/components/runner_status_badge_spec.js @@ -7,6 +7,8 @@ import { STATUS_OFFLINE, STATUS_STALE, STATUS_NEVER_CONTACTED, + I18N_NEVER_CONTACTED_TOOLTIP, + I18N_STALE_NEVER_CONTACTED_TOOLTIP, } from '~/runner/constants'; describe('RunnerTypeBadge', () => { @@ -59,7 +61,7 @@ describe('RunnerTypeBadge', () => { expect(wrapper.text()).toBe('never contacted'); expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toMatch('This runner has never contacted'); + expect(getTooltip().value).toBe(I18N_NEVER_CONTACTED_TOOLTIP); }); it('renders offline state', () => { @@ -72,9 +74,7 @@ describe('RunnerTypeBadge', () => { expect(wrapper.text()).toBe('offline'); expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toBe( - 'No recent contact from this runner; last contact was 1 day ago', - ); + expect(getTooltip().value).toBe('Runner is offline; last contact was 1 day ago'); }); it('renders stale state', () => { @@ -87,7 +87,20 @@ describe('RunnerTypeBadge', () => { expect(wrapper.text()).toBe('stale'); expect(findBadge().props('variant')).toBe('warning'); - expect(getTooltip().value).toBe('No contact from this runner in over 3 months'); + expect(getTooltip().value).toBe('Runner is stale; last contact was 1 year ago'); + }); + + it('renders stale state with no contact time', () => { + createComponent({ + runner: { + contactedAt: null, + status: STATUS_STALE, + }, + }); + + expect(wrapper.text()).toBe('stale'); + expect(findBadge().props('variant')).toBe('warning'); + expect(getTooltip().value).toBe(I18N_STALE_NEVER_CONTACTED_TOOLTIP); }); describe('does not fail when data is missing', () => { @@ -100,7 +113,7 @@ describe('RunnerTypeBadge', () => { }); expect(wrapper.text()).toBe('online'); - expect(getTooltip().value).toBe('Runner is online; last contact was n/a'); + expect(getTooltip().value).toBe('Runner is online; last contact was never'); }); it('status is missing', () => { diff --git a/spec/frontend/runner/components/runner_status_popover_spec.js b/spec/frontend/runner/components/runner_status_popover_spec.js new file mode 100644 index 00000000000..789283d1245 --- /dev/null +++ b/spec/frontend/runner/components/runner_status_popover_spec.js @@ -0,0 +1,36 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; + +describe('RunnerStatusPopover', () => { + let wrapper; + + const createComponent = ({ provide = {} } = {}) => { + wrapper = shallowMountExtended(RunnerStatusPopover, { + provide: { + onlineContactTimeoutSecs, + staleTimeoutSecs, + ...provide, + }, + stubs: { + GlSprintf, + }, + }); + }; + + const findHelpPopover = () => wrapper.findComponent(HelpPopover); + + it('renders popoover', () => { + createComponent(); + + expect(findHelpPopover().exists()).toBe(true); + }); + + it('renders complete text', () => { + createComponent(); + + expect(findHelpPopover().text()).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/runner/graphql/local_state_spec.js b/spec/frontend/runner/graphql/local_state_spec.js new file mode 100644 index 00000000000..5c4302e4aa2 --- /dev/null +++ b/spec/frontend/runner/graphql/local_state_spec.js @@ -0,0 +1,72 @@ +import createApolloClient from '~/lib/graphql'; +import { createLocalState } from '~/runner/graphql/list/local_state'; +import getCheckedRunnerIdsQuery from '~/runner/graphql/list/checked_runner_ids.query.graphql'; + +describe('~/runner/graphql/list/local_state', () => { + let localState; + let apolloClient; + + const createSubject = () => { + if (apolloClient) { + throw new Error('test subject already exists!'); + } + + localState = createLocalState(); + + const { cacheConfig, typeDefs } = localState; + + apolloClient = createApolloClient({}, { cacheConfig, typeDefs }); + }; + + const queryCheckedRunnerIds = () => { + const { checkedRunnerIds } = apolloClient.readQuery({ + query: getCheckedRunnerIdsQuery, + }); + return checkedRunnerIds; + }; + + beforeEach(() => { + createSubject(); + }); + + afterEach(() => { + localState = null; + apolloClient = null; + }); + + describe('default', () => { + it('has empty checked list', () => { + expect(queryCheckedRunnerIds()).toEqual([]); + }); + }); + + describe.each` + inputs | expected + ${[['a', true], ['b', true], ['b', true]]} | ${['a', 'b']} + ${[['a', true], ['b', true], ['a', false]]} | ${['b']} + ${[['c', true], ['b', true], ['a', true], ['d', false]]} | ${['c', 'b', 'a']} + `('setRunnerChecked', ({ inputs, expected }) => { + beforeEach(() => { + inputs.forEach(([id, isChecked]) => { + localState.localMutations.setRunnerChecked({ runner: { id }, isChecked }); + }); + }); + it(`for inputs="${inputs}" has a ids="[${expected}]"`, () => { + expect(queryCheckedRunnerIds()).toEqual(expected); + }); + }); + + describe('clearChecked', () => { + it('clears all checked items', () => { + ['a', 'b', 'c'].forEach((id) => { + localState.localMutations.setRunnerChecked({ runner: { id }, isChecked: true }); + }); + + expect(queryCheckedRunnerIds()).toEqual(['a', 'b', 'c']); + + localState.localMutations.clearChecked(); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + }); +}); 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 70e303e8626..02348bf737a 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -28,8 +28,9 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, + PARAM_KEY_PAUSED, PARAM_KEY_STATUS, - STATUS_ACTIVE, + STATUS_ONLINE, RUNNER_PAGE_SIZE, I18N_EDIT, } from '~/runner/constants'; @@ -38,7 +39,13 @@ import getGroupRunnersCountQuery from '~/runner/graphql/list/group_runners_count 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, groupRunnersCountData } from '../mock_data'; +import { + groupRunnersData, + groupRunnersDataPaginated, + groupRunnersCountData, + onlineContactTimeoutSecs, + staleTimeoutSecs, +} from '../mock_data'; Vue.use(VueApollo); Vue.use(GlToast); @@ -90,6 +97,10 @@ describe('GroupRunnersApp', () => { groupRunnersLimitedCount: mockGroupRunnersLimitedCount, ...props, }, + provide: { + onlineContactTimeoutSecs, + staleTimeoutSecs, + }, }); }; @@ -178,13 +189,16 @@ describe('GroupRunnersApp', () => { const tokens = findFilteredSearch().props('tokens'); - expect(tokens).toHaveLength(1); - expect(tokens[0]).toEqual( + expect(tokens).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_PAUSED, + options: expect.any(Array), + }), expect.objectContaining({ type: PARAM_KEY_STATUS, options: expect.any(Array), }), - ); + ]); }); describe('Single runner row', () => { @@ -193,9 +207,11 @@ describe('GroupRunnersApp', () => { const { webUrl, editUrl, node } = mockGroupRunnersEdges[0]; const { id: graphqlId, shortSha } = node; const id = getIdFromGraphQLId(graphqlId); + const COUNT_QUERIES = 6; // Smart queries that display a filtered count of runners + const FILTERED_COUNT_QUERIES = 3; // Smart queries that display a count of runners in tabs beforeEach(async () => { - mockGroupRunnersQuery.mockClear(); + mockGroupRunnersCountQuery.mockClear(); createComponent({ mountFn: mountExtended }); showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); @@ -219,12 +235,20 @@ describe('GroupRunnersApp', () => { }); }); - it('When runner is deleted, data is refetched and a toast is shown', async () => { - expect(mockGroupRunnersQuery).toHaveBeenCalledTimes(1); + it('When runner is paused or unpaused, some data is refetched', async () => { + expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES); - findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); + findRunnerActionsCell().vm.$emit('toggledPaused'); + + expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes( + COUNT_QUERIES + FILTERED_COUNT_QUERIES, + ); - expect(mockGroupRunnersQuery).toHaveBeenCalledTimes(2); + expect(showToast).toHaveBeenCalledTimes(0); + }); + + it('When runner is deleted, data is refetched and a toast message is shown', async () => { + findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Runner deleted'); @@ -233,7 +257,7 @@ describe('GroupRunnersApp', () => { describe('when a filter is preselected', () => { beforeEach(async () => { - setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`); + setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}`); createComponent(); await waitForPromises(); @@ -242,7 +266,7 @@ describe('GroupRunnersApp', () => { it('sets the filters in the search bar', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ runnerType: INSTANCE_TYPE, - filters: [{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }], + filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }], sort: 'CREATED_DESC', pagination: { page: 1 }, }); @@ -251,7 +275,7 @@ describe('GroupRunnersApp', () => { it('requests the runners with filter parameters', () => { expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, - status: STATUS_ACTIVE, + status: STATUS_ONLINE, type: INSTANCE_TYPE, sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, @@ -263,7 +287,7 @@ describe('GroupRunnersApp', () => { beforeEach(async () => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, - filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); @@ -273,14 +297,14 @@ describe('GroupRunnersApp', () => { it('updates the browser url', () => { expect(updateHistory).toHaveBeenLastCalledWith({ title: expect.any(String), - url: 'http://test.host/groups/group1/-/runners?status[]=ACTIVE&sort=CREATED_ASC', + url: 'http://test.host/groups/group1/-/runners?status[]=ONLINE&sort=CREATED_ASC', }); }); it('requests the runners with filters', () => { expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, - status: STATUS_ACTIVE, + status: STATUS_ONLINE, sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 49c25039719..fbe8926124c 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -14,6 +14,10 @@ import runnerWithGroupData from 'test_fixtures/graphql/runner/details/runner.que import runnerProjectsData from 'test_fixtures/graphql/runner/details/runner_projects.query.graphql.json'; import runnerJobsData from 'test_fixtures/graphql/runner/details/runner_jobs.query.graphql.json'; +// Other mock data +export const onlineContactTimeoutSecs = 2 * 60 * 60; +export const staleTimeoutSecs = 5259492; // Ruby's `2.months` + export { runnersData, runnersCountData, diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index aff1ec882bb..7834e76fe48 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -181,6 +181,28 @@ describe('search_params.js', () => { first: RUNNER_PAGE_SIZE, }, }, + { + name: 'paused runners', + urlQuery: '?paused[]=true', + search: { + runnerType: null, + filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { paused: true, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'active runners', + urlQuery: '?paused[]=false', + search: { + runnerType: null, + filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { paused: false, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, ]; describe('searchValidator', () => { @@ -197,14 +219,18 @@ describe('search_params.js', () => { expect(updateOutdatedUrl('http://test.host/?a=b')).toBe(null); }); - it('returns updated url for updating NOT_CONNECTED to NEVER_CONTACTED', () => { - expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED')).toBe( - 'http://test.host/admin/runners?status[]=NEVER_CONTACTED', - ); + it.each` + query | updatedQuery + ${'status[]=NOT_CONNECTED'} | ${'status[]=NEVER_CONTACTED'} + ${'status[]=NOT_CONNECTED&a=b'} | ${'status[]=NEVER_CONTACTED&a=b'} + ${'status[]=ACTIVE'} | ${'paused[]=false'} + ${'status[]=ACTIVE&a=b'} | ${'a=b&paused[]=false'} + ${'status[]=ACTIVE'} | ${'paused[]=false'} + ${'status[]=PAUSED'} | ${'paused[]=true'} + `('updates "$query" to "$updatedQuery"', ({ query, updatedQuery }) => { + const mockUrl = 'http://test.host/admin/runners?'; - expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED&a=b')).toBe( - 'http://test.host/admin/runners?status[]=NEVER_CONTACTED&a=b', - ); + expect(updateOutdatedUrl(`${mockUrl}${query}`)).toBe(`${mockUrl}${updatedQuery}`); }); }); diff --git a/spec/frontend/runner/utils_spec.js b/spec/frontend/runner/utils_spec.js index 3fa9784ecdf..1db9815dfd8 100644 --- a/spec/frontend/runner/utils_spec.js +++ b/spec/frontend/runner/utils_spec.js @@ -44,6 +44,10 @@ describe('~/runner/utils', () => { thClass: expect.arrayContaining(mockClasses), }); }); + + it('a field with custom options', () => { + expect(tableField({ foo: 'bar' })).toMatchObject({ foo: 'bar' }); + }); }); describe('getPaginationVariables', () => { diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index 5f8cee8160f..67bd3194f20 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -56,7 +56,7 @@ describe('Global Search Store Actions', () => { ${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${0} ${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${1} ${actions.fetchProjects} | ${{ method: 'onGet', code: 200, res: MOCK_PROJECTS }} | ${'success'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_SUCCESS, payload: MOCK_PROJECTS }]} | ${0} - ${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${2} + ${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${1} `(`axios calls`, ({ action, axiosMock, type, expectedMutations, flashCallCount }) => { describe(action.name, () => { describe(`on ${type}`, () => { @@ -121,8 +121,8 @@ describe('Global Search Store Actions', () => { describe('when groupId is set', () => { it('calls Api.groupProjects with expected parameters', () => { - actions.fetchProjects({ commit: mockCommit, state }); - + const callbackTest = jest.fn(); + actions.fetchProjects({ commit: mockCommit, state }, undefined, callbackTest); expect(Api.groupProjects).toHaveBeenCalledWith( state.query.group_id, state.query.search, @@ -131,7 +131,8 @@ describe('Global Search Store Actions', () => { include_subgroups: true, with_shared: false, }, - expect.any(Function), + callbackTest, + true, ); expect(Api.projects).not.toHaveBeenCalled(); }); @@ -144,15 +145,10 @@ describe('Global Search Store Actions', () => { it('calls Api.projects', () => { actions.fetchProjects({ commit: mockCommit, state }); - expect(Api.groupProjects).not.toHaveBeenCalled(); - expect(Api.projects).toHaveBeenCalledWith( - state.query.search, - { - order_by: 'similarity', - }, - expect.any(Function), - ); + expect(Api.projects).toHaveBeenCalledWith(state.query.search, { + order_by: 'similarity', + }); }); }); }); diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js index c643cf6557d..190f2803324 100644 --- a/spec/frontend/search_autocomplete_spec.js +++ b/spec/frontend/search_autocomplete_spec.js @@ -223,34 +223,22 @@ describe('Search autocomplete dropdown', () => { }); } - it('suggest Projects', (done) => { - // eslint-disable-next-line promise/catch-or-return - triggerAutocomplete().finally(() => { - const list = widget.wrap.find('.dropdown-menu').find('ul'); - const link = "a[href$='/gitlab-org/gitlab-test']"; + it('suggest Projects', async () => { + await triggerAutocomplete(); - expect(list.find(link).length).toBe(1); + const list = widget.wrap.find('.dropdown-menu').find('ul'); + const link = "a[href$='/gitlab-org/gitlab-test']"; - done(); - }); - - // Make sure jest properly acknowledge the `done` invocation - jest.runOnlyPendingTimers(); + expect(list.find(link).length).toBe(1); }); - it('suggest Groups', (done) => { - // eslint-disable-next-line promise/catch-or-return - triggerAutocomplete().finally(() => { - const list = widget.wrap.find('.dropdown-menu').find('ul'); - const link = "a[href$='/gitlab-org']"; + it('suggest Groups', async () => { + await triggerAutocomplete(); - expect(list.find(link).length).toBe(1); - - done(); - }); + const list = widget.wrap.find('.dropdown-menu').find('ul'); + const link = "a[href$='/gitlab-org']"; - // Make sure jest properly acknowledge the `done` invocation - jest.runOnlyPendingTimers(); + expect(list.find(link).length).toBe(1); }); }); diff --git a/spec/frontend/search_settings/components/search_settings_spec.js b/spec/frontend/search_settings/components/search_settings_spec.js index 6beaea8dba5..d0a2018c7f0 100644 --- a/spec/frontend/search_settings/components/search_settings_spec.js +++ b/spec/frontend/search_settings/components/search_settings_spec.js @@ -1,5 +1,6 @@ import { GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture } from 'helpers/fixtures'; import SearchSettings from '~/search_settings/components/search_settings.vue'; import { HIGHLIGHT_CLASS, HIDE_CLASS } from '~/search_settings/constants'; import { isExpanded, expandSection, closeSection } from '~/settings_panels'; @@ -11,7 +12,8 @@ describe('search_settings/components/search_settings.vue', () => { const GENERAL_SETTINGS_ID = 'js-general-settings'; const ADVANCED_SETTINGS_ID = 'js-advanced-settings'; const EXTRA_SETTINGS_ID = 'js-extra-settings'; - const TEXT_CONTAIN_SEARCH_TERM = `This text contain ${SEARCH_TERM} and <script>alert("111")</script> others.`; + const TEXT_CONTAIN_SEARCH_TERM = `This text contain ${SEARCH_TERM}.`; + const TEXT_WITH_SIBLING_ELEMENTS = `${SEARCH_TERM} <a data-testid="sibling" href="#">Learn more</a>.`; let wrapper; @@ -42,13 +44,7 @@ describe('search_settings/components/search_settings.vue', () => { }); }; - const matchParentElement = () => { - const highlightedList = Array.from(document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)); - return highlightedList.map((element) => { - return element.parentNode; - }); - }; - + const findMatchSiblingElement = () => document.querySelector(`[data-testid="sibling"]`); const findSearchBox = () => wrapper.find(GlSearchBoxByType); const search = (term) => { findSearchBox().vm.$emit('input', term); @@ -56,7 +52,7 @@ describe('search_settings/components/search_settings.vue', () => { const clearSearch = () => search(''); beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div> <div class="js-search-app"></div> <div id="${ROOT_ID}"> @@ -69,6 +65,7 @@ describe('search_settings/components/search_settings.vue', () => { <section id="${EXTRA_SETTINGS_ID}" class="settings"> <span>${SEARCH_TERM}</span> <span>${TEXT_CONTAIN_SEARCH_TERM}</span> + <span>${TEXT_WITH_SIBLING_ELEMENTS}</span> </section> </div> </div> @@ -99,7 +96,7 @@ describe('search_settings/components/search_settings.vue', () => { it('highlight elements that match the search term', () => { search(SEARCH_TERM); - expect(highlightedElementsCount()).toBe(2); + expect(highlightedElementsCount()).toBe(3); }); it('highlight only search term and not the whole line', () => { @@ -108,14 +105,26 @@ describe('search_settings/components/search_settings.vue', () => { expect(highlightedTextNodes()).toBe(true); }); - it('prevents search xss', () => { + // Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/350494 + it('preserves elements that are siblings of matches', () => { + const snapshot = ` + <a + data-testid="sibling" + href="#" + > + Learn more + </a> + `; + + expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot); + search(SEARCH_TERM); - const parentNodeList = matchParentElement(); - parentNodeList.forEach((element) => { - const scriptElement = element.getElementsByTagName('script'); - expect(scriptElement.length).toBe(0); - }); + expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot); + + clearSearch(); + + expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot); }); describe('default', () => { diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 963577fa763..9a18cb636b2 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -1,4 +1,4 @@ -import { GlTab, GlTabs } from '@gitlab/ui'; +import { GlTab, GlTabs, GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; @@ -33,6 +33,7 @@ const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; const autoDevopsPath = '/autoDevopsPath'; const gitlabCiHistoryPath = 'test/historyPath'; const projectFullPath = 'namespace/project'; +const vulnerabilityTrainingDocsPath = 'user/application_security/vulnerabilities/index'; useLocalStorageSpy(); @@ -55,6 +56,7 @@ describe('App component', () => { autoDevopsHelpPagePath, autoDevopsPath, projectFullPath, + vulnerabilityTrainingDocsPath, glFeatures: { secureVulnerabilityTraining, }, @@ -107,6 +109,7 @@ describe('App component', () => { const findUpgradeBanner = () => wrapper.findComponent(UpgradeBanner); const findAutoDevopsAlert = () => wrapper.findComponent(AutoDevopsAlert); const findAutoDevopsEnabledAlert = () => wrapper.findComponent(AutoDevopsEnabledAlert); + const findVulnerabilityManagementTab = () => wrapper.findByTestId('vulnerability-management-tab'); const securityFeaturesMock = [ { @@ -454,9 +457,14 @@ describe('App component', () => { }); it('renders security training description', () => { - const vulnerabilityManagementTab = wrapper.findByTestId('vulnerability-management-tab'); + expect(findVulnerabilityManagementTab().text()).toContain(i18n.securityTrainingDescription); + }); + + it('renders link to help docs', () => { + const trainingLink = findVulnerabilityManagementTab().findComponent(GlLink); - expect(vulnerabilityManagementTab.text()).toContain(i18n.securityTrainingDescription); + expect(trainingLink.text()).toBe('Learn more about vulnerability training'); + expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath); }); }); diff --git a/spec/frontend/security_configuration/components/feature_card_badge_spec.js b/spec/frontend/security_configuration/components/feature_card_badge_spec.js new file mode 100644 index 00000000000..dcde0808fa4 --- /dev/null +++ b/spec/frontend/security_configuration/components/feature_card_badge_spec.js @@ -0,0 +1,40 @@ +import { mount } from '@vue/test-utils'; +import { GlBadge, GlTooltip } from '@gitlab/ui'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue'; + +describe('Feature card badge component', () => { + let wrapper; + + const createComponent = (propsData) => { + wrapper = extendedWrapper( + mount(FeatureCardBadge, { + propsData, + }), + ); + }; + + const findTooltip = () => wrapper.findComponent(GlTooltip); + const findBadge = () => wrapper.findComponent(GlBadge); + + describe('tooltip render', () => { + describe.each` + context | badge | badgeHref + ${'href on a badge object'} | ${{ tooltipText: 'test', badgeHref: 'href' }} | ${undefined} + ${'href as property '} | ${{ tooltipText: null, badgeHref: '' }} | ${'link'} + ${'default href no property on badge or component'} | ${{ tooltipText: null, badgeHref: '' }} | ${undefined} + `('given $context', ({ badge, badgeHref }) => { + beforeEach(() => { + createComponent({ badge, badgeHref }); + }); + + it('should show badge when badge given in configuration and available', () => { + expect(findTooltip().exists()).toBe(Boolean(badge && badge.tooltipText)); + }); + + it('should render correct link if link is provided', () => { + expect(findBadge().attributes().href).toEqual(badgeHref); + }); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index f0d902bf9fe..d10722be8ea 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -2,6 +2,7 @@ import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; +import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; import { makeFeature } from './utils'; @@ -16,6 +17,7 @@ describe('FeatureCard component', () => { propsData, stubs: { ManageViaMr: true, + FeatureCardBadge: true, }, }), ); @@ -24,6 +26,8 @@ describe('FeatureCard component', () => { const findLinks = ({ text, href }) => wrapper.findAll(`a[href="${href}"]`).filter((link) => link.text() === text); + const findBadge = () => wrapper.findComponent(FeatureCardBadge); + const findEnableLinks = () => findLinks({ text: `Enable ${feature.shortName ?? feature.name}`, @@ -262,5 +266,28 @@ describe('FeatureCard component', () => { }); }); }); + + describe('information badge', () => { + describe.each` + context | available | badge + ${'available feature with badge'} | ${true} | ${{ text: 'test' }} + ${'unavailable feature without badge'} | ${false} | ${null} + ${'available feature without badge'} | ${true} | ${null} + ${'unavailable feature with badge'} | ${false} | ${{ text: 'test' }} + ${'available feature with empty badge'} | ${false} | ${{}} + `('given $context', ({ available, badge }) => { + beforeEach(() => { + feature = makeFeature({ + available, + badge, + }); + createComponent({ feature }); + }); + + it('should show badge when badge given in configuration and available', () => { + expect(findBadge().exists()).toBe(Boolean(available && badge && badge.text)); + }); + }); + }); }); }); diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index b8c1bef0ddd..309a9cd4cd6 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -1,5 +1,13 @@ import * as Sentry from '@sentry/browser'; -import { GlAlert, GlLink, GlToggle, GlCard, GlSkeletonLoader, GlIcon } from '@gitlab/ui'; +import { + GlAlert, + GlLink, + GlFormRadio, + GlToggle, + GlCard, + GlSkeletonLoader, + GlIcon, +} from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -87,7 +95,7 @@ describe('TrainingProviderList component', () => { const findLinks = () => wrapper.findAllComponents(GlLink); const findToggles = () => wrapper.findAllComponents(GlToggle); const findFirstToggle = () => findToggles().at(0); - const findPrimaryProviderRadios = () => wrapper.findAllByTestId('primary-provider-radio'); + const findPrimaryProviderRadios = () => wrapper.findAllComponents(GlFormRadio); const findLoader = () => wrapper.findComponent(GlSkeletonLoader); const findErrorAlert = () => wrapper.findComponent(GlAlert); const findLogos = () => wrapper.findAllByTestId('provider-logo'); @@ -177,8 +185,8 @@ describe('TrainingProviderList component', () => { const primaryProviderRadioForCurrentCard = findPrimaryProviderRadios().at(index); // if the given provider is not enabled it should not be possible select it as primary - expect(primaryProviderRadioForCurrentCard.find('input').attributes('disabled')).toBe( - isEnabled ? undefined : 'disabled', + expect(primaryProviderRadioForCurrentCard.attributes('disabled')).toBe( + isEnabled ? undefined : 'true', ); expect(primaryProviderRadioForCurrentCard.text()).toBe( diff --git a/spec/frontend/self_monitor/store/actions_spec.js b/spec/frontend/self_monitor/store/actions_spec.js index 6bcb2a713ea..59ee87c4a02 100644 --- a/spec/frontend/self_monitor/store/actions_spec.js +++ b/spec/frontend/self_monitor/store/actions_spec.js @@ -16,27 +16,25 @@ describe('self monitor actions', () => { }); describe('setSelfMonitor', () => { - it('commits the SET_ENABLED mutation', (done) => { - testAction( + it('commits the SET_ENABLED mutation', () => { + return testAction( actions.setSelfMonitor, null, state, [{ type: types.SET_ENABLED, payload: null }], [], - done, ); }); }); describe('resetAlert', () => { - it('commits the SET_ENABLED mutation', (done) => { - testAction( + it('commits the SET_ENABLED mutation', () => { + return testAction( actions.resetAlert, null, state, [{ type: types.SET_SHOW_ALERT, payload: false }], [], - done, ); }); }); @@ -54,8 +52,8 @@ describe('self monitor actions', () => { }); }); - it('dispatches status request with job data', (done) => { - testAction( + it('dispatches status request with job data', () => { + return testAction( actions.requestCreateProject, null, state, @@ -71,12 +69,11 @@ describe('self monitor actions', () => { payload: '123', }, ], - done, ); }); - it('dispatches success with project path', (done) => { - testAction( + it('dispatches success with project path', () => { + return testAction( actions.requestCreateProjectStatus, null, state, @@ -87,7 +84,6 @@ describe('self monitor actions', () => { payload: { project_full_path: '/self-monitor-url' }, }, ], - done, ); }); }); @@ -98,8 +94,8 @@ describe('self monitor actions', () => { mock.onPost(state.createProjectEndpoint).reply(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( actions.requestCreateProject, null, state, @@ -115,14 +111,13 @@ describe('self monitor actions', () => { payload: new Error('Request failed with status code 500'), }, ], - done, ); }); }); describe('requestCreateProjectSuccess', () => { - it('should commit the received data', (done) => { - testAction( + it('should commit the received data', () => { + return testAction( actions.requestCreateProjectSuccess, { project_full_path: '/self-monitor-url' }, state, @@ -146,7 +141,6 @@ describe('self monitor actions', () => { type: 'setSelfMonitor', }, ], - done, ); }); }); @@ -165,8 +159,8 @@ describe('self monitor actions', () => { }); }); - it('dispatches status request with job data', (done) => { - testAction( + it('dispatches status request with job data', () => { + return testAction( actions.requestDeleteProject, null, state, @@ -182,12 +176,11 @@ describe('self monitor actions', () => { payload: '456', }, ], - done, ); }); - it('dispatches success with status', (done) => { - testAction( + it('dispatches success with status', () => { + return testAction( actions.requestDeleteProjectStatus, null, state, @@ -198,7 +191,6 @@ describe('self monitor actions', () => { payload: { status: 'success' }, }, ], - done, ); }); }); @@ -209,8 +201,8 @@ describe('self monitor actions', () => { mock.onDelete(state.deleteProjectEndpoint).reply(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( actions.requestDeleteProject, null, state, @@ -226,14 +218,13 @@ describe('self monitor actions', () => { payload: new Error('Request failed with status code 500'), }, ], - done, ); }); }); describe('requestDeleteProjectSuccess', () => { - it('should commit mutations to remove previously set data', (done) => { - testAction( + it('should commit mutations to remove previously set data', () => { + return testAction( actions.requestDeleteProjectSuccess, null, state, @@ -252,7 +243,6 @@ describe('self monitor actions', () => { { type: types.SET_LOADING, payload: false }, ], [], - done, ); }); }); diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap index f57b9418be5..0f4dfdf8a75 100644 --- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap @@ -3,7 +3,7 @@ exports[`EmptyStateComponent should render content 1`] = ` "<section class=\\"gl-display-flex empty-state gl-text-center gl-flex-direction-column\\"> <div class=\\"gl-max-w-full\\"> - <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" role=\\"img\\" class=\\"gl-max-w-full\\"></div> + <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" role=\\"img\\" class=\\"gl-max-w-full gl-dark-invert-keep-hue\\"></div> </div> <div class=\\"gl-max-w-full gl-m-auto\\"> <div class=\\"gl-mx-auto gl-my-0 gl-p-5\\"> diff --git a/spec/frontend/serverless/store/actions_spec.js b/spec/frontend/serverless/store/actions_spec.js index 61b9bd121af..5fbecf081a6 100644 --- a/spec/frontend/serverless/store/actions_spec.js +++ b/spec/frontend/serverless/store/actions_spec.js @@ -7,13 +7,22 @@ import { mockServerlessFunctions, mockMetrics } from '../mock_data'; import { adjustMetricQuery } from '../utils'; describe('ServerlessActions', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + describe('fetchFunctions', () => { - it('should successfully fetch functions', (done) => { + it('should successfully fetch functions', () => { const endpoint = '/functions'; - const mock = new MockAdapter(axios); mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockServerlessFunctions)); - testAction( + return testAction( fetchFunctions, { functionsPath: endpoint }, {}, @@ -22,68 +31,49 @@ describe('ServerlessActions', () => { { type: 'requestFunctionsLoading' }, { type: 'receiveFunctionsSuccess', payload: mockServerlessFunctions }, ], - () => { - mock.restore(); - done(); - }, ); }); - it('should successfully retry', (done) => { + it('should successfully retry', () => { const endpoint = '/functions'; - const mock = new MockAdapter(axios); mock .onGet(endpoint) .reply(() => new Promise((resolve) => setTimeout(() => resolve(200), Infinity))); - testAction( + return testAction( fetchFunctions, { functionsPath: endpoint }, {}, [], [{ type: 'requestFunctionsLoading' }], - () => { - mock.restore(); - done(); - }, ); }); }); describe('fetchMetrics', () => { - it('should return no prometheus', (done) => { + it('should return no prometheus', () => { const endpoint = '/metrics'; - const mock = new MockAdapter(axios); mock.onGet(endpoint).reply(statusCodes.NO_CONTENT); - testAction( + return testAction( fetchMetrics, { metricsPath: endpoint, hasPrometheus: false }, {}, [], [{ type: 'receiveMetricsNoPrometheus' }], - () => { - mock.restore(); - done(); - }, ); }); - it('should successfully fetch metrics', (done) => { + it('should successfully fetch metrics', () => { const endpoint = '/metrics'; - const mock = new MockAdapter(axios); mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockMetrics)); - testAction( + return testAction( fetchMetrics, { metricsPath: endpoint, hasPrometheus: true }, {}, [], [{ type: 'receiveMetricsSuccess', payload: adjustMetricQuery(mockMetrics) }], - () => { - mock.restore(); - done(); - }, ); }); }); diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index c105810e11c..0b672cbc93e 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -26,7 +26,7 @@ describe('SetStatusModalWrapper', () => { defaultEmoji, }; - const createComponent = (props = {}, improvedEmojiPicker = false) => { + const createComponent = (props = {}) => { return shallowMount(SetStatusModalWrapper, { propsData: { ...defaultProps, @@ -35,19 +35,15 @@ describe('SetStatusModalWrapper', () => { mocks: { $toast, }, - provide: { - glFeatures: { improvedEmojiPicker }, - }, }); }; const findModal = () => wrapper.find(GlModal); const findFormField = (field) => wrapper.find(`[name="user[status][${field}]"]`); const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button'); - const findNoEmojiPlaceholder = () => wrapper.find('.js-no-emoji-placeholder'); - const findToggleEmojiButton = () => wrapper.find('.js-toggle-emoji-menu'); const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox); const findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]'); + const getEmojiPicker = () => wrapper.findComponent(EmojiPicker); const initModal = async ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => { const modal = findModal(); @@ -95,12 +91,6 @@ describe('SetStatusModalWrapper', () => { expect(findClearStatusButton().isVisible()).toBe(true); }); - it('clicking the toggle emoji button displays the emoji list', () => { - expect(wrapper.vm.showEmojiMenu).not.toHaveBeenCalled(); - findToggleEmojiButton().trigger('click'); - expect(wrapper.vm.showEmojiMenu).toHaveBeenCalled(); - }); - it('displays the clear status at dropdown', () => { expect(wrapper.find('[data-testid="clear-status-at-dropdown"]').exists()).toBe(true); }); @@ -108,16 +98,6 @@ describe('SetStatusModalWrapper', () => { it('does not display the clear status at message', () => { expect(findClearStatusAtMessage().exists()).toBe(false); }); - }); - - describe('improvedEmojiPicker is true', () => { - const getEmojiPicker = () => wrapper.findComponent(EmojiPicker); - - beforeEach(async () => { - await initEmojiMock(); - wrapper = createComponent({}, true); - return initModal(); - }); it('renders emoji picker dropdown with custom positioning', () => { expect(getEmojiPicker().props()).toMatchObject({ @@ -147,10 +127,6 @@ describe('SetStatusModalWrapper', () => { it('hides the clear status button', () => { expect(findClearStatusButton().isVisible()).toBe(false); }); - - it('shows the placeholder emoji', () => { - expect(findNoEmojiPlaceholder().isVisible()).toBe(true); - }); }); describe('with no currentEmoji set', () => { @@ -163,22 +139,6 @@ describe('SetStatusModalWrapper', () => { it('does not set the hidden status emoji field', () => { expect(findFormField('emoji').element.value).toBe(''); }); - - it('hides the placeholder emoji', () => { - expect(findNoEmojiPlaceholder().isVisible()).toBe(false); - }); - - describe('with no currentMessage set', () => { - beforeEach(async () => { - await initEmojiMock(); - wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); - return initModal(); - }); - - it('shows the placeholder emoji', () => { - expect(findNoEmojiPlaceholder().isVisible()).toBe(true); - }); - }); }); describe('with currentClearStatusAfter set', () => { diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index 49148123a1c..8b9a11056f2 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -41,7 +41,7 @@ describe('Shortcuts', () => { ).toHaveBeenCalled(); }); - it('focues preview button inside edit comment form', () => { + it('focuses preview button inside edit comment form', () => { document.querySelector('.js-note-edit').click(); Shortcuts.toggleMarkdownPreview( diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js index 2249a1c08b8..ae8f07bf901 100644 --- a/spec/frontend/sidebar/assignees_realtime_spec.js +++ b/spec/frontend/sidebar/assignees_realtime_spec.js @@ -2,11 +2,16 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; -import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data'; +import Mock, { + issuableQueryResponse, + subscriptionNullResponse, + subscriptionResponse, +} from './mock_data'; Vue.use(VueApollo); @@ -20,7 +25,6 @@ describe('Assignees Realtime', () => { const createComponent = ({ issuableType = 'issue', - issuableId = 1, subscriptionHandler = subscriptionInitialHandler, } = {}) => { fakeApollo = createMockApollo([ @@ -30,7 +34,6 @@ describe('Assignees Realtime', () => { wrapper = shallowMount(AssigneesRealtime, { propsData: { issuableType, - issuableId, queryVariables: { issuableIid: '1', projectPath: 'path/to/project', @@ -60,11 +63,23 @@ describe('Assignees Realtime', () => { }); }); - it('calls the subscription with correct variable for issue', () => { + it('calls the subscription with correct variable for issue', async () => { createComponent(); + await waitForPromises(); expect(subscriptionInitialHandler).toHaveBeenCalledWith({ issuableId: 'gid://gitlab/Issue/1', }); }); + + it('emits an `assigneesUpdated` event on subscription response', async () => { + createComponent({ + subscriptionHandler: jest.fn().mockResolvedValue(subscriptionResponse), + }); + await waitForPromises(); + + expect(wrapper.emitted('assigneesUpdated')).toEqual([ + [{ id: '1', assignees: subscriptionResponse.data.issuableAssigneesUpdated.assignees.nodes }], + ]); + }); }); diff --git a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js index 7a736624fc0..8d8c10d10f1 100644 --- a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js +++ b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js @@ -1,4 +1,5 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import EscalationStatus from '~/sidebar/components/incidents/escalation_status.vue'; import { @@ -25,6 +26,11 @@ describe('EscalationStatus', () => { const findDropdownComponent = () => wrapper.findComponent(GlDropdown); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownMenu = () => findDropdownComponent().find('.dropdown-menu'); + const toggleDropdown = async () => { + await findDropdownComponent().findComponent('button').trigger('click'); + await waitForPromises(); + }; describe('status', () => { it('shows the current status', () => { @@ -49,4 +55,32 @@ describe('EscalationStatus', () => { expect(wrapper.emitted().input[0][0]).toBe(STATUS_ACKNOWLEDGED); }); }); + + describe('close behavior', () => { + it('allows the dropdown to be closed by default', async () => { + createComponent(); + // Open dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(true); + + // Attempt to close dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(false); + }); + + it('preventDropdownClose prevents the dropdown from closing', async () => { + createComponent({ preventDropdownClose: true }); + // Open dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(true); + + // Attempt to close dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(true); + }); + }); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index fbca00636b6..2b421037339 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -415,6 +415,28 @@ export const subscriptionNullResponse = { }, }; +export const subscriptionResponse = { + data: { + issuableAssigneesUpdated: { + id: '1', + assignees: { + nodes: [ + { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + ], + }, + }, + }, +}; + const mockUser1 = { __typename: 'UserCore', id: 'gid://gitlab/User/1', diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js index 356628849d9..2517b625225 100644 --- a/spec/frontend/sidebar/participants_spec.js +++ b/spec/frontend/sidebar/participants_spec.js @@ -17,8 +17,7 @@ const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPA describe('Participants', () => { let wrapper; - const getMoreParticipantsButton = () => wrapper.find('button'); - + const getMoreParticipantsButton = () => wrapper.find('[data-testid="more-participants"]'); const getCollapsedParticipantsCount = () => wrapper.find('[data-testid="collapsed-count"]'); const mountComponent = (propsData) => @@ -167,7 +166,7 @@ describe('Participants', () => { expect(wrapper.vm.isShowingMoreParticipants).toBe(false); - getMoreParticipantsButton().trigger('click'); + getMoreParticipantsButton().vm.$emit('click'); expect(wrapper.vm.isShowingMoreParticipants).toBe(true); }); diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 61424fa1eb2..9cfe136129a 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -19,8 +19,8 @@ import { SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PUBLIC, } from '~/snippets/constants'; -import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql'; -import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql'; +import CreateSnippetMutation from '~/snippets/mutations/create_snippet.mutation.graphql'; +import UpdateSnippetMutation from '~/snippets/mutations/update_snippet.mutation.graphql'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; import TitleField from '~/vue_shared/components/form/title.vue'; import { testEntries, createGQLSnippetsQueryResponse, createGQLSnippet } from '../test_utils'; diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 1b9d170556b..b750225a383 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -8,7 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; import SnippetHeader, { i18n } from '~/snippets/components/snippet_header.vue'; -import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; +import DeleteSnippetMutation from '~/snippets/mutations/delete_snippet.mutation.graphql'; import axios from '~/lib/utils/axios_utils'; import createFlash, { FLASH_TYPES } from '~/flash'; diff --git a/spec/frontend/task_list_spec.js b/spec/frontend/task_list_spec.js index bf470e7e126..fbdb73ae6de 100644 --- a/spec/frontend/task_list_spec.js +++ b/spec/frontend/task_list_spec.js @@ -121,7 +121,7 @@ describe('TaskList', () => { }); describe('update', () => { - it('should disable task list items and make a patch request then enable them again', (done) => { + it('should disable task list items and make a patch request then enable them again', () => { const response = { data: { lock_version: 3 } }; jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {}); @@ -156,20 +156,17 @@ describe('TaskList', () => { expect(taskList.onUpdate).toHaveBeenCalled(); - update - .then(() => { - expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event); - expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData); - expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); - expect(taskList.onSuccess).toHaveBeenCalledWith(response.data); - expect(taskList.lockVersion).toEqual(response.data.lock_version); - }) - .then(done) - .catch(done.fail); + return update.then(() => { + expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event); + expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData); + expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); + expect(taskList.onSuccess).toHaveBeenCalledWith(response.data); + expect(taskList.lockVersion).toEqual(response.data.lock_version); + }); }); }); - it('should handle request error and enable task list items', (done) => { + it('should handle request error and enable task list items', () => { const response = { data: { error: 1 } }; jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); jest.spyOn(taskList, 'onUpdate').mockImplementation(() => {}); @@ -182,12 +179,9 @@ describe('TaskList', () => { expect(taskList.onUpdate).toHaveBeenCalled(); - update - .then(() => { - expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); - expect(taskList.onError).toHaveBeenCalledWith(response.data); - }) - .then(done) - .catch(done.fail); + return update.then(() => { + expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); + expect(taskList.onError).toHaveBeenCalledWith(response.data); + }); }); }); diff --git a/spec/frontend/terraform/components/empty_state_spec.js b/spec/frontend/terraform/components/empty_state_spec.js index b1303cf2b5e..21bfff5f1be 100644 --- a/spec/frontend/terraform/components/empty_state_spec.js +++ b/spec/frontend/terraform/components/empty_state_spec.js @@ -13,15 +13,20 @@ describe('EmptyStateComponent', () => { const findLink = () => wrapper.findComponent(GlLink); beforeEach(() => { - wrapper = shallowMount(EmptyState, { propsData, stubs: { GlEmptyState, GlLink } }); + wrapper = shallowMount(EmptyState, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); }); it('should render content', () => { - expect(findEmptyState().exists()).toBe(true); - expect(wrapper.text()).toContain('Get started with Terraform'); + expect(findEmptyState().props('title')).toBe( + "Your project doesn't have any Terraform state files", + ); }); - it('should have a link to the GitLab managed Terraform States docs', () => { + it('should have a link to the GitLab managed Terraform states docs', () => { expect(findLink().attributes('href')).toBe(docsUrl); }); }); diff --git a/spec/frontend/terraform/components/mock_data.js b/spec/frontend/terraform/components/mock_data.js new file mode 100644 index 00000000000..f0109047d4c --- /dev/null +++ b/spec/frontend/terraform/components/mock_data.js @@ -0,0 +1,35 @@ +export const getStatesResponse = { + data: { + project: { + id: 'project-1', + terraformStates: { + count: 1, + nodes: { + _showDetails: true, + errorMessages: [], + loadingLock: false, + loadingRemove: false, + id: 'state-1', + name: 'state', + lockedAt: '01-01-2022', + updatedAt: '01-01-2022', + lockedByUser: { + id: 'user-1', + avatarUrl: 'avatar', + name: 'User 1', + username: 'user-1', + webUrl: 'web', + }, + latestVersion: null, + }, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'prev', + endCursor: 'next', + }, + }, + }, + }, +}; diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js index a6c80b95af4..d01f6af9023 100644 --- a/spec/frontend/terraform/components/states_table_actions_spec.js +++ b/spec/frontend/terraform/components/states_table_actions_spec.js @@ -9,6 +9,8 @@ import StateActions from '~/terraform/components/states_table_actions.vue'; import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql'; import removeStateMutation from '~/terraform/graphql/mutations/remove_state.mutation.graphql'; import unlockStateMutation from '~/terraform/graphql/mutations/unlock_state.mutation.graphql'; +import getStatesQuery from '~/terraform/graphql/queries/get_states.query.graphql'; +import { getStatesResponse } from './mock_data'; Vue.use(VueApollo); @@ -49,6 +51,7 @@ describe('StatesTableActions', () => { [lockStateMutation, lockResponse], [removeStateMutation, removeResponse], [unlockStateMutation, unlockResponse], + [getStatesQuery, jest.fn().mockResolvedValue(getStatesResponse)], ], { Mutation: { diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js index d85299cdfc3..665bf44fc77 100644 --- a/spec/frontend/tracking/tracking_spec.js +++ b/spec/frontend/tracking/tracking_spec.js @@ -129,6 +129,72 @@ describe('Tracking', () => { }); }); + describe('.definition', () => { + const TEST_VALID_BASENAME = '202108302307_default_click_button'; + const TEST_EVENT_DATA = { category: undefined, action: 'click_button' }; + let eventSpy; + let dispatcherSpy; + + beforeAll(() => { + Tracking.definitionsManifest = { + '202108302307_default_click_button': 'config/events/202108302307_default_click_button.yml', + }; + }); + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + dispatcherSpy = jest.spyOn(Tracking, 'dispatchFromDefinition'); + }); + + it('throws an error if the definition does not exists', () => { + const basename = '20220230_default_missing_definition'; + const expectedError = new Error(`Missing Snowplow event definition "${basename}"`); + + expect(() => Tracking.definition(basename)).toThrow(expectedError); + }); + + it('dispatches an event from a definition present in the manifest', () => { + Tracking.definition(TEST_VALID_BASENAME); + + expect(dispatcherSpy).toHaveBeenCalledWith(TEST_VALID_BASENAME, {}); + }); + + it('push events to the queue if not loaded', () => { + Tracking.definitionsLoaded = false; + Tracking.definitionsEventsQueue = []; + + const dispatched = Tracking.definition(TEST_VALID_BASENAME); + + expect(dispatched).toBe(false); + expect(Tracking.definitionsEventsQueue[0]).toStrictEqual([TEST_VALID_BASENAME, {}]); + expect(eventSpy).not.toHaveBeenCalled(); + }); + + it('dispatch events when the definition is loaded', () => { + const definition = { key: TEST_VALID_BASENAME, ...TEST_EVENT_DATA }; + Tracking.definitions = [{ ...definition }]; + Tracking.definitionsEventsQueue = []; + Tracking.definitionsLoaded = true; + + const dispatched = Tracking.definition(TEST_VALID_BASENAME); + + expect(dispatched).not.toBe(false); + expect(Tracking.definitionsEventsQueue).toEqual([]); + expect(eventSpy).toHaveBeenCalledWith(definition.category, definition.action, {}); + }); + + it('lets defined event data takes precedence', () => { + const definition = { key: TEST_VALID_BASENAME, category: undefined, action: 'click_button' }; + const eventData = { category: TEST_CATEGORY }; + Tracking.definitions = [{ ...definition }]; + Tracking.definitionsLoaded = true; + + Tracking.definition(TEST_VALID_BASENAME, eventData); + + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, definition.action, eventData); + }); + }); + describe('.enableFormTracking', () => { it('tells snowplow to enable form tracking, with only explicit contexts', () => { const config = { forms: { allow: ['form-class1'] }, fields: { allow: ['input-class1'] } }; diff --git a/spec/frontend/user_lists/components/edit_user_list_spec.js b/spec/frontend/user_lists/components/edit_user_list_spec.js index 7cafe5e1f56..941c8244247 100644 --- a/spec/frontend/user_lists/components/edit_user_list_spec.js +++ b/spec/frontend/user_lists/components/edit_user_list_spec.js @@ -8,7 +8,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import EditUserList from '~/user_lists/components/edit_user_list.vue'; import UserListForm from '~/user_lists/components/user_list_form.vue'; import createStore from '~/user_lists/store/edit'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/user_lists/components/new_user_list_spec.js b/spec/frontend/user_lists/components/new_user_list_spec.js index 5eb44970fe4..ace4a284347 100644 --- a/spec/frontend/user_lists/components/new_user_list_spec.js +++ b/spec/frontend/user_lists/components/new_user_list_spec.js @@ -7,7 +7,7 @@ import Api from '~/api'; import { redirectTo } from '~/lib/utils/url_utility'; import NewUserList from '~/user_lists/components/new_user_list.vue'; import createStore from '~/user_lists/store/new'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/user_lists/components/user_list_form_spec.js b/spec/frontend/user_lists/components/user_list_form_spec.js index 42f7659600e..e09d8eac32f 100644 --- a/spec/frontend/user_lists/components/user_list_form_spec.js +++ b/spec/frontend/user_lists/components/user_list_form_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import Form from '~/user_lists/components/user_list_form.vue'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; describe('user_lists/components/user_list_form', () => { let wrapper; diff --git a/spec/frontend/user_lists/components/user_list_spec.js b/spec/frontend/user_lists/components/user_list_spec.js index 88dad06938b..f126c733dd5 100644 --- a/spec/frontend/user_lists/components/user_list_spec.js +++ b/spec/frontend/user_lists/components/user_list_spec.js @@ -7,7 +7,7 @@ import Api from '~/api'; import UserList from '~/user_lists/components/user_list.vue'; import createStore from '~/user_lists/store/show'; import { parseUserIds, stringifyUserIds } from '~/user_lists/store/utils'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); diff --git a/spec/frontend/user_lists/components/user_lists_spec.js b/spec/frontend/user_lists/components/user_lists_spec.js index 10742c029c1..161eb036361 100644 --- a/spec/frontend/user_lists/components/user_lists_spec.js +++ b/spec/frontend/user_lists/components/user_lists_spec.js @@ -9,7 +9,7 @@ import UserListsComponent from '~/user_lists/components/user_lists.vue'; import UserListsTable from '~/user_lists/components/user_lists_table.vue'; import createStore from '~/user_lists/store/index'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); diff --git a/spec/frontend/user_lists/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js index 63587703392..08eb8ae0843 100644 --- a/spec/frontend/user_lists/components/user_lists_table_spec.js +++ b/spec/frontend/user_lists/components/user_lists_table_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import * as timeago from 'timeago.js'; import { nextTick } from 'vue'; import UserListsTable from '~/user_lists/components/user_lists_table.vue'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('timeago.js', () => ({ format: jest.fn().mockReturnValue('2 weeks ago'), diff --git a/spec/frontend/user_lists/store/edit/actions_spec.js b/spec/frontend/user_lists/store/edit/actions_spec.js index c4b0f888d3e..ca56c935ea5 100644 --- a/spec/frontend/user_lists/store/edit/actions_spec.js +++ b/spec/frontend/user_lists/store/edit/actions_spec.js @@ -4,7 +4,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import * as actions from '~/user_lists/store/edit/actions'; import * as types from '~/user_lists/store/edit/mutation_types'; import createState from '~/user_lists/store/edit/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/user_lists/store/edit/mutations_spec.js b/spec/frontend/user_lists/store/edit/mutations_spec.js index 0943c64e934..7971906429b 100644 --- a/spec/frontend/user_lists/store/edit/mutations_spec.js +++ b/spec/frontend/user_lists/store/edit/mutations_spec.js @@ -2,7 +2,7 @@ import statuses from '~/user_lists/constants/edit'; import * as types from '~/user_lists/store/edit/mutation_types'; import mutations from '~/user_lists/store/edit/mutations'; import createState from '~/user_lists/store/edit/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; describe('User List Edit Mutations', () => { let state; diff --git a/spec/frontend/user_lists/store/index/actions_spec.js b/spec/frontend/user_lists/store/index/actions_spec.js index c5d7d557de9..4a8d0afb963 100644 --- a/spec/frontend/user_lists/store/index/actions_spec.js +++ b/spec/frontend/user_lists/store/index/actions_spec.js @@ -12,7 +12,7 @@ import { } from '~/user_lists/store/index/actions'; import * as types from '~/user_lists/store/index/mutation_types'; import createState from '~/user_lists/store/index/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api.js'); @@ -24,14 +24,13 @@ describe('~/user_lists/store/index/actions', () => { }); describe('setUserListsOptions', () => { - it('should commit SET_USER_LISTS_OPTIONS mutation', (done) => { - testAction( + it('should commit SET_USER_LISTS_OPTIONS mutation', () => { + return testAction( setUserListsOptions, { page: '1', scope: 'all' }, state, [{ type: types.SET_USER_LISTS_OPTIONS, payload: { page: '1', scope: 'all' } }], [], - done, ); }); }); @@ -42,8 +41,8 @@ describe('~/user_lists/store/index/actions', () => { }); describe('success', () => { - it('dispatches requestUserLists and receiveUserListsSuccess ', (done) => { - testAction( + it('dispatches requestUserLists and receiveUserListsSuccess ', () => { + return testAction( fetchUserLists, null, state, @@ -57,16 +56,15 @@ describe('~/user_lists/store/index/actions', () => { type: 'receiveUserListsSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestUserLists and receiveUserListsError ', (done) => { + it('dispatches requestUserLists and receiveUserListsError ', () => { Api.fetchFeatureFlagUserLists.mockRejectedValue(); - testAction( + return testAction( fetchUserLists, null, state, @@ -79,21 +77,20 @@ describe('~/user_lists/store/index/actions', () => { type: 'receiveUserListsError', }, ], - done, ); }); }); }); describe('requestUserLists', () => { - it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { - testAction(requestUserLists, null, state, [{ type: types.REQUEST_USER_LISTS }], [], done); + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', () => { + return testAction(requestUserLists, null, state, [{ type: types.REQUEST_USER_LISTS }], []); }); }); describe('receiveUserListsSuccess', () => { - it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', () => { + return testAction( receiveUserListsSuccess, { data: [userList], headers: {} }, state, @@ -104,20 +101,18 @@ describe('~/user_lists/store/index/actions', () => { }, ], [], - done, ); }); }); describe('receiveUserListsError', () => { - it('should commit RECEIVE_USER_LISTS_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_USER_LISTS_ERROR mutation', () => { + return testAction( receiveUserListsError, null, state, [{ type: types.RECEIVE_USER_LISTS_ERROR }], [], - done, ); }); }); @@ -132,14 +127,13 @@ describe('~/user_lists/store/index/actions', () => { Api.deleteFeatureFlagUserList.mockResolvedValue(); }); - it('should refresh the user lists', (done) => { - testAction( + it('should refresh the user lists', () => { + return testAction( deleteUserList, userList, state, [], [{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }], - done, ); }); }); @@ -149,8 +143,8 @@ describe('~/user_lists/store/index/actions', () => { Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } }); }); - it('should dispatch receiveDeleteUserListError', (done) => { - testAction( + it('should dispatch receiveDeleteUserListError', () => { + return testAction( deleteUserList, userList, state, @@ -162,15 +156,14 @@ describe('~/user_lists/store/index/actions', () => { payload: { list: userList, error: 'some error' }, }, ], - done, ); }); }); }); describe('receiveDeleteUserListError', () => { - it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', (done) => { - testAction( + it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', () => { + return testAction( receiveDeleteUserListError, { list: userList, error: 'mock error' }, state, @@ -181,22 +174,20 @@ describe('~/user_lists/store/index/actions', () => { }, ], [], - done, ); }); }); describe('clearAlert', () => { - it('should commit RECEIVE_CLEAR_ALERT', (done) => { + it('should commit RECEIVE_CLEAR_ALERT', () => { const alertIndex = 3; - testAction( + return testAction( clearAlert, alertIndex, state, [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }], [], - done, ); }); }); diff --git a/spec/frontend/user_lists/store/index/mutations_spec.js b/spec/frontend/user_lists/store/index/mutations_spec.js index 370838ae5fb..18d6a9b8f38 100644 --- a/spec/frontend/user_lists/store/index/mutations_spec.js +++ b/spec/frontend/user_lists/store/index/mutations_spec.js @@ -2,7 +2,7 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import * as types from '~/user_lists/store/index/mutation_types'; import mutations from '~/user_lists/store/index/mutations'; import createState from '~/user_lists/store/index/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; describe('~/user_lists/store/index/mutations', () => { let state; diff --git a/spec/frontend/user_lists/store/new/actions_spec.js b/spec/frontend/user_lists/store/new/actions_spec.js index 916ec2e6da7..fa69fa7fa66 100644 --- a/spec/frontend/user_lists/store/new/actions_spec.js +++ b/spec/frontend/user_lists/store/new/actions_spec.js @@ -4,7 +4,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import * as actions from '~/user_lists/store/new/actions'; import * as types from '~/user_lists/store/new/mutation_types'; import createState from '~/user_lists/store/new/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js index 36850e623c7..4985417ad99 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createFlash from '~/flash'; @@ -28,11 +29,6 @@ const testApprovals = () => ({ }); const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] }); -// For some reason, the `Promise.resolve()` needs to be deferred -// or the timing doesn't work. -const tick = () => Promise.resolve(); -const waitForTick = (done) => tick().then(done).catch(done.fail); - describe('MRWidget approvals', () => { let wrapper; let service; @@ -105,7 +101,7 @@ describe('MRWidget approvals', () => { // eslint-disable-next-line no-restricted-syntax wrapper.setData({ fetchingApprovals: true }); - return tick().then(() => { + return nextTick().then(() => { expect(wrapper.text()).toContain(FETCH_LOADING); }); }); @@ -116,10 +112,10 @@ describe('MRWidget approvals', () => { }); describe('when fetch approvals error', () => { - beforeEach((done) => { + beforeEach(() => { jest.spyOn(service, 'fetchApprovals').mockReturnValue(Promise.reject()); createComponent(); - waitForTick(done); + return nextTick(); }); it('still shows loading message', () => { @@ -133,13 +129,13 @@ describe('MRWidget approvals', () => { describe('action button', () => { describe('when mr is closed', () => { - beforeEach((done) => { + beforeEach(() => { mr.isOpen = false; mr.approvals.user_has_approved = false; mr.approvals.user_can_approve = true; createComponent(); - waitForTick(done); + return nextTick(); }); it('action is not rendered', () => { @@ -148,12 +144,12 @@ describe('MRWidget approvals', () => { }); describe('when user cannot approve', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_has_approved = false; mr.approvals.user_can_approve = false; createComponent(); - waitForTick(done); + return nextTick(); }); it('action is not rendered', () => { @@ -168,9 +164,9 @@ describe('MRWidget approvals', () => { }); describe('and MR is unapproved', () => { - beforeEach((done) => { + beforeEach(() => { createComponent(); - waitForTick(done); + return nextTick(); }); it('approve action is rendered', () => { @@ -188,10 +184,10 @@ describe('MRWidget approvals', () => { }); describe('with no approvers', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.approved_by = []; createComponent(); - waitForTick(done); + return nextTick(); }); it('approve action (with inverted style) is rendered', () => { @@ -204,10 +200,10 @@ describe('MRWidget approvals', () => { }); describe('with approvers', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.approved_by = [{ user: { id: 7 } }]; createComponent(); - waitForTick(done); + return nextTick(); }); it('approve additionally action is rendered', () => { @@ -221,9 +217,9 @@ describe('MRWidget approvals', () => { }); describe('when approve action is clicked', () => { - beforeEach((done) => { + beforeEach(() => { createComponent(); - waitForTick(done); + return nextTick(); }); it('shows loading icon', () => { @@ -234,15 +230,15 @@ describe('MRWidget approvals', () => { action.vm.$emit('click'); - return tick().then(() => { + return nextTick().then(() => { expect(action.props('loading')).toBe(true); }); }); describe('and after loading', () => { - beforeEach((done) => { + beforeEach(() => { findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('calls service approve', () => { @@ -259,10 +255,10 @@ describe('MRWidget approvals', () => { }); describe('and error', () => { - beforeEach((done) => { + beforeEach(() => { jest.spyOn(service, 'approveMergeRequest').mockReturnValue(Promise.reject()); findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('flashes error message', () => { @@ -273,12 +269,12 @@ describe('MRWidget approvals', () => { }); describe('when user has approved', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_has_approved = true; mr.approvals.user_can_approve = false; createComponent(); - waitForTick(done); + return nextTick(); }); it('revoke action is rendered', () => { @@ -291,9 +287,9 @@ describe('MRWidget approvals', () => { describe('when revoke action is clicked', () => { describe('and successful', () => { - beforeEach((done) => { + beforeEach(() => { findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('calls service unapprove', () => { @@ -310,10 +306,10 @@ describe('MRWidget approvals', () => { }); describe('and error', () => { - beforeEach((done) => { + beforeEach(() => { jest.spyOn(service, 'unapproveMergeRequest').mockReturnValue(Promise.reject()); findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('flashes error message', () => { @@ -333,11 +329,11 @@ describe('MRWidget approvals', () => { }); describe('and can approve', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_can_approve = true; createComponent(); - waitForTick(done); + return nextTick(); }); it('is shown', () => { @@ -350,11 +346,11 @@ describe('MRWidget approvals', () => { }); describe('and cannot approve', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_can_approve = false; createComponent(); - waitForTick(done); + return nextTick(); }); it('is shown', () => { @@ -369,9 +365,9 @@ describe('MRWidget approvals', () => { }); describe('approvals summary', () => { - beforeEach((done) => { + beforeEach(() => { createComponent(); - waitForTick(done); + return nextTick(); }); it('is rendered with props', () => { diff --git a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js index 64e802c4fa5..98cfc04eb25 100644 --- a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js +++ b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js @@ -8,7 +8,7 @@ describe('generateText', () => { ${'%{danger_start}Hello world%{danger_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-500">Hello world</span>'} ${'%{critical_start}Hello world%{critical_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-800">Hello world</span>'} ${'%{same_start}Hello world%{same_end}'} | ${'<span class="gl-font-weight-bold gl-text-gray-700">Hello world</span>'} - ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm">Hello world</span>'} + ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm gl-text-gray-700">Hello world</span>'} ${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'<span class="gl-font-weight-bold"><span class="gl-font-weight-bold gl-text-red-500">Hello world</span></span>'} ${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'} ${['array']} | ${null} diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js index c0a30a5093d..f0106914674 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js @@ -175,22 +175,19 @@ describe('MemoryUsage', () => { expect(el.querySelector('.js-usage-info')).toBeDefined(); }); - it('should show loading metrics message while metrics are being loaded', (done) => { + it('should show loading metrics message while metrics are being loaded', async () => { vm.loadingMetrics = true; vm.hasMetrics = false; vm.loadFailed = false; - nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined(); + await nextTick(); - expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined(); - - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics); - done(); - }); + expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined(); + expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics); }); - it('should show deployment memory usage when metrics are loaded', (done) => { + it('should show deployment memory usage when metrics are loaded', async () => { // ignore BoostrapVue warnings jest.spyOn(console, 'warn').mockImplementation(); @@ -199,37 +196,32 @@ describe('MemoryUsage', () => { vm.loadFailed = false; vm.memoryMetrics = metricsMockData.metrics.memory_values[0].values; - nextTick(() => { - expect(el.querySelector('.memory-graph-container')).toBeDefined(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics); - done(); - }); + await nextTick(); + + expect(el.querySelector('.memory-graph-container')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics); }); - it('should show failure message when metrics loading failed', (done) => { + it('should show failure message when metrics loading failed', async () => { vm.loadingMetrics = false; vm.hasMetrics = false; vm.loadFailed = true; - nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined(); + await nextTick(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed); - done(); - }); + expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed); }); - it('should show metrics unavailable message when metrics loading failed', (done) => { + it('should show metrics unavailable message when metrics loading failed', async () => { vm.loadingMetrics = false; vm.hasMetrics = false; vm.loadFailed = false; - nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined(); + await nextTick(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable); - done(); - }); + expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js index 7d86e453bc7..8efc4d84624 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -198,14 +198,13 @@ describe('MRWidgetMerged', () => { ); }); - it('hides button to copy commit SHA if SHA does not exist', (done) => { + it('hides button to copy commit SHA if SHA does not exist', async () => { vm.mr.mergeCommitSha = null; - nextTick(() => { - expect(selectors.copyMergeShaButton).toBe(null); - expect(vm.$el.querySelector('.mr-info-list').innerText).not.toContain('with'); - done(); - }); + await nextTick(); + + expect(selectors.copyMergeShaButton).toBe(null); + expect(vm.$el.querySelector('.mr-info-list').innerText).not.toContain('with'); }); it('shows merge commit SHA link', () => { @@ -214,24 +213,22 @@ describe('MRWidgetMerged', () => { expect(selectors.mergeCommitShaLink.href).toBe(vm.mr.mergeCommitPath); }); - it('should not show source branch deleted text', (done) => { + it('should not show source branch deleted text', async () => { vm.mr.sourceBranchRemoved = false; - nextTick(() => { - expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); - done(); - }); + await nextTick(); + + expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); }); - it('should show source branch deleting text', (done) => { + it('should show source branch deleting text', async () => { vm.mr.isRemovingSourceBranch = true; vm.mr.sourceBranchRemoved = false; - nextTick(() => { - expect(vm.$el.innerText).toContain('The source branch is being deleted'); - expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); - done(); - }); + await nextTick(); + + expect(vm.$el.innerText).toContain('The source branch is being deleted'); + expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); }); it('should use mergedEvent mergedAt as tooltip title', () => { diff --git a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js new file mode 100644 index 00000000000..88b8e32bd5d --- /dev/null +++ b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js @@ -0,0 +1,149 @@ +import { GlButton } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import testReportExtension from '~/vue_merge_request_widget/extensions/test_report'; +import { i18n } from '~/vue_merge_request_widget/extensions/test_report/constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { trimText } from 'helpers/text_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; +import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; +import httpStatusCodes from '~/lib/utils/http_status'; + +import { failedReport } from 'jest/reports/mock_data/mock_data'; +import mixedResultsTestReports from 'jest/reports/mock_data/new_and_fixed_failures_report.json'; +import newErrorsTestReports from 'jest/reports/mock_data/new_errors_report.json'; +import newFailedTestReports from 'jest/reports/mock_data/new_failures_report.json'; +import successTestReports from 'jest/reports/mock_data/no_failures_report.json'; +import resolvedFailures from 'jest/reports/mock_data/resolved_failures.json'; + +const reportWithParsingErrors = failedReport; +reportWithParsingErrors.suites[0].suite_errors = { + head: 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + base: 'JUnit data parsing failed: string not matched', +}; + +describe('Test report extension', () => { + let wrapper; + let mock; + + registerExtension(testReportExtension); + + const endpoint = '/root/repo/-/merge_requests/4/test_reports.json'; + + const mockApi = (statusCode, data = mixedResultsTestReports) => { + mock.onGet(endpoint).reply(statusCode, data); + }; + + const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button'); + const findTertiaryButton = () => wrapper.find(GlButton); + const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item'); + + const createComponent = () => { + wrapper = mountExtended(extensionsContainer, { + propsData: { + mr: { + testResultsPath: endpoint, + headBlobPath: 'head/blob/path', + pipeline: { path: 'pipeline/path' }, + }, + }, + }); + }; + + const createExpandedWidgetWithData = async (data = mixedResultsTestReports) => { + mockApi(httpStatusCodes.OK, data); + createComponent(); + await waitForPromises(); + findToggleCollapsedButton().trigger('click'); + await waitForPromises(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('summary', () => { + it('displays loading text', () => { + mockApi(httpStatusCodes.OK); + createComponent(); + + expect(wrapper.text()).toContain(i18n.loading); + }); + + it('displays failed loading text', async () => { + mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.error); + }); + + it.each` + description | mockData | expectedResult + ${'mixed test results'} | ${mixedResultsTestReports} | ${'Test summary: 2 failed and 2 fixed test results, 11 total tests'} + ${'unchanged test results'} | ${successTestReports} | ${'Test summary: no changed test results, 11 total tests'} + ${'tests with errors'} | ${newErrorsTestReports} | ${'Test summary: 2 errors, 11 total tests'} + ${'failed test results'} | ${newFailedTestReports} | ${'Test summary: 2 failed, 11 total tests'} + ${'resolved failures'} | ${resolvedFailures} | ${'Test summary: 4 fixed test results, 11 total tests'} + `('displays summary text for $description', async ({ mockData, expectedResult }) => { + mockApi(httpStatusCodes.OK, mockData); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(expectedResult); + }); + + it('displays a link to the full report', async () => { + mockApi(httpStatusCodes.OK); + createComponent(); + + await waitForPromises(); + + expect(findTertiaryButton().text()).toBe('Full report'); + expect(findTertiaryButton().attributes('href')).toBe('pipeline/path/test_report'); + }); + + it('shows an error when a suite has a parsing error', async () => { + mockApi(httpStatusCodes.OK, reportWithParsingErrors); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.error); + }); + }); + + describe('expanded data', () => { + it('displays summary for each suite', async () => { + await createExpandedWidgetWithData(); + + expect(trimText(findAllExtensionListItems().at(0).text())).toBe( + 'rspec:pg: 1 failed and 2 fixed test results, 8 total tests', + ); + expect(trimText(findAllExtensionListItems().at(1).text())).toBe( + 'java ant: 1 failed, 3 total tests', + ); + }); + + it('displays suite parsing errors', async () => { + await createExpandedWidgetWithData(reportWithParsingErrors); + + const suiteText = trimText(findAllExtensionListItems().at(0).text()); + + expect(suiteText).toContain( + 'Head report parsing error: JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + ); + expect(suiteText).toContain( + 'Base report parsing error: JUnit data parsing failed: string not matched', + ); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index 0540107ea5f..9719e81fe12 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -46,6 +46,8 @@ describe('MrWidgetOptions', () => { let mock; const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; + const findExtensionToggleButton = () => + wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]'); beforeEach(() => { gl.mrWidgetData = { ...mockData }; @@ -187,9 +189,9 @@ describe('MrWidgetOptions', () => { }); describe('when merge request is opened', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.isOpen = true; - nextTick(done); + return nextTick(); }); it('should render collaboration status', () => { @@ -198,9 +200,9 @@ describe('MrWidgetOptions', () => { }); describe('when merge request is not opened', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.isOpen = false; - nextTick(done); + return nextTick(); }); it('should not render collaboration status', () => { @@ -215,9 +217,9 @@ describe('MrWidgetOptions', () => { }); describe('when merge request is opened', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.isOpen = true; - nextTick(done); + return nextTick(); }); it('should not render collaboration status', () => { @@ -229,11 +231,11 @@ describe('MrWidgetOptions', () => { describe('showMergePipelineForkWarning', () => { describe('when the source project and target project are the same', () => { - beforeEach((done) => { + beforeEach(() => { Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true); Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); Vue.set(wrapper.vm.mr, 'targetProjectId', 1); - nextTick(done); + return nextTick(); }); it('should be false', () => { @@ -242,11 +244,11 @@ describe('MrWidgetOptions', () => { }); describe('when merge pipelines are not enabled', () => { - beforeEach((done) => { + beforeEach(() => { Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', false); Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); Vue.set(wrapper.vm.mr, 'targetProjectId', 2); - nextTick(done); + return nextTick(); }); it('should be false', () => { @@ -255,11 +257,11 @@ describe('MrWidgetOptions', () => { }); describe('when merge pipelines are enabled _and_ the source project and target project are different', () => { - beforeEach((done) => { + beforeEach(() => { Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true); Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); Vue.set(wrapper.vm.mr, 'targetProjectId', 2); - nextTick(done); + return nextTick(); }); it('should be true', () => { @@ -439,15 +441,10 @@ describe('MrWidgetOptions', () => { expect(setFaviconOverlay).toHaveBeenCalledWith(overlayDataUrl); }); - it('should not call setFavicon when there is no ciStatusFaviconPath', (done) => { + it('should not call setFavicon when there is no ciStatusFaviconPath', async () => { wrapper.vm.mr.ciStatusFaviconPath = null; - wrapper.vm - .setFaviconHelper() - .then(() => { - expect(faviconElement.getAttribute('href')).toEqual(null); - done(); - }) - .catch(done.fail); + await wrapper.vm.setFaviconHelper(); + expect(faviconElement.getAttribute('href')).toEqual(null); }); }); @@ -534,44 +531,36 @@ describe('MrWidgetOptions', () => { expect(wrapper.find('.close-related-link').exists()).toBe(true); }); - it('does not render if state is nothingToMerge', (done) => { + it('does not render if state is nothingToMerge', async () => { wrapper.vm.mr.state = stateKey.nothingToMerge; - nextTick(() => { - expect(wrapper.find('.close-related-link').exists()).toBe(false); - done(); - }); + await nextTick(); + expect(wrapper.find('.close-related-link').exists()).toBe(false); }); }); describe('rendering source branch removal status', () => { - it('renders when user cannot remove branch and branch should be removed', (done) => { + it('renders when user cannot remove branch and branch should be removed', async () => { wrapper.vm.mr.canRemoveSourceBranch = false; wrapper.vm.mr.shouldRemoveSourceBranch = true; wrapper.vm.mr.state = 'readyToMerge'; - nextTick(() => { - const tooltip = wrapper.find('[data-testid="question-o-icon"]'); - - expect(wrapper.text()).toContain('Deletes the source branch'); - expect(tooltip.attributes('title')).toBe( - 'A user with write access to the source branch selected this option', - ); + await nextTick(); + const tooltip = wrapper.find('[data-testid="question-o-icon"]'); - done(); - }); + expect(wrapper.text()).toContain('Deletes the source branch'); + expect(tooltip.attributes('title')).toBe( + 'A user with write access to the source branch selected this option', + ); }); - it('does not render in merged state', (done) => { + it('does not render in merged state', async () => { wrapper.vm.mr.canRemoveSourceBranch = false; wrapper.vm.mr.shouldRemoveSourceBranch = true; wrapper.vm.mr.state = 'merged'; - nextTick(() => { - expect(wrapper.text()).toContain('The source branch has been deleted'); - expect(wrapper.text()).not.toContain('Deletes the source branch'); - - done(); - }); + await nextTick(); + expect(wrapper.text()).toContain('The source branch has been deleted'); + expect(wrapper.text()).not.toContain('Deletes the source branch'); }); }); @@ -605,7 +594,7 @@ describe('MrWidgetOptions', () => { status: SUCCESS, }; - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.deployments.push( { ...deploymentMockData, @@ -616,7 +605,7 @@ describe('MrWidgetOptions', () => { }, ); - nextTick(done); + return nextTick(); }); it('renders multiple deployments', () => { @@ -639,7 +628,7 @@ describe('MrWidgetOptions', () => { describe('pipeline for target branch after merge', () => { describe('with information for target branch pipeline', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.state = 'merged'; wrapper.vm.mr.mergePipeline = { id: 127, @@ -747,7 +736,7 @@ describe('MrWidgetOptions', () => { }, cancel_path: '/root/ci-web-terminal/pipelines/127/cancel', }; - nextTick(done); + return nextTick(); }); it('renders pipeline block', () => { @@ -755,7 +744,7 @@ describe('MrWidgetOptions', () => { }); describe('with post merge deployments', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.postMergeDeployments = [ { id: 15, @@ -787,7 +776,7 @@ describe('MrWidgetOptions', () => { }, ]; - nextTick(done); + return nextTick(); }); it('renders post deployment information', () => { @@ -797,10 +786,10 @@ describe('MrWidgetOptions', () => { }); describe('without information for target branch pipeline', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.state = 'merged'; - nextTick(done); + return nextTick(); }); it('does not render pipeline block', () => { @@ -809,10 +798,10 @@ describe('MrWidgetOptions', () => { }); describe('when state is not merged', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.state = 'archived'; - nextTick(done); + return nextTick(); }); it('does not render pipeline block', () => { @@ -905,7 +894,7 @@ describe('MrWidgetOptions', () => { beforeEach(() => { pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); - registerExtension(workingExtension); + registerExtension(workingExtension()); createComponent(); }); @@ -937,9 +926,7 @@ describe('MrWidgetOptions', () => { it('renders full data', async () => { await waitForPromises(); - wrapper - .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') - .trigger('click'); + findExtensionToggleButton().trigger('click'); await nextTick(); @@ -975,6 +962,24 @@ describe('MrWidgetOptions', () => { }); }); + describe('expansion', () => { + it('hides collapse button', async () => { + registerExtension(workingExtension(false)); + createComponent(); + await waitForPromises(); + + expect(findExtensionToggleButton().exists()).toBe(false); + }); + + it('shows collapse button', async () => { + registerExtension(workingExtension(true)); + createComponent(); + await waitForPromises(); + + expect(findExtensionToggleButton().exists()).toBe(true); + }); + }); + describe('mock polling extension', () => { let pollRequest; let pollStop; @@ -1025,7 +1030,7 @@ describe('MrWidgetOptions', () => { it('captures sentry error and displays error when poll has failed', () => { expect(captureException).toHaveBeenCalledTimes(1); expect(captureException).toHaveBeenCalledWith(new Error('Fetch error')); - expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error'); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); }); }); }); @@ -1036,7 +1041,7 @@ describe('MrWidgetOptions', () => { const itHandlesTheException = () => { expect(captureException).toHaveBeenCalledTimes(1); expect(captureException).toHaveBeenCalledWith(new Error('Fetch error')); - expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error'); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); }; beforeEach(() => { diff --git a/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js index 9423fa17c44..22562bb4ddb 100644 --- a/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js +++ b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js @@ -22,27 +22,25 @@ describe('Artifacts App Store Actions', () => { }); describe('setEndpoint', () => { - it('should commit SET_ENDPOINT mutation', (done) => { - testAction( + it('should commit SET_ENDPOINT mutation', () => { + return testAction( setEndpoint, 'endpoint.json', mockedState, [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }], [], - done, ); }); }); describe('requestArtifacts', () => { - it('should commit REQUEST_ARTIFACTS mutation', (done) => { - testAction( + it('should commit REQUEST_ARTIFACTS mutation', () => { + return testAction( requestArtifacts, null, mockedState, [{ type: types.REQUEST_ARTIFACTS }], [], - done, ); }); }); @@ -62,7 +60,7 @@ describe('Artifacts App Store Actions', () => { }); describe('success', () => { - it('dispatches requestArtifacts and receiveArtifactsSuccess ', (done) => { + it('dispatches requestArtifacts and receiveArtifactsSuccess ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, [ { text: 'result.txt', @@ -72,7 +70,7 @@ describe('Artifacts App Store Actions', () => { }, ]); - testAction( + return testAction( fetchArtifacts, null, mockedState, @@ -96,7 +94,6 @@ describe('Artifacts App Store Actions', () => { type: 'receiveArtifactsSuccess', }, ], - done, ); }); }); @@ -106,8 +103,8 @@ describe('Artifacts App Store Actions', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); }); - it('dispatches requestArtifacts and receiveArtifactsError ', (done) => { - testAction( + it('dispatches requestArtifacts and receiveArtifactsError ', () => { + return testAction( fetchArtifacts, null, mockedState, @@ -120,45 +117,41 @@ describe('Artifacts App Store Actions', () => { type: 'receiveArtifactsError', }, ], - done, ); }); }); }); describe('receiveArtifactsSuccess', () => { - it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', (done) => { - testAction( + it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', () => { + return testAction( receiveArtifactsSuccess, { data: { summary: {} }, status: 200 }, mockedState, [{ type: types.RECEIVE_ARTIFACTS_SUCCESS, payload: { summary: {} } }], [], - done, ); }); - it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', (done) => { - testAction( + it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', () => { + return testAction( receiveArtifactsSuccess, { data: { summary: {} }, status: 204 }, mockedState, [], [], - done, ); }); }); describe('receiveArtifactsError', () => { - it('should commit RECEIVE_ARTIFACTS_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_ARTIFACTS_ERROR mutation', () => { + return testAction( receiveArtifactsError, null, mockedState, [{ type: types.RECEIVE_ARTIFACTS_ERROR }], [], - done, ); }); }); diff --git a/spec/frontend/vue_mr_widget/test_extensions.js b/spec/frontend/vue_mr_widget/test_extensions.js index 986c1d6545a..6344636873f 100644 --- a/spec/frontend/vue_mr_widget/test_extensions.js +++ b/spec/frontend/vue_mr_widget/test_extensions.js @@ -1,6 +1,6 @@ import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; -export const workingExtension = { +export const workingExtension = (shouldCollapse = true) => ({ name: 'WidgetTestExtension', props: ['targetProjectFullPath'], expandEvent: 'test_expand_event', @@ -11,6 +11,9 @@ export const workingExtension = { statusIcon({ count }) { return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; }, + shouldCollapse() { + return shouldCollapse; + }, }, methods: { fetchCollapsedData({ targetProjectFullPath }) { @@ -36,7 +39,7 @@ export const workingExtension = { ]); }, }, -}; +}); export const collapsedDataErrorExtension = { name: 'WidgetTestCollapsedErrorExtension', @@ -99,7 +102,7 @@ export const fullDataErrorExtension = { }; export const pollingExtension = { - ...workingExtension, + ...workingExtension(), enablePolling: true, }; diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js index 7ee6e29e6de..7aa54a1c55a 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -12,12 +12,17 @@ import AlertSummaryRow from '~/vue_shared/alert_details/components/alert_summary import { PAGE_CONFIG, SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants'; import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue'; +import createStore from '~/vue_shared/components/metric_images/store/'; +import service from '~/vue_shared/alert_details/service'; import mockAlerts from './mocks/alerts.json'; const mockAlert = mockAlerts[0]; const environmentName = 'Production'; const environmentPath = '/fake/path'; +jest.mock('~/vue_shared/alert_details/service'); + describe('AlertDetails', () => { let environmentData = { name: environmentName, path: environmentPath }; let mock; @@ -67,9 +72,11 @@ describe('AlertDetails', () => { $route: { params: {} }, }, stubs: { - ...stubs, AlertSummaryRow, + 'metric-images-tab': true, + ...stubs, }, + store: createStore({}, service), }), ); } @@ -91,7 +98,7 @@ describe('AlertDetails', () => { const findEnvironmentName = () => wrapper.findByTestId('environmentName'); const findEnvironmentPath = () => wrapper.findByTestId('environmentPath'); const findDetailsTable = () => wrapper.findComponent(AlertDetailsTable); - const findMetricsTab = () => wrapper.findByTestId('metrics'); + const findMetricsTab = () => wrapper.findComponent(MetricImagesTab); describe('Alert details', () => { describe('when alert is null', () => { @@ -129,8 +136,21 @@ describe('AlertDetails', () => { expect(wrapper.findByTestId('startTimeItem').exists()).toBe(true); expect(wrapper.findByTestId('startTimeItem').props('time')).toBe(mockAlert.startedAt); }); + }); + + describe('Metrics tab', () => { + it('should mount without errors', () => { + mountComponent({ + mountMethod: mount, + provide: { + canUpdate: true, + iid: '1', + }, + stubs: { + MetricImagesTab, + }, + }); - it('renders the metrics tab', () => { expect(findMetricsTab().exists()).toBe(true); }); }); @@ -312,7 +332,9 @@ describe('AlertDetails', () => { describe('header', () => { const findHeader = () => wrapper.findByTestId('alert-header'); - const stubs = { TimeAgoTooltip: { template: '<span>now</span>' } }; + const stubs = { + TimeAgoTooltip: { template: '<span>now</span>' }, + }; describe('individual header fields', () => { describe.each` diff --git a/spec/frontend/vue_shared/alert_details/service_spec.js b/spec/frontend/vue_shared/alert_details/service_spec.js new file mode 100644 index 00000000000..790854d0ca7 --- /dev/null +++ b/spec/frontend/vue_shared/alert_details/service_spec.js @@ -0,0 +1,44 @@ +import { fileList, fileListRaw } from 'jest/vue_shared/components/metric_images/mock_data'; +import { + getMetricImages, + uploadMetricImage, + updateMetricImage, + deleteMetricImage, +} from '~/vue_shared/alert_details/service'; +import * as alertManagementAlertsApi from '~/api/alert_management_alerts_api'; + +jest.mock('~/api/alert_management_alerts_api'); + +describe('Alert details service', () => { + it('fetches metric images', async () => { + alertManagementAlertsApi.fetchAlertMetricImages.mockResolvedValue({ data: fileListRaw }); + const result = await getMetricImages(); + + expect(alertManagementAlertsApi.fetchAlertMetricImages).toHaveBeenCalled(); + expect(result).toEqual(fileList); + }); + + it('uploads a metric image', async () => { + alertManagementAlertsApi.uploadAlertMetricImage.mockResolvedValue({ data: fileListRaw[0] }); + const result = await uploadMetricImage(); + + expect(alertManagementAlertsApi.uploadAlertMetricImage).toHaveBeenCalled(); + expect(result).toEqual(fileList[0]); + }); + + it('updates a metric image', async () => { + alertManagementAlertsApi.updateAlertMetricImage.mockResolvedValue({ data: fileListRaw[0] }); + const result = await updateMetricImage(); + + expect(alertManagementAlertsApi.updateAlertMetricImage).toHaveBeenCalled(); + expect(result).toEqual(fileList[0]); + }); + + it('deletes a metric image', async () => { + alertManagementAlertsApi.deleteAlertMetricImage.mockResolvedValue({ data: '' }); + const result = await deleteMetricImage(); + + expect(alertManagementAlertsApi.deleteAlertMetricImage).toHaveBeenCalled(); + expect(result).toEqual({}); + }); +}); diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index c14cf0db370..bdf5ea23812 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -218,65 +218,88 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <div class="award-menu-holder gl-my-2" > - <button - aria-label="Add reaction" - class="btn add-reaction-button js-add-award btn-default btn-md gl-button js-test-add-button-class" + <div + class="emoji-picker" + data-testid="emoji-picker" title="Add reaction" - type="button" > - <!----> - - <!----> - - <span - class="gl-button-text" + <div + boundary="scrollParent" + class="dropdown b-dropdown gl-new-dropdown btn-group" + id="__BVID__13" + lazy="" + menu-class="dropdown-extended-height" + no-flip="" > - <span - class="reaction-control-icon reaction-control-icon-neutral" + <!----> + <button + aria-expanded="false" + aria-haspopup="true" + class="btn dropdown-toggle btn-default btn-md add-reaction-button btn-icon gl-relative! gl-button gl-dropdown-toggle btn-default-secondary" + id="__BVID__13__BV_toggle_" + type="button" > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="slight-smile-icon" - role="img" + <span + class="gl-sr-only" > - <use - href="#slight-smile" - /> - </svg> - </span> - - <span - class="reaction-control-icon reaction-control-icon-positive" - > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="smiley-icon" - role="img" + Add reaction + </span> + + <span + class="reaction-control-icon reaction-control-icon-neutral" > - <use - href="#smiley" - /> - </svg> - </span> - - <span - class="reaction-control-icon reaction-control-icon-super-positive" - > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="smile-icon" - role="img" + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="slight-smile-icon" + role="img" + > + <use + href="#slight-smile" + /> + </svg> + </span> + + <span + class="reaction-control-icon reaction-control-icon-positive" > - <use - href="#smile" - /> - </svg> - </span> - </span> - </button> + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="smiley-icon" + role="img" + > + <use + href="#smiley" + /> + </svg> + </span> + + <span + class="reaction-control-icon reaction-control-icon-super-positive" + > + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="smile-icon" + role="img" + > + <use + href="#smile" + /> + </svg> + </span> + </button> + <ul + aria-labelledby="__BVID__13__BV_toggle_" + class="dropdown-menu dropdown-extended-height dropdown-menu-right" + role="menu" + tabindex="-1" + > + <!----> + </ul> + </div> + </div> </div> </div> `; diff --git a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap deleted file mode 100644 index 1d8e04b83a3..00000000000 --- a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Identicon entity id is a GraphQL id matches snapshot 1`] = ` -<div - class="avatar identicon s40 bg2" -> - - E - -</div> -`; - -exports[`Identicon entity id is a number matches snapshot 1`] = ` -<div - class="avatar identicon s40 bg2" -> - - E - -</div> -`; diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index 95e9760c181..1c8cf726aca 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -76,7 +76,7 @@ describe('vue_shared/components/awards_list', () => { count: Number(x.find('.js-counter').text()), }; }); - const findAddAwardButton = () => wrapper.find('.js-add-award'); + const findAddAwardButton = () => wrapper.find('[data-testid="emoji-picker"]'); describe('default', () => { beforeEach(() => { @@ -151,7 +151,6 @@ describe('vue_shared/components/awards_list', () => { const btn = findAddAwardButton(); expect(btn.exists()).toBe(true); - expect(btn.classes(TEST_ADD_BUTTON_CLASS)).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js index 663ebd3e12f..4b44311b253 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js @@ -2,9 +2,6 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants'; import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue'; -import LineHighlighter from '~/blob/line_highlighter'; - -jest.mock('~/blob/line_highlighter'); describe('Blob Simple Viewer component', () => { let wrapper; @@ -30,20 +27,6 @@ describe('Blob Simple Viewer component', () => { wrapper.destroy(); }); - describe('refactorBlobViewer feature flag', () => { - it('loads the LineHighlighter if refactorBlobViewer is enabled', () => { - createComponent('', false, { refactorBlobViewer: true }); - - expect(LineHighlighter).toHaveBeenCalled(); - }); - - it('does not load the LineHighlighter if refactorBlobViewer is disabled', () => { - createComponent('', false, { refactorBlobViewer: false }); - - expect(LineHighlighter).not.toHaveBeenCalled(); - }); - }); - it('does not fail if content is empty', () => { const spy = jest.spyOn(window.console, 'error'); createComponent(''); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index 575e8a73050..b6a181e6a0b 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -26,7 +26,6 @@ import { tokenValueMilestone, tokenValueMembership, tokenValueConfidential, - tokenValueEmpty, } from './mock_data'; jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ @@ -207,33 +206,14 @@ describe('FilteredSearchBarRoot', () => { }); }); - describe('watchers', () => { - describe('filterValue', () => { - it('emits component event `onFilter` with empty array and false when filter was never selected', async () => { - wrapper = createComponent({ initialFilterValue: [tokenValueEmpty] }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - initialRender: false, - filterValue: [tokenValueEmpty], - }); - - await nextTick(); - expect(wrapper.emitted('onFilter')[0]).toEqual([[], false]); - }); + describe('events', () => { + it('emits component event `onFilter` with empty array and true when initially selected filter value was cleared', async () => { + wrapper = createComponent({ initialFilterValue: [tokenValueLabel] }); - it('emits component event `onFilter` with empty array and true when initially selected filter value was cleared', async () => { - wrapper = createComponent({ initialFilterValue: [tokenValueLabel] }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - initialRender: false, - filterValue: [tokenValueEmpty], - }); + wrapper.find(GlFilteredSearch).vm.$emit('clear'); - await nextTick(); - expect(wrapper.emitted('onFilter')[0]).toEqual([[], true]); - }); + await nextTick(); + expect(wrapper.emitted('onFilter')[0]).toEqual([[], true]); }); }); diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js index b67385cc43e..e636f58d868 100644 --- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js +++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js @@ -89,8 +89,11 @@ describe('InputCopyToggleVisibility', () => { }); describe('when clicked', () => { + let event; + beforeEach(async () => { - await findRevealButton().trigger('click'); + event = { stopPropagation: jest.fn() }; + await findRevealButton().trigger('click', event); }); it('displays value', () => { @@ -110,6 +113,11 @@ describe('InputCopyToggleVisibility', () => { it('emits `visibility-change` event', () => { expect(wrapper.emitted('visibility-change')[0]).toEqual([true]); }); + + it('stops propagation on click event', () => { + // in case the input is located in a dropdown or modal + expect(event.stopPropagation).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/spec/frontend/vue_shared/components/help_popover_spec.js b/spec/frontend/vue_shared/components/help_popover_spec.js index 597fb63d95c..64dce194327 100644 --- a/spec/frontend/vue_shared/components/help_popover_spec.js +++ b/spec/frontend/vue_shared/components/help_popover_spec.js @@ -34,7 +34,7 @@ describe('HelpPopover', () => { it('renders a link button with an icon question', () => { expect(findQuestionButton().props()).toMatchObject({ - icon: 'question', + icon: 'question-o', variant: 'link', }); }); diff --git a/spec/frontend/vue_shared/components/identicon_spec.js b/spec/frontend/vue_shared/components/identicon_spec.js deleted file mode 100644 index 24fc3713e2b..00000000000 --- a/spec/frontend/vue_shared/components/identicon_spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import IdenticonComponent from '~/vue_shared/components/identicon.vue'; - -describe('Identicon', () => { - let wrapper; - - const defaultProps = { - entityId: 1, - entityName: 'entity-name', - sizeClass: 's40', - }; - - const createComponent = (props = {}) => { - wrapper = shallowMount(IdenticonComponent, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('entity id is a number', () => { - beforeEach(() => createComponent()); - - it('matches snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('adds a correct class to identicon', () => { - expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); - }); - }); - - describe('entity id is a GraphQL id', () => { - beforeEach(() => createComponent({ entityId: 'gid://gitlab/Project/8' })); - - it('matches snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('adds a correct class to identicon', () => { - expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/line_numbers_spec.js b/spec/frontend/vue_shared/components/line_numbers_spec.js deleted file mode 100644 index 38c26226863..00000000000 --- a/spec/frontend/vue_shared/components/line_numbers_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlIcon, GlLink } from '@gitlab/ui'; -import LineNumbers from '~/vue_shared/components/line_numbers.vue'; - -describe('Line Numbers component', () => { - let wrapper; - const lines = 10; - - const createComponent = () => { - wrapper = shallowMount(LineNumbers, { propsData: { lines } }); - }; - - const findGlIcon = () => wrapper.findComponent(GlIcon); - const findLineNumbers = () => wrapper.findAllComponents(GlLink); - const findFirstLineNumber = () => findLineNumbers().at(0); - - beforeEach(() => createComponent()); - - afterEach(() => wrapper.destroy()); - - describe('rendering', () => { - it('renders Line Numbers', () => { - expect(findLineNumbers().length).toBe(lines); - expect(findFirstLineNumber().attributes()).toMatchObject({ - id: 'L1', - to: '#LC1', - }); - }); - - it('renders a link icon', () => { - expect(findGlIcon().props()).toMatchObject({ - size: 12, - name: 'link', - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js index dac633fe6c8..a80717a1aea 100644 --- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js +++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js @@ -1,31 +1,29 @@ import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +const STORAGE_KEY = 'key'; + describe('Local Storage Sync', () => { let wrapper; - const createComponent = ({ props = {}, slots = {} } = {}) => { + const createComponent = ({ value, asString = false, slots = {} } = {}) => { wrapper = shallowMount(LocalStorageSync, { - propsData: props, + propsData: { storageKey: STORAGE_KEY, value, asString }, slots, }); }; + const setStorageValue = (value) => localStorage.setItem(STORAGE_KEY, value); + const getStorageValue = (value) => localStorage.getItem(STORAGE_KEY, value); + afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - wrapper = null; + wrapper.destroy(); localStorage.clear(); }); it('is a renderless component', () => { const html = '<div class="test-slot"></div>'; createComponent({ - props: { - storageKey: 'key', - }, slots: { default: html, }, @@ -35,233 +33,136 @@ describe('Local Storage Sync', () => { }); describe('localStorage empty', () => { - const storageKey = 'issue_list_order'; - it('does not emit input event', () => { - createComponent({ - props: { - storageKey, - value: 'ascending', - }, - }); - - expect(wrapper.emitted('input')).toBeFalsy(); - }); - - it.each('foo', 3, true, ['foo', 'bar'], { foo: 'bar' })( - 'saves updated value to localStorage', - async (newValue) => { - createComponent({ - props: { - storageKey, - value: 'initial', - }, - }); - - wrapper.setProps({ value: newValue }); + createComponent({ value: 'ascending' }); - await nextTick(); - expect(localStorage.getItem(storageKey)).toBe(String(newValue)); - }, - ); - - it('does not save default value', () => { - const value = 'ascending'; + expect(wrapper.emitted('input')).toBeUndefined(); + }); - createComponent({ - props: { - storageKey, - value, - }, - }); + it('does not save initial value if it did not change', () => { + createComponent({ value: 'ascending' }); - expect(localStorage.getItem(storageKey)).toBe(null); + expect(getStorageValue()).toBeNull(); }); }); describe('localStorage has saved value', () => { - const storageKey = 'issue_list_order_by'; const savedValue = 'last_updated'; beforeEach(() => { - localStorage.setItem(storageKey, savedValue); + setStorageValue(savedValue); + createComponent({ asString: true }); }); it('emits input event with saved value', () => { - createComponent({ - props: { - storageKey, - value: 'ascending', - }, - }); - expect(wrapper.emitted('input')[0][0]).toBe(savedValue); }); - it('does not overwrite localStorage with prop value', () => { - createComponent({ - props: { - storageKey, - value: 'created', - }, - }); - - expect(localStorage.getItem(storageKey)).toBe(savedValue); + it('does not overwrite localStorage with initial prop value', () => { + expect(getStorageValue()).toBe(savedValue); }); it('updating the value updates localStorage', async () => { - createComponent({ - props: { - storageKey, - value: 'created', - }, - }); - const newValue = 'last_updated'; - wrapper.setProps({ - value: newValue, - }); + await wrapper.setProps({ value: newValue }); - await nextTick(); - expect(localStorage.getItem(storageKey)).toBe(newValue); + expect(getStorageValue()).toBe(newValue); }); + }); + describe('persist prop', () => { it('persists the value by default', async () => { const persistedValue = 'persisted'; + createComponent({ asString: true }); + // Sanity check to make sure we start with nothing saved. + expect(getStorageValue()).toBeNull(); - createComponent({ - props: { - storageKey, - }, - }); + await wrapper.setProps({ value: persistedValue }); - wrapper.setProps({ value: persistedValue }); - await nextTick(); - expect(localStorage.getItem(storageKey)).toBe(persistedValue); + expect(getStorageValue()).toBe(persistedValue); }); it('does not save a value if persist is set to false', async () => { + const value = 'saved'; const notPersistedValue = 'notPersisted'; + createComponent({ asString: true }); + // Save some value so we can test that it's not overwritten. + await wrapper.setProps({ value }); - createComponent({ - props: { - storageKey, - }, - }); + expect(getStorageValue()).toBe(value); - wrapper.setProps({ persist: false, value: notPersistedValue }); - await nextTick(); - expect(localStorage.getItem(storageKey)).not.toBe(notPersistedValue); + await wrapper.setProps({ persist: false, value: notPersistedValue }); + + expect(getStorageValue()).toBe(value); }); }); - describe('with "asJson" prop set to "true"', () => { - const storageKey = 'testStorageKey'; - - describe.each` - value | serializedValue - ${null} | ${'null'} - ${''} | ${'""'} - ${true} | ${'true'} - ${false} | ${'false'} - ${42} | ${'42'} - ${'42'} | ${'"42"'} - ${'{ foo: '} | ${'"{ foo: "'} - ${['test']} | ${'["test"]'} - ${{ foo: 'bar' }} | ${'{"foo":"bar"}'} - `('given $value', ({ value, serializedValue }) => { - describe('is a new value', () => { - beforeEach(async () => { - createComponent({ - props: { - storageKey, - value: 'initial', - asJson: true, - }, - }); - - wrapper.setProps({ value }); - - await nextTick(); - }); - - it('serializes the value correctly to localStorage', () => { - expect(localStorage.getItem(storageKey)).toBe(serializedValue); - }); - }); - - describe('is already stored', () => { - beforeEach(() => { - localStorage.setItem(storageKey, serializedValue); - - createComponent({ - props: { - storageKey, - value: 'initial', - asJson: true, - }, - }); - }); - - it('emits an input event with the deserialized value', () => { - expect(wrapper.emitted('input')).toEqual([[value]]); - }); - }); + describe('saving and restoring', () => { + it.each` + value | asString + ${'foo'} | ${true} + ${'foo'} | ${false} + ${'{ a: 1 }'} | ${true} + ${'{ a: 1 }'} | ${false} + ${3} | ${false} + ${['foo', 'bar']} | ${false} + ${{ foo: 'bar' }} | ${false} + ${null} | ${false} + ${' '} | ${false} + ${true} | ${false} + ${false} | ${false} + ${42} | ${false} + ${'42'} | ${false} + ${'{ foo: '} | ${false} + `('saves and restores the same value', async ({ value, asString }) => { + // Create an initial component to save the value. + createComponent({ asString }); + await wrapper.setProps({ value }); + wrapper.destroy(); + // Create a second component to restore the value. Restore is only done once, when the + // component is first mounted. + createComponent({ asString }); + + expect(wrapper.emitted('input')[0][0]).toEqual(value); }); - describe('with bad JSON in storage', () => { - const badJSON = '{ badJSON'; - - beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(); - localStorage.setItem(storageKey, badJSON); - - createComponent({ - props: { - storageKey, - value: 'initial', - asJson: true, - }, - }); - }); - - it('should console warn', () => { - // eslint-disable-next-line no-console - expect(console.warn).toHaveBeenCalledWith( - `[gitlab] Failed to deserialize value from localStorage (key=${storageKey})`, - badJSON, - ); - }); - - it('should not emit an input event', () => { - expect(wrapper.emitted('input')).toBeUndefined(); - }); + it('shows a warning when trying to save a non-string value when asString prop is true', async () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(); + createComponent({ asString: true }); + await wrapper.setProps({ value: [] }); + + expect(spy).toHaveBeenCalled(); }); }); - it('clears localStorage when clear property is true', async () => { - const storageKey = 'key'; - const value = 'initial'; + describe('with bad JSON in storage', () => { + const badJSON = '{ badJSON'; + let spy; - createComponent({ - props: { - storageKey, - }, + beforeEach(() => { + spy = jest.spyOn(console, 'warn').mockImplementation(); + setStorageValue(badJSON); + createComponent(); }); - wrapper.setProps({ - value, + + it('should console warn', () => { + expect(spy).toHaveBeenCalled(); }); - await nextTick(); + it('should not emit an input event', () => { + expect(wrapper.emitted('input')).toBeUndefined(); + }); + }); - expect(localStorage.getItem(storageKey)).toBe(value); + it('clears localStorage when clear property is true', async () => { + const value = 'initial'; + createComponent({ asString: true }); + await wrapper.setProps({ value }); - wrapper.setProps({ - clear: true, - }); + expect(getStorageValue()).toBe(value); - await nextTick(); + await wrapper.setProps({ clear: true }); - expect(localStorage.getItem(storageKey)).toBe(null); + expect(getStorageValue()).toBeNull(); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js index c56628fcbcd..ecb2b37c3a5 100644 --- a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js +++ b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlFormTextarea, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ApplySuggestionComponent from '~/vue_shared/components/markdown/apply_suggestion.vue'; @@ -10,9 +10,10 @@ describe('Apply Suggestion component', () => { wrapper = shallowMount(ApplySuggestionComponent, { propsData: { ...propsData, ...props } }); }; - const findDropdown = () => wrapper.find(GlDropdown); - const findTextArea = () => wrapper.find(GlFormTextarea); - const findApplyButton = () => wrapper.find(GlButton); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findTextArea = () => wrapper.findComponent(GlFormTextarea); + const findApplyButton = () => wrapper.findComponent(GlButton); + const findAlert = () => wrapper.findComponent(GlAlert); beforeEach(() => createWrapper()); @@ -53,6 +54,20 @@ describe('Apply Suggestion component', () => { }); }); + describe('error', () => { + it('displays an error message', () => { + const errorMessage = 'Error message'; + createWrapper({ errorMessage }); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.props('variant')).toBe('danger'); + expect(alert.props('dismissible')).toBe(false); + expect(alert.text()).toBe(errorMessage); + }); + }); + describe('apply suggestion', () => { it('emits an apply event with no message if no message was added', () => { findTextArea().vm.$emit('input', null); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index b5daa389fc6..d1c4d777d44 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -85,7 +85,7 @@ describe('Markdown field component', () => { describe('mounted', () => { const previewHTML = ` <p>markdown preview</p> - <video src="${FIXTURES_PATH}/static/mock-video.mp4" muted="muted"></video> + <video src="${FIXTURES_PATH}/static/mock-video.mp4"></video> `; let previewLink; let writeLink; @@ -101,6 +101,21 @@ describe('Markdown field component', () => { expect(subject.find('.zen-backdrop textarea').element).not.toBeNull(); }); + it('renders referenced commands on markdown preview', async () => { + axiosMock + .onPost(markdownPreviewPath) + .reply(200, { references: { users: [], commands: 'test command' } }); + + previewLink = getPreviewLink(); + previewLink.vm.$emit('click', { target: {} }); + + await axios.waitFor(markdownPreviewPath); + const referencedCommands = subject.find('[data-testid="referenced-commands"]'); + + expect(referencedCommands.exists()).toBe(true); + expect(referencedCommands.text()).toContain('test command'); + }); + describe('markdown preview', () => { beforeEach(() => { axiosMock.onPost(markdownPreviewPath).reply(200, { body: previewHTML }); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index 9ffb9c6a541..fa4ca63f910 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -95,7 +95,7 @@ describe('Markdown field header component', () => { it('hides toolbar in preview mode', () => { createWrapper({ previewMarkdown: true }); - expect(findToolbar().classes().includes('gl-display-none')).toBe(true); + expect(findToolbar().classes().includes('gl-display-none!')).toBe(true); }); it('emits toggle markdown event when clicking preview tab', async () => { diff --git a/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap new file mode 100644 index 00000000000..5dd12d9edf5 --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Metrics upload item render the metrics image component 1`] = ` +<gl-card-stub + bodyclass="gl-border-1,gl-border-t-solid,gl-border-gray-100,[object Object]" + class="collapsible-card border gl-p-0 gl-mb-5" + footerclass="" + headerclass="gl-display-flex gl-align-items-center gl-border-b-0 gl-py-3" +> + <gl-modal-stub + actioncancel="[object Object]" + actionprimary="[object Object]" + body-class="gl-pb-0! gl-min-h-6!" + dismisslabel="Close" + modalclass="" + modalid="delete-metric-modal" + size="sm" + titletag="h4" + > + + <p> + Are you sure you wish to delete this image? + </p> + </gl-modal-stub> + + <gl-modal-stub + actioncancel="[object Object]" + actionprimary="[object Object]" + data-testid="metric-image-edit-modal" + dismisslabel="Close" + modalclass="" + modalid="edit-metric-modal" + size="sm" + titletag="h4" + > + + <gl-form-group-stub + label="Text (optional)" + label-for="upload-text-input" + labeldescription="" + optionaltext="(optional)" + > + <gl-form-input-stub + data-testid="metric-image-text-field" + id="upload-text-input" + /> + </gl-form-group-stub> + + <gl-form-group-stub + description="Must start with http or https" + label="Link (optional)" + label-for="upload-url-input" + labeldescription="" + optionaltext="(optional)" + > + <gl-form-input-stub + data-testid="metric-image-url-field" + id="upload-url-input" + /> + </gl-form-group-stub> + </gl-modal-stub> + + <div + class="gl-display-flex gl-flex-direction-column" + data-testid="metric-image-body" + > + <img + class="gl-max-w-full gl-align-self-center" + src="test_file_path" + /> + </div> +</gl-card-stub> +`; diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js new file mode 100644 index 00000000000..2cefa77b72d --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js @@ -0,0 +1,174 @@ +import { GlFormInput, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import merge from 'lodash/merge'; +import Vuex from 'vuex'; +import MetricImagesTable from '~/vue_shared/components/metric_images/metric_images_table.vue'; +import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue'; +import createStore from '~/vue_shared/components/metric_images/store'; +import waitForPromises from 'helpers/wait_for_promises'; +import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; +import { fileList, initialData } from './mock_data'; + +const service = { + getMetricImages: jest.fn(), +}; + +const mockEvent = { preventDefault: jest.fn() }; + +Vue.use(Vuex); + +describe('Metric images tab', () => { + let wrapper; + let store; + + const mountComponent = (options = {}) => { + store = createStore({}, service); + + wrapper = shallowMount( + MetricImagesTab, + merge( + { + store, + provide: { + canUpdate: true, + iid: initialData.issueIid, + projectId: initialData.projectId, + }, + }, + options, + ), + ); + }; + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findUploadDropzone = () => wrapper.findComponent(UploadDropzone); + const findImages = () => wrapper.findAllComponents(MetricImagesTable); + const findModal = () => wrapper.findComponent(GlModal); + const submitModal = () => findModal().vm.$emit('primary', mockEvent); + const cancelModal = () => findModal().vm.$emit('hidden'); + + describe('empty state', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders the upload component', () => { + expect(findUploadDropzone().exists()).toBe(true); + }); + }); + + describe('permissions', () => { + beforeEach(() => { + mountComponent({ provide: { canUpdate: false } }); + }); + + it('hides the upload component when disallowed', () => { + expect(findUploadDropzone().exists()).toBe(false); + }); + }); + + describe('onLoad action', () => { + it('should load images', async () => { + service.getMetricImages.mockImplementation(() => Promise.resolve(fileList)); + + mountComponent(); + + await waitForPromises(); + + expect(findImages().length).toBe(1); + }); + }); + + describe('add metric dialog', () => { + const testUrl = 'test url'; + + it('should open the add metric dialog when clicked', async () => { + mountComponent(); + + findUploadDropzone().vm.$emit('change'); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBe('true'); + }); + + it('should close when cancelled', async () => { + mountComponent({ + data() { + return { modalVisible: true }; + }, + }); + + cancelModal(); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBeFalsy(); + }); + + it('should add files and url when selected', async () => { + mountComponent({ + data() { + return { modalVisible: true, modalUrl: testUrl, currentFiles: fileList }; + }, + }); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + submitModal(); + + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith('uploadImage', { + files: fileList, + url: testUrl, + urlText: '', + }); + }); + + describe('url field', () => { + beforeEach(() => { + mountComponent({ + data() { + return { modalVisible: true, modalUrl: testUrl }; + }, + }); + }); + + it('should display the url field', () => { + expect(wrapper.find('#upload-url-input').attributes('value')).toBe(testUrl); + }); + + it('should display the url text field', () => { + expect(wrapper.find('#upload-text-input').attributes('value')).toBe(''); + }); + + it('should clear url when cancelled', async () => { + cancelModal(); + + await waitForPromises(); + + expect(wrapper.findComponent(GlFormInput).attributes('value')).toBe(''); + }); + + it('should clear url when submitted', async () => { + submitModal(); + + await waitForPromises(); + + expect(wrapper.findComponent(GlFormInput).attributes('value')).toBe(''); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js new file mode 100644 index 00000000000..d792bd46ccd --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js @@ -0,0 +1,230 @@ +import { GlLink, GlModal } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import Vue from 'vue'; +import merge from 'lodash/merge'; +import Vuex from 'vuex'; +import createStore from '~/vue_shared/components/metric_images/store'; +import MetricsImageTable from '~/vue_shared/components/metric_images/metric_images_table.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + +const defaultProps = { + id: 1, + filePath: 'test_file_path', + filename: 'test_file_name', +}; + +const mockEvent = { preventDefault: jest.fn() }; + +Vue.use(Vuex); + +describe('Metrics upload item', () => { + let wrapper; + let store; + + const mountComponent = (options = {}, mountMethod = mount) => { + store = createStore(); + + wrapper = mountMethod( + MetricsImageTable, + merge( + { + store, + propsData: { + ...defaultProps, + }, + provide: { canUpdate: true }, + }, + options, + ), + ); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findImageLink = () => wrapper.findComponent(GlLink); + const findLabelTextSpan = () => wrapper.find('[data-testid="metric-image-label-span"]'); + const findCollapseButton = () => wrapper.find('[data-testid="collapse-button"]'); + const findMetricImageBody = () => wrapper.find('[data-testid="metric-image-body"]'); + const findModal = () => wrapper.findComponent(GlModal); + const findEditModal = () => wrapper.find('[data-testid="metric-image-edit-modal"]'); + const findDeleteButton = () => wrapper.find('[data-testid="delete-button"]'); + const findEditButton = () => wrapper.find('[data-testid="edit-button"]'); + const findImageTextInput = () => wrapper.find('[data-testid="metric-image-text-field"]'); + const findImageUrlInput = () => wrapper.find('[data-testid="metric-image-url-field"]'); + + const closeModal = () => findModal().vm.$emit('hidden'); + const submitModal = () => findModal().vm.$emit('primary', mockEvent); + const deleteImage = () => findDeleteButton().vm.$emit('click'); + const closeEditModal = () => findEditModal().vm.$emit('hidden'); + const submitEditModal = () => findEditModal().vm.$emit('primary', mockEvent); + const editImage = () => findEditButton().vm.$emit('click'); + + it('render the metrics image component', () => { + mountComponent({}, shallowMount); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('shows a link with the correct url', () => { + const testUrl = 'test_url'; + mountComponent({ propsData: { url: testUrl } }); + + expect(findImageLink().attributes('href')).toBe(testUrl); + expect(findImageLink().text()).toBe(defaultProps.filename); + }); + + it('shows a link with the url text, if url text is present', () => { + const testUrl = 'test_url'; + const testUrlText = 'test_url_text'; + mountComponent({ propsData: { url: testUrl, urlText: testUrlText } }); + + expect(findImageLink().attributes('href')).toBe(testUrl); + expect(findImageLink().text()).toBe(testUrlText); + }); + + it('shows the url text with no url, if no url is present', () => { + const testUrlText = 'test_url_text'; + mountComponent({ propsData: { urlText: testUrlText } }); + + expect(findLabelTextSpan().text()).toBe(testUrlText); + }); + + describe('expand and collapse', () => { + beforeEach(() => { + mountComponent(); + }); + + it('the card is expanded by default', () => { + expect(findMetricImageBody().isVisible()).toBe(true); + }); + + it('the card is collapsed when clicked', async () => { + findCollapseButton().trigger('click'); + + await waitForPromises(); + + expect(findMetricImageBody().isVisible()).toBe(false); + }); + }); + + describe('delete functionality', () => { + it('should open the delete modal when clicked', async () => { + mountComponent({ stubs: { GlModal: true } }); + + deleteImage(); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBe('true'); + }); + + describe('when the modal is open', () => { + beforeEach(() => { + mountComponent( + { + data() { + return { modalVisible: true }; + }, + }, + shallowMount, + ); + }); + + it('should close the modal when cancelled', async () => { + closeModal(); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBeFalsy(); + }); + + it('should delete the image when selected', async () => { + const dispatchSpy = jest.spyOn(store, 'dispatch').mockImplementation(jest.fn()); + + submitModal(); + + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith('deleteImage', defaultProps.id); + }); + }); + + describe('canUpdate permission', () => { + it('delete button is hidden when user lacks update permissions', () => { + mountComponent({ provide: { canUpdate: false } }); + + expect(findDeleteButton().exists()).toBe(false); + }); + }); + }); + + describe('edit functionality', () => { + it('should open the delete modal when clicked', async () => { + mountComponent({ stubs: { GlModal: true } }); + + editImage(); + + await waitForPromises(); + + expect(findEditModal().attributes('visible')).toBe('true'); + }); + + describe('when the modal is open', () => { + beforeEach(() => { + mountComponent({ + data() { + return { editModalVisible: true }; + }, + propsData: { urlText: 'test' }, + stubs: { GlModal: true }, + }); + }); + + it('should close the modal when cancelled', async () => { + closeEditModal(); + + await waitForPromises(); + + expect(findEditModal().attributes('visible')).toBeFalsy(); + }); + + it('should delete the image when selected', async () => { + const dispatchSpy = jest.spyOn(store, 'dispatch').mockImplementation(jest.fn()); + + submitEditModal(); + + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith('updateImage', { + imageId: defaultProps.id, + url: null, + urlText: 'test', + }); + }); + + it('should clear edits when the modal is closed', async () => { + await findImageTextInput().setValue('test value'); + await findImageUrlInput().setValue('http://www.gitlab.com'); + + expect(findImageTextInput().element.value).toBe('test value'); + expect(findImageUrlInput().element.value).toBe('http://www.gitlab.com'); + + closeEditModal(); + + await waitForPromises(); + + editImage(); + + await waitForPromises(); + + expect(findImageTextInput().element.value).toBe('test'); + expect(findImageUrlInput().element.value).toBe(''); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/metric_images/mock_data.js b/spec/frontend/vue_shared/components/metric_images/mock_data.js new file mode 100644 index 00000000000..480491077fb --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/mock_data.js @@ -0,0 +1,5 @@ +export const fileList = [{ filePath: 'test', filename: 'hello', id: 5, url: null }]; + +export const fileListRaw = [{ file_path: 'test', filename: 'hello', id: 5, url: null }]; + +export const initialData = { issueIid: '123', projectId: 456 }; diff --git a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js new file mode 100644 index 00000000000..518cf354675 --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js @@ -0,0 +1,158 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import actionsFactory from '~/vue_shared/components/metric_images/store/actions'; +import * as types from '~/vue_shared/components/metric_images/store/mutation_types'; +import createStore from '~/vue_shared/components/metric_images/store'; +import testAction from 'helpers/vuex_action_helper'; +import createFlash from '~/flash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { fileList, initialData } from '../mock_data'; + +jest.mock('~/flash'); +const service = { + getMetricImages: jest.fn(), + uploadMetricImage: jest.fn(), + updateMetricImage: jest.fn(), + deleteMetricImage: jest.fn(), +}; + +const actions = actionsFactory(service); + +const defaultState = { + issueIid: 1, + projectId: '2', +}; + +Vue.use(Vuex); + +describe('Metrics tab store actions', () => { + let store; + let state; + + beforeEach(() => { + store = createStore(defaultState); + state = store.state; + }); + + afterEach(() => { + createFlash.mockClear(); + }); + + describe('fetching metric images', () => { + it('should call success action when fetching metric images', () => { + service.getMetricImages.mockImplementation(() => Promise.resolve(fileList)); + + testAction(actions.fetchImages, null, state, [ + { type: types.REQUEST_METRIC_IMAGES }, + { + type: types.RECEIVE_METRIC_IMAGES_SUCCESS, + payload: convertObjectPropsToCamelCase(fileList, { deep: true }), + }, + ]); + }); + + it('should call error action when fetching metric images with an error', async () => { + service.getMetricImages.mockImplementation(() => Promise.reject()); + + await testAction( + actions.fetchImages, + null, + state, + [{ type: types.REQUEST_METRIC_IMAGES }, { type: types.RECEIVE_METRIC_IMAGES_ERROR }], + [], + ); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('uploading metric images', () => { + const payload = { + // mock the FileList api + files: { + item() { + return fileList[0]; + }, + }, + url: 'test_url', + }; + + it('should call success action when uploading an image', () => { + service.uploadMetricImage.mockImplementation(() => Promise.resolve(fileList[0])); + + testAction(actions.uploadImage, payload, state, [ + { type: types.REQUEST_METRIC_UPLOAD }, + { + type: types.RECEIVE_METRIC_UPLOAD_SUCCESS, + payload: fileList[0], + }, + ]); + }); + + it('should call error action when failing to upload an image', async () => { + service.uploadMetricImage.mockImplementation(() => Promise.reject()); + + await testAction( + actions.uploadImage, + payload, + state, + [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }], + [], + ); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('updating metric images', () => { + const payload = { + url: 'test_url', + urlText: 'url text', + }; + + it('should call success action when updating an image', () => { + service.updateMetricImage.mockImplementation(() => Promise.resolve()); + + testAction(actions.updateImage, payload, state, [ + { type: types.REQUEST_METRIC_UPLOAD }, + { + type: types.RECEIVE_METRIC_UPDATE_SUCCESS, + }, + ]); + }); + + it('should call error action when failing to update an image', async () => { + service.updateMetricImage.mockImplementation(() => Promise.reject()); + + await testAction( + actions.updateImage, + payload, + state, + [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }], + [], + ); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('deleting a metric image', () => { + const payload = fileList[0].id; + + it('should call success action when deleting an image', () => { + service.deleteMetricImage.mockImplementation(() => Promise.resolve()); + + testAction(actions.deleteImage, payload, state, [ + { + type: types.RECEIVE_METRIC_DELETE_SUCCESS, + payload, + }, + ]); + }); + }); + + describe('initial data', () => { + it('should set the initial data correctly', () => { + testAction(actions.setInitialData, initialData, state, [ + { type: types.SET_INITIAL_DATA, payload: initialData }, + ]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js b/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js new file mode 100644 index 00000000000..754f729e657 --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js @@ -0,0 +1,147 @@ +import { cloneDeep } from 'lodash'; +import * as types from '~/vue_shared/components/metric_images/store/mutation_types'; +import mutations from '~/vue_shared/components/metric_images/store/mutations'; +import { initialData } from '../mock_data'; + +const defaultState = { + metricImages: [], + isLoadingMetricImages: false, + isUploadingImage: false, +}; + +const testImages = [ + { filename: 'test.filename', id: 5, filePath: 'test/file/path', url: null }, + { filename: 'second.filename', id: 6, filePath: 'second/file/path', url: 'test/url' }, + { filename: 'third.filename', id: 7, filePath: 'third/file/path', url: 'test/url' }, +]; + +describe('Metric images mutations', () => { + let state; + + const createState = (customState = {}) => { + state = { + ...cloneDeep(defaultState), + ...customState, + }; + }; + + beforeEach(() => { + createState(); + }); + + describe('REQUEST_METRIC_IMAGES', () => { + beforeEach(() => { + mutations[types.REQUEST_METRIC_IMAGES](state); + }); + + it('should set the loading state', () => { + expect(state.isLoadingMetricImages).toBe(true); + }); + }); + + describe('RECEIVE_METRIC_IMAGES_SUCCESS', () => { + beforeEach(() => { + mutations[types.RECEIVE_METRIC_IMAGES_SUCCESS](state, testImages); + }); + + it('should unset the loading state', () => { + expect(state.isLoadingMetricImages).toBe(false); + }); + + it('should set the metric images', () => { + expect(state.metricImages).toEqual(testImages); + }); + }); + + describe('RECEIVE_METRIC_IMAGES_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_METRIC_IMAGES_ERROR](state); + }); + + it('should unset the loading state', () => { + expect(state.isLoadingMetricImages).toBe(false); + }); + }); + + describe('REQUEST_METRIC_UPLOAD', () => { + beforeEach(() => { + mutations[types.REQUEST_METRIC_UPLOAD](state); + }); + + it('should set the loading state', () => { + expect(state.isUploadingImage).toBe(true); + }); + }); + + describe('RECEIVE_METRIC_UPLOAD_SUCCESS', () => { + const initialImage = testImages[0]; + const newImage = testImages[1]; + + beforeEach(() => { + createState({ metricImages: [initialImage] }); + mutations[types.RECEIVE_METRIC_UPLOAD_SUCCESS](state, newImage); + }); + + it('should unset the loading state', () => { + expect(state.isUploadingImage).toBe(false); + }); + + it('should add the new metric image after the existing one', () => { + expect(state.metricImages).toMatchObject([initialImage, newImage]); + }); + }); + + describe('RECEIVE_METRIC_UPLOAD_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_METRIC_UPLOAD_ERROR](state); + }); + + it('should unset the loading state', () => { + expect(state.isUploadingImage).toBe(false); + }); + }); + + describe('RECEIVE_METRIC_UPDATE_SUCCESS', () => { + const initialImage = testImages[0]; + const newImage = testImages[0]; + newImage.url = 'https://www.gitlab.com'; + + beforeEach(() => { + createState({ metricImages: [initialImage] }); + mutations[types.RECEIVE_METRIC_UPDATE_SUCCESS](state, newImage); + }); + + it('should unset the loading state', () => { + expect(state.isUploadingImage).toBe(false); + }); + + it('should replace the existing image with the new one', () => { + expect(state.metricImages).toMatchObject([newImage]); + }); + }); + + describe('RECEIVE_METRIC_DELETE_SUCCESS', () => { + const deletedImageId = testImages[1].id; + const expectedResult = [testImages[0], testImages[2]]; + + beforeEach(() => { + createState({ metricImages: [...testImages] }); + mutations[types.RECEIVE_METRIC_DELETE_SUCCESS](state, deletedImageId); + }); + + it('should remove the correct metric image', () => { + expect(state.metricImages).toEqual(expectedResult); + }); + }); + + describe('SET_INITIAL_DATA', () => { + beforeEach(() => { + mutations[types.SET_INITIAL_DATA](state, initialData); + }); + + it('should unset the loading state', () => { + expect(state.modelIid).toBe(initialData.modelIid); + expect(state.projectId).toBe(initialData.projectId); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index c8dab0204d3..6881cb79740 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { userDataMock } from '../../../notes/mock_data'; +import { userDataMock } from 'jest/notes/mock_data'; Vue.use(Vuex); diff --git a/spec/frontend/vue_shared/components/project_avatar/default_spec.js b/spec/frontend/vue_shared/components/project_avatar/default_spec.js deleted file mode 100644 index d042db6051c..00000000000 --- a/spec/frontend/vue_shared/components/project_avatar/default_spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import Vue, { nextTick } from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import { projectData } from 'jest/ide/mock_data'; -import { TEST_HOST } from 'spec/test_constants'; -import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; -import ProjectAvatarDefault from '~/vue_shared/components/deprecated_project_avatar/default.vue'; - -describe('ProjectAvatarDefault component', () => { - const Component = Vue.extend(ProjectAvatarDefault); - let vm; - - beforeEach(() => { - vm = mountComponent(Component, { - project: projectData, - }); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('renders identicon if project has no avatar_url', async () => { - const expectedText = getFirstCharacterCapitalized(projectData.name); - - vm.project = { - ...vm.project, - avatar_url: null, - }; - - await nextTick(); - const identiconEl = vm.$el.querySelector('.identicon'); - - expect(identiconEl).not.toBe(null); - expect(identiconEl.textContent.trim()).toEqual(expectedText); - }); - - it('renders avatar image if project has avatar_url', async () => { - const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`; - - vm.project = { - ...vm.project, - avatar_url: avatarUrl, - }; - - await nextTick(); - expect(vm.$el.querySelector('.avatar')).not.toBeNull(); - expect(vm.$el.querySelector('.identicon')).toBeNull(); - expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl); - }); -}); diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js index 5afa017aa76..397ab2254b9 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import mockProjects from 'test_fixtures_static/projects.json'; import { trimText } from 'helpers/text_helper'; -import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; describe('ProjectListItem component', () => { @@ -52,8 +52,13 @@ describe('ProjectListItem component', () => { it(`renders the project avatar`, () => { wrapper = shallowMount(Component, options); + const avatar = wrapper.findComponent(ProjectAvatar); - expect(wrapper.findComponent(ProjectAvatar).exists()).toBe(true); + expect(avatar.exists()).toBe(true); + expect(avatar.props()).toMatchObject({ + projectAvatarUrl: '', + projectName: project.name_with_namespace, + }); }); it(`renders a simple namespace name with a trailing slash`, () => { diff --git a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js index c65ded000d3..616fefe847e 100644 --- a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js +++ b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js @@ -36,10 +36,10 @@ describe('Persisted dropdown selection', () => { }); describe('local storage sync', () => { - it('uses the local storage sync component', () => { + it('uses the local storage sync component with the correct props', () => { createComponent(); - expect(findLocalStorageSync().exists()).toBe(true); + expect(findLocalStorageSync().props('asString')).toBe(true); }); it('passes the right props', () => { diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap index 6954bd5ccff..ac313e556fc 100644 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap @@ -42,7 +42,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` <gl-accordion-item-stub class="gl-font-weight-normal" title="More Details" - title-visible="Less Details" + titlevisible="Less Details" > <p class="gl-pt-2" @@ -76,7 +76,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` <gl-accordion-item-stub class="gl-font-weight-normal" title="More Details" - title-visible="Less Details" + titlevisible="Less Details" > <p class="gl-pt-2" @@ -110,7 +110,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` <gl-accordion-item-stub class="gl-font-weight-normal" title="More Details" - title-visible="Less Details" + titlevisible="Less Details" > <p class="gl-pt-2" @@ -144,7 +144,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` <gl-accordion-item-stub class="gl-font-weight-normal" title="More Details" - title-visible="Less Details" + titlevisible="Less Details" > <p class="gl-pt-2" diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js index 2e4c056df61..2bc513e87bf 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -21,87 +21,81 @@ describe('LabelsSelect Actions', () => { }); describe('setInitialState', () => { - it('sets initial store state', (done) => { - testAction( + it('sets initial store state', () => { + return testAction( actions.setInitialState, mockInitialState, state, [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }], [], - done, ); }); }); describe('toggleDropdownButton', () => { - it('toggles dropdown button', (done) => { - testAction( + it('toggles dropdown button', () => { + return testAction( actions.toggleDropdownButton, {}, state, [{ type: types.TOGGLE_DROPDOWN_BUTTON }], [], - done, ); }); }); describe('toggleDropdownContents', () => { - it('toggles dropdown contents', (done) => { - testAction( + it('toggles dropdown contents', () => { + return testAction( actions.toggleDropdownContents, {}, state, [{ type: types.TOGGLE_DROPDOWN_CONTENTS }], [], - done, ); }); }); describe('toggleDropdownContentsCreateView', () => { - it('toggles dropdown create view', (done) => { - testAction( + it('toggles dropdown create view', () => { + return testAction( actions.toggleDropdownContentsCreateView, {}, state, [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }], [], - done, ); }); }); describe('requestLabels', () => { - it('sets value of `state.labelsFetchInProgress` to `true`', (done) => { - testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done); + it('sets value of `state.labelsFetchInProgress` to `true`', () => { + return testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], []); }); }); describe('receiveLabelsSuccess', () => { - it('sets provided labels to `state.labels`', (done) => { + it('sets provided labels to `state.labels`', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - testAction( + return testAction( actions.receiveLabelsSuccess, labels, state, [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }], [], - done, ); }); }); describe('receiveLabelsFailure', () => { - it('sets value `state.labelsFetchInProgress` to `false`', (done) => { - testAction( + it('sets value `state.labelsFetchInProgress` to `false`', () => { + return testAction( actions.receiveLabelsFailure, {}, state, [{ type: types.RECEIVE_SET_LABELS_FAILURE }], [], - done, ); }); @@ -125,72 +119,67 @@ describe('LabelsSelect Actions', () => { }); describe('on success', () => { - it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', (done) => { + it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; mock.onGet(/labels.json/).replyOnce(200, labels); - testAction( + return testAction( actions.fetchLabels, {}, state, [], [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }], - done, ); }); }); describe('on failure', () => { - it('dispatches `requestLabels` & `receiveLabelsFailure` actions', (done) => { + it('dispatches `requestLabels` & `receiveLabelsFailure` actions', () => { mock.onGet(/labels.json/).replyOnce(500, {}); - testAction( + return testAction( actions.fetchLabels, {}, state, [], [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }], - done, ); }); }); }); describe('requestCreateLabel', () => { - it('sets value `state.labelCreateInProgress` to `true`', (done) => { - testAction( + it('sets value `state.labelCreateInProgress` to `true`', () => { + return testAction( actions.requestCreateLabel, {}, state, [{ type: types.REQUEST_CREATE_LABEL }], [], - done, ); }); }); describe('receiveCreateLabelSuccess', () => { - it('sets value `state.labelCreateInProgress` to `false`', (done) => { - testAction( + it('sets value `state.labelCreateInProgress` to `false`', () => { + return testAction( actions.receiveCreateLabelSuccess, {}, state, [{ type: types.RECEIVE_CREATE_LABEL_SUCCESS }], [], - done, ); }); }); describe('receiveCreateLabelFailure', () => { - it('sets value `state.labelCreateInProgress` to `false`', (done) => { - testAction( + it('sets value `state.labelCreateInProgress` to `false`', () => { + return testAction( actions.receiveCreateLabelFailure, {}, state, [{ type: types.RECEIVE_CREATE_LABEL_FAILURE }], [], - done, ); }); @@ -214,11 +203,11 @@ describe('LabelsSelect Actions', () => { }); describe('on success', () => { - it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', (done) => { + it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', () => { const label = { id: 1 }; mock.onPost(/labels.json/).replyOnce(200, label); - testAction( + return testAction( actions.createLabel, {}, state, @@ -229,38 +218,35 @@ describe('LabelsSelect Actions', () => { { type: 'receiveCreateLabelSuccess' }, { type: 'toggleDropdownContentsCreateView' }, ], - done, ); }); }); describe('on failure', () => { - it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', (done) => { + it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', () => { mock.onPost(/labels.json/).replyOnce(500, {}); - testAction( + return testAction( actions.createLabel, {}, state, [], [{ type: 'requestCreateLabel' }, { type: 'receiveCreateLabelFailure' }], - done, ); }); }); }); describe('updateSelectedLabels', () => { - it('updates `state.labels` based on provided `labels` param', (done) => { + it('updates `state.labels` based on provided `labels` param', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - testAction( + return 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/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index 67e1a3ce932..1b27a294b90 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 @@ -11,9 +11,15 @@ import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/ import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; +import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql'; import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import { mockConfig, issuableLabelsQueryResponse, updateLabelsMutationResponse } from './mock_data'; +import { + mockConfig, + issuableLabelsQueryResponse, + updateLabelsMutationResponse, + issuableLabelsSubscriptionResponse, +} from './mock_data'; jest.mock('~/flash'); @@ -21,6 +27,7 @@ Vue.use(VueApollo); const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse); const successfulMutationHandler = jest.fn().mockResolvedValue(updateLabelsMutationResponse); +const subscriptionHandler = jest.fn().mockResolvedValue(issuableLabelsSubscriptionResponse); const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const updateLabelsMutation = { @@ -42,10 +49,12 @@ describe('LabelsSelectRoot', () => { issuableType = IssuableType.Issue, queryHandler = successfulQueryHandler, mutationHandler = successfulMutationHandler, + isRealtimeEnabled = false, } = {}) => { const mockApollo = createMockApollo([ [issueLabelsQuery, queryHandler], [updateLabelsMutation[issuableType], mutationHandler], + [issuableLabelsSubscription, subscriptionHandler], ]); wrapper = shallowMount(LabelsSelectRoot, { @@ -65,6 +74,9 @@ describe('LabelsSelectRoot', () => { allowLabelEdit: true, allowLabelCreate: true, labelsManagePath: 'test', + glFeatures: { + realtimeLabels: isRealtimeEnabled, + }, }, }); }; @@ -190,5 +202,26 @@ describe('LabelsSelectRoot', () => { message: 'An error occurred while updating labels.', }); }); + + it('does not emit `updateSelectedLabels` event when the subscription is triggered and FF is disabled', async () => { + createComponent(); + await waitForPromises(); + + expect(wrapper.emitted('updateSelectedLabels')).toBeUndefined(); + }); + + it('emits `updateSelectedLabels` event when the subscription is triggered and FF is enabled', async () => { + createComponent({ isRealtimeEnabled: true }); + await waitForPromises(); + + expect(wrapper.emitted('updateSelectedLabels')).toEqual([ + [ + { + id: '1', + labels: issuableLabelsSubscriptionResponse.data.issuableLabelsUpdated.labels.nodes, + }, + ], + ]); + }); }); }); 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 49224fb915c..afad9314ace 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 @@ -141,6 +141,34 @@ export const issuableLabelsQueryResponse = { }, }; +export const issuableLabelsSubscriptionResponse = { + data: { + issuableLabelsUpdated: { + id: '1', + labels: { + nodes: [ + { + __typename: 'Label', + color: '#330066', + description: null, + id: 'gid://gitlab/ProjectLabel/1', + title: 'Label1', + textColor: '#000000', + }, + { + __typename: 'Label', + color: '#000000', + description: null, + id: 'gid://gitlab/ProjectLabel/2', + title: 'Label2', + textColor: '#ffffff', + }, + ], + }, + }, + }, +}; + export const updateLabelsMutationResponse = { data: { updateIssuableLabels: { diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js new file mode 100644 index 00000000000..eb2eec92534 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js @@ -0,0 +1,69 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; +import { + BIDI_CHARS, + BIDI_CHARS_CLASS_LIST, + BIDI_CHAR_TOOLTIP, +} from '~/vue_shared/components/source_viewer/constants'; + +const DEFAULT_PROPS = { + number: 2, + content: '// Line content', + language: 'javascript', +}; + +describe('Chunk Line component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(ChunkLine, { propsData: { ...DEFAULT_PROPS, ...props } }); + }; + + const findLink = () => wrapper.findComponent(GlLink); + const findContent = () => wrapper.findByTestId('content'); + const findWrappedBidiChars = () => wrapper.findAllByTestId('bidi-wrapper'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('rendering', () => { + it('wraps BiDi characters', () => { + const content = `// some content ${BIDI_CHARS.toString()} with BiDi chars`; + createComponent({ content }); + const wrappedBidiChars = findWrappedBidiChars(); + + expect(wrappedBidiChars.length).toBe(BIDI_CHARS.length); + + wrappedBidiChars.wrappers.forEach((_, i) => { + expect(wrappedBidiChars.at(i).text()).toBe(BIDI_CHARS[i]); + expect(wrappedBidiChars.at(i).attributes()).toMatchObject({ + class: BIDI_CHARS_CLASS_LIST, + title: BIDI_CHAR_TOOLTIP, + }); + }); + }); + + it('renders a line number', () => { + expect(findLink().attributes()).toMatchObject({ + 'data-line-number': `${DEFAULT_PROPS.number}`, + to: `#L${DEFAULT_PROPS.number}`, + id: `L${DEFAULT_PROPS.number}`, + }); + + expect(findLink().text()).toBe(DEFAULT_PROPS.number.toString()); + }); + + it('renders content', () => { + expect(findContent().attributes()).toMatchObject({ + id: `LC${DEFAULT_PROPS.number}`, + lang: DEFAULT_PROPS.language, + }); + + expect(findContent().text()).toBe(DEFAULT_PROPS.content); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js new file mode 100644 index 00000000000..42c4f2eacb8 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js @@ -0,0 +1,82 @@ +import { GlIntersectionObserver } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; +import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; + +const DEFAULT_PROPS = { + chunkIndex: 2, + isHighlighted: false, + content: '// Line 1 content \n // Line 2 content', + startingFrom: 140, + totalLines: 50, + language: 'javascript', +}; + +describe('Chunk component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(Chunk, { propsData: { ...DEFAULT_PROPS, ...props } }); + }; + + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + const findChunkLines = () => wrapper.findAllComponents(ChunkLine); + const findLineNumbers = () => wrapper.findAllByTestId('line-number'); + const findContent = () => wrapper.findByTestId('content'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('Intersection observer', () => { + it('renders an Intersection observer component', () => { + expect(findIntersectionObserver().exists()).toBe(true); + }); + + it('emits an appear event when intersection-observer appears', () => { + findIntersectionObserver().vm.$emit('appear'); + + expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]); + }); + + it('does not emit an appear event is isHighlighted is true', () => { + createComponent({ isHighlighted: true }); + findIntersectionObserver().vm.$emit('appear'); + + expect(wrapper.emitted('appear')).toEqual(undefined); + }); + }); + + describe('rendering', () => { + it('does not render a Chunk Line component if isHighlighted is false', () => { + expect(findChunkLines().length).toBe(0); + }); + + it('renders simplified line numbers and content if isHighlighted is false', () => { + expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines); + + expect(findLineNumbers().at(0).attributes()).toMatchObject({ + 'data-line-number': `${DEFAULT_PROPS.startingFrom + 1}`, + href: `#L${DEFAULT_PROPS.startingFrom + 1}`, + id: `L${DEFAULT_PROPS.startingFrom + 1}`, + }); + + expect(findContent().text()).toBe(DEFAULT_PROPS.content); + }); + + it('renders Chunk Line components if isHighlighted is true', () => { + const splitContent = DEFAULT_PROPS.content.split('\n'); + createComponent({ isHighlighted: true }); + + expect(findChunkLines().length).toBe(splitContent.length); + + expect(findChunkLines().at(0).props()).toMatchObject({ + number: DEFAULT_PROPS.startingFrom + 1, + content: splitContent[0], + language: DEFAULT_PROPS.language, + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index ab579945e22..6a9ea75127d 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -1,24 +1,38 @@ import hljs from 'highlight.js/lib/core'; -import { GlLoadingIcon } from '@gitlab/ui'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants'; -import LineNumbers from '~/vue_shared/components/line_numbers.vue'; import waitForPromises from 'helpers/wait_for_promises'; -import * as sourceViewerUtils from '~/vue_shared/components/source_viewer/utils'; +import LineHighlighter from '~/blob/line_highlighter'; +import eventHub from '~/notes/event_hub'; +jest.mock('~/blob/line_highlighter'); jest.mock('highlight.js/lib/core'); Vue.use(VueRouter); const router = new VueRouter(); +const generateContent = (content, totalLines = 1) => { + let generatedContent = ''; + for (let i = 0; i < totalLines; i += 1) { + generatedContent += `Line: ${i + 1} = ${content}\n`; + } + return generatedContent; +}; + +const execImmediately = (callback) => callback(); + describe('Source Viewer component', () => { let wrapper; const language = 'docker'; const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; - const content = `// Some source code`; - const DEFAULT_BLOB_DATA = { language, rawTextBlob: content }; + const chunk1 = generateContent('// Some source code 1', 70); + const chunk2 = generateContent('// Some source code 2', 70); + const content = chunk1 + chunk2; + const path = 'some/path.js'; + const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path }; const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; const createComponent = async (blob = {}) => { @@ -29,15 +43,13 @@ describe('Source Viewer component', () => { await waitForPromises(); }; - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findLineNumbers = () => wrapper.findComponent(LineNumbers); - const findHighlightedContent = () => wrapper.findByTestId('test-highlighted'); - const findFirstLine = () => wrapper.find('#LC1'); + const findChunks = () => wrapper.findAllComponents(Chunk); beforeEach(() => { hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); - jest.spyOn(sourceViewerUtils, 'wrapLines'); + jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); + jest.spyOn(eventHub, '$emit'); return createComponent(); }); @@ -45,6 +57,8 @@ describe('Source Viewer component', () => { afterEach(() => wrapper.destroy()); describe('highlight.js', () => { + beforeEach(() => createComponent({ language: mappedLanguage })); + it('registers the language definition', async () => { const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); @@ -54,72 +68,51 @@ describe('Source Viewer component', () => { ); }); - it('highlights the content', () => { - expect(hljs.highlight).toHaveBeenCalledWith(content, { language: mappedLanguage }); + it('highlights the first chunk', () => { + expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); }); describe('auto-detects if a language cannot be loaded', () => { beforeEach(() => createComponent({ language: 'some_unknown_language' })); it('highlights the content with auto-detection', () => { - expect(hljs.highlightAuto).toHaveBeenCalledWith(content); + expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim()); }); }); }); describe('rendering', () => { - it('renders a loading icon if no highlighted content is available yet', async () => { - hljs.highlight.mockImplementation(() => ({ value: null })); - await createComponent(); - - expect(findLoadingIcon().exists()).toBe(true); - }); + it('renders the first chunk', async () => { + const firstChunk = findChunks().at(0); - it('calls the wrapLines helper method with highlightedContent and mappedLanguage', () => { - expect(sourceViewerUtils.wrapLines).toHaveBeenCalledWith(highlightedContent, mappedLanguage); - }); - - it('renders Line Numbers', () => { - expect(findLineNumbers().props('lines')).toBe(1); - }); + expect(firstChunk.props('content')).toContain(chunk1); - it('renders the highlighted content', () => { - expect(findHighlightedContent().exists()).toBe(true); + expect(firstChunk.props()).toMatchObject({ + totalLines: 70, + startingFrom: 0, + }); }); - }); - describe('selecting a line', () => { - let firstLine; - let firstLineElement; + it('renders the second chunk', async () => { + const secondChunk = findChunks().at(1); - beforeEach(() => { - firstLine = findFirstLine(); - firstLineElement = firstLine.element; + expect(secondChunk.props('content')).toContain(chunk2.trim()); - jest.spyOn(firstLineElement, 'scrollIntoView'); - jest.spyOn(firstLineElement.classList, 'add'); - jest.spyOn(firstLineElement.classList, 'remove'); - }); - - it('adds the highlight (hll) class', async () => { - wrapper.vm.$router.push('#LC1'); - await nextTick(); - - expect(firstLineElement.classList.add).toHaveBeenCalledWith('hll'); + expect(secondChunk.props()).toMatchObject({ + totalLines: 70, + startingFrom: 70, + }); }); + }); - it('removes the highlight (hll) class from a previously highlighted line', async () => { - wrapper.vm.$router.push('#LC2'); - await nextTick(); - - expect(firstLineElement.classList.remove).toHaveBeenCalledWith('hll'); - }); + it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { + findChunks().at(0).vm.$emit('appear'); + expect(eventHub.$emit).toBeCalledWith('showBlobInteractionZones', path); + }); - it('scrolls the line into view', () => { - expect(firstLineElement.scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'center', - }); + describe('LineHighlighter', () => { + it('instantiates the lineHighlighter class', async () => { + expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js deleted file mode 100644 index 0631e7efd54..00000000000 --- a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js +++ /dev/null @@ -1,26 +0,0 @@ -import { wrapLines } from '~/vue_shared/components/source_viewer/utils'; - -describe('Wrap lines', () => { - it.each` - content | language | output - ${'line 1'} | ${'javascript'} | ${'<span id="LC1" lang="javascript" class="line">line 1</span>'} - ${'line 1\nline 2'} | ${'html'} | ${`<span id="LC1" lang="html" class="line">line 1</span>\n<span id="LC2" lang="html" class="line">line 2</span>`} - ${'<span class="hljs-code">line 1\nline 2</span>'} | ${'html'} | ${`<span id="LC1" lang="html" class="hljs-code">line 1\n<span id="LC2" lang="html" class="line">line 2</span></span>`} - ${'<span class="hljs-code">```bash'} | ${'bash'} | ${'<span id="LC1" lang="bash" class="hljs-code">```bash'} - ${'<span class="hljs-code">```bash'} | ${'valid-language1'} | ${'<span id="LC1" lang="valid-language1" class="hljs-code">```bash'} - ${'<span class="hljs-code">```bash'} | ${'valid_language2'} | ${'<span id="LC1" lang="valid_language2" class="hljs-code">```bash'} - `('returns lines wrapped in spans containing line numbers', ({ content, language, output }) => { - expect(wrapLines(content, language)).toBe(output); - }); - - it.each` - language - ${'invalidLanguage>'} - ${'"invalidLanguage"'} - ${'<invalidLanguage'} - `('returns lines safely without XSS language is not valid', ({ language }) => { - expect(wrapLines('<span class="hljs-code">```bash', language)).toBe( - '<span id="LC1" lang="" class="hljs-code">```bash', - ); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js index f624f84eabd..5e05b54cb8c 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js @@ -109,19 +109,33 @@ describe('User Avatar Image Component', () => { default: ['Action!'], }; - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: PROVIDED_PROPS, - slots, + describe('when `tooltipText` is provided and no default slot', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + }); }); - }); - it('renders the tooltip slot', () => { - expect(wrapper.findComponent(GlTooltip).exists()).toBe(true); + it('renders the tooltip with `tooltipText` as content', () => { + expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); + }); }); - it('renders the tooltip content', () => { - expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); + describe('when `tooltipText` and default slot is provided', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + slots, + }); + }); + + it('does not render `tooltipText` inside the tooltip', () => { + expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); + }); + + it('renders the content provided via default slot', () => { + expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js index 5051b2b9cae..2c1be6ec47e 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js @@ -90,33 +90,38 @@ describe('User Avatar Image Component', () => { }); }); - describe('dynamic tooltip content', () => { - const props = PROVIDED_PROPS; + describe('Dynamic tooltip content', () => { const slots = { default: ['Action!'], }; - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { props }, - slots, + describe('when `tooltipText` is provided and no default slot', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + }); }); - }); - it('renders the tooltip slot', () => { - expect(wrapper.findComponent(GlTooltip).exists()).toBe(true); + it('renders the tooltip with `tooltipText` as content', () => { + expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); + }); }); - it('renders the tooltip content', () => { - expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); - }); + describe('when `tooltipText` and default slot is provided', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + slots, + }); + }); - it('does not render tooltip data attributes on avatar image', () => { - const avatarImg = wrapper.find('img'); + it('does not render `tooltipText` inside the tooltip', () => { + expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); + }); - expect(avatarImg.attributes('title')).toBeFalsy(); - expect(avatarImg.attributes('data-placement')).not.toBeDefined(); - expect(avatarImg.attributes('data-container')).not.toBeDefined(); + it('renders the content provided via default slot', () => { + expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js index 66bb234aef6..20ff0848cff 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js @@ -153,4 +153,29 @@ describe('UserAvatarList', () => { }); }); }); + + describe('additional styling for the image', () => { + it('should not add CSS class when feature flag `glAvatarForAllUserAvatars` is disabled', () => { + factory({ + propsData: { items: createList(1) }, + }); + + const link = wrapper.findComponent(UserAvatarLink); + expect(link.props('imgCssClasses')).not.toBe('gl-mr-3'); + }); + + it('should add CSS class when feature flag `glAvatarForAllUserAvatars` is enabled', () => { + factory({ + propsData: { items: createList(1) }, + provide: { + glFeatures: { + glAvatarForAllUserAvatars: true, + }, + }, + }); + + const link = wrapper.findComponent(UserAvatarLink); + expect(link.props('imgCssClasses')).toBe('gl-mr-3'); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js index cb476910944..ec9128d5e38 100644 --- a/spec/frontend/vue_shared/components/user_select_spec.js +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -16,7 +16,7 @@ import { searchResponseOnMR, projectMembersResponse, participantsQueryResponse, -} from '../../sidebar/mock_data'; +} from 'jest/sidebar/mock_data'; const assignee = { id: 'gid://gitlab/User/4', diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index e79935f8fa6..040461f6be4 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -261,7 +261,10 @@ describe('Web IDE link component', () => { }); it('should update local storage when selection changes', async () => { - expect(findLocalStorageSync().props('value')).toBe(ACTION_WEB_IDE.key); + expect(findLocalStorageSync().props()).toMatchObject({ + asString: true, + value: ACTION_WEB_IDE.key, + }); findActionsButton().vm.$emit('select', ACTION_GITPOD.key); diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js index 64823cd4c6c..058cb30c1d5 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js @@ -1,4 +1,9 @@ -import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { + GlAlert, + GlKeysetPagination, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlPagination, +} from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import VueDraggable from 'vuedraggable'; diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js index 6af07273cf6..46bfd7eceb1 100644 --- a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js +++ b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js @@ -26,8 +26,8 @@ describe('sast report actions', () => { }); describe('setDiffEndpoint', () => { - it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, (done) => { - testAction( + it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, () => { + return testAction( actions.setDiffEndpoint, diffEndpoint, state, @@ -38,20 +38,19 @@ describe('sast report actions', () => { }, ], [], - done, ); }); }); describe('requestDiff', () => { - it(`should commit ${types.REQUEST_DIFF}`, (done) => { - testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done); + it(`should commit ${types.REQUEST_DIFF}`, () => { + return testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], []); }); }); describe('receiveDiffSuccess', () => { - it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, () => { + return testAction( actions.receiveDiffSuccess, reports, state, @@ -62,14 +61,13 @@ describe('sast report actions', () => { }, ], [], - done, ); }); }); describe('receiveDiffError', () => { - it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, () => { + return testAction( actions.receiveDiffError, error, state, @@ -80,7 +78,6 @@ describe('sast report actions', () => { }, ], [], - done, ); }); }); @@ -107,9 +104,9 @@ describe('sast report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffSuccess` action', (done) => { + it('should dispatch the `receiveDiffSuccess` action', () => { const { diff, enrichData } = reports; - testAction( + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -124,7 +121,6 @@ describe('sast report actions', () => { }, }, ], - done, ); }); }); @@ -135,10 +131,10 @@ describe('sast report actions', () => { mock.onGet(diffEndpoint).replyOnce(200, reports.diff); }); - it('should dispatch the `receiveDiffSuccess` action with empty enrich data', (done) => { + it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => { const { diff } = reports; const enrichData = []; - testAction( + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -153,7 +149,6 @@ describe('sast report actions', () => { }, }, ], - done, ); }); }); @@ -167,14 +162,13 @@ describe('sast report actions', () => { .replyOnce(404); }); - it('should dispatch the `receiveError` action', (done) => { - testAction( + it('should dispatch the `receiveError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); @@ -188,14 +182,13 @@ describe('sast report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffError` action', (done) => { - testAction( + it('should dispatch the `receiveDiffError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js index d22fee864e7..4f4f653bb72 100644 --- a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js +++ b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js @@ -26,8 +26,8 @@ describe('secret detection report actions', () => { }); describe('setDiffEndpoint', () => { - it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, (done) => { - testAction( + it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, () => { + return testAction( actions.setDiffEndpoint, diffEndpoint, state, @@ -38,20 +38,19 @@ describe('secret detection report actions', () => { }, ], [], - done, ); }); }); describe('requestDiff', () => { - it(`should commit ${types.REQUEST_DIFF}`, (done) => { - testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done); + it(`should commit ${types.REQUEST_DIFF}`, () => { + return testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], []); }); }); describe('receiveDiffSuccess', () => { - it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, () => { + return testAction( actions.receiveDiffSuccess, reports, state, @@ -62,14 +61,13 @@ describe('secret detection report actions', () => { }, ], [], - done, ); }); }); describe('receiveDiffError', () => { - it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, () => { + return testAction( actions.receiveDiffError, error, state, @@ -80,7 +78,6 @@ describe('secret detection report actions', () => { }, ], [], - done, ); }); }); @@ -107,9 +104,10 @@ describe('secret detection report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffSuccess` action', (done) => { + it('should dispatch the `receiveDiffSuccess` action', () => { const { diff, enrichData } = reports; - testAction( + + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -124,7 +122,6 @@ describe('secret detection report actions', () => { }, }, ], - done, ); }); }); @@ -135,10 +132,10 @@ describe('secret detection report actions', () => { mock.onGet(diffEndpoint).replyOnce(200, reports.diff); }); - it('should dispatch the `receiveDiffSuccess` action with empty enrich data', (done) => { + it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => { const { diff } = reports; const enrichData = []; - testAction( + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -153,7 +150,6 @@ describe('secret detection report actions', () => { }, }, ], - done, ); }); }); @@ -167,14 +163,13 @@ describe('secret detection report actions', () => { .replyOnce(404); }); - it('should dispatch the `receiveDiffError` action', (done) => { - testAction( + it('should dispatch the `receiveDiffError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); @@ -188,14 +183,13 @@ describe('secret detection report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffError` action', (done) => { - testAction( + it('should dispatch the `receiveDiffError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); diff --git a/spec/frontend/vuex_shared/modules/modal/actions_spec.js b/spec/frontend/vuex_shared/modules/modal/actions_spec.js index c151049df2d..928ed7d0d5f 100644 --- a/spec/frontend/vuex_shared/modules/modal/actions_spec.js +++ b/spec/frontend/vuex_shared/modules/modal/actions_spec.js @@ -4,28 +4,28 @@ import * as types from '~/vuex_shared/modules/modal/mutation_types'; describe('Vuex ModalModule actions', () => { describe('open', () => { - it('works', (done) => { + it('works', () => { const data = { id: 7 }; - testAction(actions.open, data, {}, [{ type: types.OPEN, payload: data }], [], done); + return testAction(actions.open, data, {}, [{ type: types.OPEN, payload: data }], []); }); }); describe('close', () => { - it('works', (done) => { - testAction(actions.close, null, {}, [{ type: types.CLOSE }], [], done); + it('works', () => { + return testAction(actions.close, null, {}, [{ type: types.CLOSE }], []); }); }); describe('show', () => { - it('works', (done) => { - testAction(actions.show, null, {}, [{ type: types.SHOW }], [], done); + it('works', () => { + return testAction(actions.show, null, {}, [{ type: types.SHOW }], []); }); }); describe('hide', () => { - it('works', (done) => { - testAction(actions.hide, null, {}, [{ type: types.HIDE }], [], done); + it('works', () => { + return testAction(actions.hide, null, {}, [{ type: types.HIDE }], []); }); }); }); diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js index 0f6e7091c59..0d85df25b4f 100644 --- a/spec/frontend/work_items/components/item_title_spec.js +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -4,10 +4,10 @@ import ItemTitle from '~/work_items/components/item_title.vue'; jest.mock('lodash/escape', () => jest.fn((fn) => fn)); -const createComponent = ({ initialTitle = 'Sample title', disabled = false } = {}) => +const createComponent = ({ title = 'Sample title', disabled = false } = {}) => shallowMount(ItemTitle, { propsData: { - initialTitle, + title, disabled, }, }); diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js new file mode 100644 index 00000000000..d0e9cfee353 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -0,0 +1,103 @@ +import { GlDropdownItem, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import WorkItemActions from '~/work_items/components/work_item_actions.vue'; +import deleteWorkItem from '~/work_items/graphql/delete_work_item.mutation.graphql'; +import { deleteWorkItemResponse, deleteWorkItemFailureResponse } from '../mock_data'; + +describe('WorkItemActions component', () => { + let wrapper; + let glModalDirective; + + Vue.use(VueApollo); + + const findModal = () => wrapper.findComponent(GlModal); + const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + + const createComponent = ({ + canUpdate = true, + deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse), + } = {}) => { + glModalDirective = jest.fn(); + wrapper = shallowMount(WorkItemActions, { + apolloProvider: createMockApollo([[deleteWorkItem, deleteWorkItemHandler]]), + propsData: { workItemId: '123', canUpdate }, + directives: { + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders modal', () => { + createComponent(); + + expect(findModal().exists()).toBe(true); + expect(findModal().props('visible')).toBe(false); + }); + + it('shows confirm modal when clicking Delete work item', () => { + createComponent(); + + findDeleteButton().vm.$emit('click'); + + expect(glModalDirective).toHaveBeenCalled(); + }); + + it('calls delete mutation when clicking OK button', () => { + const deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse); + + createComponent({ + deleteWorkItemHandler, + }); + + findModal().vm.$emit('ok'); + + expect(deleteWorkItemHandler).toHaveBeenCalled(); + expect(wrapper.emitted('error')).toBeUndefined(); + }); + + it('emits event after delete success', async () => { + createComponent(); + + findModal().vm.$emit('ok'); + + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).not.toBeUndefined(); + expect(wrapper.emitted('error')).toBeUndefined(); + }); + + it('emits error event after delete failure', async () => { + createComponent({ + deleteWorkItemHandler: jest.fn().mockResolvedValue(deleteWorkItemFailureResponse), + }); + + findModal().vm.$emit('ok'); + + await waitForPromises(); + + expect(wrapper.emitted('error')[0]).toEqual([ + "The resource that you are attempting to access does not exist or you don't have permission to perform this action", + ]); + expect(wrapper.emitted('workItemDeleted')).toBeUndefined(); + }); + + it('does not render when canUpdate is false', () => { + createComponent({ + canUpdate: false, + }); + + expect(wrapper.html()).toBe(''); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js new file mode 100644 index 00000000000..9f35ccb853b --- /dev/null +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -0,0 +1,58 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import WorkItemActions from '~/work_items/components/work_item_actions.vue'; + +describe('WorkItemDetailModal component', () => { + let wrapper; + + Vue.use(VueApollo); + + const findModal = () => wrapper.findComponent(GlModal); + const findWorkItemActions = () => wrapper.findComponent(WorkItemActions); + const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); + + const createComponent = ({ visible = true, workItemId = '1', canUpdate = false } = {}) => { + wrapper = shallowMount(WorkItemDetailModal, { + propsData: { visible, workItemId, canUpdate }, + stubs: { + GlModal, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each([true, false])('when visible=%s', (visible) => { + it(`${visible ? 'renders' : 'does not render'} modal`, () => { + createComponent({ visible }); + + expect(findModal().props('visible')).toBe(visible); + }); + }); + + it('renders heading', () => { + createComponent(); + + expect(wrapper.find('h2').text()).toBe('Work Item'); + }); + + it('renders WorkItemDetail', () => { + createComponent(); + + expect(findWorkItemDetail().props()).toEqual({ workItemId: '1' }); + }); + + it('shows work item actions', () => { + createComponent({ + canUpdate: true, + }); + + expect(findWorkItemActions().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js deleted file mode 100644 index 305f43ad8ba..00000000000 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import { GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import WorkItemTitle from '~/work_items/components/item_title.vue'; -import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; -import { resolvers } from '~/work_items/graphql/resolvers'; - -describe('WorkItemDetailModal component', () => { - let wrapper; - - Vue.use(VueApollo); - - const findModal = () => wrapper.findComponent(GlModal); - const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); - - const createComponent = () => { - wrapper = shallowMount(WorkItemDetailModal, { - apolloProvider: createMockApollo([], resolvers), - propsData: { visible: true }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders modal', () => { - createComponent(); - - expect(findModal().props()).toMatchObject({ visible: true }); - }); - - it('renders work item title', () => { - createComponent(); - - expect(findWorkItemTitle().exists()).toBe(true); - }); -}); diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js new file mode 100644 index 00000000000..9b1ef2d14e4 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_title_spec.js @@ -0,0 +1,117 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ItemTitle from '~/work_items/components/item_title.vue'; +import WorkItemTitle from '~/work_items/components/work_item_title.vue'; +import { i18n } from '~/work_items/constants'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data'; + +describe('WorkItemTitle component', () => { + let wrapper; + + Vue.use(VueApollo); + + const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findItemTitle = () => wrapper.findComponent(ItemTitle); + + const createComponent = ({ loading = false, mutationHandler = mutationSuccessHandler } = {}) => { + const { id, title, workItemType } = workItemQueryResponse.data.workItem; + wrapper = shallowMount(WorkItemTitle, { + apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), + propsData: { + loading, + workItemId: id, + workItemTitle: title, + workItemType: workItemType.name, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent({ loading: true }); + }); + + it('renders loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not render title', () => { + expect(findItemTitle().exists()).toBe(false); + }); + }); + + describe('when loaded', () => { + beforeEach(() => { + createComponent({ loading: false }); + }); + + it('does not render loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders title', () => { + expect(findItemTitle().props('title')).toBe(workItemQueryResponse.data.workItem.title); + }); + }); + + describe('when updating the title', () => { + it('calls a mutation', () => { + const title = 'new title!'; + + createComponent(); + + findItemTitle().vm.$emit('title-changed', title); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemQueryResponse.data.workItem.id, + title, + }, + }); + }); + + it('does not call a mutation when the title has not changed', () => { + createComponent(); + + findItemTitle().vm.$emit('title-changed', workItemQueryResponse.data.workItem.title); + + expect(mutationSuccessHandler).not.toHaveBeenCalled(); + }); + + it('emits an error message when the mutation was unsuccessful', async () => { + createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') }); + + findItemTitle().vm.$emit('title-changed', 'new title'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + }); + + it('tracks editing the title', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + createComponent(); + + findItemTitle().vm.$emit('title-changed', 'new title'); + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'updated_title', { + category: 'workItems:show', + label: 'item_title', + property: 'type_Task', + }); + }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 832795fc4ac..722e1708c15 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -1,21 +1,14 @@ export const workItemQueryResponse = { - workItem: { - __typename: 'WorkItem', - id: '1', - title: 'Test', - workItemType: { - __typename: 'WorkItemType', - id: 'work-item-type-1', - }, - widgets: { - __typename: 'LocalWorkItemWidgetConnection', - nodes: [ - { - __typename: 'LocalTitleWidget', - type: 'TITLE', - contentText: 'Test', - }, - ], + data: { + workItem: { + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1', + title: 'Test', + workItemType: { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + }, }, }, }; @@ -23,25 +16,15 @@ export const workItemQueryResponse = { export const updateWorkItemMutationResponse = { data: { workItemUpdate: { - __typename: 'LocalUpdateWorkItemPayload', + __typename: 'WorkItemUpdatePayload', workItem: { - __typename: 'LocalWorkItem', - id: '1', + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1', title: 'Updated title', workItemType: { __typename: 'WorkItemType', - id: 'work-item-type-1', - }, - widgets: { - __typename: 'LocalWorkItemWidgetConnection', - nodes: [ - { - __typename: 'LocalTitleWidget', - type: 'TITLE', - enabled: true, - contentText: 'Updated title', - }, - ], + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', }, }, }, @@ -51,11 +34,11 @@ export const updateWorkItemMutationResponse = { export const projectWorkItemTypesQueryResponse = { data: { workspace: { - id: '1', + id: 'gid://gitlab/WorkItem/1', workItemTypes: { nodes: [ - { id: 'work-item-1', name: 'Issue' }, - { id: 'work-item-2', name: 'Incident' }, + { id: 'gid://gitlab/WorkItems::Type/1', name: 'Issue' }, + { id: 'gid://gitlab/WorkItems::Type/2', name: 'Incident' }, ], }, }, @@ -68,13 +51,53 @@ export const createWorkItemMutationResponse = { __typename: 'WorkItemCreatePayload', workItem: { __typename: 'WorkItem', - id: '1', + id: 'gid://gitlab/WorkItem/1', title: 'Updated title', workItemType: { __typename: 'WorkItemType', - id: 'work-item-type-1', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', }, }, }, }, }; + +export const createWorkItemFromTaskMutationResponse = { + data: { + workItemCreateFromTask: { + __typename: 'WorkItemCreateFromTaskPayload', + errors: [], + workItem: { + descriptionHtml: '<p>New description</p>', + id: 'gid://gitlab/WorkItem/13', + __typename: 'WorkItem', + }, + }, + }, +}; + +export const deleteWorkItemResponse = { + data: { workItemDelete: { errors: [], __typename: 'WorkItemDeletePayload' } }, +}; + +export const deleteWorkItemFailureResponse = { + data: { workItemDelete: null }, + errors: [ + { + message: + "The resource that you are attempting to access does not exist or you don't have permission to perform this action", + locations: [{ line: 2, column: 3 }], + path: ['workItemDelete'], + }, + ], +}; + +export const workItemTitleSubscriptionResponse = { + data: { + issuableTitleUpdated: { + id: 'gid://gitlab/WorkItem/1', + title: 'new title', + }, + }, +}; diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js index 185b05c5191..fb1f1d56356 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -1,15 +1,19 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import { GlAlert, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlAlert, GlFormSelect } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import ItemTitle from '~/work_items/components/item_title.vue'; -import { resolvers } from '~/work_items/graphql/resolvers'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; -import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data'; +import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql'; +import { + projectWorkItemTypesQueryResponse, + createWorkItemMutationResponse, + createWorkItemFromTaskMutationResponse, +} from '../mock_data'; jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] })); @@ -20,12 +24,15 @@ describe('Create work item component', () => { let fakeApollo; const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse); - const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse); + const createWorkItemSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse); + const createWorkItemFromTaskSuccessHandler = jest + .fn() + .mockResolvedValue(createWorkItemFromTaskMutationResponse); + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const findAlert = () => wrapper.findComponent(GlAlert); const findTitleInput = () => wrapper.findComponent(ItemTitle); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findSelect = () => wrapper.findComponent(GlFormSelect); const findCreateButton = () => wrapper.find('[data-testid="create-button"]'); const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); @@ -36,15 +43,13 @@ describe('Create work item component', () => { data = {}, props = {}, queryHandler = querySuccessHandler, - mutationHandler = mutationSuccessHandler, + mutationHandler = createWorkItemSuccessHandler, } = {}) => { - fakeApollo = createMockApollo( - [ - [projectWorkItemTypesQuery, queryHandler], - [createWorkItemMutation, mutationHandler], - ], - resolvers, - ); + fakeApollo = createMockApollo([ + [projectWorkItemTypesQuery, queryHandler], + [createWorkItemMutation, mutationHandler], + [createWorkItemFromTaskMutation, mutationHandler], + ]); wrapper = shallowMount(CreateWorkItem, { apolloProvider: fakeApollo, data() { @@ -123,6 +128,7 @@ describe('Create work item component', () => { props: { isModal: true, }, + mutationHandler: createWorkItemFromTaskSuccessHandler, }); }); @@ -133,14 +139,12 @@ describe('Create work item component', () => { }); it('emits `onCreate` on successful mutation', async () => { - const mockTitle = 'Test title'; findTitleInput().vm.$emit('title-input', 'Test title'); wrapper.find('form').trigger('submit'); await waitForPromises(); - const expected = { id: '1', title: mockTitle }; - expect(wrapper.emitted('onCreate')).toEqual([[expected]]); + expect(wrapper.emitted('onCreate')).toEqual([['<p>New description</p>']]); }); it('does not right margin for create button', () => { @@ -177,16 +181,14 @@ describe('Create work item component', () => { }); it('displays a list of work item types', () => { - expect(findDropdownItems()).toHaveLength(2); - expect(findDropdownItems().at(0).text()).toContain('Issue'); + expect(findSelect().attributes('options').split(',')).toHaveLength(3); }); it('selects a work item type on click', async () => { - expect(findDropdown().props('text')).toBe('Type'); - findDropdownItems().at(0).vm.$emit('click'); + const mockId = 'work-item-1'; + findSelect().vm.$emit('input', mockId); await nextTick(); - - expect(findDropdown().props('text')).toBe('Issue'); + expect(findSelect().attributes('value')).toBe(mockId); }); }); @@ -206,21 +208,36 @@ describe('Create work item component', () => { createComponent({ props: { initialTitle }, }); - expect(findTitleInput().props('initialTitle')).toBe(initialTitle); + expect(findTitleInput().props('title')).toBe(initialTitle); }); describe('when title input field has a text', () => { - beforeEach(() => { + beforeEach(async () => { const mockTitle = 'Test title'; createComponent(); + await waitForPromises(); findTitleInput().vm.$emit('title-input', mockTitle); }); - it('renders a non-disabled Create button', () => { + it('renders a disabled Create button', () => { + expect(findCreateButton().props('disabled')).toBe(true); + }); + + it('renders a non-disabled Create button when work item type is selected', async () => { + findSelect().vm.$emit('input', 'work-item-1'); + await nextTick(); expect(findCreateButton().props('disabled')).toBe(false); }); + }); + + it('shows an alert on mutation error', async () => { + createComponent({ mutationHandler: errorHandler }); + await waitForPromises(); + findTitleInput().vm.$emit('title-input', 'some title'); + findSelect().vm.$emit('input', 'work-item-1'); + wrapper.find('form').trigger('submit'); + await waitForPromises(); - // TODO: write a proper test here when we have a backend implementation - it.todo('shows an alert on mutation error'); + expect(findAlert().text()).toBe(CreateWorkItem.createErrorText); }); }); diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js new file mode 100644 index 00000000000..1eb6c0145e7 --- /dev/null +++ b/spec/frontend/work_items/pages/work_item_detail_spec.js @@ -0,0 +1,99 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import WorkItemTitle from '~/work_items/components/work_item_title.vue'; +import { i18n } from '~/work_items/constants'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; +import { workItemTitleSubscriptionResponse, workItemQueryResponse } from '../mock_data'; + +describe('WorkItemDetail component', () => { + let wrapper; + + Vue.use(VueApollo); + + const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); + + const findAlert = () => wrapper.findComponent(GlAlert); + const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); + + const createComponent = ({ + workItemId = workItemQueryResponse.data.workItem.id, + handler = successHandler, + subscriptionHandler = initialSubscriptionHandler, + } = {}) => { + wrapper = shallowMount(WorkItemDetail, { + apolloProvider: createMockApollo([ + [workItemQuery, handler], + [workItemTitleSubscription, subscriptionHandler], + ]), + propsData: { workItemId }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when there is no `workItemId` prop', () => { + beforeEach(() => { + createComponent({ workItemId: null }); + }); + + it('skips the work item query', () => { + expect(successHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders WorkItemTitle in loading state', () => { + expect(findWorkItemTitle().props('loading')).toBe(true); + }); + }); + + describe('when loaded', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('does not render WorkItemTitle in loading state', () => { + expect(findWorkItemTitle().props('loading')).toBe(false); + }); + }); + + it('shows an error message when the work item query was unsuccessful', async () => { + const errorHandler = jest.fn().mockRejectedValue('Oops'); + createComponent({ handler: errorHandler }); + await waitForPromises(); + + expect(errorHandler).toHaveBeenCalled(); + expect(findAlert().text()).toBe(i18n.fetchError); + }); + + it('shows an error message when WorkItemTitle emits an `error` event', async () => { + createComponent(); + + findWorkItemTitle().vm.$emit('error', i18n.updateError); + await waitForPromises(); + + expect(findAlert().text()).toBe(i18n.updateError); + }); + + it('calls the subscription', () => { + createComponent(); + + expect(initialSubscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemQueryResponse.data.workItem.id, + }); + }); +}); diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 728495e0e23..2803724b9af 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -1,108 +1,31 @@ -import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; -import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; -import ItemTitle from '~/work_items/components/item_title.vue'; -import { resolvers } from '~/work_items/graphql/resolvers'; -import { workItemQueryResponse, updateWorkItemMutationResponse } from '../mock_data'; Vue.use(VueApollo); -const WORK_ITEM_ID = '1'; -const WORK_ITEM_GID = `gid://gitlab/WorkItem/${WORK_ITEM_ID}`; - describe('Work items root component', () => { - const mockUpdatedTitle = 'Updated title'; let wrapper; - let fakeApollo; - - const findTitle = () => wrapper.findComponent(ItemTitle); - const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => { - fakeApollo = createMockApollo( - [[updateWorkItemMutation, jest.fn().mockResolvedValue(updateWorkItemMutationResponse)]], - resolvers, - { - possibleTypes: { - LocalWorkItemWidget: ['LocalTitleWidget'], - }, - }, - ); - fakeApollo.clients.defaultClient.cache.writeQuery({ - query: workItemQuery, - variables: { - id: WORK_ITEM_GID, - }, - data: queryResponse, - }); + const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); + const createComponent = () => { wrapper = shallowMount(WorkItemsRoot, { propsData: { - id: WORK_ITEM_ID, + id: '1', }, - apolloProvider: fakeApollo, }); }; afterEach(() => { wrapper.destroy(); - fakeApollo = null; }); - it('renders the title', () => { + it('renders WorkItemDetail', () => { createComponent(); - expect(findTitle().exists()).toBe(true); - expect(findTitle().props('initialTitle')).toBe('Test'); - }); - - it('updates the title when it is edited', async () => { - createComponent(); - jest.spyOn(wrapper.vm.$apollo, 'mutate'); - - await findTitle().vm.$emit('title-changed', mockUpdatedTitle); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateWorkItemMutation, - variables: { - input: { - id: WORK_ITEM_GID, - title: mockUpdatedTitle, - }, - }, - }); - }); - - describe('tracking', () => { - let trackingSpy; - - beforeEach(() => { - trackingSpy = mockTracking('_category_', undefined, jest.spyOn); - - createComponent(); - }); - - afterEach(() => { - unmockTracking(); - }); - - it('tracks item title updates', async () => { - await findTitle().vm.$emit('title-changed', mockUpdatedTitle); - - await waitForPromises(); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith('workItems:show', undefined, { - action: 'updated_title', - category: 'workItems:show', - label: 'item_title', - property: '[type_work_item]', - }); - }); + expect(findWorkItemDetail().props()).toEqual({ workItemId: 'gid://gitlab/WorkItem/1' }); }); }); diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index 8c9054920a8..7e68c5e4f0e 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -37,7 +37,7 @@ describe('Work items router', () => { it('renders work item on `/1` route', async () => { await createComponent('/1'); - expect(wrapper.find(WorkItemsRoot).exists()).toBe(true); + expect(wrapper.findComponent(WorkItemsRoot).exists()).toBe(true); }); it('renders create work item page on `/new` route', async () => { diff --git a/spec/frontend_integration/content_editor/content_editor_integration_spec.js b/spec/frontend_integration/content_editor/content_editor_integration_spec.js new file mode 100644 index 00000000000..1b45c0d43a3 --- /dev/null +++ b/spec/frontend_integration/content_editor/content_editor_integration_spec.js @@ -0,0 +1,63 @@ +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { ContentEditor } from '~/content_editor'; + +/** + * This spec exercises some workflows in the Content Editor without mocking + * any component. + * + */ +describe('content_editor', () => { + let wrapper; + let renderMarkdown; + let contentEditorService; + + const buildWrapper = () => { + renderMarkdown = jest.fn(); + wrapper = mountExtended(ContentEditor, { + propsData: { + renderMarkdown, + uploadsPath: '/', + }, + listeners: { + initialized(contentEditor) { + contentEditorService = contentEditor; + }, + }, + }); + }; + + describe('when loading initial content', () => { + describe('when the initial content is empty', () => { + it('still hides the loading indicator', async () => { + buildWrapper(); + + renderMarkdown.mockResolvedValue(''); + + await contentEditorService.setSerializedContent(''); + await nextTick(); + + expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false); + }); + }); + + describe('when the initial content is not empty', () => { + const initialContent = '<p><strong>bold text</strong></p>'; + beforeEach(async () => { + buildWrapper(); + + renderMarkdown.mockResolvedValue(initialContent); + + await contentEditorService.setSerializedContent('**bold text**'); + await nextTick(); + }); + it('hides the loading indicator', async () => { + expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false); + }); + + it('displays the initial content', async () => { + expect(wrapper.html()).toContain(initialContent); + }); + }); + }); +}); diff --git a/spec/graphql/graphql_triggers_spec.rb b/spec/graphql/graphql_triggers_spec.rb index 2d83edca363..84af33a5cb3 100644 --- a/spec/graphql/graphql_triggers_spec.rb +++ b/spec/graphql/graphql_triggers_spec.rb @@ -31,4 +31,20 @@ RSpec.describe GraphqlTriggers do GraphqlTriggers.issuable_title_updated(work_item) end end + + describe '.issuable_labels_updated' do + it 'triggers the issuableLabelsUpdated subscription' do + project = create(:project) + labels = create_list(:label, 3, project: project) + issue = create(:issue, labels: labels) + + expect(GitlabSchema.subscriptions).to receive(:trigger).with( + 'issuableLabelsUpdated', + { issuable_id: issue.to_gid }, + issue + ) + + GraphqlTriggers.issuable_labels_updated(issue) + end + end end diff --git a/spec/graphql/mutations/ci/runner/delete_spec.rb b/spec/graphql/mutations/ci/runner/delete_spec.rb index c0f979e43cc..ee640b21918 100644 --- a/spec/graphql/mutations/ci/runner/delete_spec.rb +++ b/spec/graphql/mutations/ci/runner/delete_spec.rb @@ -37,7 +37,9 @@ RSpec.describe Mutations::Ci::Runner::Delete do it 'raises an error' do mutation_params[:id] = two_projects_runner.to_global_id - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + subject + end end end end @@ -115,7 +117,10 @@ RSpec.describe Mutations::Ci::Runner::Delete do allow_next_instance_of(::Ci::Runners::UnregisterRunnerService) do |service| expect(service).not_to receive(:execute) end - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + subject + end end end end diff --git a/spec/graphql/mutations/environments/canary_ingress/update_spec.rb b/spec/graphql/mutations/environments/canary_ingress/update_spec.rb index 48e55828a6b..fdf9cbaf25b 100644 --- a/spec/graphql/mutations/environments/canary_ingress/update_spec.rb +++ b/spec/graphql/mutations/environments/canary_ingress/update_spec.rb @@ -36,6 +36,20 @@ RSpec.describe Mutations::Environments::CanaryIngress::Update do it 'returns no errors' do expect(subject[:errors]).to be_empty end + + context 'with certificate_based_clusters disabled' do + before do + stub_feature_flags(certificate_based_clusters: false) + end + + it 'returns notice about feature removal' do + expect(subject[:errors]).to match_array([ + 'This endpoint was deactivated as part of the certificate-based' \ + 'kubernetes integration removal. See Epic:' \ + 'https://gitlab.com/groups/gitlab-org/configure/-/epics/8' + ]) + end + end end context 'when service encounters a problem' do diff --git a/spec/graphql/mutations/saved_replies/destroy_spec.rb b/spec/graphql/mutations/saved_replies/destroy_spec.rb new file mode 100644 index 00000000000..6cff28ec0b2 --- /dev/null +++ b/spec/graphql/mutations/saved_replies/destroy_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::SavedReplies::Destroy do + let_it_be(:current_user) { create(:user) } + let_it_be(:saved_reply) { create(:saved_reply, user: current_user) } + + let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) } + + describe '#resolve' do + subject(:resolve) do + mutation.resolve(id: saved_reply.to_global_id) + end + + context 'when feature is disabled' do + before do + stub_feature_flags(saved_replies: false) + end + + it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled') + end + end + + context 'when feature is enabled for current user' do + before do + stub_feature_flags(saved_replies: current_user) + end + + context 'when service fails to delete a new saved reply' do + before do + saved_reply.destroy! + end + + it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when service successfully deletes the saved reply' do + it { expect(subject[:errors]).to be_empty } + end + end + end +end diff --git a/spec/graphql/resolvers/blobs_resolver_spec.rb b/spec/graphql/resolvers/blobs_resolver_spec.rb index 4b75351147c..a666ed2a9fc 100644 --- a/spec/graphql/resolvers/blobs_resolver_spec.rb +++ b/spec/graphql/resolvers/blobs_resolver_spec.rb @@ -75,10 +75,9 @@ RSpec.describe Resolvers::BlobsResolver do let(:ref) { 'ma:in' } it 'raises an ArgumentError' do - expect { resolve_blobs }.to raise_error( - Gitlab::Graphql::Errors::ArgumentError, - 'Ref is not valid' - ) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'Ref is not valid') do + resolve_blobs + end end end @@ -86,10 +85,9 @@ RSpec.describe Resolvers::BlobsResolver do let(:ref) { '' } it 'raises an ArgumentError' do - expect { resolve_blobs }.to raise_error( - Gitlab::Graphql::Errors::ArgumentError, - 'Ref is not valid' - ) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'Ref is not valid') do + resolve_blobs + end end end end diff --git a/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb b/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb index fcf67120b0e..8d0b8f9398d 100644 --- a/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb +++ b/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb @@ -35,7 +35,9 @@ RSpec.describe Resolvers::GroupMembers::NotificationEmailResolver do let(:current_user) { create(:user) } it 'raises ResourceNotAvailable error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + subject + end end end end diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index 5e9a3d0a68b..81aeee0a3d2 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -522,11 +522,53 @@ RSpec.describe Resolvers::IssuesResolver do end end + context 'when sorting by escalation status' do + let_it_be(:project) { create(:project, :public) } + let_it_be(:triggered_incident) { create(:incident, :with_escalation_status, project: project) } + let_it_be(:issue_no_status) { create(:issue, project: project) } + let_it_be(:resolved_incident) do + create(:incident, :with_escalation_status, project: project) + .tap { |issue| issue.escalation_status.resolve } + end + + it 'sorts issues ascending' do + issues = resolve_issues(sort: :escalation_status_asc).to_a + expect(issues).to eq([triggered_incident, resolved_incident, issue_no_status]) + end + + it 'sorts issues descending' do + issues = resolve_issues(sort: :escalation_status_desc).to_a + expect(issues).to eq([resolved_incident, triggered_incident, issue_no_status]) + end + + it 'sorts issues created_at' do + issues = resolve_issues(sort: :created_desc).to_a + expect(issues).to eq([resolved_incident, issue_no_status, triggered_incident]) + end + + context 'when incident_escalations feature flag is disabled' do + before do + stub_feature_flags(incident_escalations: false) + end + + it 'defaults ascending status sort to created_desc' do + issues = resolve_issues(sort: :escalation_status_asc).to_a + expect(issues).to eq([resolved_incident, issue_no_status, triggered_incident]) + end + + it 'defaults descending status sort to created_desc' do + issues = resolve_issues(sort: :escalation_status_desc).to_a + expect(issues).to eq([resolved_incident, issue_no_status, triggered_incident]) + end + end + end + context 'when sorting with non-stable cursors' do %i[priority_asc priority_desc popularity_asc popularity_desc label_priority_asc label_priority_desc - milestone_due_asc milestone_due_desc].each do |sort_by| + milestone_due_asc milestone_due_desc + escalation_status_asc escalation_status_desc].each do |sort_by| it "uses offset-pagination when sorting by #{sort_by}" do resolved = resolve_issues(sort: sort_by) diff --git a/spec/graphql/resolvers/project_jobs_resolver_spec.rb b/spec/graphql/resolvers/project_jobs_resolver_spec.rb index 94df2999163..bb711a4c857 100644 --- a/spec/graphql/resolvers/project_jobs_resolver_spec.rb +++ b/spec/graphql/resolvers/project_jobs_resolver_spec.rb @@ -9,9 +9,10 @@ RSpec.describe Resolvers::ProjectJobsResolver do let_it_be(:irrelevant_project) { create(:project, :repository) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) } let_it_be(:irrelevant_pipeline) { create(:ci_pipeline, project: irrelevant_project) } - let_it_be(:build_one) { create(:ci_build, :success, name: 'Build One', pipeline: pipeline) } - let_it_be(:build_two) { create(:ci_build, :success, name: 'Build Two', pipeline: pipeline) } - let_it_be(:build_three) { create(:ci_build, :failed, name: 'Build Three', pipeline: pipeline) } + let_it_be(:successful_build) { create(:ci_build, :success, name: 'Build One', pipeline: pipeline) } + let_it_be(:successful_build_two) { create(:ci_build, :success, name: 'Build Two', pipeline: pipeline) } + let_it_be(:failed_build) { create(:ci_build, :failed, name: 'Build Three', pipeline: pipeline) } + let_it_be(:pending_build) { create(:ci_build, :pending, name: 'Build Three', pipeline: pipeline) } let(:irrelevant_build) { create(:ci_build, name: 'Irrelevant Build', pipeline: irrelevant_pipeline)} let(:args) { {} } @@ -28,11 +29,17 @@ RSpec.describe Resolvers::ProjectJobsResolver do context 'with statuses argument' do let(:args) { { statuses: [Types::Ci::JobStatusEnum.coerce_isolated_input('SUCCESS')] } } - it { is_expected.to contain_exactly(build_one, build_two) } + it { is_expected.to contain_exactly(successful_build, successful_build_two) } + end + + context 'with multiple statuses' do + let(:args) { { statuses: [Types::Ci::JobStatusEnum.coerce_isolated_input('SUCCESS'), Types::Ci::JobStatusEnum.coerce_isolated_input('FAILED')] } } + + it { is_expected.to contain_exactly(successful_build, successful_build_two, failed_build) } end context 'without statuses argument' do - it { is_expected.to contain_exactly(build_one, build_two, build_three) } + it { is_expected.to contain_exactly(successful_build, successful_build_two, failed_build, pending_build) } end end diff --git a/spec/graphql/resolvers/users_resolver_spec.rb b/spec/graphql/resolvers/users_resolver_spec.rb index b01cc0d43e3..1ba296912a3 100644 --- a/spec/graphql/resolvers/users_resolver_spec.rb +++ b/spec/graphql/resolvers/users_resolver_spec.rb @@ -74,7 +74,9 @@ RSpec.describe Resolvers::UsersResolver do let_it_be(:current_user) { nil } it 'prohibits search without usernames passed' do - expect { resolve_users }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + resolve_users + end end it 'allows to search by username' do diff --git a/spec/graphql/resolvers/work_item_resolver_spec.rb b/spec/graphql/resolvers/work_item_resolver_spec.rb index c7e2beecb51..bfa0cf1d8a2 100644 --- a/spec/graphql/resolvers/work_item_resolver_spec.rb +++ b/spec/graphql/resolvers/work_item_resolver_spec.rb @@ -22,7 +22,9 @@ RSpec.describe Resolvers::WorkItemResolver do let(:current_user) { create(:user) } it 'raises a resource not available error' do - expect { resolved_work_item }.to raise_error(::Gitlab::Graphql::Errors::ResourceNotAvailable) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + resolved_work_item + end end end diff --git a/spec/graphql/resolvers/work_items/types_resolver_spec.rb b/spec/graphql/resolvers/work_items/types_resolver_spec.rb index f7aeed30fd3..868f4566ad6 100644 --- a/spec/graphql/resolvers/work_items/types_resolver_spec.rb +++ b/spec/graphql/resolvers/work_items/types_resolver_spec.rb @@ -53,5 +53,15 @@ RSpec.describe Resolvers::WorkItems::TypesResolver do it_behaves_like 'a work item type resolver' end + + context 'when parent is not a group or project' do + let(:object) { 'not a project/group' } + + it 'returns nil because of feature flag check' do + result = resolve(described_class, obj: object, args: {}) + + expect(result).to be_nil + end + end end end diff --git a/spec/graphql/types/base_object_spec.rb b/spec/graphql/types/base_object_spec.rb index d8f2ef58ea5..45dc885ecba 100644 --- a/spec/graphql/types/base_object_spec.rb +++ b/spec/graphql/types/base_object_spec.rb @@ -428,5 +428,25 @@ RSpec.describe Types::BaseObject do expect(result.dig('data', 'users', 'nodes')) .to contain_exactly({ 'name' => active_users.first.name }) end + + describe '.authorize' do + let_it_be(:read_only_type) do + Class.new(described_class) do + authorize :read_only + end + end + + let_it_be(:inherited_read_only_type) { Class.new(read_only_type) } + + it 'keeps track of the specified value' do + expect(described_class.authorize).to be_nil + expect(read_only_type.authorize).to match_array [:read_only] + expect(inherited_read_only_type.authorize).to match_array [:read_only] + end + + it 'can not redefine the authorize value' do + expect { read_only_type.authorize(:write_only) }.to raise_error('Cannot redefine authorize') + end + end end end diff --git a/spec/graphql/types/ci/job_kind_enum_spec.rb b/spec/graphql/types/ci/job_kind_enum_spec.rb new file mode 100644 index 00000000000..b48d20b71e2 --- /dev/null +++ b/spec/graphql/types/ci/job_kind_enum_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CiJobKind'] do + it 'exposes some job type values' do + expect(described_class.values.keys).to match_array( + (%w[BRIDGE BUILD]) + ) + end +end diff --git a/spec/graphql/types/ci/job_type_spec.rb b/spec/graphql/types/ci/job_type_spec.rb index 47d697ab8b8..655c3636883 100644 --- a/spec/graphql/types/ci/job_type_spec.rb +++ b/spec/graphql/types/ci/job_type_spec.rb @@ -21,6 +21,7 @@ RSpec.describe Types::Ci::JobType do downstreamPipeline finished_at id + kind manual_job name needs diff --git a/spec/graphql/types/container_repository_details_type_spec.rb b/spec/graphql/types/container_repository_details_type_spec.rb index aa770284f89..d94516c6fce 100644 --- a/spec/graphql/types/container_repository_details_type_spec.rb +++ b/spec/graphql/types/container_repository_details_type_spec.rb @@ -3,7 +3,9 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['ContainerRepositoryDetails'] do - fields = %i[id name path location created_at updated_at expiration_policy_started_at status tags_count can_delete expiration_policy_cleanup_status tags size project] + fields = %i[id name path location created_at updated_at expiration_policy_started_at + status tags_count can_delete expiration_policy_cleanup_status tags size + project migration_state] it { expect(described_class.graphql_name).to eq('ContainerRepositoryDetails') } diff --git a/spec/graphql/types/container_repository_type_spec.rb b/spec/graphql/types/container_repository_type_spec.rb index 87e1c11ce19..9815449dd68 100644 --- a/spec/graphql/types/container_repository_type_spec.rb +++ b/spec/graphql/types/container_repository_type_spec.rb @@ -3,7 +3,9 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['ContainerRepository'] do - fields = %i[id name path location created_at updated_at expiration_policy_started_at status tags_count can_delete expiration_policy_cleanup_status project] + fields = %i[id name path location created_at updated_at expiration_policy_started_at + status tags_count can_delete expiration_policy_cleanup_status project + migration_state] it { expect(described_class.graphql_name).to eq('ContainerRepository') } diff --git a/spec/graphql/types/dependency_proxy/manifest_type_spec.rb b/spec/graphql/types/dependency_proxy/manifest_type_spec.rb index b251ca63c4f..f688b085b10 100644 --- a/spec/graphql/types/dependency_proxy/manifest_type_spec.rb +++ b/spec/graphql/types/dependency_proxy/manifest_type_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['DependencyProxyManifest'] do it 'includes dependency proxy manifest fields' do expected_fields = %w[ - id file_name image_name size created_at updated_at digest + id file_name image_name size created_at updated_at digest status ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/issue_sort_enum_spec.rb b/spec/graphql/types/issue_sort_enum_spec.rb index 4433709d193..95184477e75 100644 --- a/spec/graphql/types/issue_sort_enum_spec.rb +++ b/spec/graphql/types/issue_sort_enum_spec.rb @@ -9,7 +9,7 @@ RSpec.describe GitlabSchema.types['IssueSort'] do it 'exposes all the existing issue sort values' do expect(described_class.values.keys).to include( - *%w[DUE_DATE_ASC DUE_DATE_DESC RELATIVE_POSITION_ASC SEVERITY_ASC SEVERITY_DESC] + *%w[DUE_DATE_ASC DUE_DATE_DESC RELATIVE_POSITION_ASC SEVERITY_ASC SEVERITY_DESC ESCALATION_STATUS_ASC ESCALATION_STATUS_DESC] ) end end diff --git a/spec/graphql/types/range_input_type_spec.rb b/spec/graphql/types/range_input_type_spec.rb index fc9126247fa..dbfcf4a41c7 100644 --- a/spec/graphql/types/range_input_type_spec.rb +++ b/spec/graphql/types/range_input_type_spec.rb @@ -24,7 +24,7 @@ RSpec.describe ::Types::RangeInputType do it 'follows expected subtyping relationships for instances' do context = GraphQL::Query::Context.new( - query: double('query', schema: nil), + query: GraphQL::Query.new(GitlabSchema), values: {}, object: nil ) diff --git a/spec/graphql/types/repository/blob_type_spec.rb b/spec/graphql/types/repository/blob_type_spec.rb index a813ef85e6e..787b5f4a311 100644 --- a/spec/graphql/types/repository/blob_type_spec.rb +++ b/spec/graphql/types/repository/blob_type_spec.rb @@ -34,7 +34,6 @@ RSpec.describe Types::Repository::BlobType do :environment_external_url_for_route_map, :code_navigation_path, :project_blob_path_root, - :code_owners, :simple_viewer, :rich_viewer, :plain_data, @@ -47,6 +46,6 @@ RSpec.describe Types::Repository::BlobType do :ide_fork_and_edit_path, :fork_and_view_path, :language - ) + ).at_least end end diff --git a/spec/graphql/types/subscription_type_spec.rb b/spec/graphql/types/subscription_type_spec.rb index 593795de004..1a2629ed422 100644 --- a/spec/graphql/types/subscription_type_spec.rb +++ b/spec/graphql/types/subscription_type_spec.rb @@ -8,6 +8,7 @@ RSpec.describe GitlabSchema.types['Subscription'] do issuable_assignees_updated issue_crm_contacts_updated issuable_title_updated + issuable_labels_updated ] expect(described_class).to have_graphql_fields(*expected_fields).only diff --git a/spec/haml_lint/linter/documentation_links_spec.rb b/spec/haml_lint/linter/documentation_links_spec.rb index 75002097d69..f2aab4304c1 100644 --- a/spec/haml_lint/linter/documentation_links_spec.rb +++ b/spec/haml_lint/linter/documentation_links_spec.rb @@ -43,6 +43,12 @@ RSpec.describe HamlLint::Linter::DocumentationLinks do let(:haml) { "= link_to 'Description', #{link_pattern}('wrong.md'), target: '_blank'" } it { is_expected.to report_lint } + + context 'when haml ends with block definition' do + let(:haml) { "= link_to 'Description', #{link_pattern}('wrong.md') do" } + + it { is_expected.to report_lint } + end end context 'when link with wrong file path is assigned to a variable' do diff --git a/spec/helpers/admin/background_migrations_helper_spec.rb b/spec/helpers/admin/background_migrations_helper_spec.rb index 9c1bb0b9c55..e3639ef778e 100644 --- a/spec/helpers/admin/background_migrations_helper_spec.rb +++ b/spec/helpers/admin/background_migrations_helper_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do describe '#batched_migration_status_badge_variant' do using RSpec::Parameterized::TableSyntax - where(:status, :variant) do + where(:status_name, :variant) do :active | :info :paused | :warning :failed | :danger @@ -16,7 +16,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do subject { helper.batched_migration_status_badge_variant(migration) } with_them do - let(:migration) { build(:batched_background_migration, status: status) } + let(:migration) { build(:batched_background_migration, status_name) } it { is_expected.to eq(variant) } end @@ -25,7 +25,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do describe '#batched_migration_progress' do subject { helper.batched_migration_progress(migration, completed_rows) } - let(:migration) { build(:batched_background_migration, status: :active, total_tuple_count: 100) } + let(:migration) { build(:batched_background_migration, :active, total_tuple_count: 100) } let(:completed_rows) { 25 } it 'returns completion percentage' do @@ -33,7 +33,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do end context 'when migration is finished' do - let(:migration) { build(:batched_background_migration, status: :finished, total_tuple_count: nil) } + let(:migration) { build(:batched_background_migration, :finished, total_tuple_count: nil) } it 'returns 100 percent' do expect(subject).to eq(100) @@ -41,7 +41,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do end context 'when total_tuple_count is nil' do - let(:migration) { build(:batched_background_migration, status: :active, total_tuple_count: nil) } + let(:migration) { build(:batched_background_migration, :active, total_tuple_count: nil) } it 'returns nil' do expect(subject).to eq(nil) diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb index 26d48bef24e..c93762416f5 100644 --- a/spec/helpers/application_settings_helper_spec.rb +++ b/spec/helpers/application_settings_helper_spec.rb @@ -149,7 +149,7 @@ RSpec.describe ApplicationSettingsHelper do end end - describe '.storage_weights' do + describe '#storage_weights' do let(:application_setting) { build(:application_setting) } before do @@ -158,12 +158,13 @@ RSpec.describe ApplicationSettingsHelper do stub_application_setting(repository_storages_weighted: { 'default' => 100, 'storage_1' => 50, 'storage_2' => nil }) end - it 'returns storages correctly' do - expect(helper.storage_weights).to eq(OpenStruct.new( - default: 100, - storage_1: 50, - storage_2: 0 - )) + it 'returns storage objects with assigned weights' do + expect(helper.storage_weights) + .to have_attributes( + default: 100, + storage_1: 50, + storage_2: 0 + ) end end diff --git a/spec/helpers/boards_helper_spec.rb b/spec/helpers/boards_helper_spec.rb index ec949fde30e..8d5dc3fb4be 100644 --- a/spec/helpers/boards_helper_spec.rb +++ b/spec/helpers/boards_helper_spec.rb @@ -102,6 +102,7 @@ RSpec.describe BoardsHelper do allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, project_board).and_return(true) allow(helper).to receive(:can?).with(user, :admin_issue, project_board).and_return(true) allow(helper).to receive(:can?).with(user, :admin_issue_board_list, project).and_return(false) + allow(helper).to receive(:can?).with(user, :admin_issue_board, project).and_return(false) end it 'returns a board_lists_path as lists_endpoint' do @@ -129,12 +130,23 @@ RSpec.describe BoardsHelper do it 'returns can_admin_list as false by default' do expect(helper.board_data[:can_admin_list]).to eq('false') end - it 'returns can_admin_list as true when user can admin the board' do + it 'returns can_admin_list as true when user can admin the board lists' do allow(helper).to receive(:can?).with(user, :admin_issue_board_list, project).and_return(true) expect(helper.board_data[:can_admin_list]).to eq('true') end end + + context 'can_admin_board' do + it 'returns can_admin_board as false by default' do + expect(helper.board_data[:can_admin_board]).to eq('false') + end + it 'returns can_admin_board as true when user can admin the board' do + allow(helper).to receive(:can?).with(user, :admin_issue_board, project).and_return(true) + + expect(helper.board_data[:can_admin_board]).to eq('true') + end + end end context 'group board' do @@ -146,6 +158,7 @@ RSpec.describe BoardsHelper do allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, group_board).and_return(true) allow(helper).to receive(:can?).with(user, :admin_issue, group_board).and_return(true) allow(helper).to receive(:can?).with(user, :admin_issue_board_list, base_group).and_return(false) + allow(helper).to receive(:can?).with(user, :admin_issue_board, base_group).and_return(false) end it 'returns correct path for base group' do @@ -165,7 +178,7 @@ RSpec.describe BoardsHelper do it 'returns can_admin_list as false by default' do expect(helper.board_data[:can_admin_list]).to eq('false') end - it 'returns can_admin_list as true when user can admin the board' do + it 'returns can_admin_list as true when user can admin the board lists' do allow(helper).to receive(:can?).with(user, :admin_issue_board_list, base_group).and_return(true) expect(helper.board_data[:can_admin_list]).to eq('true') diff --git a/spec/helpers/broadcast_messages_helper_spec.rb b/spec/helpers/broadcast_messages_helper_spec.rb index e721a3fdc95..d4021a2eb59 100644 --- a/spec/helpers/broadcast_messages_helper_spec.rb +++ b/spec/helpers/broadcast_messages_helper_spec.rb @@ -115,37 +115,8 @@ RSpec.describe BroadcastMessagesHelper do end it 'includes the current message' do - allow(helper).to receive(:broadcast_message_style).and_return(nil) - expect(helper.broadcast_message(current_broadcast_message)).to include 'Current Message' end - - it 'includes custom style' do - allow(helper).to receive(:broadcast_message_style).and_return('foo') - - expect(helper.broadcast_message(current_broadcast_message)).to include 'style="foo"' - end - end - - describe 'broadcast_message_style' do - it 'defaults to no style' do - broadcast_message = spy - - expect(helper.broadcast_message_style(broadcast_message)).to eq '' - end - - it 'allows custom style for banner messages' do - broadcast_message = BroadcastMessage.new(color: '#f2dede', font: '#b94a48', broadcast_type: "banner") - - expect(helper.broadcast_message_style(broadcast_message)) - .to match('background-color: #f2dede; color: #b94a48') - end - - it 'does not add style for notification messages' do - broadcast_message = BroadcastMessage.new(color: '#f2dede', broadcast_type: "notification") - - expect(helper.broadcast_message_style(broadcast_message)).to eq '' - end end describe 'broadcast_message_status' do diff --git a/spec/helpers/button_helper_spec.rb b/spec/helpers/button_helper_spec.rb index 851e13d908f..a7f65aa3134 100644 --- a/spec/helpers/button_helper_spec.rb +++ b/spec/helpers/button_helper_spec.rb @@ -164,7 +164,7 @@ RSpec.describe ButtonHelper do context 'with default options' do context 'when no `text` attribute is not provided' do it 'shows copy to clipboard button with default configuration and no text set to copy' do - expect(element.attr('class')).to eq('btn btn-clipboard btn-transparent') + expect(element.attr('class')).to eq('btn btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm') expect(element.attr('type')).to eq('button') expect(element.attr('aria-label')).to eq('Copy') expect(element.attr('aria-live')).to eq('polite') diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb index b844cc2e22b..12456deb538 100644 --- a/spec/helpers/ci/pipeline_editor_helper_spec.rb +++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb @@ -45,8 +45,8 @@ RSpec.describe Ci::PipelineEditorHelper do "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => 'foo', "initial-branch-name" => nil, - "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), - "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available'), + "lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'), + "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'), "needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'), "new-merge-request-path" => '/mock/project/-/merge_requests/new', "pipeline_etag" => graphql_etag_pipeline_sha_path(project.commit.sha), @@ -72,8 +72,8 @@ RSpec.describe Ci::PipelineEditorHelper do "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => 'foo', "initial-branch-name" => nil, - "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), - "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available'), + "lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'), + "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'), "needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'), "new-merge-request-path" => '/mock/project/-/merge_requests/new', "pipeline_etag" => '', diff --git a/spec/helpers/ci/pipelines_helper_spec.rb b/spec/helpers/ci/pipelines_helper_spec.rb index 2b76eaa87bc..c473e1e4ab6 100644 --- a/spec/helpers/ci/pipelines_helper_spec.rb +++ b/spec/helpers/ci/pipelines_helper_spec.rb @@ -151,5 +151,46 @@ RSpec.describe Ci::PipelinesHelper do end end end + + describe 'the `registration_token` attribute' do + subject { data[:registration_token] } + + describe 'when the project is eligible for the `ios_specific_templates` experiment' do + let_it_be(:project) { create(:project, :auto_devops_disabled) } + let_it_be(:user) { create(:user) } + + before do + allow(helper).to receive(:current_user).and_return(user) + project.add_developer(user) + create(:project_setting, project: project, target_platforms: %w(ios)) + end + + context 'when the `ios_specific_templates` experiment variant is control' do + before do + stub_experiments(ios_specific_templates: :control) + end + + it { is_expected.to be_nil } + end + + context 'when the `ios_specific_templates` experiment variant is candidate' do + before do + stub_experiments(ios_specific_templates: :candidate) + end + + context 'when the user cannot register project runners' do + before do + allow(helper).to receive(:can?).with(user, :register_project_runners, project).and_return(false) + end + + it { is_expected.to be_nil } + end + + context 'when the user can register project runners' do + it { is_expected.to eq(project.runners_token) } + end + end + end + end end end diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb index 832b4da0e20..0046d481282 100644 --- a/spec/helpers/ci/runners_helper_spec.rb +++ b/spec/helpers/ci/runners_helper_spec.rb @@ -10,24 +10,31 @@ RSpec.describe Ci::RunnersHelper do end describe '#runner_status_icon', :clean_gitlab_redis_cache do - it "returns - not contacted yet" do + it "returns online text" do + runner = create(:ci_runner, contacted_at: 1.second.ago) + expect(helper.runner_status_icon(runner)).to include("is online") + end + + it "returns never contacted" do runner = create(:ci_runner) - expect(helper.runner_status_icon(runner)).to include("not contacted yet") + expect(helper.runner_status_icon(runner)).to include("never contacted") end it "returns offline text" do - runner = create(:ci_runner, contacted_at: 1.day.ago, active: true) - expect(helper.runner_status_icon(runner)).to include("Runner is offline") + runner = create(:ci_runner, contacted_at: 1.day.ago) + expect(helper.runner_status_icon(runner)).to include("is offline") end - it "returns online text" do - runner = create(:ci_runner, contacted_at: 1.second.ago, active: true) - expect(helper.runner_status_icon(runner)).to include("Runner is online") + it "returns stale text" do + runner = create(:ci_runner, created_at: 4.months.ago, contacted_at: 4.months.ago) + expect(helper.runner_status_icon(runner)).to include("is stale") + expect(helper.runner_status_icon(runner)).to include("last contact was") end - it "returns paused text" do - runner = create(:ci_runner, contacted_at: 1.second.ago, active: false) - expect(helper.runner_status_icon(runner)).to include("Runner is paused") + it "returns stale text, when runner never contacted" do + runner = create(:ci_runner, created_at: 4.months.ago) + expect(helper.runner_status_icon(runner)).to include("is stale") + expect(helper.runner_status_icon(runner)).to include("never contacted") end end @@ -79,7 +86,9 @@ RSpec.describe Ci::RunnersHelper do it 'returns the data in format' do expect(helper.admin_runners_data_attributes).to eq({ runner_install_help_page: 'https://docs.gitlab.com/runner/install/', - registration_token: Gitlab::CurrentSettings.runners_registration_token + registration_token: Gitlab::CurrentSettings.runners_registration_token, + online_contact_timeout_secs: 7200, + stale_timeout_secs: 7889238 }) end end @@ -121,12 +130,14 @@ RSpec.describe Ci::RunnersHelper do let(:group) { create(:group) } it 'returns group data to render a runner list' do - data = helper.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/') + expect(helper.group_runners_data_attributes(group)).to eq({ + registration_token: group.runners_token, + group_id: group.id, + group_full_path: group.full_path, + runner_install_help_page: 'https://docs.gitlab.com/runner/install/', + online_contact_timeout_secs: 7200, + stale_timeout_secs: 7889238 + }) end end diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb index 53d33f2875f..4feb9d1a2cd 100644 --- a/spec/helpers/clusters_helper_spec.rb +++ b/spec/helpers/clusters_helper_spec.rb @@ -74,6 +74,10 @@ RSpec.describe ClustersHelper do expect(subject[:add_cluster_path]).to eq("#{project_path(project)}/-/clusters/connect") end + it 'displays create cluster path' do + expect(subject[:new_cluster_docs_path]).to eq("#{project_path(project)}/-/clusters/new_cluster_docs") + end + it 'displays project default branch' do expect(subject[:default_branch_name]).to eq(project.default_branch) end diff --git a/spec/helpers/colors_helper_spec.rb b/spec/helpers/colors_helper_spec.rb new file mode 100644 index 00000000000..ca5cafb7ebe --- /dev/null +++ b/spec/helpers/colors_helper_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ColorsHelper do + using RSpec::Parameterized::TableSyntax + + describe '#hex_color_to_rgb_array' do + context 'valid hex color' do + where(:hex_color, :rgb_array) do + '#000000' | [0, 0, 0] + '#aaaaaa' | [170, 170, 170] + '#cCcCcC' | [204, 204, 204] + '#FFFFFF' | [255, 255, 255] + '#000abc' | [0, 10, 188] + '#123456' | [18, 52, 86] + '#a1b2c3' | [161, 178, 195] + '#000' | [0, 0, 0] + '#abc' | [170, 187, 204] + '#321' | [51, 34, 17] + '#7E2' | [119, 238, 34] + '#fFf' | [255, 255, 255] + end + + with_them do + it 'returns correct RGB array' do + expect(helper.hex_color_to_rgb_array(hex_color)).to eq(rgb_array) + end + end + end + + context 'invalid hex color' do + where(:hex_color) { ['', '0', '#00', '#ffff', '#1234567', 'invalid', [], 1, nil] } + + with_them do + it 'raise ArgumentError' do + expect { helper.hex_color_to_rgb_array(hex_color) }.to raise_error(ArgumentError) + end + end + end + end + + describe '#rgb_array_to_hex_color' do + context 'valid RGB array' do + where(:rgb_array, :hex_color) do + [0, 0, 0] | '#000000' + [0, 0, 255] | '#0000ff' + [0, 255, 0] | '#00ff00' + [255, 0, 0] | '#ff0000' + [12, 34, 56] | '#0c2238' + [222, 111, 88] | '#de6f58' + [255, 255, 255] | '#ffffff' + end + + with_them do + it 'returns correct hex color' do + expect(helper.rgb_array_to_hex_color(rgb_array)).to eq(hex_color) + end + end + end + + context 'invalid RGB array' do + where(:rgb_array) do + [ + '', + '#000000', + 0, + nil, + [], + [0], + [0, 0], + [0, 0, 0, 0], + [-1, 0, 0], + [0, -1, 0], + [0, 0, -1], + [256, 0, 0], + [0, 256, 0], + [0, 0, 256] + ] + end + + with_them do + it 'raise ArgumentError' do + expect { helper.rgb_array_to_hex_color(rgb_array) }.to raise_error(ArgumentError) + end + end + end + end +end diff --git a/spec/helpers/commits_helper_spec.rb b/spec/helpers/commits_helper_spec.rb index 98db185c180..961e7688202 100644 --- a/spec/helpers/commits_helper_spec.rb +++ b/spec/helpers/commits_helper_spec.rb @@ -163,13 +163,7 @@ RSpec.describe CommitsHelper do end end - let(:params) do - { - page: page - } - end - - subject { helper.conditionally_paginate_diff_files(diffs_collection, paginate: paginate, per: Projects::CommitController::COMMIT_DIFFS_PER_PAGE) } + subject { helper.conditionally_paginate_diff_files(diffs_collection, paginate: paginate, page: page, per: Projects::CommitController::COMMIT_DIFFS_PER_PAGE) } before do allow(helper).to receive(:params).and_return(params) @@ -183,7 +177,7 @@ RSpec.describe CommitsHelper do end it "can change the number of items per page" do - commits = helper.conditionally_paginate_diff_files(diffs_collection, paginate: paginate, per: 10) + commits = helper.conditionally_paginate_diff_files(diffs_collection, page: page, paginate: paginate, per: 10) expect(commits).to be_an(Array) expect(commits.size).to eq(10) diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb index 29708f10de4..84e702cd6a9 100644 --- a/spec/helpers/diff_helper_spec.rb +++ b/spec/helpers/diff_helper_spec.rb @@ -290,6 +290,53 @@ RSpec.describe DiffHelper do end end + describe "#diff_nomappinginraw_line" do + using RSpec::Parameterized::TableSyntax + + let(:line) { double("line") } + let(:line_type) { 'line_type' } + + before do + allow(line).to receive(:rich_text).and_return('line_text') + allow(line).to receive(:type).and_return(line_type) + end + + it 'generates only single line num' do + output = diff_nomappinginraw_line(line, ['line_num_1'], nil, ['line_content']) + + expect(output).to be_html_safe + expect(output).to have_css 'td:nth-child(1).line_num_1' + expect(output).to have_css 'td:nth-child(2).line_content', text: 'line_text' + expect(output).not_to have_css 'td:nth-child(3)' + end + + it 'generates only both line nums' do + output = diff_nomappinginraw_line(line, ['line_num_1'], ['line_num_2'], ['line_content']) + + expect(output).to be_html_safe + expect(output).to have_css 'td:nth-child(1).line_num_1' + expect(output).to have_css 'td:nth-child(2).line_num_2' + expect(output).to have_css 'td:nth-child(3).line_content', text: 'line_text' + end + + where(:line_type, :added_class) do + 'old-nomappinginraw' | '.old' + 'new-nomappinginraw' | '.new' + 'unchanged-nomappinginraw' | '' + end + + with_them do + it "appends the correct class" do + output = diff_nomappinginraw_line(line, ['line_num_1'], ['line_num_2'], ['line_content']) + + expect(output).to be_html_safe + expect(output).to have_css 'td:nth-child(1).line_num_1' + added_class + expect(output).to have_css 'td:nth-child(2).line_num_2' + added_class + expect(output).to have_css 'td:nth-child(3).line_content' + added_class, text: 'line_text' + end + end + end + describe '#render_overflow_warning?' do using RSpec::Parameterized::TableSyntax @@ -378,16 +425,6 @@ RSpec.describe DiffHelper do end end - describe '#diff_file_path_text' do - it 'returns full path by default' do - expect(diff_file_path_text(diff_file)).to eq(diff_file.new_path) - end - - it 'returns truncated path' do - expect(diff_file_path_text(diff_file, max: 10)).to eq("...open.rb") - end - end - describe "#collapsed_diff_url" do let(:params) do { diff --git a/spec/helpers/environment_helper_spec.rb b/spec/helpers/environment_helper_spec.rb index 8e5f38cd95a..1fcbcd8c4f9 100644 --- a/spec/helpers/environment_helper_spec.rb +++ b/spec/helpers/environment_helper_spec.rb @@ -55,7 +55,7 @@ RSpec.describe EnvironmentHelper do can_destroy_environment: true, can_stop_environment: true, can_admin_environment: true, - environment_metrics_path: environment_metrics_path(environment), + environment_metrics_path: project_metrics_dashboard_path(project, environment: environment), environments_fetch_path: project_environments_path(project, format: :json), environment_edit_path: edit_project_environment_path(project, environment), environment_stop_path: stop_project_environment_path(project, environment), diff --git a/spec/helpers/environments_helper_spec.rb b/spec/helpers/environments_helper_spec.rb index 38f06b19b94..52f02fba4ec 100644 --- a/spec/helpers/environments_helper_spec.rb +++ b/spec/helpers/environments_helper_spec.rb @@ -20,7 +20,7 @@ RSpec.describe EnvironmentsHelper do expect(metrics_data).to include( 'settings_path' => edit_project_integration_path(project, 'prometheus'), 'clusters_path' => project_clusters_path(project), - 'metrics_dashboard_base_path' => environment_metrics_path(environment), + 'metrics_dashboard_base_path' => project_metrics_dashboard_path(project, environment: environment), 'current_environment_name' => environment.name, 'documentation_path' => help_page_path('administration/monitoring/prometheus/index.md'), 'add_dashboard_documentation_path' => help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'), diff --git a/spec/helpers/groups/group_members_helper_spec.rb b/spec/helpers/groups/group_members_helper_spec.rb index f5bc587bce3..ab11bc1f5fd 100644 --- a/spec/helpers/groups/group_members_helper_spec.rb +++ b/spec/helpers/groups/group_members_helper_spec.rb @@ -38,7 +38,9 @@ RSpec.describe Groups::GroupMembersHelper do shared_group, members: present_members(members_collection), invited: present_members(invited), - access_requests: present_members(access_requests) + access_requests: present_members(access_requests), + include_relations: [:inherited, :direct], + search: nil ) end @@ -96,6 +98,64 @@ RSpec.describe Groups::GroupMembersHelper do it 'sets `member_path` property' do expect(subject[:group][:member_path]).to eq('/groups/foo-bar/-/group_links/:id') end + + context 'inherited' do + let_it_be(:sub_shared_group) { create(:group, parent: shared_group) } + let_it_be(:sub_shared_with_group) { create(:group) } + let_it_be(:sub_group_group_link) { create(:group_group_link, shared_group: sub_shared_group, shared_with_group: sub_shared_with_group) } + + let_it_be(:subject_group) { sub_shared_group } + + before do + allow(helper).to receive(:group_group_member_path).with(sub_shared_group, ':id').and_return('/groups/foo-bar/-/group_members/:id') + allow(helper).to receive(:group_group_link_path).with(sub_shared_group, ':id').and_return('/groups/foo-bar/-/group_links/:id') + allow(helper).to receive(:can?).with(current_user, :admin_group_member, sub_shared_group).and_return(true) + allow(helper).to receive(:can?).with(current_user, :export_group_memberships, sub_shared_group).and_return(true) + end + + subject do + helper.group_members_app_data( + sub_shared_group, + members: present_members(members_collection), + invited: present_members(invited), + access_requests: present_members(access_requests), + include_relations: include_relations, + search: nil + ) + end + + using RSpec::Parameterized::TableSyntax + + where(:include_relations, :result) do + [:inherited, :direct] | lazy { [group_group_link, sub_group_group_link].map(&:id) } + [:inherited] | lazy { [group_group_link].map(&:id) } + [:direct] | lazy { [sub_group_group_link].map(&:id) } + end + + with_them do + it 'returns correct group links' do + expect(subject[:group][:members].map { |link| link[:id] }).to match_array(result) + end + end + + context 'when group_member_inherited_group disabled' do + before do + stub_feature_flags(group_member_inherited_group: false) + end + + where(:include_relations, :result) do + [:inherited, :direct] | lazy { [sub_group_group_link.id] } + [:inherited] | lazy { [sub_group_group_link.id] } + [:direct] | lazy { [sub_group_group_link.id] } + end + + with_them do + it 'always returns direct member links' do + expect(subject[:group][:members].map { |link| link[:id] }).to match_array(result) + end + end + end + end end context 'when pagination is not available' do diff --git a/spec/helpers/invite_members_helper_spec.rb b/spec/helpers/invite_members_helper_spec.rb index 796d68e290e..859d145eb53 100644 --- a/spec/helpers/invite_members_helper_spec.rb +++ b/spec/helpers/invite_members_helper_spec.rb @@ -19,6 +19,7 @@ RSpec.describe InviteMembersHelper do it 'has expected common attributes' do attributes = { id: project.id, + root_id: project.root_ancestor.id, name: project.name, default_access_level: Gitlab::Access::GUEST, invalid_groups: project.related_group_ids, @@ -35,6 +36,7 @@ RSpec.describe InviteMembersHelper do it 'has expected common attributes' do attributes = { id: project.id, + root_id: project.root_ancestor.id, name: project.name, default_access_level: Gitlab::Access::GUEST } diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index ed50a4daae8..ee5b0145d13 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -288,7 +288,7 @@ RSpec.describe IssuablesHelper do canUpdate: true, canDestroy: true, issuableRef: "##{issue.iid}", - markdownPreviewPath: "/#{@project.full_path}/preview_markdown", + markdownPreviewPath: "/#{@project.full_path}/preview_markdown?target_id=#{issue.iid}&target_type=Issue", markdownDocsPath: '/help/user/markdown', lockVersion: issue.lock_version, projectPath: @project.path, diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index a85b1bd0a48..0f653fdd282 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -74,8 +74,8 @@ RSpec.describe IssuesHelper do expect(helper.award_state_class(awardable, AwardEmoji.all, build(:user))).to eq('disabled') end - it 'returns active string for author' do - expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq('active') + it 'returns selected class for author' do + expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq('selected') end it 'is blank for a user that has access to the awardable' do @@ -368,6 +368,16 @@ RSpec.describe IssuesHelper do end end + describe '#issues_form_data' do + it 'returns expected result' do + expected = { + new_issue_path: new_project_issue_path(project) + } + + expect(helper.issues_form_data(project)).to include(expected) + end + end + describe '#issue_manual_ordering_class' do context 'when sorting by relative position' do before do diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb index 00aa0fd1cba..52c1130e818 100644 --- a/spec/helpers/namespaces_helper_spec.rb +++ b/spec/helpers/namespaces_helper_spec.rb @@ -268,4 +268,15 @@ RSpec.describe NamespacesHelper do end end end + + describe '#pipeline_usage_quota_app_data' do + it 'returns a hash with necessary data for the frontend' do + expect(helper.pipeline_usage_quota_app_data(user_group)).to eql({ + namespace_actual_plan_name: user_group.actual_plan_name, + namespace_path: user_group.full_path, + namespace_id: user_group.id, + page_size: Kaminari.config.default_per_page + }) + end + end end diff --git a/spec/helpers/packages_helper_spec.rb b/spec/helpers/packages_helper_spec.rb index d7be4194e67..fc69aee4e04 100644 --- a/spec/helpers/packages_helper_spec.rb +++ b/spec/helpers/packages_helper_spec.rb @@ -65,11 +65,11 @@ RSpec.describe PackagesHelper do end end - describe '#show_cleanup_policy_on_alert' do + describe '#show_cleanup_policy_link' do let_it_be(:user) { create(:user) } let_it_be_with_reload(:container_repository) { create(:container_repository) } - subject { helper.show_cleanup_policy_on_alert(project.reload) } + subject { helper.show_cleanup_policy_link(project.reload) } where(:com, :config_registry, :project_registry, :nil_policy, :container_repositories_exist, :expected_result) do false | false | false | false | false | false diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb index 8c13afc2b45..01235c7bb51 100644 --- a/spec/helpers/preferences_helper_spec.rb +++ b/spec/helpers/preferences_helper_spec.rb @@ -145,6 +145,67 @@ RSpec.describe PreferencesHelper do end end + describe '#user_diffs_colors' do + context 'with a user' do + it "returns user's diffs colors" do + stub_user(diffs_addition_color: '#123456', diffs_deletion_color: '#abcdef') + + expect(helper.user_diffs_colors).to eq({ addition: '#123456', deletion: '#abcdef' }) + end + + it 'omits property if nil' do + stub_user(diffs_addition_color: '#123456', diffs_deletion_color: nil) + + expect(helper.user_diffs_colors).to eq({ addition: '#123456' }) + end + + it 'omits property if blank' do + stub_user(diffs_addition_color: '', diffs_deletion_color: '#abcdef') + + expect(helper.user_diffs_colors).to eq({ deletion: '#abcdef' }) + end + end + + context 'without a user' do + it 'returns no properties' do + stub_user + + expect(helper.user_diffs_colors).to eq({}) + end + end + end + + describe '#custom_diff_color_classes' do + context 'with a user' do + it 'returns color classes' do + stub_user(diffs_addition_color: '#123456', diffs_deletion_color: '#abcdef') + + expect(helper.custom_diff_color_classes) + .to match_array(%w[diff-custom-addition-color diff-custom-deletion-color]) + end + + it 'omits property if nil' do + stub_user(diffs_addition_color: '#123456', diffs_deletion_color: nil) + + expect(helper.custom_diff_color_classes).to match_array(['diff-custom-addition-color']) + end + + it 'omits property if blank' do + stub_user(diffs_addition_color: '', diffs_deletion_color: '#abcdef') + + expect(helper.custom_diff_color_classes).to match_array(['diff-custom-deletion-color']) + end + end + + context 'without a user' do + it 'returns no classes' do + stub_user + + expect(helper.custom_diff_color_classes).to match_array([]) + end + end + end + describe '#language_choices' do include StubLanguagesTranslationPercentage diff --git a/spec/helpers/projects/alert_management_helper_spec.rb b/spec/helpers/projects/alert_management_helper_spec.rb index 0a5c4bedaa6..a78a8add336 100644 --- a/spec/helpers/projects/alert_management_helper_spec.rb +++ b/spec/helpers/projects/alert_management_helper_spec.rb @@ -110,15 +110,34 @@ RSpec.describe Projects::AlertManagementHelper do describe '#alert_management_detail_data' do let(:alert_id) { 1 } let(:issues_path) { project_issues_path(project) } + let(:can_update_alert) { true } + + before do + allow(helper) + .to receive(:can?) + .with(current_user, :update_alert_management_alert, project) + .and_return(can_update_alert) + end it 'returns detail page configuration' do - expect(helper.alert_management_detail_data(project, alert_id)).to eq( + expect(helper.alert_management_detail_data(current_user, project, alert_id)).to eq( 'alert-id' => alert_id, 'project-path' => project_path, 'project-id' => project_id, 'project-issues-path' => issues_path, - 'page' => 'OPERATIONS' + 'page' => 'OPERATIONS', + 'can-update' => 'true' ) end + + context 'when user cannot update alert' do + let(:can_update_alert) { false } + + it 'shows error tracking enablement as disabled' do + expect(helper.alert_management_detail_data(current_user, project, alert_id)).to include( + 'can-update' => 'false' + ) + end + end end end diff --git a/spec/helpers/projects/pipeline_helper_spec.rb b/spec/helpers/projects/pipeline_helper_spec.rb new file mode 100644 index 00000000000..67405ee3b21 --- /dev/null +++ b/spec/helpers/projects/pipeline_helper_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::PipelineHelper do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:raw_pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } + let_it_be(:pipeline) { Ci::PipelinePresenter.new(raw_pipeline, current_user: user)} + + describe '#js_pipeline_tabs_data' do + subject(:pipeline_tabs_data) { helper.js_pipeline_tabs_data(project, pipeline) } + + it 'returns pipeline tabs data' do + expect(pipeline_tabs_data).to include({ + can_generate_codequality_reports: pipeline.can_generate_codequality_reports?.to_json, + graphql_resource_etag: graphql_etag_pipeline_path(pipeline), + metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json), + pipeline_project_path: project.full_path + }) + end + end +end diff --git a/spec/helpers/projects/security/configuration_helper_spec.rb b/spec/helpers/projects/security/configuration_helper_spec.rb index 4c30ba87897..034bfd27844 100644 --- a/spec/helpers/projects/security/configuration_helper_spec.rb +++ b/spec/helpers/projects/security/configuration_helper_spec.rb @@ -10,4 +10,10 @@ RSpec.describe Projects::Security::ConfigurationHelper do it { is_expected.to eq("https://#{ApplicationHelper.promo_host}/pricing/") } end + + describe 'vulnerability_training_docs_path' do + subject { helper.vulnerability_training_docs_path } + + it { is_expected.to eq(help_page_path('user/application_security/vulnerabilities/index', anchor: 'enable-security-training-for-vulnerabilities')) } + end end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 24d908a5dd3..1cf36fd69cf 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -1000,6 +1000,54 @@ RSpec.describe ProjectsHelper do end end + context 'fork security helpers' do + using RSpec::Parameterized::TableSyntax + + describe "#able_to_see_merge_requests?" do + subject { helper.able_to_see_merge_requests?(project, user) } + + where(:can_read_merge_request, :merge_requests_enabled, :expected) do + false | false | false + true | false | false + false | true | false + true | true | true + end + + with_them do + before do + allow(project).to receive(:merge_requests_enabled?).and_return(merge_requests_enabled) + allow(helper).to receive(:can?).with(user, :read_merge_request, project).and_return(can_read_merge_request) + end + + it 'returns the correct response' do + expect(subject).to eq(expected) + end + end + end + + describe "#able_to_see_issues?" do + subject { helper.able_to_see_issues?(project, user) } + + where(:can_read_issues, :issues_enabled, :expected) do + false | false | false + true | false | false + false | true | false + true | true | true + end + + with_them do + before do + allow(project).to receive(:issues_enabled?).and_return(issues_enabled) + allow(helper).to receive(:can?).with(user, :read_issue, project).and_return(can_read_issues) + end + + it 'returns the correct response' do + expect(subject).to eq(expected) + end + end + end + end + describe '#fork_button_disabled_tooltip' do using RSpec::Parameterized::TableSyntax diff --git a/spec/helpers/routing/pseudonymization_helper_spec.rb b/spec/helpers/routing/pseudonymization_helper_spec.rb index 1221917e6b7..cf716931fe2 100644 --- a/spec/helpers/routing/pseudonymization_helper_spec.rb +++ b/spec/helpers/routing/pseudonymization_helper_spec.rb @@ -180,7 +180,7 @@ RSpec.describe ::Routing::PseudonymizationHelper do end context 'when some query params are not required to be masked' do - let(:masked_url) { "http://localhost/dashboard/issues?author_username=masked_author_username&scope=all&state=masked_state" } + let(:masked_url) { "http://localhost/dashboard/issues?author_username=masked_author_username&scope=all&state=masked_state&tab=2" } let(:request) do double(:Request, path_parameters: { @@ -189,11 +189,11 @@ RSpec.describe ::Routing::PseudonymizationHelper do }, protocol: 'http', host: 'localhost', - query_string: 'author_username=root&scope=all&state=opened') + query_string: 'author_username=root&scope=all&state=opened&tab=2') end before do - stub_const('Routing::PseudonymizationHelper::MaskHelper::QUERY_PARAMS_TO_NOT_MASK', %w[scope].freeze) + stub_const('Routing::PseudonymizationHelper::MaskHelper::QUERY_PARAMS_TO_NOT_MASK', %w[scope tab].freeze) allow(helper).to receive(:request).and_return(request) end diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index 78cc1dcee01..d1be451a759 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -71,7 +71,7 @@ RSpec.describe SearchHelper do create(:group).add_owner(user) result = search_autocomplete_opts("gro").first - expect(result.keys).to match_array(%i[category id label url avatar_url]) + expect(result.keys).to match_array(%i[category id value label url avatar_url]) end it 'includes the users recently viewed issues', :aggregate_failures do @@ -467,6 +467,12 @@ RSpec.describe SearchHelper do describe '#show_user_search_tab?' do subject { show_user_search_tab? } + let(:current_user) { build(:user) } + + before do + allow(self).to receive(:current_user).and_return(current_user) + end + context 'when project search' do before do @project = :some_project @@ -481,20 +487,48 @@ RSpec.describe SearchHelper do end end - context 'when not project search' do + context 'when group search' do + before do + @group = :some_group + end + + context 'when current_user can read_users_list' do + before do + allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(true) + end + + it { is_expected.to eq(true) } + end + + context 'when current_user cannot read_users_list' do + before do + allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(false) + end + + it { is_expected.to eq(false) } + end + end + + context 'when global search' do context 'when current_user can read_users_list' do before do - allow(self).to receive(:current_user).and_return(:the_current_user) - allow(self).to receive(:can?).with(:the_current_user, :read_users_list).and_return(true) + allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(true) end it { is_expected.to eq(true) } + + context 'when global_search_user_tab feature flag is disabled' do + before do + stub_feature_flags(global_search_users_tab: false) + end + + it { is_expected.to eq(false) } + end end context 'when current_user cannot read_users_list' do before do - allow(self).to receive(:current_user).and_return(:the_current_user) - allow(self).to receive(:can?).with(:the_current_user, :read_users_list).and_return(false) + allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(false) end it { is_expected.to eq(false) } diff --git a/spec/helpers/timeboxes_helper_spec.rb b/spec/helpers/timeboxes_helper_spec.rb index 1b9442c0a09..e31f2df7372 100644 --- a/spec/helpers/timeboxes_helper_spec.rb +++ b/spec/helpers/timeboxes_helper_spec.rb @@ -24,34 +24,6 @@ RSpec.describe TimeboxesHelper do end end - describe '#milestone_counts' do - let(:project) { create(:project) } - let(:counts) { helper.milestone_counts(project.milestones) } - - context 'when there are milestones' do - it 'returns the correct counts' do - create_list(:active_milestone, 2, project: project) - create(:closed_milestone, project: project) - - expect(counts).to eq(opened: 2, closed: 1, all: 3) - end - end - - context 'when there are only milestones of one type' do - it 'returns the correct counts' do - create_list(:active_milestone, 2, project: project) - - expect(counts).to eq(opened: 2, closed: 0, all: 2) - end - end - - context 'when there are no milestones' do - it 'returns the correct counts' do - expect(counts).to eq(opened: 0, closed: 0, all: 0) - end - end - end - describe "#group_milestone_route" do let(:group) { build_stubbed(:group) } let(:subgroup) { build_stubbed(:group, parent: group, name: "Test Subgrp") } diff --git a/spec/helpers/wiki_helper_spec.rb b/spec/helpers/wiki_helper_spec.rb index 0d04ca2b876..5adcbe3334d 100644 --- a/spec/helpers/wiki_helper_spec.rb +++ b/spec/helpers/wiki_helper_spec.rb @@ -145,4 +145,8 @@ RSpec.describe WikiHelper do expect(subject).to include('wiki-directory-nest-level' => 0) end end + + it_behaves_like 'wiki endpoint helpers' do + let_it_be(:page) { create(:wiki_page) } + end end diff --git a/spec/initializers/mail_encoding_patch_spec.rb b/spec/initializers/mail_encoding_patch_spec.rb index 52a0d041f48..12539c9ca52 100644 --- a/spec/initializers/mail_encoding_patch_spec.rb +++ b/spec/initializers/mail_encoding_patch_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true +# rubocop:disable RSpec/VariableDefinition, RSpec/VariableName require 'fast_spec_helper' - require 'mail' require_relative '../../config/initializers/mail_encoding_patch' @@ -205,3 +205,4 @@ RSpec.describe 'Mail quoted-printable transfer encoding patch and Unicode charac end end end +# rubocop:enable RSpec/VariableDefinition, RSpec/VariableName diff --git a/spec/initializers/omniauth_spec.rb b/spec/initializers/omniauth_spec.rb new file mode 100644 index 00000000000..928eac8c533 --- /dev/null +++ b/spec/initializers/omniauth_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'OmniAuth initializer for GitLab' do + let(:load_omniauth_initializer) do + load Rails.root.join('config/initializers/omniauth.rb') + end + + describe '#full_host' do + subject { OmniAuth.config.full_host } + + let(:base_url) { 'http://localhost/test' } + + before do + allow(Settings).to receive(:gitlab).and_return({ 'base_url' => base_url }) + allow(Gitlab::OmniauthInitializer).to receive(:full_host).and_return('proc') + end + + context 'with feature flags not available' do + before do + expect(Feature).to receive(:feature_flags_available?).and_return(false) + load_omniauth_initializer + end + + it { is_expected.to eq(base_url) } + end + + context 'with the omniauth_initializer_fullhost_proc FF disabled' do + before do + stub_feature_flags(omniauth_initializer_fullhost_proc: false) + load_omniauth_initializer + end + + it { is_expected.to eq(base_url) } + end + + context 'with the omniauth_initializer_fullhost_proc FF disabled' do + before do + load_omniauth_initializer + end + + it { is_expected.to eq('proc') } + end + end +end diff --git a/spec/lib/api/entities/application_setting_spec.rb b/spec/lib/api/entities/application_setting_spec.rb new file mode 100644 index 00000000000..5adb825672c --- /dev/null +++ b/spec/lib/api/entities/application_setting_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Entities::ApplicationSetting do + let_it_be(:application_setting, reload: true) { create(:application_setting) } + + subject(:output) { described_class.new(application_setting).as_json } + + context 'housekeeping_bitmaps_enabled usage is deprecated and always enabled' do + before do + application_setting.housekeeping_bitmaps_enabled = housekeeping_bitmaps_enabled + end + + context 'when housekeeping_bitmaps_enabled db column is false' do + let(:housekeeping_bitmaps_enabled) { false } + + it 'returns true' do + expect(subject[:housekeeping_bitmaps_enabled]).to eq(true) + end + end + + context 'when housekeeping_bitmaps_enabled db column is true' do + let(:housekeeping_bitmaps_enabled) { false } + + it 'returns true' do + expect(subject[:housekeeping_bitmaps_enabled]).to eq(true) + end + end + end +end diff --git a/spec/lib/api/validations/validators/limit_spec.rb b/spec/lib/api/validations/validators/limit_spec.rb index d71dde470cc..0c10e2f74d2 100644 --- a/spec/lib/api/validations/validators/limit_spec.rb +++ b/spec/lib/api/validations/validators/limit_spec.rb @@ -22,4 +22,10 @@ RSpec.describe API::Validations::Validators::Limit do expect_validation_error('test' => "#{'a' * 256}") end end + + context 'value is nil' do + it 'does not raise a validation error' do + expect_no_validation_error('test' => nil) + end + end end diff --git a/spec/lib/backup/artifacts_spec.rb b/spec/lib/backup/artifacts_spec.rb deleted file mode 100644 index d830692d96b..00000000000 --- a/spec/lib/backup/artifacts_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::Artifacts do - let(:progress) { StringIO.new } - - subject(:backup) { described_class.new(progress) } - - describe '#dump' do - before do - allow(File).to receive(:realpath).with('/var/gitlab-artifacts').and_return('/var/gitlab-artifacts') - allow(File).to receive(:realpath).with('/var/gitlab-artifacts/..').and_return('/var') - allow(JobArtifactUploader).to receive(:root) { '/var/gitlab-artifacts' } - end - - it 'excludes tmp from backup tar' do - expect(backup).to receive(:tar).and_return('blabla-tar') - expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./tmp -C /var/gitlab-artifacts -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], '']) - expect(backup).to receive(:pipeline_succeeded?).and_return(true) - backup.dump('artifacts.tar.gz') - end - end -end diff --git a/spec/lib/backup/files_spec.rb b/spec/lib/backup/files_spec.rb index bbc465a26c9..f98b5e1414f 100644 --- a/spec/lib/backup/files_spec.rb +++ b/spec/lib/backup/files_spec.rb @@ -39,7 +39,7 @@ RSpec.describe Backup::Files do end describe '#restore' do - subject { described_class.new(progress, 'registry', '/var/gitlab-registry') } + subject { described_class.new(progress, '/var/gitlab-registry') } let(:timestamp) { Time.utc(2017, 3, 22) } @@ -110,7 +110,7 @@ RSpec.describe Backup::Files do end describe '#dump' do - subject { described_class.new(progress, 'pages', '/var/gitlab-pages', excludes: ['@pages.tmp']) } + subject { described_class.new(progress, '/var/gitlab-pages', excludes: ['@pages.tmp']) } before do allow(subject).to receive(:run_pipeline!).and_return([[true, true], '']) @@ -118,14 +118,14 @@ RSpec.describe Backup::Files do end it 'raises no errors' do - expect { subject.dump('registry.tar.gz') }.not_to raise_error + expect { subject.dump('registry.tar.gz', 'backup_id') }.not_to raise_error end it 'excludes tmp dirs from archive' do expect(subject).to receive(:tar).and_return('blabla-tar') expect(subject).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./@pages.tmp -C /var/gitlab-pages -cf - .), 'gzip -c -1'], any_args) - subject.dump('registry.tar.gz') + subject.dump('registry.tar.gz', 'backup_id') end it 'raises an error on failure' do @@ -133,7 +133,7 @@ RSpec.describe Backup::Files do expect(subject).to receive(:pipeline_succeeded?).and_return(false) expect do - subject.dump('registry.tar.gz') + subject.dump('registry.tar.gz', 'backup_id') end.to raise_error(/Failed to create compressed file/) end @@ -149,7 +149,7 @@ RSpec.describe Backup::Files do .with(%w(rsync -a --delete --exclude=lost+found --exclude=/gitlab-pages/@pages.tmp /var/gitlab-pages /var/gitlab-backup)) .and_return(['', 0]) - subject.dump('registry.tar.gz') + subject.dump('registry.tar.gz', 'backup_id') end it 'retries if rsync fails due to vanishing files' do @@ -158,7 +158,7 @@ RSpec.describe Backup::Files do .and_return(['rsync failed', 24], ['', 0]) expect do - subject.dump('registry.tar.gz') + subject.dump('registry.tar.gz', 'backup_id') end.to output(/files vanished during rsync, retrying/).to_stdout end @@ -168,7 +168,7 @@ RSpec.describe Backup::Files do .and_return(['rsync failed', 1]) expect do - subject.dump('registry.tar.gz') + subject.dump('registry.tar.gz', 'backup_id') end.to output(/rsync failed/).to_stdout .and raise_error(/Failed to create compressed file/) end @@ -176,7 +176,7 @@ RSpec.describe Backup::Files do end describe '#exclude_dirs' do - subject { described_class.new(progress, 'pages', '/var/gitlab-pages', excludes: ['@pages.tmp']) } + subject { described_class.new(progress, '/var/gitlab-pages', excludes: ['@pages.tmp']) } it 'prepends a leading dot slash to tar excludes' do expect(subject.exclude_dirs(:tar)).to eq(['--exclude=lost+found', '--exclude=./@pages.tmp']) @@ -188,7 +188,7 @@ RSpec.describe Backup::Files do end describe '#run_pipeline!' do - subject { described_class.new(progress, 'registry', '/var/gitlab-registry') } + subject { described_class.new(progress, '/var/gitlab-registry') } it 'executes an Open3.pipeline for cmd_list' do expect(Open3).to receive(:pipeline).with(%w[whew command], %w[another cmd], any_args) @@ -222,7 +222,7 @@ RSpec.describe Backup::Files do end describe '#pipeline_succeeded?' do - subject { described_class.new(progress, 'registry', '/var/gitlab-registry') } + subject { described_class.new(progress, '/var/gitlab-registry') } it 'returns true if both tar and gzip succeeeded' do expect( @@ -262,7 +262,7 @@ RSpec.describe Backup::Files do end describe '#tar_ignore_non_success?' do - subject { described_class.new(progress, 'registry', '/var/gitlab-registry') } + subject { described_class.new(progress, '/var/gitlab-registry') } context 'if `tar` command exits with 1 exitstatus' do it 'returns true' do @@ -310,7 +310,7 @@ RSpec.describe Backup::Files do end describe '#noncritical_warning?' do - subject { described_class.new(progress, 'registry', '/var/gitlab-registry') } + subject { described_class.new(progress, '/var/gitlab-registry') } it 'returns true if given text matches noncritical warnings list' do expect( diff --git a/spec/lib/backup/gitaly_backup_spec.rb b/spec/lib/backup/gitaly_backup_spec.rb index f5295c2b04c..399e4ffa72b 100644 --- a/spec/lib/backup/gitaly_backup_spec.rb +++ b/spec/lib/backup/gitaly_backup_spec.rb @@ -25,11 +25,11 @@ RSpec.describe Backup::GitalyBackup do progress.close end - subject { described_class.new(progress, max_parallelism: max_parallelism, storage_parallelism: storage_parallelism, backup_id: backup_id) } + subject { described_class.new(progress, max_parallelism: max_parallelism, storage_parallelism: storage_parallelism) } context 'unknown' do it 'fails to start unknown' do - expect { subject.start(:unknown, destination) }.to raise_error(::Backup::Error, 'unknown backup type: unknown') + expect { subject.start(:unknown, destination, backup_id: backup_id) }.to raise_error(::Backup::Error, 'unknown backup type: unknown') end end @@ -44,7 +44,7 @@ RSpec.describe Backup::GitalyBackup do expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-layout', 'pointer', '-id', backup_id).and_call_original - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) subject.enqueue(project, Gitlab::GlRepository::PROJECT) subject.enqueue(project, Gitlab::GlRepository::WIKI) subject.enqueue(project, Gitlab::GlRepository::DESIGN) @@ -65,7 +65,7 @@ RSpec.describe Backup::GitalyBackup do it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel', '3', '-layout', 'pointer', '-id', backup_id).and_call_original - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) subject.finish! end end @@ -76,7 +76,7 @@ RSpec.describe Backup::GitalyBackup do it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel-storage', '3', '-layout', 'pointer', '-id', backup_id).and_call_original - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) subject.finish! end end @@ -84,10 +84,16 @@ RSpec.describe Backup::GitalyBackup do it 'raises when the exit code not zero' do expect(subject).to receive(:bin_path).and_return(Gitlab::Utils.which('false')) - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) expect { subject.finish! }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1') end + it 'raises when gitaly_backup_path is not set' do + stub_backup_setting(gitaly_backup_path: nil) + + expect { subject.start(:create, destination, backup_id: backup_id) }.to raise_error(::Backup::Error, 'gitaly-backup binary not found and gitaly_backup_path is not configured') + end + context 'feature flag incremental_repository_backup disabled' do before do stub_feature_flags(incremental_repository_backup: false) @@ -102,7 +108,7 @@ RSpec.describe Backup::GitalyBackup do expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything).and_call_original - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) subject.enqueue(project, Gitlab::GlRepository::PROJECT) subject.enqueue(project, Gitlab::GlRepository::WIKI) subject.enqueue(project, Gitlab::GlRepository::DESIGN) @@ -146,7 +152,7 @@ RSpec.describe Backup::GitalyBackup do it 'passes through SSL envs' do expect(Open3).to receive(:popen2).with(ssl_env, anything, 'create', '-path', anything, '-layout', 'pointer', '-id', backup_id).and_call_original - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) subject.finish! end end @@ -171,7 +177,7 @@ RSpec.describe Backup::GitalyBackup do expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-layout', 'pointer').and_call_original - subject.start(:restore, destination) + subject.start(:restore, destination, backup_id: backup_id) subject.enqueue(project, Gitlab::GlRepository::PROJECT) subject.enqueue(project, Gitlab::GlRepository::WIKI) subject.enqueue(project, Gitlab::GlRepository::DESIGN) @@ -194,7 +200,7 @@ RSpec.describe Backup::GitalyBackup do it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel', '3', '-layout', 'pointer').and_call_original - subject.start(:restore, destination) + subject.start(:restore, destination, backup_id: backup_id) subject.finish! end end @@ -205,7 +211,7 @@ RSpec.describe Backup::GitalyBackup do it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel-storage', '3', '-layout', 'pointer').and_call_original - subject.start(:restore, destination) + subject.start(:restore, destination, backup_id: backup_id) subject.finish! end end @@ -224,7 +230,7 @@ RSpec.describe Backup::GitalyBackup do expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything).and_call_original - subject.start(:restore, destination) + subject.start(:restore, destination, backup_id: backup_id) subject.enqueue(project, Gitlab::GlRepository::PROJECT) subject.enqueue(project, Gitlab::GlRepository::WIKI) subject.enqueue(project, Gitlab::GlRepository::DESIGN) @@ -245,8 +251,14 @@ RSpec.describe Backup::GitalyBackup do it 'raises when the exit code not zero' do expect(subject).to receive(:bin_path).and_return(Gitlab::Utils.which('false')) - subject.start(:restore, destination) + subject.start(:restore, destination, backup_id: backup_id) expect { subject.finish! }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1') end + + it 'raises when gitaly_backup_path is not set' do + stub_backup_setting(gitaly_backup_path: nil) + + expect { subject.start(:restore, destination, backup_id: backup_id) }.to raise_error(::Backup::Error, 'gitaly-backup binary not found and gitaly_backup_path is not configured') + end end end diff --git a/spec/lib/backup/gitaly_rpc_backup_spec.rb b/spec/lib/backup/gitaly_rpc_backup_spec.rb deleted file mode 100644 index 6cba8c5c9b1..00000000000 --- a/spec/lib/backup/gitaly_rpc_backup_spec.rb +++ /dev/null @@ -1,154 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::GitalyRpcBackup do - let(:progress) { spy(:stdout) } - let(:destination) { File.join(Gitlab.config.backup.path, 'repositories') } - - subject { described_class.new(progress) } - - after do - # make sure we do not leave behind any backup files - FileUtils.rm_rf(File.join(Gitlab.config.backup.path, 'repositories')) - end - - context 'unknown' do - it 'fails to start unknown' do - expect { subject.start(:unknown, destination) }.to raise_error(::Backup::Error, 'unknown backup type: unknown') - end - end - - context 'create' do - RSpec.shared_examples 'creates a repository backup' do - it 'creates repository bundles', :aggregate_failures do - # Add data to the wiki, design repositories, and snippets, so they will be included in the dump. - create(:wiki_page, container: project) - create(:design, :with_file, issue: create(:issue, project: project)) - project_snippet = create(:project_snippet, :repository, project: project) - personal_snippet = create(:personal_snippet, :repository, author: project.first_owner) - - subject.start(:create, destination) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.enqueue(project, Gitlab::GlRepository::WIKI) - subject.enqueue(project, Gitlab::GlRepository::DESIGN) - subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) - subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.finish! - - expect(File).to exist(File.join(destination, project.disk_path + '.bundle')) - expect(File).to exist(File.join(destination, project.disk_path + '.wiki.bundle')) - expect(File).to exist(File.join(destination, project.disk_path + '.design.bundle')) - expect(File).to exist(File.join(destination, personal_snippet.disk_path + '.bundle')) - expect(File).to exist(File.join(destination, project_snippet.disk_path + '.bundle')) - end - - context 'failure' do - before do - allow_next_instance_of(Repository) do |repository| - allow(repository).to receive(:bundle_to_disk) { raise 'Fail in tests' } - end - end - - it 'logs an appropriate message', :aggregate_failures do - subject.start(:create, destination) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.finish! - - expect(progress).to have_received(:puts).with("[Failed] backing up #{project.full_path} (#{project.disk_path})") - expect(progress).to have_received(:puts).with("Error Fail in tests") - end - end - end - - context 'hashed storage' do - let_it_be(:project) { create(:project, :repository) } - - it_behaves_like 'creates a repository backup' - end - - context 'legacy storage' do - let_it_be(:project) { create(:project, :repository, :legacy_storage) } - - it_behaves_like 'creates a repository backup' - end - end - - context 'restore' do - let_it_be(:project) { create(:project, :repository) } - let_it_be(:personal_snippet) { create(:personal_snippet, author: project.first_owner) } - let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.first_owner) } - - def copy_bundle_to_backup_path(bundle_name, destination) - FileUtils.mkdir_p(File.join(Gitlab.config.backup.path, 'repositories', File.dirname(destination))) - FileUtils.cp(Rails.root.join('spec/fixtures/lib/backup', bundle_name), File.join(Gitlab.config.backup.path, 'repositories', destination)) - end - - it 'restores from repository bundles', :aggregate_failures do - copy_bundle_to_backup_path('project_repo.bundle', project.disk_path + '.bundle') - copy_bundle_to_backup_path('wiki_repo.bundle', project.disk_path + '.wiki.bundle') - copy_bundle_to_backup_path('design_repo.bundle', project.disk_path + '.design.bundle') - copy_bundle_to_backup_path('personal_snippet_repo.bundle', personal_snippet.disk_path + '.bundle') - copy_bundle_to_backup_path('project_snippet_repo.bundle', project_snippet.disk_path + '.bundle') - - subject.start(:restore, destination) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.enqueue(project, Gitlab::GlRepository::WIKI) - subject.enqueue(project, Gitlab::GlRepository::DESIGN) - subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) - subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.finish! - - collect_commit_shas = -> (repo) { repo.commits('master', limit: 10).map(&:sha) } - - expect(collect_commit_shas.call(project.repository)).to eq(['393a7d860a5a4c3cc736d7eb00604e3472bb95ec']) - expect(collect_commit_shas.call(project.wiki.repository)).to eq(['c74b9948d0088d703ee1fafeddd9ed9add2901ea']) - expect(collect_commit_shas.call(project.design_repository)).to eq(['c3cd4d7bd73a51a0f22045c3a4c871c435dc959d']) - expect(collect_commit_shas.call(personal_snippet.repository)).to eq(['3b3c067a3bc1d1b695b51e2be30c0f8cf698a06e']) - expect(collect_commit_shas.call(project_snippet.repository)).to eq(['6e44ba56a4748be361a841e759c20e421a1651a1']) - end - - it 'cleans existing repositories', :aggregate_failures do - expect_next_instance_of(DesignManagement::Repository) do |repository| - expect(repository).to receive(:remove) - end - - # 4 times = project repo + wiki repo + project_snippet repo + personal_snippet repo - expect(Repository).to receive(:new).exactly(4).times.and_wrap_original do |method, *original_args| - full_path, container, kwargs = original_args - - repository = method.call(full_path, container, **kwargs) - - expect(repository).to receive(:remove) - - repository - end - - subject.start(:restore, destination) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.enqueue(project, Gitlab::GlRepository::WIKI) - subject.enqueue(project, Gitlab::GlRepository::DESIGN) - subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) - subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.finish! - end - - context 'failure' do - before do - allow_next_instance_of(Repository) do |repository| - allow(repository).to receive(:create_repository) { raise 'Fail in tests' } - allow(repository).to receive(:create_from_bundle) { raise 'Fail in tests' } - end - end - - it 'logs an appropriate message', :aggregate_failures do - subject.start(:restore, destination) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.finish! - - expect(progress).to have_received(:puts).with("[Failed] restoring #{project.full_path} (#{project.disk_path})") - expect(progress).to have_received(:puts).with("Error Fail in tests") - end - end - end -end diff --git a/spec/lib/backup/lfs_spec.rb b/spec/lib/backup/lfs_spec.rb deleted file mode 100644 index a27f60f20d0..00000000000 --- a/spec/lib/backup/lfs_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::Lfs do - let(:progress) { StringIO.new } - - subject(:backup) { described_class.new(progress) } - - describe '#dump' do - before do - allow(File).to receive(:realpath).and_call_original - allow(File).to receive(:realpath).with('/var/lfs-objects').and_return('/var/lfs-objects') - allow(File).to receive(:realpath).with('/var/lfs-objects/..').and_return('/var') - allow(Settings.lfs).to receive(:storage_path).and_return('/var/lfs-objects') - end - - it 'uses the correct lfs dir in tar command', :aggregate_failures do - expect(backup).to receive(:tar).and_return('blabla-tar') - expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found -C /var/lfs-objects -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], '']) - expect(backup).to receive(:pipeline_succeeded?).and_return(true) - - backup.dump('lfs.tar.gz') - end - end -end diff --git a/spec/lib/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb index 9cf78a11bc7..192739d05a7 100644 --- a/spec/lib/backup/manager_spec.rb +++ b/spec/lib/backup/manager_spec.rb @@ -22,13 +22,13 @@ RSpec.describe Backup::Manager do describe '#run_create_task' do let(:enabled) { true } - let(:task) { instance_double(Backup::Task, human_name: 'my task', enabled: enabled) } - let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, destination_path: 'my_task.tar.gz') } } + let(:task) { instance_double(Backup::Task) } + let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, enabled: enabled, destination_path: 'my_task.tar.gz', human_name: 'my task') } } it 'calls the named task' do expect(task).to receive(:dump) expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... ') - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done') + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... done') subject.run_create_task('my_task') end @@ -37,8 +37,7 @@ RSpec.describe Backup::Manager do let(:enabled) { false } it 'informs the user' do - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... ') - expect(Gitlab::BackupLogger).to receive(:info).with(message: '[DISABLED]') + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... [DISABLED]') subject.run_create_task('my_task') end @@ -48,8 +47,7 @@ RSpec.describe Backup::Manager do it 'informs the user' do stub_env('SKIP', 'my_task') - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... ') - expect(Gitlab::BackupLogger).to receive(:info).with(message: '[SKIPPED]') + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... [SKIPPED]') subject.run_create_task('my_task') end @@ -60,12 +58,10 @@ RSpec.describe Backup::Manager do let(:enabled) { true } let(:pre_restore_warning) { nil } let(:post_restore_warning) { nil } - let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, destination_path: 'my_task.tar.gz') } } + let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, enabled: enabled, human_name: 'my task', destination_path: 'my_task.tar.gz') } } let(:backup_information) { {} } let(:task) do instance_double(Backup::Task, - human_name: 'my task', - enabled: enabled, pre_restore_warning: pre_restore_warning, post_restore_warning: post_restore_warning) end @@ -78,7 +74,7 @@ RSpec.describe Backup::Manager do it 'calls the named task' do expect(task).to receive(:restore) expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered subject.run_restore_task('my_task') end @@ -87,8 +83,7 @@ RSpec.describe Backup::Manager do let(:enabled) { false } it 'informs the user' do - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered - expect(Gitlab::BackupLogger).to receive(:info).with(message: '[DISABLED]').ordered + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... [DISABLED]').ordered subject.run_restore_task('my_task') end @@ -100,7 +95,7 @@ RSpec.describe Backup::Manager do it 'displays and waits for the user' do expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Watch out!').ordered - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered expect(Gitlab::TaskHelpers).to receive(:ask_to_continue) expect(task).to receive(:restore) @@ -124,7 +119,7 @@ RSpec.describe Backup::Manager do it 'displays and waits for the user' do expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Watch out!').ordered expect(Gitlab::TaskHelpers).to receive(:ask_to_continue) expect(task).to receive(:restore) @@ -134,7 +129,7 @@ RSpec.describe Backup::Manager do it 'does not continue when the user quits' do expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Watch out!').ordered expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Quitting...').ordered expect(task).to receive(:restore) @@ -148,8 +143,10 @@ RSpec.describe Backup::Manager do end describe '#create' do + let(:incremental_env) { 'false' } let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz task2.tar.gz} } - let(:tar_file) { '1546300800_2019_01_01_12.3_gitlab_backup.tar' } + let(:backup_id) { '1546300800_2019_01_01_12.3' } + let(:tar_file) { "#{backup_id}_gitlab_backup.tar" } let(:tar_system_options) { { out: [tar_file, 'w', Gitlab.config.backup.archive_permissions] } } let(:tar_cmdline) { ['tar', '-cf', '-', *expected_backup_contents, tar_system_options] } let(:backup_information) do @@ -159,24 +156,27 @@ RSpec.describe Backup::Manager do } end - let(:task1) { instance_double(Backup::Task, human_name: 'task 1', enabled: true) } - let(:task2) { instance_double(Backup::Task, human_name: 'task 2', enabled: true) } + let(:task1) { instance_double(Backup::Task) } + let(:task2) { instance_double(Backup::Task) } let(:definitions) do { - 'task1' => Backup::Manager::TaskDefinition.new(task: task1, destination_path: 'task1.tar.gz'), - 'task2' => Backup::Manager::TaskDefinition.new(task: task2, destination_path: 'task2.tar.gz') + 'task1' => Backup::Manager::TaskDefinition.new(task: task1, human_name: 'task 1', destination_path: 'task1.tar.gz'), + 'task2' => Backup::Manager::TaskDefinition.new(task: task2, human_name: 'task 2', destination_path: 'task2.tar.gz') } end before do + stub_env('INCREMENTAL', incremental_env) allow(ActiveRecord::Base.connection).to receive(:reconnect!) + allow(Gitlab::BackupLogger).to receive(:info) allow(Kernel).to receive(:system).and_return(true) + allow(YAML).to receive(:load_file).and_call_original allow(YAML).to receive(:load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml')) .and_return(backup_information) allow(subject).to receive(:backup_information).and_return(backup_information) - allow(task1).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz')) - allow(task2).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz')) + allow(task1).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz'), backup_id) + allow(task2).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz'), backup_id) end it 'executes tar' do @@ -185,8 +185,22 @@ RSpec.describe Backup::Manager do expect(Kernel).to have_received(:system).with(*tar_cmdline) end + context 'tar fails' do + before do + expect(Kernel).to receive(:system).with(*tar_cmdline).and_return(false) + end + + it 'logs a failure' do + expect do + subject.create # rubocop:disable Rails/SaveBang + end.to raise_error(Backup::Error, 'Backup failed') + + expect(Gitlab::BackupLogger).to have_received(:info).with(message: "Creating archive #{tar_file} failed") + end + end + context 'when BACKUP is set' do - let(:tar_file) { 'custom_gitlab_backup.tar' } + let(:backup_id) { 'custom' } it 'uses the given value as tar file name' do stub_env('BACKUP', '/ignored/path/custom') @@ -213,6 +227,20 @@ RSpec.describe Backup::Manager do end end + context 'when SKIP env is set' do + let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz} } + + before do + stub_env('SKIP', 'task2') + end + + it 'executes tar' do + subject.create # rubocop:disable Rails/SaveBang + + expect(Kernel).to have_received(:system).with(*tar_cmdline) + end + end + context 'when the destination is optional' do let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz} } let(:definitions) do @@ -248,6 +276,7 @@ RSpec.describe Backup::Manager do end before do + allow(Gitlab::BackupLogger).to receive(:info) allow(Dir).to receive(:chdir).and_yield allow(Dir).to receive(:glob).and_return(files) allow(FileUtils).to receive(:rm) @@ -266,7 +295,7 @@ RSpec.describe Backup::Manager do end it 'prints a skipped message' do - expect(progress).to have_received(:puts).with('skipping') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... [SKIPPED]') end end @@ -290,7 +319,7 @@ RSpec.describe Backup::Manager do end it 'prints a done message' do - expect(progress).to have_received(:puts).with('done. (0 removed)') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (0 removed)') end end @@ -307,7 +336,7 @@ RSpec.describe Backup::Manager do end it 'prints a done message' do - expect(progress).to have_received(:puts).with('done. (0 removed)') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (0 removed)') end end @@ -348,7 +377,7 @@ RSpec.describe Backup::Manager do end it 'prints a done message' do - expect(progress).to have_received(:puts).with('done. (8 removed)') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (8 removed)') end end @@ -372,11 +401,11 @@ RSpec.describe Backup::Manager do end it 'sets the correct removed count' do - expect(progress).to have_received(:puts).with('done. (7 removed)') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (7 removed)') end it 'prints the error from file that could not be removed' do - expect(progress).to have_received(:puts).with(a_string_matching(message)) + expect(Gitlab::BackupLogger).to have_received(:info).with(message: a_string_matching(message)) end end end @@ -386,6 +415,7 @@ RSpec.describe Backup::Manager do let(:backup_filename) { File.basename(backup_file.path) } before do + allow(Gitlab::BackupLogger).to receive(:info) allow(subject).to receive(:tar_file).and_return(backup_filename) stub_backup_setting( @@ -410,6 +440,23 @@ RSpec.describe Backup::Manager do connection.directories.create(key: Gitlab.config.backup.upload.remote_directory) # rubocop:disable Rails/SaveBang end + context 'skipped upload' do + let(:backup_information) do + { + backup_created_at: Time.zone.parse('2019-01-01'), + gitlab_version: '12.3', + skipped: ['remote'] + } + end + + it 'informs the user' do + stub_env('SKIP', 'remote') + subject.create # rubocop:disable Rails/SaveBang + + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... [SKIPPED]') + end + end + context 'target path' do it 'uses the tar filename by default' do expect_any_instance_of(Fog::Collection).to receive(:create) @@ -462,7 +509,7 @@ RSpec.describe Backup::Manager do it 'sets encryption attributes' do subject.create # rubocop:disable Rails/SaveBang - expect(progress).to have_received(:puts).with("done (encrypted with AES256)") + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with AES256)') end end @@ -473,7 +520,7 @@ RSpec.describe Backup::Manager do it 'sets encryption attributes' do subject.create # rubocop:disable Rails/SaveBang - expect(progress).to have_received(:puts).with("done (encrypted with AES256)") + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with AES256)') end end @@ -488,7 +535,7 @@ RSpec.describe Backup::Manager do it 'sets encryption attributes' do subject.create # rubocop:disable Rails/SaveBang - expect(progress).to have_received(:puts).with("done (encrypted with aws:kms)") + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with aws:kms)') end end end @@ -546,15 +593,169 @@ RSpec.describe Backup::Manager do end end end + + context 'incremental' do + let(:incremental_env) { 'true' } + let(:gitlab_version) { Gitlab::VERSION } + let(:backup_id) { "1546300800_2019_01_01_#{gitlab_version}" } + let(:tar_file) { "#{backup_id}_gitlab_backup.tar" } + let(:backup_information) do + { + backup_created_at: Time.zone.parse('2019-01-01'), + gitlab_version: gitlab_version + } + end + + context 'when there are no backup files in the directory' do + before do + allow(Dir).to receive(:glob).and_return([]) + end + + it 'fails the operation and prints an error' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(progress).to have_received(:puts) + .with(a_string_matching('No backups found')) + end + end + + context 'when there are two backup files in the directory and BACKUP variable is not set' do + before do + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_1.2.3_gitlab_backup.tar', + '1451520000_2015_12_31_gitlab_backup.tar' + ] + ) + end + + it 'prints the list of available backups' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(progress).to have_received(:puts) + .with(a_string_matching('1451606400_2016_01_01_1.2.3\n 1451520000_2015_12_31')) + end + + it 'fails the operation and prints an error' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(progress).to have_received(:puts) + .with(a_string_matching('Found more than one backup')) + end + end + + context 'when BACKUP variable is set to a non-existing file' do + before do + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_gitlab_backup.tar' + ] + ) + allow(File).to receive(:exist?).and_return(false) + + stub_env('BACKUP', 'wrong') + end + + it 'fails the operation and prints an error' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(File).to have_received(:exist?).with('wrong_gitlab_backup.tar') + expect(progress).to have_received(:puts) + .with(a_string_matching('The backup file wrong_gitlab_backup.tar does not exist')) + end + end + + context 'when BACKUP variable is set to a correct file' do + let(:backup_id) { '1451606400_2016_01_01_1.2.3' } + let(:tar_cmdline) { %w{tar -xf 1451606400_2016_01_01_1.2.3_gitlab_backup.tar} } + + before do + allow(Gitlab::BackupLogger).to receive(:info) + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_1.2.3_gitlab_backup.tar' + ] + ) + allow(File).to receive(:exist?).and_return(true) + allow(Kernel).to receive(:system).and_return(true) + + stub_env('BACKUP', '/ignored/path/1451606400_2016_01_01_1.2.3') + end + + it 'unpacks the file' do + subject.create # rubocop:disable Rails/SaveBang + + expect(Kernel).to have_received(:system).with(*tar_cmdline) + end + + context 'tar fails' do + before do + expect(Kernel).to receive(:system).with(*tar_cmdline).and_return(false) + end + + it 'logs a failure' do + expect do + subject.create # rubocop:disable Rails/SaveBang + end.to raise_error(SystemExit) + + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Unpacking backup failed') + end + end + + context 'on version mismatch' do + let(:backup_information) do + { + backup_created_at: Time.zone.parse('2019-01-01'), + gitlab_version: "not #{gitlab_version}" + } + end + + it 'stops the process' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(progress).to have_received(:puts) + .with(a_string_matching('GitLab version mismatch')) + end + end + end + + context 'when there is a non-tarred backup in the directory' do + before do + allow(Dir).to receive(:glob).and_return( + [ + 'backup_information.yml' + ] + ) + allow(File).to receive(:exist?).and_return(true) + end + + it 'selects the non-tarred backup to restore from' do + subject.create # rubocop:disable Rails/SaveBang + + expect(progress).to have_received(:puts) + .with(a_string_matching('Non tarred backup found ')) + end + + context 'on version mismatch' do + let(:backup_information) do + { + backup_created_at: Time.zone.parse('2019-01-01'), + gitlab_version: "not #{gitlab_version}" + } + end + + it 'stops the process' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(progress).to have_received(:puts) + .with(a_string_matching('GitLab version mismatch')) + end + end + end + end end describe '#restore' do - let(:task1) { instance_double(Backup::Task, human_name: 'task 1', enabled: true, pre_restore_warning: nil, post_restore_warning: nil) } - let(:task2) { instance_double(Backup::Task, human_name: 'task 2', enabled: true, pre_restore_warning: nil, post_restore_warning: nil) } + let(:task1) { instance_double(Backup::Task, pre_restore_warning: nil, post_restore_warning: nil) } + let(:task2) { instance_double(Backup::Task, pre_restore_warning: nil, post_restore_warning: nil) } let(:definitions) do { - 'task1' => Backup::Manager::TaskDefinition.new(task: task1, destination_path: 'task1.tar.gz'), - 'task2' => Backup::Manager::TaskDefinition.new(task: task2, destination_path: 'task2.tar.gz') + 'task1' => Backup::Manager::TaskDefinition.new(task: task1, human_name: 'task 1', destination_path: 'task1.tar.gz'), + 'task2' => Backup::Manager::TaskDefinition.new(task: task2, human_name: 'task 2', destination_path: 'task2.tar.gz') } end @@ -570,6 +771,7 @@ RSpec.describe Backup::Manager do Rake.application.rake_require 'tasks/gitlab/shell' Rake.application.rake_require 'tasks/cache' + allow(Gitlab::BackupLogger).to receive(:info) allow(task1).to receive(:restore).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz')) allow(task2).to receive(:restore).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz')) allow(YAML).to receive(:load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml')) @@ -634,7 +836,10 @@ RSpec.describe Backup::Manager do end context 'when BACKUP variable is set to a correct file' do + let(:tar_cmdline) { %w{tar -xf 1451606400_2016_01_01_1.2.3_gitlab_backup.tar} } + before do + allow(Gitlab::BackupLogger).to receive(:info) allow(Dir).to receive(:glob).and_return( [ '1451606400_2016_01_01_1.2.3_gitlab_backup.tar' @@ -649,8 +854,21 @@ RSpec.describe Backup::Manager do it 'unpacks the file' do subject.restore - expect(Kernel).to have_received(:system) - .with("tar", "-xf", "1451606400_2016_01_01_1.2.3_gitlab_backup.tar") + expect(Kernel).to have_received(:system).with(*tar_cmdline) + end + + context 'tar fails' do + before do + expect(Kernel).to receive(:system).with(*tar_cmdline).and_return(false) + end + + it 'logs a failure' do + expect do + subject.restore + end.to raise_error(SystemExit) + + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Unpacking backup failed') + end end context 'on version mismatch' do @@ -680,7 +898,7 @@ RSpec.describe Backup::Manager do subject.restore - expect(progress).to have_received(:print).with('Deleting backups/tmp ... ') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting backups/tmp ... ') end end end @@ -731,7 +949,7 @@ RSpec.describe Backup::Manager do subject.restore - expect(progress).to have_received(:print).with('Deleting backups/tmp ... ') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting backups/tmp ... ') end end end diff --git a/spec/lib/backup/object_backup_spec.rb b/spec/lib/backup/object_backup_spec.rb deleted file mode 100644 index 85658173b0e..00000000000 --- a/spec/lib/backup/object_backup_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.shared_examples 'backup object' do |setting| - let(:progress) { StringIO.new } - let(:backup_path) { "/var/#{setting}" } - - subject(:backup) { described_class.new(progress) } - - describe '#dump' do - before do - allow(File).to receive(:realpath).and_call_original - allow(File).to receive(:realpath).with(backup_path).and_return(backup_path) - allow(File).to receive(:realpath).with("#{backup_path}/..").and_return('/var') - allow(Settings.send(setting)).to receive(:storage_path).and_return(backup_path) - end - - it 'uses the correct storage dir in tar command and excludes tmp', :aggregate_failures do - expect(backup).to receive(:tar).and_return('blabla-tar') - expect(backup).to receive(:run_pipeline!).with([%W(blabla-tar --exclude=lost+found --exclude=./tmp -C #{backup_path} -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], '']) - expect(backup).to receive(:pipeline_succeeded?).and_return(true) - - backup.dump('backup_object.tar.gz') - end - end -end - -RSpec.describe Backup::Packages do - it_behaves_like 'backup object', 'packages' -end - -RSpec.describe Backup::TerraformState do - it_behaves_like 'backup object', 'terraform_state' -end diff --git a/spec/lib/backup/pages_spec.rb b/spec/lib/backup/pages_spec.rb deleted file mode 100644 index 095dda61cf4..00000000000 --- a/spec/lib/backup/pages_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::Pages do - let(:progress) { StringIO.new } - - subject { described_class.new(progress) } - - before do - allow(File).to receive(:realpath).with("/var/gitlab-pages").and_return("/var/gitlab-pages") - allow(File).to receive(:realpath).with("/var/gitlab-pages/..").and_return("/var") - end - - describe '#dump' do - it 'excludes tmp from backup tar' do - allow(Gitlab.config.pages).to receive(:path) { '/var/gitlab-pages' } - - expect(subject).to receive(:tar).and_return('blabla-tar') - expect(subject).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./@pages.tmp -C /var/gitlab-pages -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], '']) - expect(subject).to receive(:pipeline_succeeded?).and_return(true) - subject.dump('pages.tar.gz') - end - end -end diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb index db3e507596f..c6f611e727c 100644 --- a/spec/lib/backup/repositories_spec.rb +++ b/spec/lib/backup/repositories_spec.rb @@ -4,18 +4,14 @@ require 'spec_helper' RSpec.describe Backup::Repositories do let(:progress) { spy(:stdout) } - let(:parallel_enqueue) { true } - let(:strategy) { spy(:strategy, parallel_enqueue?: parallel_enqueue) } - let(:max_concurrency) { 1 } - let(:max_storage_concurrency) { 1 } + let(:strategy) { spy(:strategy) } let(:destination) { 'repositories' } + let(:backup_id) { 'backup_id' } subject do described_class.new( progress, - strategy: strategy, - max_concurrency: max_concurrency, - max_storage_concurrency: max_storage_concurrency + strategy: strategy ) end @@ -27,9 +23,9 @@ RSpec.describe Backup::Repositories do project_snippet = create(:project_snippet, :repository, project: project) personal_snippet = create(:personal_snippet, :repository, author: project.first_owner) - subject.dump(destination) + subject.dump(destination, backup_id) - expect(strategy).to have_received(:start).with(:create, destination) + expect(strategy).to have_received(:start).with(:create, destination, backup_id: backup_id) expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::PROJECT) expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::WIKI) expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::DESIGN) @@ -51,139 +47,30 @@ RSpec.describe Backup::Repositories do it_behaves_like 'creates repository bundles' end - context 'no concurrency' do - it 'creates the expected number of threads' do - expect(Thread).not_to receive(:new) + describe 'command failure' do + it 'enqueue_project raises an error' do + allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError) - expect(strategy).to receive(:start).with(:create, destination) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:finish!) - - subject.dump(destination) - end - - describe 'command failure' do - it 'enqueue_project raises an error' do - allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError) - - expect { subject.dump(destination) }.to raise_error(IOError) - end - - it 'project query raises an error' do - allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout) - - expect { subject.dump(destination) }.to raise_error(ActiveRecord::StatementTimeout) - end + expect { subject.dump(destination, backup_id) }.to raise_error(IOError) end - it 'avoids N+1 database queries' do - control_count = ActiveRecord::QueryRecorder.new do - subject.dump(destination) - end.count + it 'project query raises an error' do + allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout) - create_list(:project, 2, :repository) - - expect do - subject.dump(destination) - end.not_to exceed_query_limit(control_count) + expect { subject.dump(destination, backup_id) }.to raise_error(ActiveRecord::StatementTimeout) end end - context 'concurrency with a strategy without parallel enqueueing support' do - let(:parallel_enqueue) { false } - let(:max_concurrency) { 2 } - let(:max_storage_concurrency) { 2 } - - it 'enqueues all projects sequentially' do - expect(Thread).not_to receive(:new) - - expect(strategy).to receive(:start).with(:create, destination) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:finish!) - - subject.dump(destination) - end - end - - [4, 10].each do |max_storage_concurrency| - context "max_storage_concurrency #{max_storage_concurrency}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/241701' do - let(:storage_keys) { %w[default test_second_storage] } - let(:max_storage_concurrency) { max_storage_concurrency } - - before do - allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(storage_keys) - end - - it 'creates the expected number of threads' do - expect(Thread).to receive(:new) - .exactly(storage_keys.length * (max_storage_concurrency + 1)).times - .and_call_original + it 'avoids N+1 database queries' do + control_count = ActiveRecord::QueryRecorder.new do + subject.dump(destination, backup_id) + end.count - expect(strategy).to receive(:start).with(:create, destination) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:finish!) + create_list(:project, 2, :repository) - subject.dump(destination) - end - - context 'with extra max concurrency' do - let(:max_concurrency) { 3 } - - it 'creates the expected number of threads' do - expect(Thread).to receive(:new) - .exactly(storage_keys.length * (max_storage_concurrency + 1)).times - .and_call_original - - expect(strategy).to receive(:start).with(:create, destination) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:finish!) - - subject.dump(destination) - end - end - - describe 'command failure' do - it 'enqueue_project raises an error' do - allow(strategy).to receive(:enqueue).and_raise(IOError) - - expect { subject.dump(destination) }.to raise_error(IOError) - end - - it 'project query raises an error' do - allow(Project).to receive_message_chain(:for_repository_storage, :includes, :find_each).and_raise(ActiveRecord::StatementTimeout) - - expect { subject.dump(destination) }.to raise_error(ActiveRecord::StatementTimeout) - end - - context 'misconfigured storages' do - let(:storage_keys) { %w[test_second_storage] } - - it 'raises an error' do - expect { subject.dump(destination) }.to raise_error(Backup::Error, 'repositories.storages in gitlab.yml is misconfigured') - end - end - end - - it 'avoids N+1 database queries' do - control_count = ActiveRecord::QueryRecorder.new do - subject.dump(destination) - end.count - - create_list(:project, 2, :repository) - - expect do - subject.dump(destination) - end.not_to exceed_query_limit(control_count) - end - end + expect do + subject.dump(destination, backup_id) + end.not_to exceed_query_limit(control_count) end end diff --git a/spec/lib/backup/task_spec.rb b/spec/lib/backup/task_spec.rb index b0eb885d3f4..80f1fe01b78 100644 --- a/spec/lib/backup/task_spec.rb +++ b/spec/lib/backup/task_spec.rb @@ -7,15 +7,9 @@ RSpec.describe Backup::Task do subject { described_class.new(progress) } - describe '#human_name' do - it 'must be implemented by the subclass' do - expect { subject.human_name }.to raise_error(NotImplementedError) - end - end - describe '#dump' do it 'must be implemented by the subclass' do - expect { subject.dump('some/path') }.to raise_error(NotImplementedError) + expect { subject.dump('some/path', 'backup_id') }.to raise_error(NotImplementedError) end end diff --git a/spec/lib/backup/uploads_spec.rb b/spec/lib/backup/uploads_spec.rb deleted file mode 100644 index 0cfc80a9cb9..00000000000 --- a/spec/lib/backup/uploads_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::Uploads do - let(:progress) { StringIO.new } - - subject(:backup) { described_class.new(progress) } - - describe '#dump' do - before do - allow(File).to receive(:realpath).and_call_original - allow(File).to receive(:realpath).with('/var/uploads').and_return('/var/uploads') - allow(File).to receive(:realpath).with('/var/uploads/..').and_return('/var') - allow(Gitlab.config.uploads).to receive(:storage_path) { '/var' } - end - - it 'excludes tmp from backup tar' do - expect(backup).to receive(:tar).and_return('blabla-tar') - expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./tmp -C /var/uploads -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], '']) - expect(backup).to receive(:pipeline_succeeded?).and_return(true) - backup.dump('uploads.tar.gz') - end - end -end diff --git a/spec/lib/banzai/filter/custom_emoji_filter_spec.rb b/spec/lib/banzai/filter/custom_emoji_filter_spec.rb index 94e77663d0f..6e29b910a6c 100644 --- a/spec/lib/banzai/filter/custom_emoji_filter_spec.rb +++ b/spec/lib/banzai/filter/custom_emoji_filter_spec.rb @@ -18,31 +18,30 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do doc = filter('<p>:tanuki:</p>', project: project) expect(doc.css('gl-emoji').first.attributes['title'].value).to eq('tanuki') - expect(doc.css('gl-emoji img').size).to eq 1 end it 'correctly uses the custom emoji URL' do doc = filter('<p>:tanuki:</p>') - expect(doc.css('img').first.attributes['src'].value).to eq(custom_emoji.file) + expect(doc.css('gl-emoji').first.attributes['data-fallback-src'].value).to eq(custom_emoji.file) end it 'matches multiple same custom emoji' do doc = filter(':tanuki: :tanuki:') - expect(doc.css('img').size).to eq 2 + expect(doc.css('gl-emoji').size).to eq 2 end it 'matches multiple custom emoji' do doc = filter(':tanuki: (:happy_tanuki:)') - expect(doc.css('img').size).to eq 2 + expect(doc.css('gl-emoji').size).to eq 2 end it 'does not match enclosed colons' do doc = filter('tanuki:tanuki:') - expect(doc.css('img').size).to be 0 + expect(doc.css('gl-emoji').size).to be 0 end it 'does not do N+1 query' do diff --git a/spec/lib/banzai/filter/image_link_filter_spec.rb b/spec/lib/banzai/filter/image_link_filter_spec.rb index 238c3cdb9c1..6326d894b08 100644 --- a/spec/lib/banzai/filter/image_link_filter_spec.rb +++ b/spec/lib/banzai/filter/image_link_filter_spec.rb @@ -46,6 +46,16 @@ RSpec.describe Banzai::Filter::ImageLinkFilter do expect(doc.at_css('img')['data-canonical-src']).to eq doc.at_css('a')['data-canonical-src'] end + it 'moves the data-diagram* attributes' do + doc = filter(%q(<img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw==">), context) + + expect(doc.at_css('a')['data-diagram']).to eq "plantuml" + expect(doc.at_css('a')['data-diagram-src']).to eq "data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw==" + + expect(doc.at_css('a img')['data-diagram']).to be_nil + expect(doc.at_css('a img')['data-diagram-src']).to be_nil + end + it 'adds no-attachment icon class to the link' do doc = filter(image(path), context) diff --git a/spec/lib/banzai/filter/kroki_filter_spec.rb b/spec/lib/banzai/filter/kroki_filter_spec.rb index c9594ac702d..1fb61ad1991 100644 --- a/spec/lib/banzai/filter/kroki_filter_spec.rb +++ b/spec/lib/banzai/filter/kroki_filter_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Banzai::Filter::KrokiFilter do stub_application_setting(kroki_enabled: true, kroki_url: "http://localhost:8000") doc = filter("<pre lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>") - expect(doc.to_s).to eq '<img class="js-render-kroki" src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==">' + expect(doc.to_s).to eq '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==" class="js-render-kroki" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDogSW50fHJhaWQoKTtwaWxsYWdlKCl8CiAgW2JlYXJkXS0tW3BhcnJvdF0KICBbYmVhcmRdLTo+W2ZvdWwgbW91dGhdCl0=">' end it 'replaces nomnoml pre tag with img tag if both kroki and plantuml are enabled' do @@ -19,7 +19,7 @@ RSpec.describe Banzai::Filter::KrokiFilter do plantuml_url: "http://localhost:8080") doc = filter("<pre lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>") - expect(doc.to_s).to eq '<img class="js-render-kroki" src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==">' + expect(doc.to_s).to eq '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==" class="js-render-kroki" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDogSW50fHJhaWQoKTtwaWxsYWdlKCl8CiAgW2JlYXJkXS0tW3BhcnJvdF0KICBbYmVhcmRdLTo+W2ZvdWwgbW91dGhdCl0=">' end it 'does not replace nomnoml pre tag with img tag if kroki is disabled' do @@ -44,6 +44,6 @@ RSpec.describe Banzai::Filter::KrokiFilter do text = '[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]' * 25 doc = filter("<pre lang='nomnoml'><code>#{text}</code></pre>") - expect(doc.to_s).to eq '<img class="js-render-kroki" src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KyJyVNQiE5KTSxKidXVjS5ILCrKL4lFFrSyi07LL81RyM0vLckAysRGjxo8avCowaMGjxo8avCowaMGU8lgAE7mIdc=" hidden>' + expect(doc.to_s).to start_with '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KyJyVNQiE5KTSxKidXVjS5ILCrKL4lFFrSyi07LL81RyM0vLckAysRGjxo8avCowaMGjxo8avCowaMGU8lgAE7mIdc=" hidden="" class="js-render-kroki" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDog' end end diff --git a/spec/lib/banzai/filter/plantuml_filter_spec.rb b/spec/lib/banzai/filter/plantuml_filter_spec.rb index 2d1a01116e0..dcfeb2ce3ba 100644 --- a/spec/lib/banzai/filter/plantuml_filter_spec.rb +++ b/spec/lib/banzai/filter/plantuml_filter_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Banzai::Filter::PlantumlFilter do stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080") input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' - output = '<div class="imageblock"><div class="content"><img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq"></div></div>' + output = '<img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw==">' doc = filter(input) expect(doc.to_s).to eq output @@ -29,7 +29,7 @@ RSpec.describe Banzai::Filter::PlantumlFilter do stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid") input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' - output = '<div class="listingblock"><div class="content"><pre class="plantuml plantuml-error"> Error: cannot connect to PlantUML server at "invalid"</pre></div></div>' + output = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' doc = filter(input) expect(doc.to_s).to eq output diff --git a/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb b/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb index c1a9ea7b7e2..f03a178b993 100644 --- a/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb +++ b/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb @@ -21,6 +21,8 @@ RSpec.describe BulkImports::Common::Pipelines::EntityFinisher do ) end + expect(context.portable).to receive(:try).with(:after_import) + expect { subject.run } .to change(entity, :status_name).to(:finished) end diff --git a/spec/lib/bulk_imports/groups/stage_spec.rb b/spec/lib/bulk_imports/groups/stage_spec.rb index b6bb8a7d195..645dee4a6f1 100644 --- a/spec/lib/bulk_imports/groups/stage_spec.rb +++ b/spec/lib/bulk_imports/groups/stage_spec.rb @@ -3,7 +3,10 @@ require 'spec_helper' RSpec.describe BulkImports::Groups::Stage do + let(:ancestor) { create(:group) } + let(:group) { create(:group, parent: ancestor) } let(:bulk_import) { build(:bulk_import) } + let(:entity) { build(:bulk_import_entity, bulk_import: bulk_import, group: group, destination_namespace: ancestor.full_path) } let(:pipelines) do [ @@ -19,26 +22,46 @@ RSpec.describe BulkImports::Groups::Stage do end it 'raises error when initialized without a BulkImport' do - expect { described_class.new({}) }.to raise_error(ArgumentError, 'Expected an argument of type ::BulkImport') + expect { described_class.new({}) }.to raise_error(ArgumentError, 'Expected an argument of type ::BulkImports::Entity') end describe '.pipelines' do it 'list all the pipelines with their stage number, ordered by stage' do - expect(described_class.new(bulk_import).pipelines & pipelines).to contain_exactly(*pipelines) - expect(described_class.new(bulk_import).pipelines.last.last).to eq(BulkImports::Common::Pipelines::EntityFinisher) + expect(described_class.new(entity).pipelines & pipelines).to contain_exactly(*pipelines) + expect(described_class.new(entity).pipelines.last.last).to eq(BulkImports::Common::Pipelines::EntityFinisher) end - it 'includes project entities pipeline' do - stub_feature_flags(bulk_import_projects: true) + context 'when bulk_import_projects feature flag is enabled' do + it 'includes project entities pipeline' do + stub_feature_flags(bulk_import_projects: true) - expect(described_class.new(bulk_import).pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline]) + expect(described_class.new(entity).pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline]) + end + + context 'when feature flag is enabled on root ancestor level' do + it 'includes project entities pipeline' do + stub_feature_flags(bulk_import_projects: ancestor) + + expect(described_class.new(entity).pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline]) + end + end + + context 'when destination namespace is not present' do + it 'includes project entities pipeline' do + stub_feature_flags(bulk_import_projects: true) + + entity = create(:bulk_import_entity, destination_namespace: '') + + expect(described_class.new(entity).pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline]) + end + end end context 'when bulk_import_projects feature flag is disabled' do it 'does not include project entities pipeline' do stub_feature_flags(bulk_import_projects: false) - expect(described_class.new(bulk_import).pipelines.flatten).not_to include(BulkImports::Groups::Pipelines::ProjectEntitiesPipeline) + expect(described_class.new(entity).pipelines.flatten).not_to include(BulkImports::Groups::Pipelines::ProjectEntitiesPipeline) end end end diff --git a/spec/lib/bulk_imports/projects/stage_spec.rb b/spec/lib/bulk_imports/projects/stage_spec.rb index ef98613dc25..9fce30f3a81 100644 --- a/spec/lib/bulk_imports/projects/stage_spec.rb +++ b/spec/lib/bulk_imports/projects/stage_spec.rb @@ -34,9 +34,9 @@ RSpec.describe BulkImports::Projects::Stage do end subject do - bulk_import = build(:bulk_import) + entity = build(:bulk_import_entity, :project_entity) - described_class.new(bulk_import) + described_class.new(entity) end describe '#pipelines' do diff --git a/spec/lib/container_registry/gitlab_api_client_spec.rb b/spec/lib/container_registry/gitlab_api_client_spec.rb index 4fe229024e5..16d2c42f332 100644 --- a/spec/lib/container_registry/gitlab_api_client_spec.rb +++ b/spec/lib/container_registry/gitlab_api_client_spec.rb @@ -62,6 +62,7 @@ RSpec.describe ContainerRegistry::GitlabApiClient do where(:status_code, :expected_result) do 200 | :already_imported 202 | :ok + 400 | :bad_request 401 | :unauthorized 404 | :not_found 409 | :already_being_imported @@ -86,6 +87,7 @@ RSpec.describe ContainerRegistry::GitlabApiClient do where(:status_code, :expected_result) do 200 | :already_imported 202 | :ok + 400 | :bad_request 401 | :unauthorized 404 | :not_found 409 | :already_being_imported @@ -104,54 +106,106 @@ RSpec.describe ContainerRegistry::GitlabApiClient do end end - describe '#import_status' do - subject { client.import_status(path) } + describe '#cancel_repository_import' do + let(:force) { false } - before do - stub_import_status(path, status) + subject { client.cancel_repository_import(path, force: force) } + + where(:status_code, :expected_result) do + 200 | :already_imported + 202 | :ok + 400 | :bad_request + 401 | :unauthorized + 404 | :not_found + 409 | :already_being_imported + 418 | :error + 424 | :pre_import_failed + 425 | :already_being_imported + 429 | :too_many_imports end - context 'with a status' do + with_them do + before do + stub_import_cancel(path, status_code, force: force) + end + + it { is_expected.to eq({ status: expected_result, migration_state: nil }) } + end + + context 'bad request' do let(:status) { 'this_is_a_test' } - it { is_expected.to eq(status) } + before do + stub_import_cancel(path, 400, status: status, force: force) + end + + it { is_expected.to eq({ status: :bad_request, migration_state: status }) } end - context 'with no status' do - let(:status) { nil } + context 'force cancel' do + let(:force) { true } - it { is_expected.to eq('error') } + before do + stub_import_cancel(path, 202, force: force) + end + + it { is_expected.to eq({ status: :ok, migration_state: nil }) } end end - describe '#repository_details' do - let(:path) { 'namespace/path/to/repository' } - let(:response) { { foo: :bar, this: :is_a_test } } - let(:with_size) { true } - - subject { client.repository_details(path, with_size: with_size) } + describe '#import_status' do + subject { client.import_status(path) } - context 'with size' do + context 'with successful response' do before do - stub_repository_details(path, with_size: with_size, respond_with: response) + stub_import_status(path, status) end - it { is_expected.to eq(response.stringify_keys.deep_transform_values(&:to_s)) } - end + context 'with a status' do + let(:status) { 'this_is_a_test' } + + it { is_expected.to eq(status) } + end + + context 'with no status' do + let(:status) { nil } - context 'without_size' do - let(:with_size) { false } + it { is_expected.to eq('error') } + end + end + context 'with non successful response' do before do - stub_repository_details(path, with_size: with_size, respond_with: response) + stub_import_status(path, nil, status_code: 404) end - it { is_expected.to eq(response.stringify_keys.deep_transform_values(&:to_s)) } + it { is_expected.to eq('pre_import_failed') } + end + end + + describe '#repository_details' do + let(:path) { 'namespace/path/to/repository' } + let(:response) { { foo: :bar, this: :is_a_test } } + + subject { client.repository_details(path, sizing: sizing) } + + [:self, :self_with_descendants, nil].each do |size_type| + context "with sizing #{size_type}" do + let(:sizing) { size_type } + + before do + stub_repository_details(path, sizing: sizing, respond_with: response) + end + + it { is_expected.to eq(response.stringify_keys.deep_transform_values(&:to_s)) } + end end context 'with non successful response' do + let(:sizing) { nil } + before do - stub_repository_details(path, with_size: with_size, status_code: 404) + stub_repository_details(path, sizing: sizing, status_code: 404) end it { is_expected.to eq({}) } @@ -216,6 +270,54 @@ RSpec.describe ContainerRegistry::GitlabApiClient do end end + describe '.deduplicated_size' do + let(:path) { 'foo/bar' } + let(:response) { { 'size_bytes': 555 } } + let(:registry_enabled) { true } + + subject { described_class.deduplicated_size(path) } + + before do + stub_container_registry_config(enabled: registry_enabled, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key') + end + + context 'with successful response' do + before do + expect(Auth::ContainerRegistryAuthenticationService).to receive(:pull_nested_repositories_access_token).with(path).and_return(token) + stub_repository_details(path, sizing: :self_with_descendants, status_code: 200, respond_with: response) + end + + it { is_expected.to eq(555) } + end + + context 'with unsuccessful response' do + before do + expect(Auth::ContainerRegistryAuthenticationService).to receive(:pull_nested_repositories_access_token).with(path).and_return(token) + stub_repository_details(path, sizing: :self_with_descendants, status_code: 404, respond_with: response) + end + + it { is_expected.to eq(nil) } + end + + context 'with the registry disabled' do + let(:registry_enabled) { false } + + it { is_expected.to eq(nil) } + end + + context 'with a nil path' do + let(:path) { nil } + let(:token) { nil } + + before do + expect(Auth::ContainerRegistryAuthenticationService).not_to receive(:pull_nested_repositories_access_token) + stub_repository_details(path, sizing: :self_with_descendants, status_code: 401, respond_with: response) + end + + it { is_expected.to eq(nil) } + end + end + def stub_pre_import(path, status_code, pre:) import_type = pre ? 'pre' : 'final' stub_request(:put, "#{registry_api_url}/gitlab/v1/import/#{path}/?import_type=#{import_type}") @@ -230,21 +332,50 @@ RSpec.describe ContainerRegistry::GitlabApiClient do .to_return(status: status_code, body: '') end - def stub_import_status(path, status) + def stub_import_status(path, status, status_code: 200) stub_request(:get, "#{registry_api_url}/gitlab/v1/import/#{path}/") .with(headers: { 'Accept' => described_class::JSON_TYPE, 'Authorization' => "bearer #{import_token}" }) .to_return( - status: 200, + status: status_code, body: { status: status }.to_json, headers: { content_type: 'application/json' } ) end - def stub_repository_details(path, with_size: true, status_code: 200, respond_with: {}) + def stub_import_cancel(path, http_status, status: nil, force: false) + body = {} + + if http_status == 400 + body = { status: status } + end + + headers = { + 'Accept' => described_class::JSON_TYPE, + 'Authorization' => "bearer #{import_token}", + 'User-Agent' => "GitLab/#{Gitlab::VERSION}", + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3' + } + + params = force ? '?force=true' : '' + + stub_request(:delete, "#{registry_api_url}/gitlab/v1/import/#{path}/#{params}") + .with(headers: headers) + .to_return( + status: http_status, + body: body.to_json, + headers: { content_type: 'application/json' } + ) + end + + def stub_repository_details(path, sizing: nil, status_code: 200, respond_with: {}) url = "#{registry_api_url}/gitlab/v1/repositories/#{path}/" - url += "?size=self" if with_size + url += "?size=#{sizing}" if sizing + + headers = { 'Accept' => described_class::JSON_TYPE } + headers['Authorization'] = "bearer #{token}" if token + stub_request(:get, url) - .with(headers: { 'Accept' => described_class::JSON_TYPE, 'Authorization' => "bearer #{token}" }) + .with(headers: headers) .to_return(status: status_code, body: respond_with.to_json, headers: { 'Content-Type' => described_class::JSON_TYPE }) end end diff --git a/spec/lib/container_registry/migration_spec.rb b/spec/lib/container_registry/migration_spec.rb index ffbbfb249e3..6c0fc94e27f 100644 --- a/spec/lib/container_registry/migration_spec.rb +++ b/spec/lib/container_registry/migration_spec.rb @@ -37,8 +37,8 @@ RSpec.describe ContainerRegistry::Migration do subject { described_class.enqueue_waiting_time } where(:slow_enabled, :fast_enabled, :expected_result) do - false | false | 1.hour - true | false | 6.hours + false | false | 45.minutes + true | false | 165.minutes false | true | 0 true | true | 0 end @@ -154,15 +154,35 @@ RSpec.describe ContainerRegistry::Migration do end end - describe '.target_plan' do - let_it_be(:plan) { create(:plan) } + describe '.target_plans' do + subject { described_class.target_plans } - before do - stub_application_setting(container_registry_import_target_plan: plan.name) + where(:target_plan, :result) do + 'free' | described_class::FREE_TIERS + 'premium' | described_class::PREMIUM_TIERS + 'ultimate' | described_class::ULTIMATE_TIERS end - it 'returns the matching application_setting' do - expect(described_class.target_plan).to eq(plan) + with_them do + before do + stub_application_setting(container_registry_import_target_plan: target_plan) + end + + it { is_expected.to eq(result) } + end + end + + describe '.all_plans?' do + subject { described_class.all_plans? } + + it { is_expected.to eq(true) } + + context 'feature flag disabled' do + before do + stub_feature_flags(container_registry_migration_phase2_all_plans: false) + end + + it { is_expected.to eq(false) } end end end diff --git a/spec/lib/error_tracking/sentry_client/issue_spec.rb b/spec/lib/error_tracking/sentry_client/issue_spec.rb index 82db0f70f2e..d7bb0ca5c9a 100644 --- a/spec/lib/error_tracking/sentry_client/issue_spec.rb +++ b/spec/lib/error_tracking/sentry_client/issue_spec.rb @@ -13,7 +13,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do describe '#list_issues' do shared_examples 'issues have correct return type' do |klass| it "returns objects of type #{klass}" do - expect(subject[:issues]).to all( be_a(klass) ) + expect(subject[:issues]).to all(be_a(klass)) end end @@ -41,10 +41,18 @@ RSpec.describe ErrorTracking::SentryClient::Issue do let(:cursor) { nil } let(:sort) { 'last_seen' } let(:sentry_api_response) { issues_sample_response } - let(:sentry_request_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' } + let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved" } let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) } - subject { client.list_issues(issue_status: issue_status, limit: limit, search_term: search_term, sort: sort, cursor: cursor) } + subject do + client.list_issues( + issue_status: issue_status, + limit: limit, + search_term: search_term, + sort: sort, + cursor: cursor + ) + end it_behaves_like 'calls sentry api' @@ -52,7 +60,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do it_behaves_like 'issues have correct length', 3 shared_examples 'has correct external_url' do - context 'external_url' do + describe '#external_url' do it 'is constructed correctly' do expect(subject[:issues][0].external_url).to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project/issues/11') end @@ -62,7 +70,8 @@ RSpec.describe ErrorTracking::SentryClient::Issue do context 'when response has a pagination info' do let(:headers) do { - link: '<https://sentrytest.gitlab.com>; rel="previous"; results="true"; cursor="1573556671000:0:1", <https://sentrytest.gitlab.com>; rel="next"; results="true"; cursor="1572959139000:0:0"' + link: '<https://sentrytest.gitlab.com>; rel="previous"; results="true"; cursor="1573556671000:0:1",' \ + '<https://sentrytest.gitlab.com>; rel="next"; results="true"; cursor="1572959139000:0:0"' } end @@ -76,7 +85,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do end end - context 'error object created from sentry response' do + context 'when error object created from sentry response' do using RSpec::Parameterized::TableSyntax where(:error_object, :sentry_response) do @@ -104,13 +113,13 @@ RSpec.describe ErrorTracking::SentryClient::Issue do it_behaves_like 'has correct external_url' end - context 'redirects' do - let(:sentry_api_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' } + context 'with redirects' do + let(:sentry_api_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved" } it_behaves_like 'no Sentry redirects' end - context 'requests with sort parameter in sentry api' do + context 'with sort parameter in sentry api' do let(:sentry_request_url) do 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \ 'issues/?limit=20&query=is:unresolved&sort=freq' @@ -140,7 +149,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do end end - context 'Older sentry versions where keys are not present' do + context 'with older sentry versions where keys are not present' do let(:sentry_api_response) do issues_sample_response[0...1].map do |issue| issue[:project].delete(:id) @@ -156,7 +165,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do it_behaves_like 'has correct external_url' end - context 'essential keys missing in API response' do + context 'when essential keys are missing in API response' do let(:sentry_api_response) do issues_sample_response[0...1].map do |issue| issue.except(:id) @@ -164,16 +173,18 @@ RSpec.describe ErrorTracking::SentryClient::Issue do end it 'raises exception' do - expect { subject }.to raise_error(ErrorTracking::SentryClient::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"') + expect { subject }.to raise_error(ErrorTracking::SentryClient::MissingKeysError, + 'Sentry API response is missing keys. key not found: "id"') end end - context 'sentry api response too large' do + context 'when sentry api response is too large' do it 'raises exception' do - deep_size = double('Gitlab::Utils::DeepSize', valid?: false) + deep_size = instance_double(Gitlab::Utils::DeepSize, valid?: false) allow(Gitlab::Utils::DeepSize).to receive(:new).with(sentry_api_response).and_return(deep_size) - expect { subject }.to raise_error(ErrorTracking::SentryClient::ResponseInvalidSizeError, 'Sentry API response is too big. Limit is 1 MB.') + expect { subject }.to raise_error(ErrorTracking::SentryClient::ResponseInvalidSizeError, + 'Sentry API response is too big. Limit is 1 MB.') end end @@ -212,7 +223,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do subject { client.issue_details(issue_id: issue_id) } - context 'error object created from sentry response' do + context 'with error object created from sentry response' do using RSpec::Parameterized::TableSyntax where(:error_object, :sentry_response) do @@ -298,17 +309,16 @@ RSpec.describe ErrorTracking::SentryClient::Issue do describe '#update_issue' do let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0' } let(:sentry_request_url) { "#{sentry_url}/issues/#{issue_id}/" } - - before do - stub_sentry_request(sentry_request_url, :put) - end - let(:params) do { status: 'resolved' } end + before do + stub_sentry_request(sentry_request_url, :put) + end + subject { client.update_issue(issue_id: issue_id, params: params) } it_behaves_like 'calls sentry api' do @@ -319,7 +329,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do expect(subject).to be_truthy end - context 'error encountered' do + context 'when error is encountered' do let(:error) { StandardError.new('error') } before do diff --git a/spec/lib/gitlab/application_context_spec.rb b/spec/lib/gitlab/application_context_spec.rb index 55f5ae7d7dc..f9e18a65af4 100644 --- a/spec/lib/gitlab/application_context_spec.rb +++ b/spec/lib/gitlab/application_context_spec.rb @@ -146,7 +146,8 @@ RSpec.describe Gitlab::ApplicationContext do where(:provided_options, :client) do [:remote_ip] | :remote_ip [:remote_ip, :runner] | :runner - [:remote_ip, :runner, :user] | :user + [:remote_ip, :runner, :user] | :runner + [:remote_ip, :user] | :user end with_them do @@ -195,6 +196,16 @@ RSpec.describe Gitlab::ApplicationContext do expect(result(context)).to include(project: nil) end end + + context 'when using job context' do + let_it_be(:job) { create(:ci_build, :pending, :queued, user: user, project: project) } + + it 'sets expected values' do + context = described_class.new(job: job) + + expect(result(context)).to include(job_id: job.id, project: project.full_path, pipeline_id: job.pipeline_id) + end + end end describe '#use' do diff --git a/spec/lib/gitlab/auth/o_auth/user_spec.rb b/spec/lib/gitlab/auth/o_auth/user_spec.rb index 1a9e2f02de6..6cb9085c3ad 100644 --- a/spec/lib/gitlab/auth/o_auth/user_spec.rb +++ b/spec/lib/gitlab/auth/o_auth/user_spec.rb @@ -6,11 +6,15 @@ RSpec.describe Gitlab::Auth::OAuth::User do include LdapHelpers let(:oauth_user) { described_class.new(auth_hash) } + let(:oauth_user_2) { described_class.new(auth_hash_2) } let(:gl_user) { oauth_user.gl_user } + let(:gl_user_2) { oauth_user_2.gl_user } let(:uid) { 'my-uid' } + let(:uid_2) { 'my-uid-2' } let(:dn) { 'uid=user1,ou=people,dc=example' } let(:provider) { 'my-provider' } let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash) } + let(:auth_hash_2) { OmniAuth::AuthHash.new(uid: uid_2, provider: provider, info: info_hash) } let(:info_hash) do { nickname: '-john+gitlab-ETC%.git@gmail.com', @@ -24,6 +28,7 @@ RSpec.describe Gitlab::Auth::OAuth::User do end let(:ldap_user) { Gitlab::Auth::Ldap::Person.new(Net::LDAP::Entry.new, 'ldapmain') } + let(:ldap_user_2) { Gitlab::Auth::Ldap::Person.new(Net::LDAP::Entry.new, 'ldapmain') } describe '.find_by_uid_and_provider' do let(:dn) { 'CN=John Åström, CN=Users, DC=Example, DC=com' } @@ -46,12 +51,12 @@ RSpec.describe Gitlab::Auth::OAuth::User do let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') } it "finds an existing user based on uid and provider (facebook)" do - expect( oauth_user.persisted? ).to be_truthy + expect(oauth_user.persisted?).to be_truthy end it 'returns false if user is not found in database' do allow(auth_hash).to receive(:uid).and_return('non-existing') - expect( oauth_user.persisted? ).to be_falsey + expect(oauth_user.persisted?).to be_falsey end end @@ -78,15 +83,27 @@ RSpec.describe Gitlab::Auth::OAuth::User do context 'when signup is disabled' do before do stub_application_setting signup_enabled: false + stub_omniauth_config(allow_single_sign_on: [provider]) end it 'creates the user' do - stub_omniauth_config(allow_single_sign_on: [provider]) - oauth_user.save # rubocop:disable Rails/SaveBang expect(gl_user).to be_persisted end + + it 'does not repeat the default user password' do + oauth_user.save # rubocop:disable Rails/SaveBang + oauth_user_2.save # rubocop:disable Rails/SaveBang + + expect(gl_user.password).not_to eq(gl_user_2.password) + end + + it 'has the password length within specified range' do + oauth_user.save # rubocop:disable Rails/SaveBang + + expect(gl_user.password.length).to be_between(Devise.password_length.min, Devise.password_length.max) + end end context 'when user confirmation email is enabled' do @@ -330,6 +347,12 @@ RSpec.describe Gitlab::Auth::OAuth::User do allow(ldap_user).to receive(:name) { 'John Doe' } allow(ldap_user).to receive(:email) { ['johndoe@example.com', 'john2@example.com'] } allow(ldap_user).to receive(:dn) { dn } + + allow(ldap_user_2).to receive(:uid) { uid_2 } + allow(ldap_user_2).to receive(:username) { uid_2 } + allow(ldap_user_2).to receive(:name) { 'Beck Potter' } + allow(ldap_user_2).to receive(:email) { ['beckpotter@example.com', 'beck2@example.com'] } + allow(ldap_user_2).to receive(:dn) { dn } end context "and no account for the LDAP user" do @@ -340,6 +363,14 @@ RSpec.describe Gitlab::Auth::OAuth::User do oauth_user.save # rubocop:disable Rails/SaveBang end + it 'does not repeat the default user password' do + allow(Gitlab::Auth::Ldap::Person).to receive(:find_by_uid).and_return(ldap_user_2) + + oauth_user_2.save # rubocop:disable Rails/SaveBang + + expect(gl_user.password).not_to eq(gl_user_2.password) + end + it "creates a user with dual LDAP and omniauth identities" do expect(gl_user).to be_valid expect(gl_user.username).to eql uid @@ -609,6 +640,7 @@ RSpec.describe Gitlab::Auth::OAuth::User do context 'signup with SAML' do let(:provider) { 'saml' } + let(:block_auto_created_users) { false } before do stub_omniauth_config({ @@ -625,6 +657,13 @@ RSpec.describe Gitlab::Auth::OAuth::User do it_behaves_like 'not being blocked on creation' do let(:block_auto_created_users) { false } end + + it 'does not repeat the default user password' do + oauth_user.save # rubocop:disable Rails/SaveBang + oauth_user_2.save # rubocop:disable Rails/SaveBang + + expect(gl_user.password).not_to eq(gl_user_2.password) + end end context 'signup with omniauth only' do diff --git a/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb b/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb index a7895623d6f..1158eedfe7c 100644 --- a/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequests do +RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequests, :migration, schema: 20220326161803 do let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } let(:merge_requests) { table(:merge_requests) } @@ -50,5 +50,19 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequests d subject.perform(mr_ids.first, mr_ids.last) end + + it_behaves_like 'marks background migration job records' do + let!(:non_eligible_mrs) do + Array.new(2) do + create_merge_request( + title: "Not a d-r-a-f-t 1", + draft: false, + state_id: 1 + ) + end + end + + let(:arguments) { [non_eligible_mrs.first.id, non_eligible_mrs.last.id] } + end end end diff --git a/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb b/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb new file mode 100644 index 00000000000..4705f0d0ab9 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillGroupFeatures, :migration, schema: 20220302114046 do + let(:group_features) { table(:group_features) } + let(:namespaces) { table(:namespaces) } + + subject { described_class.new(connection: ActiveRecord::Base.connection) } + + describe '#perform' do + it 'creates settings for all group namespaces in range' do + namespaces.create!(id: 1, name: 'group1', path: 'group1', type: 'Group') + namespaces.create!(id: 2, name: 'user', path: 'user') + namespaces.create!(id: 3, name: 'group2', path: 'group2', type: 'Group') + + # Checking that no error is raised if the group_feature for a group already exists + namespaces.create!(id: 4, name: 'group3', path: 'group3', type: 'Group') + group_features.create!(id: 1, group_id: 4) + expect(group_features.count).to eq 1 + + expect { subject.perform(1, 4, :namespaces, :id, 10, 0, 4) }.to change { group_features.count }.by(2) + + expect(group_features.count).to eq 3 + expect(group_features.all.pluck(:group_id)).to contain_exactly(1, 3, 4) + end + end +end diff --git a/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb b/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb deleted file mode 100644 index 242da383453..00000000000 --- a/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::BackfillIncidentIssueEscalationStatuses, schema: 20211214012507 do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:issues) { table(:issues) } - let(:issuable_escalation_statuses) { table(:incident_management_issuable_escalation_statuses) } - - subject(:migration) { described_class.new } - - it 'correctly backfills issuable escalation status records' do - namespace = namespaces.create!(name: 'foo', path: 'foo') - project = projects.create!(namespace_id: namespace.id) - - issues.create!(project_id: project.id, title: 'issue 1', issue_type: 0) # non-incident issue - issues.create!(project_id: project.id, title: 'incident 1', issue_type: 1) - issues.create!(project_id: project.id, title: 'incident 2', issue_type: 1) - incident_issue_existing_status = issues.create!(project_id: project.id, title: 'incident 3', issue_type: 1) - issuable_escalation_statuses.create!(issue_id: incident_issue_existing_status.id) - - migration.perform(1, incident_issue_existing_status.id) - - expect(issuable_escalation_statuses.count).to eq(3) - end -end diff --git a/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb b/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb index b29d4c3583b..f98aea2dda7 100644 --- a/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillIssueSearchData do +RSpec.describe Gitlab::BackgroundMigration::BackfillIssueSearchData, :migration, schema: 20220326161803 do let(:namespaces_table) { table(:namespaces) } let(:projects_table) { table(:projects) } let(:issue_search_data_table) { table(:issue_search_data) } diff --git a/spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb b/spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb new file mode 100644 index 00000000000..2dcd4645c84 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceIdForProjectRoute do + let(:migration) { described_class.new } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:routes) { table(:routes) } + + let(:namespace1) { namespaces.create!(name: 'batchtest1', type: 'Group', path: 'space1') } + let(:namespace2) { namespaces.create!(name: 'batchtest2', type: 'Group', parent_id: namespace1.id, path: 'space2') } + let(:namespace3) { namespaces.create!(name: 'batchtest3', type: 'Group', parent_id: namespace2.id, path: 'space3') } + + let(:proj_namespace1) { namespaces.create!(name: 'proj1', path: 'proj1', type: 'Project', parent_id: namespace1.id) } + let(:proj_namespace2) { namespaces.create!(name: 'proj2', path: 'proj2', type: 'Project', parent_id: namespace2.id) } + let(:proj_namespace3) { namespaces.create!(name: 'proj3', path: 'proj3', type: 'Project', parent_id: namespace3.id) } + let(:proj_namespace4) { namespaces.create!(name: 'proj4', path: 'proj4', type: 'Project', parent_id: namespace3.id) } + + # rubocop:disable Layout/LineLength + let(:proj1) { projects.create!(name: 'proj1', path: 'proj1', namespace_id: namespace1.id, project_namespace_id: proj_namespace1.id) } + let(:proj2) { projects.create!(name: 'proj2', path: 'proj2', namespace_id: namespace2.id, project_namespace_id: proj_namespace2.id) } + let(:proj3) { projects.create!(name: 'proj3', path: 'proj3', namespace_id: namespace3.id, project_namespace_id: proj_namespace3.id) } + let(:proj4) { projects.create!(name: 'proj4', path: 'proj4', namespace_id: namespace3.id, project_namespace_id: proj_namespace4.id) } + # rubocop:enable Layout/LineLength + + let!(:namespace_route1) { routes.create!(path: 'space1', source_id: namespace1.id, source_type: 'Namespace') } + let!(:namespace_route2) { routes.create!(path: 'space1/space2', source_id: namespace2.id, source_type: 'Namespace') } + let!(:namespace_route3) { routes.create!(path: 'space1/space3', source_id: namespace3.id, source_type: 'Namespace') } + + let!(:proj_route1) { routes.create!(path: 'space1/proj1', source_id: proj1.id, source_type: 'Project') } + let!(:proj_route2) { routes.create!(path: 'space1/space2/proj2', source_id: proj2.id, source_type: 'Project') } + let!(:proj_route3) { routes.create!(path: 'space1/space3/proj3', source_id: proj3.id, source_type: 'Project') } + let!(:proj_route4) { routes.create!(path: 'space1/space3/proj4', source_id: proj4.id, source_type: 'Project') } + + subject(:perform_migration) { migration.perform(proj_route1.id, proj_route4.id, :routes, :id, 2, 0) } + + it 'backfills namespace_id for the selected records', :aggregate_failures do + perform_migration + + expected_namespaces = [proj_namespace1.id, proj_namespace2.id, proj_namespace3.id, proj_namespace4.id] + + expected_projects = [proj_route1.id, proj_route2.id, proj_route3.id, proj_route4.id] + expect(routes.where.not(namespace_id: nil).pluck(:id)).to match_array(expected_projects) + expect(routes.where.not(namespace_id: nil).pluck(:namespace_id)).to match_array(expected_namespaces) + end + + it 'tracks timings of queries' do + expect(migration.batch_metrics.timings).to be_empty + + expect { perform_migration }.to change { migration.batch_metrics.timings } + end +end diff --git a/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb b/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb new file mode 100644 index 00000000000..8d82c533d20 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillWorkItemTypeIdForIssues, :migration, schema: 20220326161803 do + subject(:migrate) { migration.perform(start_id, end_id, batch_table, batch_column, sub_batch_size, pause_ms, issue_type_enum[:issue], issue_type.id) } + + let(:migration) { described_class.new } + + let(:batch_table) { 'issues' } + let(:batch_column) { 'id' } + let(:sub_batch_size) { 2 } + let(:pause_ms) { 0 } + + # let_it_be can't be used in migration specs because all tables but `work_item_types` are deleted after each spec + let(:issue_type_enum) { { issue: 0, incident: 1, test_case: 2, requirement: 3, task: 4 } } + let(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') } + let(:project) { table(:projects).create!(namespace_id: namespace.id) } + let(:issues_table) { table(:issues) } + let(:issue_type) { table(:work_item_types).find_by!(namespace_id: nil, base_type: issue_type_enum[:issue]) } + + let(:issue1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let(:issue2) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let(:issue3) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let(:incident1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:incident]) } + # test_case and requirement are EE only, but enum values exist on the FOSS model + let(:test_case1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:test_case]) } + let(:requirement1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:requirement]) } + + let(:start_id) { issue1.id } + let(:end_id) { requirement1.id } + + let(:all_issues) { [issue1, issue2, issue3, incident1, test_case1, requirement1] } + + it 'sets work_item_type_id only for the given type' do + expect(all_issues).to all(have_attributes(work_item_type_id: nil)) + + expect { migrate }.to make_queries_matching(/UPDATE \"issues\" SET "work_item_type_id"/, 2) + all_issues.each(&:reload) + + expect([issue1, issue2, issue3]).to all(have_attributes(work_item_type_id: issue_type.id)) + expect(all_issues - [issue1, issue2, issue3]).to all(have_attributes(work_item_type_id: nil)) + end + + it 'tracks timings of queries' do + expect(migration.batch_metrics.timings).to be_empty + + expect { migrate }.to change { migration.batch_metrics.timings } + end + + context 'when database timeouts' do + using RSpec::Parameterized::TableSyntax + + where(error_class: [ActiveRecord::StatementTimeout, ActiveRecord::QueryCanceled]) + + with_them do + it 'retries on timeout error' do + expect(migration).to receive(:update_batch).exactly(3).times.and_raise(error_class) + expect(migration).to receive(:sleep).with(30).twice + + expect do + migrate + end.to raise_error(error_class) + end + end + end +end diff --git a/spec/lib/gitlab/background_migration/batching_strategies/backfill_issue_work_item_type_batching_strategy_spec.rb b/spec/lib/gitlab/background_migration/batching_strategies/backfill_issue_work_item_type_batching_strategy_spec.rb new file mode 100644 index 00000000000..3cba99bfe51 --- /dev/null +++ b/spec/lib/gitlab/background_migration/batching_strategies/backfill_issue_work_item_type_batching_strategy_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::BackfillIssueWorkItemTypeBatchingStrategy, '#next_batch', schema: 20220326161803 do # rubocop:disable Layout/LineLength + # let! can't be used in migration specs because all tables but `work_item_types` are deleted after each spec + let!(:issue_type_enum) { { issue: 0, incident: 1, test_case: 2, requirement: 3, task: 4 } } + let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') } + let!(:project) { table(:projects).create!(namespace_id: namespace.id) } + let!(:issues_table) { table(:issues) } + let!(:task_type) { table(:work_item_types).find_by!(namespace_id: nil, base_type: issue_type_enum[:task]) } + + let!(:issue1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let!(:task1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task]) } + let!(:issue2) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let!(:issue3) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let!(:task2) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task]) } + let!(:incident1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:incident]) } + # test_case is EE only, but enum values exist on the FOSS model + let!(:test_case1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:test_case]) } + + let!(:task3) do + issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task], work_item_type_id: task_type.id) + end + + let!(:task4) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task]) } + + let!(:batching_strategy) { described_class.new(connection: ActiveRecord::Base.connection) } + + context 'when issue_type is issue' do + let(:job_arguments) { [issue_type_enum[:issue], 'irrelevant_work_item_id'] } + + context 'when starting on the first batch' do + it 'returns the bounds of the next batch' do + batch_bounds = next_batch(issue1.id, 2) + + expect(batch_bounds).to match_array([issue1.id, issue2.id]) + end + end + + context 'when additional batches remain' do + it 'returns the bounds of the next batch' do + batch_bounds = next_batch(issue2.id, 2) + + expect(batch_bounds).to match_array([issue2.id, issue3.id]) + end + end + + context 'when on the final batch' do + it 'returns the bounds of the next batch' do + batch_bounds = next_batch(issue3.id, 2) + + expect(batch_bounds).to match_array([issue3.id, issue3.id]) + end + end + + context 'when no additional batches remain' do + it 'returns nil' do + batch_bounds = next_batch(issue3.id + 1, 1) + + expect(batch_bounds).to be_nil + end + end + end + + context 'when issue_type is incident' do + let(:job_arguments) { [issue_type_enum[:incident], 'irrelevant_work_item_id'] } + + context 'when starting on the first batch' do + it 'returns the bounds of the next batch with only one element' do + batch_bounds = next_batch(incident1.id, 2) + + expect(batch_bounds).to match_array([incident1.id, incident1.id]) + end + end + end + + context 'when issue_type is requirement and there are no matching records' do + let(:job_arguments) { [issue_type_enum[:requirement], 'irrelevant_work_item_id'] } + + context 'when starting on the first batch' do + it 'returns nil' do + batch_bounds = next_batch(1, 2) + + expect(batch_bounds).to be_nil + end + end + end + + context 'when issue_type is task' do + let(:job_arguments) { [issue_type_enum[:task], 'irrelevant_work_item_id'] } + + context 'when starting on the first batch' do + it 'returns the bounds of the next batch' do + batch_bounds = next_batch(task1.id, 2) + + expect(batch_bounds).to match_array([task1.id, task2.id]) + end + end + + context 'when additional batches remain' do + it 'returns the bounds of the next batch, does not skip records where FK is already set' do + batch_bounds = next_batch(task2.id, 2) + + expect(batch_bounds).to match_array([task2.id, task3.id]) + end + end + + context 'when on the final batch' do + it 'returns the bounds of the next batch' do + batch_bounds = next_batch(task4.id, 2) + + expect(batch_bounds).to match_array([task4.id, task4.id]) + end + end + + context 'when no additional batches remain' do + it 'returns nil' do + batch_bounds = next_batch(task4.id + 1, 1) + + expect(batch_bounds).to be_nil + end + end + end + + def next_batch(min_value, batch_size) + batching_strategy.next_batch( + :issues, + :id, + batch_min_value: min_value, + batch_size: batch_size, + job_arguments: job_arguments + ) + end +end diff --git a/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb b/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb index b01dd5b410e..dc0935efa94 100644 --- a/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb +++ b/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::BackfillProjectNamespacePerGroupBatchingStrategy, '#next_batch' do +RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::BackfillProjectNamespacePerGroupBatchingStrategy, '#next_batch', :migration, schema: 20220326161803 do let!(:namespaces) { table(:namespaces) } let!(:projects) { table(:projects) } let!(:background_migrations) { table(:batched_background_migrations) } diff --git a/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb b/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb index 4e0ebd4b692..521e2067744 100644 --- a/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb +++ b/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi context 'when starting on the first batch' do it 'returns the bounds of the next batch' do - batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace1.id, batch_size: 3, job_arguments: nil) + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace1.id, batch_size: 3, job_arguments: []) expect(batch_bounds).to eq([namespace1.id, namespace3.id]) end @@ -23,7 +23,7 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi context 'when additional batches remain' do it 'returns the bounds of the next batch' do - batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace2.id, batch_size: 3, job_arguments: nil) + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace2.id, batch_size: 3, job_arguments: []) expect(batch_bounds).to eq([namespace2.id, namespace4.id]) end @@ -31,7 +31,7 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi context 'when on the final batch' do it 'returns the bounds of the next batch' do - batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id, batch_size: 3, job_arguments: nil) + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id, batch_size: 3, job_arguments: []) expect(batch_bounds).to eq([namespace4.id, namespace4.id]) end @@ -39,9 +39,30 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi context 'when no additional batches remain' do it 'returns nil' do - batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id + 1, batch_size: 1, job_arguments: nil) + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id + 1, batch_size: 1, job_arguments: []) expect(batch_bounds).to be_nil end end + + context 'additional filters' do + let(:strategy_with_filters) do + Class.new(described_class) do + def apply_additional_filters(relation, job_arguments:) + min_id = job_arguments.first + + relation.where.not(type: 'Project').where('id >= ?', min_id) + end + end + end + + let(:batching_strategy) { strategy_with_filters.new(connection: ActiveRecord::Base.connection) } + let!(:namespace5) { namespaces.create!(name: 'batchtest5', path: 'batch-test5', type: 'Project') } + + it 'applies additional filters' do + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id, batch_size: 3, job_arguments: [1]) + + expect(batch_bounds).to eq([namespace4.id, namespace4.id]) + end + end end diff --git a/spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb b/spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb new file mode 100644 index 00000000000..d1ef7ca2188 --- /dev/null +++ b/spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::CleanupDraftDataFromFaultyRegex, :migration, schema: 20220326161803 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:merge_requests) { table(:merge_requests) } + + let(:group) { namespaces.create!(name: 'gitlab', path: 'gitlab') } + let(:project) { projects.create!(namespace_id: group.id) } + + let(:draft_prefixes) { ["[Draft]", "(Draft)", "Draft:", "Draft", "[WIP]", "WIP:", "WIP"] } + + def create_merge_request(params) + common_params = { + target_project_id: project.id, + target_branch: 'feature1', + source_branch: 'master' + } + + merge_requests.create!(common_params.merge(params)) + end + + context "mr.draft == true, and title matches the leaky regex and not the corrected regex" do + let(:mr_ids) { merge_requests.all.collect(&:id) } + + before do + draft_prefixes.each do |prefix| + (1..4).each do |n| + create_merge_request( + title: "#{prefix} This is a title", + draft: true, + state_id: 1 + ) + end + end + + create_merge_request(title: "This has draft in the title", draft: true, state_id: 1) + end + + it "updates all open draft merge request's draft field to true" do + expect { subject.perform(mr_ids.first, mr_ids.last) } + .to change { MergeRequest.where(draft: true).count } + .by(-1) + end + + it "marks successful slices as completed" do + expect(subject).to receive(:mark_job_as_succeeded).with(mr_ids.first, mr_ids.last) + + subject.perform(mr_ids.first, mr_ids.last) + end + end +end diff --git a/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb b/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb index 04eb9ad475f..8a63673bf38 100644 --- a/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb +++ b/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::DisableExpirationPoliciesLinkedToNoContainerImages do +RSpec.describe Gitlab::BackgroundMigration::DisableExpirationPoliciesLinkedToNoContainerImages, :migration, schema: 20220326161803 do # rubocop:disable Layout/LineLength let_it_be(:projects) { table(:projects) } let_it_be(:container_expiration_policies) { table(:container_expiration_policies) } let_it_be(:container_repositories) { table(:container_repositories) } diff --git a/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb b/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb index 94d9f4509a7..4e7b97d33f6 100644 --- a/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb +++ b/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb @@ -39,6 +39,14 @@ RSpec.describe Gitlab::BackgroundMigration::EncryptStaticObjectToken do expect(new_state[user_with_encrypted_token.id]).to match_array([nil, 'encrypted']) end + context 'when id range does not include existing user ids' do + let(:arguments) { [non_existing_record_id, non_existing_record_id.succ] } + + it_behaves_like 'marks background migration job records' do + subject { described_class.new } + end + end + private def create_user!(name:, token: nil, encrypted_token: nil) diff --git a/spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb b/spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb new file mode 100644 index 00000000000..65663d26f37 --- /dev/null +++ b/spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::FixDuplicateProjectNameAndPath, :migration, schema: 20220325155953 do + let(:migration) { described_class.new } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:routes) { table(:routes) } + + let(:namespace1) { namespaces.create!(name: 'batchtest1', type: 'Group', path: 'batch-test1') } + let(:namespace2) { namespaces.create!(name: 'batchtest2', type: 'Group', parent_id: namespace1.id, path: 'batch-test2') } + let(:namespace3) { namespaces.create!(name: 'batchtest3', type: 'Group', parent_id: namespace2.id, path: 'batch-test3') } + + let(:project_namespace2) { namespaces.create!(name: 'project2', path: 'project2', type: 'Project', parent_id: namespace2.id, visibility_level: 20) } + let(:project_namespace3) { namespaces.create!(name: 'project3', path: 'project3', type: 'Project', parent_id: namespace3.id, visibility_level: 20) } + + let(:project1) { projects.create!(name: 'project1', path: 'project1', namespace_id: namespace1.id, visibility_level: 20) } + let(:project2) { projects.create!(name: 'project2', path: 'project2', namespace_id: namespace2.id, project_namespace_id: project_namespace2.id, visibility_level: 20) } + let(:project2_dup) { projects.create!(name: 'project2', path: 'project2', namespace_id: namespace2.id, visibility_level: 20) } + let(:project3) { projects.create!(name: 'project3', path: 'project3', namespace_id: namespace3.id, project_namespace_id: project_namespace3.id, visibility_level: 20) } + let(:project3_dup) { projects.create!(name: 'project3', path: 'project3', namespace_id: namespace3.id, visibility_level: 20) } + + let!(:namespace_route1) { routes.create!(path: 'batch-test1', source_id: namespace1.id, source_type: 'Namespace') } + let!(:namespace_route2) { routes.create!(path: 'batch-test1/batch-test2', source_id: namespace2.id, source_type: 'Namespace') } + let!(:namespace_route3) { routes.create!(path: 'batch-test1/batch-test3', source_id: namespace3.id, source_type: 'Namespace') } + + let!(:proj_route1) { routes.create!(path: 'batch-test1/project1', source_id: project1.id, source_type: 'Project') } + let!(:proj_route2) { routes.create!(path: 'batch-test1/batch-test2/project2', source_id: project2.id, source_type: 'Project') } + let!(:proj_route2_dup) { routes.create!(path: "batch-test1/batch-test2/project2-route-#{project2_dup.id}", source_id: project2_dup.id, source_type: 'Project') } + let!(:proj_route3) { routes.create!(path: 'batch-test1/batch-test3/project3', source_id: project3.id, source_type: 'Project') } + let!(:proj_route3_dup) { routes.create!(path: "batch-test1/batch-test3/project3-route-#{project3_dup.id}", source_id: project3_dup.id, source_type: 'Project') } + + subject(:perform_migration) { migration.perform(projects.minimum(:id), projects.maximum(:id)) } + + describe '#up' do + it 'backfills namespace_id for the selected records', :aggregate_failures do + expect(namespaces.where(type: 'Project').count).to eq(2) + + perform_migration + + expect(namespaces.where(type: 'Project').count).to eq(5) + + expect(project1.reload.name).to eq("project1-#{project1.id}") + expect(project1.path).to eq('project1') + + expect(project2.reload.name).to eq('project2') + expect(project2.path).to eq('project2') + + expect(project2_dup.reload.name).to eq("project2-#{project2_dup.id}") + expect(project2_dup.path).to eq("project2-route-#{project2_dup.id}") + + expect(project3.reload.name).to eq("project3") + expect(project3.path).to eq("project3") + + expect(project3_dup.reload.name).to eq("project3-#{project3_dup.id}") + expect(project3_dup.path).to eq("project3-route-#{project3_dup.id}") + + projects.all.each do |pr| + project_namespace = namespaces.find(pr.project_namespace_id) + expect(project_namespace).to be_in_sync_with_project(pr) + end + end + end +end diff --git a/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb b/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb new file mode 100644 index 00000000000..254b4fea698 --- /dev/null +++ b/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::MergeTopicsWithSameName, schema: 20220223124428 do + def set_avatar(topic_id, avatar) + topic = ::Projects::Topic.find(topic_id) + topic.avatar = avatar + topic.save! + topic.avatar.absolute_path + end + + it 'merges project topics with same case insensitive name' do + namespaces = table(:namespaces) + projects = table(:projects) + topics = table(:topics) + project_topics = table(:project_topics) + + group = namespaces.create!(name: 'group', path: 'group') + project_1 = projects.create!(namespace_id: group.id, visibility_level: 20) + project_2 = projects.create!(namespace_id: group.id, visibility_level: 10) + project_3 = projects.create!(namespace_id: group.id, visibility_level: 0) + topic_1_keep = topics.create!( + name: 'topic1', + description: 'description 1 to keep', + total_projects_count: 2, + non_private_projects_count: 2 + ) + topic_1_remove = topics.create!( + name: 'TOPIC1', + description: 'description 1 to remove', + total_projects_count: 2, + non_private_projects_count: 1 + ) + topic_2_remove = topics.create!( + name: 'topic2', + total_projects_count: 0 + ) + topic_2_keep = topics.create!( + name: 'TOPIC2', + description: 'description 2 to keep', + total_projects_count: 1 + ) + topic_3_remove_1 = topics.create!( + name: 'topic3', + total_projects_count: 2, + non_private_projects_count: 1 + ) + topic_3_keep = topics.create!( + name: 'Topic3', + total_projects_count: 2, + non_private_projects_count: 2 + ) + topic_3_remove_2 = topics.create!( + name: 'TOPIC3', + description: 'description 3 to keep', + total_projects_count: 2, + non_private_projects_count: 1 + ) + topic_4_keep = topics.create!( + name: 'topic4' + ) + + project_topics_1 = [] + project_topics_3 = [] + project_topics_removed = [] + + project_topics_1 << project_topics.create!(topic_id: topic_1_keep.id, project_id: project_1.id) + project_topics_1 << project_topics.create!(topic_id: topic_1_keep.id, project_id: project_2.id) + project_topics_removed << project_topics.create!(topic_id: topic_1_remove.id, project_id: project_2.id) + project_topics_1 << project_topics.create!(topic_id: topic_1_remove.id, project_id: project_3.id) + + project_topics_3 << project_topics.create!(topic_id: topic_3_keep.id, project_id: project_1.id) + project_topics_3 << project_topics.create!(topic_id: topic_3_keep.id, project_id: project_2.id) + project_topics_removed << project_topics.create!(topic_id: topic_3_remove_1.id, project_id: project_1.id) + project_topics_3 << project_topics.create!(topic_id: topic_3_remove_1.id, project_id: project_3.id) + project_topics_removed << project_topics.create!(topic_id: topic_3_remove_2.id, project_id: project_1.id) + project_topics_removed << project_topics.create!(topic_id: topic_3_remove_2.id, project_id: project_3.id) + + avatar_paths = { + topic_1_keep: set_avatar(topic_1_keep.id, fixture_file_upload('spec/fixtures/avatars/avatar1.png')), + topic_1_remove: set_avatar(topic_1_remove.id, fixture_file_upload('spec/fixtures/avatars/avatar2.png')), + topic_2_remove: set_avatar(topic_2_remove.id, fixture_file_upload('spec/fixtures/avatars/avatar3.png')), + topic_3_remove_1: set_avatar(topic_3_remove_1.id, fixture_file_upload('spec/fixtures/avatars/avatar4.png')), + topic_3_remove_2: set_avatar(topic_3_remove_2.id, fixture_file_upload('spec/fixtures/avatars/avatar5.png')) + } + + subject.perform(%w[topic1 topic2 topic3 topic4]) + + # Topics + [topic_1_keep, topic_2_keep, topic_3_keep, topic_4_keep].each(&:reload) + expect(topic_1_keep.name).to eq('topic1') + expect(topic_1_keep.description).to eq('description 1 to keep') + expect(topic_1_keep.total_projects_count).to eq(3) + expect(topic_1_keep.non_private_projects_count).to eq(2) + expect(topic_2_keep.name).to eq('TOPIC2') + expect(topic_2_keep.description).to eq('description 2 to keep') + expect(topic_2_keep.total_projects_count).to eq(0) + expect(topic_2_keep.non_private_projects_count).to eq(0) + expect(topic_3_keep.name).to eq('Topic3') + expect(topic_3_keep.description).to eq('description 3 to keep') + expect(topic_3_keep.total_projects_count).to eq(3) + expect(topic_3_keep.non_private_projects_count).to eq(2) + expect(topic_4_keep.reload.name).to eq('topic4') + + [topic_1_remove, topic_2_remove, topic_3_remove_1, topic_3_remove_2].each do |topic| + expect { topic.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + # Topic avatars + expect(topic_1_keep.avatar).to eq('avatar1.png') + expect(File.exist?(::Projects::Topic.find(topic_1_keep.id).avatar.absolute_path)).to be_truthy + expect(topic_2_keep.avatar).to eq('avatar3.png') + expect(File.exist?(::Projects::Topic.find(topic_2_keep.id).avatar.absolute_path)).to be_truthy + expect(topic_3_keep.avatar).to eq('avatar4.png') + expect(File.exist?(::Projects::Topic.find(topic_3_keep.id).avatar.absolute_path)).to be_truthy + + [:topic_1_remove, :topic_2_remove, :topic_3_remove_1, :topic_3_remove_2].each do |topic| + expect(File.exist?(avatar_paths[topic])).to be_falsey + end + + # Project Topic assignments + project_topics_1.each do |project_topic| + expect(project_topic.reload.topic_id).to eq(topic_1_keep.id) + end + + project_topics_3.each do |project_topic| + expect(project_topic.reload.topic_id).to eq(topic_3_keep.id) + end + + project_topics_removed.each do |project_topic| + expect { project_topic.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb b/spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb new file mode 100644 index 00000000000..8bc6bb8ae0a --- /dev/null +++ b/spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::MigrateShimoConfluenceIntegrationCategory, schema: 20220326161803 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:integrations) { table(:integrations) } + let(:perform) { described_class.new.perform(1, 5) } + + before do + namespace = namespaces.create!(name: 'test', path: 'test') + projects.create!(id: 1, namespace_id: namespace.id, name: 'gitlab', path: 'gitlab') + integrations.create!(id: 1, active: true, type_new: "Integrations::SlackSlashCommands", + category: 'chat', project_id: 1) + integrations.create!(id: 3, active: true, type_new: "Integrations::Confluence", category: 'common', project_id: 1) + integrations.create!(id: 5, active: true, type_new: "Integrations::Shimo", category: 'common', project_id: 1) + end + + describe '#up' do + it 'updates category to third_party_wiki for Shimo and Confluence' do + perform + + expect(integrations.where(category: 'third_party_wiki').count).to eq(2) + expect(integrations.where(category: 'chat').count).to eq(1) + end + end +end diff --git a/spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb b/spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb new file mode 100644 index 00000000000..0463f5a0c0d --- /dev/null +++ b/spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::PopulateContainerRepositoryMigrationPlan, schema: 20220316202640 do + let_it_be(:container_repositories) { table(:container_repositories) } + let_it_be(:projects) { table(:projects) } + let_it_be(:namespaces) { table(:namespaces) } + let_it_be(:gitlab_subscriptions) { table(:gitlab_subscriptions) } + let_it_be(:plans) { table(:plans) } + let_it_be(:namespace_statistics) { table(:namespace_statistics) } + + let!(:namepace1) { namespaces.create!(id: 1, type: 'Group', name: 'group1', path: 'group1', traversal_ids: [1]) } + let!(:namepace2) { namespaces.create!(id: 2, type: 'Group', name: 'group2', path: 'group2', traversal_ids: [2]) } + let!(:namepace3) { namespaces.create!(id: 3, type: 'Group', name: 'group3', path: 'group3', traversal_ids: [3]) } + let!(:sub_namespace) { namespaces.create!(id: 4, type: 'Group', name: 'group3', path: 'group3', parent_id: 1, traversal_ids: [1, 4]) } + let!(:plan1) { plans.create!(id: 1, name: 'plan1') } + let!(:plan2) { plans.create!(id: 2, name: 'plan2') } + let!(:gitlab_subscription1) { gitlab_subscriptions.create!(id: 1, namespace_id: 1, hosted_plan_id: 1) } + let!(:gitlab_subscription2) { gitlab_subscriptions.create!(id: 2, namespace_id: 2, hosted_plan_id: 2) } + let!(:project1) { projects.create!(id: 1, name: 'project1', path: 'project1', namespace_id: 4) } + let!(:project2) { projects.create!(id: 2, name: 'project2', path: 'project2', namespace_id: 2) } + let!(:project3) { projects.create!(id: 3, name: 'project3', path: 'project3', namespace_id: 3) } + let!(:container_repository1) { container_repositories.create!(id: 1, name: 'cr1', project_id: 1) } + let!(:container_repository2) { container_repositories.create!(id: 2, name: 'cr2', project_id: 2) } + let!(:container_repository3) { container_repositories.create!(id: 3, name: 'cr3', project_id: 3) } + + let(:migration) { described_class.new } + + subject do + migration.perform(1, 4) + end + + it 'updates the migration_plan to match the actual plan', :aggregate_failures do + expect(Gitlab::Database::BackgroundMigrationJob).to receive(:mark_all_as_succeeded) + .with('PopulateContainerRepositoryMigrationPlan', [1, 4]).and_return(true) + + subject + + expect(container_repository1.reload.migration_plan).to eq('plan1') + expect(container_repository2.reload.migration_plan).to eq('plan2') + expect(container_repository3.reload.migration_plan).to eq(nil) + end +end diff --git a/spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb b/spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb new file mode 100644 index 00000000000..98b2bc437f3 --- /dev/null +++ b/spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::PopulateNamespaceStatistics do + let_it_be(:namespaces) { table(:namespaces) } + let_it_be(:namespace_statistics) { table(:namespace_statistics) } + let_it_be(:dependency_proxy_manifests) { table(:dependency_proxy_manifests) } + let_it_be(:dependency_proxy_blobs) { table(:dependency_proxy_blobs) } + + let!(:group1) { namespaces.create!(id: 10, type: 'Group', name: 'group1', path: 'group1') } + let!(:group2) { namespaces.create!(id: 20, type: 'Group', name: 'group2', path: 'group2') } + + let!(:group1_manifest) do + dependency_proxy_manifests.create!(group_id: 10, size: 20, file_name: 'test-file', file: 'test', digest: 'abc123') + end + + let!(:group2_manifest) do + dependency_proxy_manifests.create!(group_id: 20, size: 20, file_name: 'test-file', file: 'test', digest: 'abc123') + end + + let!(:group1_stats) { namespace_statistics.create!(id: 10, namespace_id: 10) } + + let(:ids) { namespaces.pluck(:id) } + let(:statistics) { [] } + + subject(:perform) { described_class.new.perform(ids, statistics) } + + it 'creates/updates all namespace_statistics and updates root storage statistics', :aggregate_failures do + expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async).with(group1.id) + expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async).with(group2.id) + + expect { perform }.to change(namespace_statistics, :count).from(1).to(2) + + namespace_statistics.all.each do |stat| + expect(stat.dependency_proxy_size).to eq 20 + expect(stat.storage_size).to eq 20 + end + end + + context 'when just a stat is passed' do + let(:statistics) { [:dependency_proxy_size] } + + it 'calls the statistics update service with just that stat' do + expect(Groups::UpdateStatisticsService) + .to receive(:new) + .with(anything, statistics: [:dependency_proxy_size]) + .twice.and_call_original + + perform + end + end + + context 'when a statistics update fails' do + before do + error_response = instance_double(ServiceResponse, message: 'an error', error?: true) + + allow_next_instance_of(Groups::UpdateStatisticsService) do |instance| + allow(instance).to receive(:execute).and_return(error_response) + end + end + + it 'logs an error' do + expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance| + expect(instance).to receive(:error).twice + end + + perform + end + end +end diff --git a/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb b/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb index a265fa95b23..3de84a4e880 100644 --- a/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::PopulateVulnerabilityReads do +RSpec.describe Gitlab::BackgroundMigration::PopulateVulnerabilityReads, :migration, schema: 20220326161803 do let(:vulnerabilities) { table(:vulnerabilities) } let(:vulnerability_reads) { table(:vulnerability_reads) } let(:vulnerabilities_findings) { table(:vulnerability_occurrences) } diff --git a/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb b/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb index 2c5de448fbc..2ad561ead87 100644 --- a/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb +++ b/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::ProjectNamespaces::BackfillProjectNamespaces, :migration do +RSpec.describe Gitlab::BackgroundMigration::ProjectNamespaces::BackfillProjectNamespaces, :migration, schema: 20220326161803 do include MigrationsHelpers context 'when migrating data', :aggregate_failures do diff --git a/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb b/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb index f6f4a3f6115..8003159f59e 100644 --- a/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb +++ b/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindings do +RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindings, :migration, schema: 20220326161803 do let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } let(:users) { table(:users) } let(:user) { create_user! } diff --git a/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb b/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb index 28aa9efde4f..07cff32304e 100644 --- a/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb +++ b/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::RemoveOccurrencePipelinesAndDuplicateVulnerabilitiesFindings do +RSpec.describe Gitlab::BackgroundMigration::RemoveOccurrencePipelinesAndDuplicateVulnerabilitiesFindings, :migration, schema: 20220326161803 do let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } let(:users) { table(:users) } let(:user) { create_user! } diff --git a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb index 6aea549b136..d02f7245c15 100644 --- a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb +++ b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenEncryptedValuesOnProjects do +RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenEncryptedValuesOnProjects, :migration, schema: 20220326161803 do # rubocop:disable Layout/LineLength let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } diff --git a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb index cbe762c2680..fd61047d851 100644 --- a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb +++ b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenValuesOnProjects do +RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenValuesOnProjects, :migration, schema: 20220326161803 do # rubocop:disable Layout/LineLength let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } diff --git a/spec/lib/gitlab/blame_spec.rb b/spec/lib/gitlab/blame_spec.rb index e22399723ac..f636ce283ae 100644 --- a/spec/lib/gitlab/blame_spec.rb +++ b/spec/lib/gitlab/blame_spec.rb @@ -3,13 +3,31 @@ require 'spec_helper' RSpec.describe Gitlab::Blame do - let(:project) { create(:project, :repository) } + let_it_be(:project) { create(:project, :repository) } + let(:path) { 'files/ruby/popen.rb' } let(:commit) { project.commit('master') } let(:blob) { project.repository.blob_at(commit.id, path) } + let(:range) { nil } + + subject(:blame) { described_class.new(blob, commit, range: range) } + + describe '#first_line' do + subject { blame.first_line } + + it { is_expected.to eq(1) } + + context 'with a range' do + let(:range) { 2..3 } + + it { is_expected.to eq(range.first) } + end + end describe "#groups" do - let(:subject) { described_class.new(blob, commit).groups(highlight: false) } + let(:highlighted) { false } + + subject(:groups) { blame.groups(highlight: highlighted) } it 'groups lines properly' do expect(subject.count).to eq(18) @@ -22,5 +40,62 @@ RSpec.describe Gitlab::Blame do expect(subject[-1][:commit].sha).to eq('913c66a37b4a45b9769037c55c2d238bd0942d2e') expect(subject[-1][:lines]).to eq([" end", "end"]) end + + context 'with a range 1..5' do + let(:range) { 1..5 } + + it 'returns the correct lines' do + expect(groups.count).to eq(2) + expect(groups[0][:lines]).to eq(["require 'fileutils'", "require 'open3'", ""]) + expect(groups[1][:lines]).to eq(['module Popen', ' extend self']) + end + + context 'with highlighted lines' do + let(:highlighted) { true } + + it 'returns the correct lines' do + expect(groups.count).to eq(2) + expect(groups[0][:lines][0]).to match(/LC1.*fileutils/) + expect(groups[0][:lines][1]).to match(/LC2.*open3/) + expect(groups[0][:lines][2]).to eq("<span id=\"LC3\" class=\"line\" lang=\"ruby\"></span>\n") + expect(groups[1][:lines][0]).to match(/LC4.*Popen/) + expect(groups[1][:lines][1]).to match(/LC5.*extend/) + end + end + end + + context 'with a range 2..4' do + let(:range) { 2..4 } + + it 'returns the correct lines' do + expect(groups.count).to eq(2) + expect(groups[0][:lines]).to eq(["require 'open3'", ""]) + expect(groups[1][:lines]).to eq(['module Popen']) + end + + context 'with highlighted lines' do + let(:highlighted) { true } + + it 'returns the correct lines' do + expect(groups.count).to eq(2) + expect(groups[0][:lines][0]).to match(/LC2.*open3/) + expect(groups[0][:lines][1]).to eq("<span id=\"LC3\" class=\"line\" lang=\"ruby\"></span>\n") + expect(groups[1][:lines][0]).to match(/LC4.*Popen/) + end + end + end + + context 'renamed file' do + let(:path) { 'files/plain_text/renamed' } + let(:commit) { project.commit('blame-on-renamed') } + + it 'adds previous path' do + expect(subject[0][:previous_path]).to be nil + expect(subject[0][:lines]).to match_array(['Initial commit', 'Initial commit']) + + expect(subject[1][:previous_path]).to eq('files/plain_text/initial-commit') + expect(subject[1][:lines]).to match_array(['Renamed as "filename"']) + end + end end end diff --git a/spec/lib/gitlab/ci/build/image_spec.rb b/spec/lib/gitlab/ci/build/image_spec.rb index 71cd57d317c..630dfcd06bb 100644 --- a/spec/lib/gitlab/ci/build/image_spec.rb +++ b/spec/lib/gitlab/ci/build/image_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Gitlab::Ci::Build::Image do subject { described_class.from_image(job) } context 'when image is defined in job' do - let(:image_name) { 'ruby:2.7' } + let(:image_name) { 'image:1.0' } let(:job) { create(:ci_build, options: { image: image_name } ) } context 'when image is defined as string' do diff --git a/spec/lib/gitlab/ci/config/entry/image_spec.rb b/spec/lib/gitlab/ci/config/entry/image_spec.rb index e810d65d560..e16a9a7a74a 100644 --- a/spec/lib/gitlab/ci/config/entry/image_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/image_spec.rb @@ -6,11 +6,11 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do let(:entry) { described_class.new(config) } context 'when configuration is a string' do - let(:config) { 'ruby:2.7' } + let(:config) { 'image:1.0' } describe '#value' do it 'returns image hash' do - expect(entry.value).to eq({ name: 'ruby:2.7' }) + expect(entry.value).to eq({ name: 'image:1.0' }) end end @@ -28,7 +28,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do describe '#image' do it "returns image's name" do - expect(entry.name).to eq 'ruby:2.7' + expect(entry.name).to eq 'image:1.0' end end @@ -46,7 +46,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do end context 'when configuration is a hash' do - let(:config) { { name: 'ruby:2.7', entrypoint: %w(/bin/sh run) } } + let(:config) { { name: 'image:1.0', entrypoint: %w(/bin/sh run) } } describe '#value' do it 'returns image hash' do @@ -68,7 +68,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do describe '#image' do it "returns image's name" do - expect(entry.name).to eq 'ruby:2.7' + expect(entry.name).to eq 'image:1.0' end end @@ -80,7 +80,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do context 'when configuration has ports' do let(:ports) { [{ number: 80, protocol: 'http', name: 'foobar' }] } - let(:config) { { name: 'ruby:2.7', entrypoint: %w(/bin/sh run), ports: ports } } + let(:config) { { name: 'image:1.0', entrypoint: %w(/bin/sh run), ports: ports } } let(:entry) { described_class.new(config, with_image_ports: image_ports) } let(:image_ports) { false } @@ -112,7 +112,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do end context 'when entry value is not correct' do - let(:config) { ['ruby:2.7'] } + let(:config) { ['image:1.0'] } describe '#errors' do it 'saves errors' do @@ -129,7 +129,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do end context 'when unexpected key is specified' do - let(:config) { { name: 'ruby:2.7', non_existing: 'test' } } + let(:config) { { name: 'image:1.0', non_existing: 'test' } } describe '#errors' do it 'saves errors' do diff --git a/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb b/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb index 588f53150ff..0fd9a83a4fa 100644 --- a/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' RSpec.describe Gitlab::Ci::Config::Entry::Reports::CoverageReport do let(:entry) { described_class.new(config) } diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb index daf58aff116..b9c32bc51be 100644 --- a/spec/lib/gitlab/ci/config/entry/root_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do let(:hash) do { before_script: %w(ls pwd), - image: 'ruby:2.7', + image: 'image:1.0', default: {}, services: ['postgres:9.1', 'mysql:5.5'], variables: { VAR: 'root', VAR2: { value: 'val 2', description: 'this is var 2' } }, @@ -154,7 +154,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do { name: :rspec, script: %w[rspec ls], before_script: %w(ls pwd), - image: { name: 'ruby:2.7' }, + image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], @@ -169,7 +169,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do { name: :spinach, before_script: [], script: %w[spinach], - image: { name: 'ruby:2.7' }, + image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], @@ -186,7 +186,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do before_script: [], script: ["make changelog | tee release_changelog.txt"], release: { name: "Release $CI_TAG_NAME", tag_name: 'v0.06', description: "./release_changelog.txt" }, - image: { name: "ruby:2.7" }, + image: { name: "image:1.0" }, services: [{ name: "postgres:9.1" }, { name: "mysql:5.5" }], cache: [{ key: "k", untracked: true, paths: ["public/"], policy: "pull-push", when: 'on_success' }], only: { refs: %w(branches tags) }, @@ -206,7 +206,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do { before_script: %w(ls pwd), after_script: ['make clean'], default: { - image: 'ruby:2.7', + image: 'image:1.0', services: ['postgres:9.1', 'mysql:5.5'] }, variables: { VAR: 'root' }, @@ -233,7 +233,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do rspec: { name: :rspec, script: %w[rspec ls], before_script: %w(ls pwd), - image: { name: 'ruby:2.7' }, + image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], @@ -246,7 +246,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do spinach: { name: :spinach, before_script: [], script: %w[spinach], - image: { name: 'ruby:2.7' }, + image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], diff --git a/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb b/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb index b59fc95a8cc..9da8d106862 100644 --- a/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb @@ -4,8 +4,9 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Config::External::File::Artifact do let(:parent_pipeline) { create(:ci_pipeline) } + let(:variables) {} let(:context) do - Gitlab::Ci::Config::External::Context.new(parent_pipeline: parent_pipeline) + Gitlab::Ci::Config::External::Context.new(variables: variables, parent_pipeline: parent_pipeline) end let(:external_file) { described_class.new(params, context) } @@ -29,14 +30,15 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do end describe '#valid?' do - shared_examples 'is invalid' do - it 'is not valid' do - expect(external_file).not_to be_valid - end + subject(:valid?) do + external_file.validate! + external_file.valid? + end + shared_examples 'is invalid' do it 'sets the expected error' do - expect(external_file.errors) - .to contain_exactly(expected_error) + expect(valid?).to be_falsy + expect(external_file.errors).to contain_exactly(expected_error) end end @@ -148,7 +150,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do context 'when file is not empty' do it 'is valid' do - expect(external_file).to be_valid + expect(valid?).to be_truthy expect(external_file.content).to be_present end @@ -160,6 +162,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do user: anything } expect(context).to receive(:mutate).with(expected_attrs).and_call_original + external_file.validate! external_file.content end end @@ -168,6 +171,58 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do end end end + + context 'when job is provided as a variable' do + let(:variables) do + Gitlab::Ci::Variables::Collection.new([ + { key: 'VAR1', value: 'a_secret_variable_value', masked: true } + ]) + end + + let(:params) { { artifact: 'generated.yml', job: 'a_secret_variable_value' } } + + context 'when job does not exist in the parent pipeline' do + let(:expected_error) do + 'Job `xxxxxxxxxxxxxxxxxxxxxxx` not found in parent pipeline or does not have artifacts!' + end + + it_behaves_like 'is invalid' + end + end + end + end + + describe '#metadata' do + let(:params) { { artifact: 'generated.yml' } } + + subject(:metadata) { external_file.metadata } + + it { + is_expected.to eq( + context_project: nil, + context_sha: nil, + type: :artifact, + location: 'generated.yml', + extra: { job_name: nil } + ) + } + + context 'when job name includes a masked variable' do + let(:variables) do + Gitlab::Ci::Variables::Collection.new([{ key: 'VAR1', value: 'a_secret_variable_value', masked: true }]) + end + + let(:params) { { artifact: 'generated.yml', job: 'a_secret_variable_value' } } + + it { + is_expected.to eq( + context_project: nil, + context_sha: nil, + type: :artifact, + location: 'generated.yml', + extra: { job_name: 'xxxxxxxxxxxxxxxxxxxxxxx' } + ) + } end end end diff --git a/spec/lib/gitlab/ci/config/external/file/base_spec.rb b/spec/lib/gitlab/ci/config/external/file/base_spec.rb index 536f48ecba6..280bebe1a7c 100644 --- a/spec/lib/gitlab/ci/config/external/file/base_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/base_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do end end - subject { test_class.new(location, context) } + subject(:file) { test_class.new(location, context) } before do allow_any_instance_of(test_class) @@ -32,7 +32,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do let(:location) { 'some-location' } it 'returns true' do - expect(subject).to be_matching + expect(file).to be_matching end end @@ -40,40 +40,45 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do let(:location) { nil } it 'returns false' do - expect(subject).not_to be_matching + expect(file).not_to be_matching end end end describe '#valid?' do + subject(:valid?) do + file.validate! + file.valid? + end + context 'when location is not a string' do let(:location) { %w(some/file.txt other/file.txt) } - it { is_expected.not_to be_valid } + it { is_expected.to be_falsy } end context 'when location is not a YAML file' do let(:location) { 'some/file.txt' } - it { is_expected.not_to be_valid } + it { is_expected.to be_falsy } end context 'when location has not a valid naming scheme' do let(:location) { 'some/file/.yml' } - it { is_expected.not_to be_valid } + it { is_expected.to be_falsy } end context 'when location is a valid .yml extension' do let(:location) { 'some/file/config.yml' } - it { is_expected.to be_valid } + it { is_expected.to be_truthy } end context 'when location is a valid .yaml extension' do let(:location) { 'some/file/config.yaml' } - it { is_expected.to be_valid } + it { is_expected.to be_truthy } end context 'when there are YAML syntax errors' do @@ -86,8 +91,8 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do end it 'is not a valid file' do - expect(subject).not_to be_valid - expect(subject.error_message).to eq('Included file `some/file/xxxxxxxxxxxxxxxx.yml` does not have valid YAML syntax!') + expect(valid?).to be_falsy + expect(file.error_message).to eq('Included file `some/file/xxxxxxxxxxxxxxxx.yml` does not have valid YAML syntax!') end end end @@ -103,8 +108,56 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do end it 'does expand hash to include the template' do - expect(subject.to_hash).to include(:before_script) + expect(file.to_hash).to include(:before_script) end end end + + describe '#metadata' do + let(:location) { 'some/file/config.yml' } + + subject(:metadata) { file.metadata } + + it { + is_expected.to eq( + context_project: nil, + context_sha: 'HEAD' + ) + } + end + + describe '#eql?' do + let(:location) { 'some/file/config.yml' } + + subject(:eql) { file.eql?(other_file) } + + context 'when the other file has the same params' do + let(:other_file) { test_class.new(location, context) } + + it { is_expected.to eq(true) } + end + + context 'when the other file has not the same params' do + let(:other_file) { test_class.new('some/other/file', context) } + + it { is_expected.to eq(false) } + end + end + + describe '#hash' do + let(:location) { 'some/file/config.yml' } + + subject(:filehash) { file.hash } + + context 'with a project' do + let(:project) { create(:project) } + let(:context_params) { { project: project, sha: 'HEAD', variables: variables } } + + it { is_expected.to eq([location, project.full_path, 'HEAD'].hash) } + end + + context 'without a project' do + it { is_expected.to eq([location, nil, 'HEAD'].hash) } + end + end end diff --git a/spec/lib/gitlab/ci/config/external/file/local_spec.rb b/spec/lib/gitlab/ci/config/external/file/local_spec.rb index b9314dfc44e..c0a0b0009ce 100644 --- a/spec/lib/gitlab/ci/config/external/file/local_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/local_spec.rb @@ -55,6 +55,11 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do end describe '#valid?' do + subject(:valid?) do + local_file.validate! + local_file.valid? + end + context 'when is a valid local path' do let(:location) { '/lib/gitlab/ci/templates/existent-file.yml' } @@ -62,25 +67,19 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do allow_any_instance_of(described_class).to receive(:fetch_local_content).and_return("image: 'ruby2:2'") end - it 'returns true' do - expect(local_file.valid?).to be_truthy - end + it { is_expected.to be_truthy } end context 'when it is not a valid local path' do let(:location) { '/lib/gitlab/ci/templates/non-existent-file.yml' } - it 'returns false' do - expect(local_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end context 'when it is not a yaml file' do let(:location) { '/config/application.rb' } - it 'returns false' do - expect(local_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end context 'when it is an empty file' do @@ -89,6 +88,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do it 'returns false and adds an error message about an empty file' do allow_any_instance_of(described_class).to receive(:fetch_local_content).and_return("") + local_file.validate! expect(local_file.errors).to include("Local file `/lib/gitlab/ci/templates/xxxxxx/existent-file.yml` is empty!") end end @@ -98,7 +98,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do let(:sha) { ':' } it 'returns false and adds an error message stating that included file does not exist' do - expect(local_file).not_to be_valid + expect(valid?).to be_falsy expect(local_file.errors).to include("Sha #{sha} is not valid!") end end @@ -140,6 +140,10 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do let(:location) { '/lib/gitlab/ci/templates/secret_file.yml' } let(:variables) { Gitlab::Ci::Variables::Collection.new([{ 'key' => 'GITLAB_TOKEN', 'value' => 'secret_file', 'masked' => true }]) } + before do + local_file.validate! + end + it 'returns an error message' do expect(local_file.error_message).to eq("Local file `/lib/gitlab/ci/templates/xxxxxxxxxxx.yml` does not exist!") end @@ -174,6 +178,8 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do allow(project.repository).to receive(:blob_data_at).with(sha, another_location) .and_return(another_content) + + local_file.validate! end it 'does expand hash to include the template' do @@ -181,4 +187,20 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do end end end + + describe '#metadata' do + let(:location) { '/lib/gitlab/ci/templates/existent-file.yml' } + + subject(:metadata) { local_file.metadata } + + it { + is_expected.to eq( + context_project: project.full_path, + context_sha: '12345', + type: :local, + location: location, + extra: {} + ) + } + end end diff --git a/spec/lib/gitlab/ci/config/external/file/project_spec.rb b/spec/lib/gitlab/ci/config/external/file/project_spec.rb index 74720c0a3ca..5d3412a148b 100644 --- a/spec/lib/gitlab/ci/config/external/file/project_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/project_spec.rb @@ -66,6 +66,11 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end describe '#valid?' do + subject(:valid?) do + project_file.validate! + project_file.valid? + end + context 'when a valid path is used' do let(:params) do { project: project.full_path, file: '/file.yml' } @@ -74,18 +79,16 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do let(:root_ref_sha) { project.repository.root_ref_sha } before do - stub_project_blob(root_ref_sha, '/file.yml') { 'image: ruby:2.7' } + stub_project_blob(root_ref_sha, '/file.yml') { 'image: image:1.0' } end - it 'returns true' do - expect(project_file).to be_valid - end + it { is_expected.to be_truthy } context 'when user does not have permission to access file' do let(:context_user) { create(:user) } it 'returns false' do - expect(project_file).not_to be_valid + expect(valid?).to be_falsy expect(project_file.error_message).to include("Project `#{project.full_path}` not found or access denied!") end end @@ -99,12 +102,10 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do let(:ref_sha) { project.commit('master').sha } before do - stub_project_blob(ref_sha, '/file.yml') { 'image: ruby:2.7' } + stub_project_blob(ref_sha, '/file.yml') { 'image: image:1.0' } end - it 'returns true' do - expect(project_file).to be_valid - end + it { is_expected.to be_truthy } end context 'when an empty file is used' do @@ -120,7 +121,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end it 'returns false' do - expect(project_file).not_to be_valid + expect(valid?).to be_falsy expect(project_file.error_message).to include("Project `#{project.full_path}` file `/xxxxxxxxxxx.yml` is empty!") end end @@ -131,7 +132,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end it 'returns false' do - expect(project_file).not_to be_valid + expect(valid?).to be_falsy expect(project_file.error_message).to include("Project `#{project.full_path}` reference `I-Do-Not-Exist` does not exist!") end end @@ -144,7 +145,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end it 'returns false' do - expect(project_file).not_to be_valid + expect(valid?).to be_falsy expect(project_file.error_message).to include("Project `#{project.full_path}` file `/xxxxxxxxxxxxxxxxxxx.yml` does not exist!") end end @@ -155,10 +156,27 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end it 'returns false' do - expect(project_file).not_to be_valid + expect(valid?).to be_falsy expect(project_file.error_message).to include('Included file `/invalid-file` does not have YAML extension!') end end + + context 'when non-existing project is used with a masked variable' do + let(:variables) do + Gitlab::Ci::Variables::Collection.new([ + { key: 'VAR1', value: 'a_secret_variable_value', masked: true } + ]) + end + + let(:params) do + { project: 'a_secret_variable_value', file: '/file.yml' } + end + + it 'returns false with masked project name' do + expect(valid?).to be_falsy + expect(project_file.error_message).to include("Project `xxxxxxxxxxxxxxxxxxxxxxx` not found or access denied!") + end + end end describe '#expand_context' do @@ -176,6 +194,45 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end end + describe '#metadata' do + let(:params) do + { project: project.full_path, file: '/file.yml' } + end + + subject(:metadata) { project_file.metadata } + + it { + is_expected.to eq( + context_project: context_project.full_path, + context_sha: '12345', + type: :file, + location: '/file.yml', + extra: { project: project.full_path, ref: 'HEAD' } + ) + } + + context 'when project name and ref include masked variables' do + let(:variables) do + Gitlab::Ci::Variables::Collection.new([ + { key: 'VAR1', value: 'a_secret_variable_value1', masked: true }, + { key: 'VAR2', value: 'a_secret_variable_value2', masked: true } + ]) + end + + let(:params) { { project: 'a_secret_variable_value1', ref: 'a_secret_variable_value2', file: '/file.yml' } } + + it { + is_expected.to eq( + context_project: context_project.full_path, + context_sha: '12345', + type: :file, + location: '/file.yml', + extra: { project: 'xxxxxxxxxxxxxxxxxxxxxxxx', ref: 'xxxxxxxxxxxxxxxxxxxxxxxx' } + ) + } + end + end + private def stub_project_blob(ref, path) diff --git a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb index 2613bfbfdcf..5c07c87fd5a 100644 --- a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb @@ -54,22 +54,23 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do end describe "#valid?" do + subject(:valid?) do + remote_file.validate! + remote_file.valid? + end + context 'when is a valid remote url' do before do stub_full_request(location).to_return(body: remote_file_content) end - it 'returns true' do - expect(remote_file.valid?).to be_truthy - end + it { is_expected.to be_truthy } end context 'with an irregular url' do let(:location) { 'not-valid://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' } - it 'returns false' do - expect(remote_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end context 'with a timeout' do @@ -77,25 +78,19 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do allow(Gitlab::HTTP).to receive(:get).and_raise(Timeout::Error) end - it 'is falsy' do - expect(remote_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end context 'when is not a yaml file' do let(:location) { 'https://asdasdasdaj48ggerexample.com' } - it 'is falsy' do - expect(remote_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end context 'with an internal url' do let(:location) { 'http://localhost:8080' } - it 'is falsy' do - expect(remote_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end end @@ -142,7 +137,10 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do end describe "#error_message" do - subject { remote_file.error_message } + subject(:error_message) do + remote_file.validate! + remote_file.error_message + end context 'when remote file location is not valid' do let(:location) { 'not-valid://gitlab.com/gitlab-org/gitlab-foss/blob/1234/?secret_file.yml' } @@ -201,4 +199,22 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do is_expected.to be_empty end end + + describe '#metadata' do + before do + stub_full_request(location).to_return(body: remote_file_content) + end + + subject(:metadata) { remote_file.metadata } + + it { + is_expected.to eq( + context_project: nil, + context_sha: '12345', + type: :remote, + location: 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.xxxxxxxxxxx.yml', + extra: {} + ) + } + end end diff --git a/spec/lib/gitlab/ci/config/external/file/template_spec.rb b/spec/lib/gitlab/ci/config/external/file/template_spec.rb index 66a06de3d28..4da9a933a9f 100644 --- a/spec/lib/gitlab/ci/config/external/file/template_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/template_spec.rb @@ -45,12 +45,15 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do end describe "#valid?" do + subject(:valid?) do + template_file.validate! + template_file.valid? + end + context 'when is a valid template name' do let(:template) { 'Auto-DevOps.gitlab-ci.yml' } - it 'returns true' do - expect(template_file).to be_valid - end + it { is_expected.to be_truthy } end context 'with invalid template name' do @@ -59,7 +62,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do let(:context_params) { { project: project, sha: '12345', user: user, variables: variables } } it 'returns false' do - expect(template_file).not_to be_valid + expect(valid?).to be_falsy expect(template_file.error_message).to include('`xxxxxxxxxxxxxx.yml` is not a valid location!') end end @@ -68,7 +71,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do let(:template) { 'I-Do-Not-Have-This-Template.gitlab-ci.yml' } it 'returns false' do - expect(template_file).not_to be_valid + expect(valid?).to be_falsy expect(template_file.error_message).to include('Included file `I-Do-Not-Have-This-Template.gitlab-ci.yml` is empty or does not exist!') end end @@ -111,4 +114,18 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do is_expected.to be_empty end end + + describe '#metadata' do + subject(:metadata) { template_file.metadata } + + it { + is_expected.to eq( + context_project: project.full_path, + context_sha: '12345', + type: :template, + location: template, + extra: {} + ) + } + end end diff --git a/spec/lib/gitlab/ci/config/external/mapper_spec.rb b/spec/lib/gitlab/ci/config/external/mapper_spec.rb index f69feba5e59..2d2adf09a42 100644 --- a/spec/lib/gitlab/ci/config/external/mapper_spec.rb +++ b/spec/lib/gitlab/ci/config/external/mapper_spec.rb @@ -17,10 +17,12 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do let(:file_content) do <<~HEREDOC - image: 'ruby:2.7' + image: 'image:1.0' HEREDOC end + subject(:mapper) { described_class.new(values, context) } + before do stub_full_request(remote_url).to_return(body: file_content) @@ -30,13 +32,13 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do end describe '#process' do - subject { described_class.new(values, context).process } + subject(:process) { mapper.process } context "when single 'include' keyword is defined" do context 'when the string is a local file' do let(:values) do { include: local_file, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns File instances' do @@ -48,7 +50,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when the key is a local file hash' do let(:values) do { include: { 'local' => local_file }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns File instances' do @@ -59,7 +61,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when the string is a remote file' do let(:values) do - { include: remote_url, image: 'ruby:2.7' } + { include: remote_url, image: 'image:1.0' } end it 'returns File instances' do @@ -71,7 +73,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when the key is a remote file hash' do let(:values) do { include: { 'remote' => remote_url }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns File instances' do @@ -83,7 +85,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when the key is a template file hash' do let(:values) do { include: { 'template' => template_file }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns File instances' do @@ -98,7 +100,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do let(:remote_url) { 'https://gitlab.com/secret-file.yml' } let(:values) do { include: { 'local' => local_file, 'remote' => remote_url }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns ambigious specification error' do @@ -109,7 +111,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context "when the key is a project's file" do let(:values) do { include: { project: project.full_path, file: local_file }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns File instances' do @@ -121,7 +123,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context "when the key is project's files" do let(:values) do { include: { project: project.full_path, file: [local_file, 'another_file_path.yml'] }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns two File instances' do @@ -135,7 +137,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context "when 'include' is defined as an array" do let(:values) do { include: [remote_url, local_file], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns Files instances' do @@ -147,7 +149,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context "when 'include' is defined as an array of hashes" do let(:values) do { include: [{ remote: remote_url }, { local: local_file }], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns Files instances' do @@ -158,7 +160,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when it has ambigious match' do let(:values) do { include: [{ remote: remote_url, local: local_file }], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns ambigious specification error' do @@ -170,7 +172,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context "when 'include' is not defined" do let(:values) do { - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -185,11 +187,16 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do { 'local' => local_file }, { 'local' => local_file } ], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'does not raise an exception' do - expect { subject }.not_to raise_error + expect { process }.not_to raise_error + end + + it 'has expanset with one' do + process + expect(mapper.expandset.size).to eq(1) end end @@ -199,7 +206,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do { 'local' => local_file }, { 'remote' => remote_url } ], - image: 'ruby:2.7' } + image: 'image:1.0' } end before do @@ -217,7 +224,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do { 'local' => local_file }, { 'remote' => remote_url } ], - image: 'ruby:2.7' } + image: 'image:1.0' } end before do @@ -269,7 +276,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'defined as an array' do let(:values) do { include: [full_local_file_path, remote_url], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'expands the variable' do @@ -281,7 +288,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'defined as an array of hashes' do let(:values) do { include: [{ local: full_local_file_path }, { remote: remote_url }], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'expands the variable' do @@ -303,7 +310,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'project name' do let(:values) do { include: { project: '$CI_PROJECT_PATH', file: local_file }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'expands the variable', :aggregate_failures do @@ -315,7 +322,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'with multiple files' do let(:values) do { include: { project: project.full_path, file: [full_local_file_path, 'another_file_path.yml'] }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'expands the variable' do @@ -327,7 +334,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when include variable has an unsupported type for variable expansion' do let(:values) do { include: { project: project.id, file: local_file }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'does not invoke expansion for the variable', :aggregate_failures do @@ -365,7 +372,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do let(:values) do { include: [{ remote: remote_url }, { local: local_file, rules: [{ if: "$CI_PROJECT_ID == '#{project_id}'" }] }], - image: 'ruby:2.7' } + image: 'image:1.0' } end context 'when the rules matches' do @@ -385,5 +392,27 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do end end end + + context "when locations are same after masking variables" do + let(:variables) do + Gitlab::Ci::Variables::Collection.new([ + { 'key' => 'GITLAB_TOKEN', 'value' => 'secret-file1', 'masked' => true }, + { 'key' => 'GITLAB_TOKEN', 'value' => 'secret-file2', 'masked' => true } + ]) + end + + let(:values) do + { include: [ + { 'local' => 'hello/secret-file1.yml' }, + { 'local' => 'hello/secret-file2.yml' } + ], + image: 'ruby:2.7' } + end + + it 'has expanset with two' do + process + expect(mapper.expandset.size).to eq(2) + end + end end end diff --git a/spec/lib/gitlab/ci/config/external/processor_spec.rb b/spec/lib/gitlab/ci/config/external/processor_spec.rb index 97bd74721f2..56cd006717e 100644 --- a/spec/lib/gitlab/ci/config/external/processor_spec.rb +++ b/spec/lib/gitlab/ci/config/external/processor_spec.rb @@ -22,10 +22,10 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do end describe "#perform" do - subject { processor.perform } + subject(:perform) { processor.perform } context 'when no external files defined' do - let(:values) { { image: 'ruby:2.7' } } + let(:values) { { image: 'image:1.0' } } it 'returns the same values' do expect(processor.perform).to eq(values) @@ -33,7 +33,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do end context 'when an invalid local file is defined' do - let(:values) { { include: '/lib/gitlab/ci/templates/non-existent-file.yml', image: 'ruby:2.7' } } + let(:values) { { include: '/lib/gitlab/ci/templates/non-existent-file.yml', image: 'image:1.0' } } it 'raises an error' do expect { processor.perform }.to raise_error( @@ -45,7 +45,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do context 'when an invalid remote file is defined' do let(:remote_file) { 'http://doesntexist.com/.gitlab-ci-1.yml' } - let(:values) { { include: remote_file, image: 'ruby:2.7' } } + let(:values) { { include: remote_file, image: 'image:1.0' } } before do stub_full_request(remote_file).and_raise(SocketError.new('Some HTTP error')) @@ -61,7 +61,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do context 'with a valid remote external file is defined' do let(:remote_file) { 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' } - let(:values) { { include: remote_file, image: 'ruby:2.7' } } + let(:values) { { include: remote_file, image: 'image:1.0' } } let(:external_file_content) do <<-HEREDOC before_script: @@ -95,7 +95,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do end context 'with a valid local external file is defined' do - let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'ruby:2.7' } } + let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'image:1.0' } } let(:local_file_content) do <<-HEREDOC before_script: @@ -133,7 +133,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do let(:values) do { include: external_files, - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -165,7 +165,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do end context 'when external files are defined but not valid' do - let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'ruby:2.7' } } + let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'image:1.0' } } let(:local_file_content) { 'invalid content file ////' } @@ -187,7 +187,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do let(:values) do { include: remote_file, - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -200,7 +200,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do it 'takes precedence' do stub_full_request(remote_file).to_return(body: remote_file_content) - expect(processor.perform[:image]).to eq('ruby:2.7') + expect(processor.perform[:image]).to eq('image:1.0') end end @@ -210,7 +210,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do include: [ { local: '/local/file.yml' } ], - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -262,6 +262,18 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do expect(process_obs_count).to eq(3) end + + it 'stores includes' do + perform + + expect(context.includes).to contain_exactly( + { type: :local, location: '/local/file.yml', extra: {}, context_project: project.full_path, context_sha: '12345' }, + { type: :template, location: 'Ruby.gitlab-ci.yml', extra: {}, context_project: project.full_path, context_sha: '12345' }, + { type: :remote, location: 'http://my.domain.com/config.yml', extra: {}, context_project: project.full_path, context_sha: '12345' }, + { type: :file, location: '/templates/my-workflow.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' }, + { type: :local, location: '/templates/my-build.yml', extra: {}, context_project: another_project.full_path, context_sha: another_project.commit.sha } + ) + end end context 'when user is reporter of another project' do @@ -294,7 +306,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do context 'when config includes an external configuration file via SSL web request' do before do stub_full_request('https://sha256.badssl.com/fake.yml', ip_address: '8.8.8.8') - .to_return(body: 'image: ruby:2.6', status: 200) + .to_return(body: 'image: image:1.0', status: 200) stub_full_request('https://self-signed.badssl.com/fake.yml', ip_address: '8.8.8.9') .to_raise(OpenSSL::SSL::SSLError.new('SSL_connect returned=1 errno=0 state=error: certificate verify failed (self signed certificate)')) @@ -303,7 +315,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do context 'with an acceptable certificate' do let(:values) { { include: 'https://sha256.badssl.com/fake.yml' } } - it { is_expected.to include(image: 'ruby:2.6') } + it { is_expected.to include(image: 'image:1.0') } end context 'with a self-signed certificate' do @@ -319,7 +331,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do let(:values) do { include: { project: another_project.full_path, file: '/templates/my-build.yml' }, - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -349,7 +361,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do project: another_project.full_path, file: ['/templates/my-build.yml', '/templates/my-test.yml'] }, - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -377,13 +389,22 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do output = processor.perform expect(output.keys).to match_array([:image, :my_build, :my_test]) end + + it 'stores includes' do + perform + + expect(context.includes).to contain_exactly( + { type: :file, location: '/templates/my-build.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' }, + { type: :file, location: '/templates/my-test.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' } + ) + end end context 'when local file path has wildcard' do - let_it_be(:project) { create(:project, :repository) } + let(:project) { create(:project, :repository) } let(:values) do - { include: 'myfolder/*.yml', image: 'ruby:2.7' } + { include: 'myfolder/*.yml', image: 'image:1.0' } end before do @@ -412,6 +433,15 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do output = processor.perform expect(output.keys).to match_array([:image, :my_build, :my_test]) end + + it 'stores includes' do + perform + + expect(context.includes).to contain_exactly( + { type: :local, location: 'myfolder/file1.yml', extra: {}, context_project: project.full_path, context_sha: '12345' }, + { type: :local, location: 'myfolder/file2.yml', extra: {}, context_project: project.full_path, context_sha: '12345' } + ) + end end context 'when rules defined' do diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb index 05ff1f3618b..3ba6a9059c6 100644 --- a/spec/lib/gitlab/ci/config_spec.rb +++ b/spec/lib/gitlab/ci/config_spec.rb @@ -20,7 +20,7 @@ RSpec.describe Gitlab::Ci::Config do context 'when config is valid' do let(:yml) do <<-EOS - image: ruby:2.7 + image: image:1.0 rspec: script: @@ -32,7 +32,7 @@ RSpec.describe Gitlab::Ci::Config do describe '#to_hash' do it 'returns hash created from string' do hash = { - image: 'ruby:2.7', + image: 'image:1.0', rspec: { script: ['gem install rspec', 'rspec'] @@ -104,12 +104,32 @@ RSpec.describe Gitlab::Ci::Config do end it { is_expected.to contain_exactly('Jobs/Deploy.gitlab-ci.yml', 'Jobs/Build.gitlab-ci.yml') } + + it 'stores includes' do + expect(config.metadata[:includes]).to contain_exactly( + { type: :template, + location: 'Jobs/Deploy.gitlab-ci.yml', + extra: {}, + context_project: nil, + context_sha: nil }, + { type: :template, + location: 'Jobs/Build.gitlab-ci.yml', + extra: {}, + context_project: nil, + context_sha: nil }, + { type: :remote, + location: 'https://example.com/gitlab-ci.yml', + extra: {}, + context_project: nil, + context_sha: nil } + ) + end end context 'when using extendable hash' do let(:yml) do <<-EOS - image: ruby:2.7 + image: image:1.0 rspec: script: rspec @@ -122,7 +142,7 @@ RSpec.describe Gitlab::Ci::Config do it 'correctly extends the hash' do hash = { - image: 'ruby:2.7', + image: 'image:1.0', rspec: { script: 'rspec' }, test: { extends: 'rspec', @@ -212,7 +232,7 @@ RSpec.describe Gitlab::Ci::Config do let(:yml) do <<-EOS image: - name: ruby:2.7 + name: image:1.0 ports: - 80 EOS @@ -226,12 +246,12 @@ RSpec.describe Gitlab::Ci::Config do context 'in the job image' do let(:yml) do <<-EOS - image: ruby:2.7 + image: image:1.0 test: script: rspec image: - name: ruby:2.7 + name: image:1.0 ports: - 80 EOS @@ -245,11 +265,11 @@ RSpec.describe Gitlab::Ci::Config do context 'in the services' do let(:yml) do <<-EOS - image: ruby:2.7 + image: image:1.0 test: script: rspec - image: ruby:2.7 + image: image:1.0 services: - name: test alias: test @@ -325,7 +345,7 @@ RSpec.describe Gitlab::Ci::Config do - project: '$MAIN_PROJECT' ref: '$REF' file: '$FILENAME' - image: ruby:2.7 + image: image:1.0 HEREDOC end @@ -364,7 +384,7 @@ RSpec.describe Gitlab::Ci::Config do it 'returns a composed hash' do composed_hash = { before_script: local_location_hash[:before_script], - image: "ruby:2.7", + image: "image:1.0", rspec: { script: ["bundle exec rspec"] }, variables: remote_file_hash[:variables] } @@ -403,6 +423,26 @@ RSpec.describe Gitlab::Ci::Config do end end end + + it 'stores includes' do + expect(config.metadata[:includes]).to contain_exactly( + { type: :local, + location: local_location, + extra: {}, + context_project: project.full_path, + context_sha: '12345' }, + { type: :remote, + location: remote_location, + extra: {}, + context_project: project.full_path, + context_sha: '12345' }, + { type: :file, + location: '.gitlab-ci.yml', + extra: { project: main_project.full_path, ref: 'HEAD' }, + context_project: project.full_path, + context_sha: '12345' } + ) + end end context "when gitlab_ci.yml has invalid 'include' defined" do @@ -481,7 +521,7 @@ RSpec.describe Gitlab::Ci::Config do include: - #{remote_location} - image: ruby:2.7 + image: image:1.0 HEREDOC end @@ -492,7 +532,7 @@ RSpec.describe Gitlab::Ci::Config do end it 'takes precedence' do - expect(config.to_hash).to eq({ image: 'ruby:2.7' }) + expect(config.to_hash).to eq({ image: 'image:1.0' }) end end @@ -699,7 +739,7 @@ RSpec.describe Gitlab::Ci::Config do - #{local_location} - #{other_file_location} - image: ruby:2.7 + image: image:1.0 HEREDOC end @@ -718,7 +758,7 @@ RSpec.describe Gitlab::Ci::Config do it 'returns a composed hash' do composed_hash = { before_script: local_location_hash[:before_script], - image: "ruby:2.7", + image: "image:1.0", build: { stage: "build", script: "echo hello" }, rspec: { stage: "test", script: "bundle exec rspec" } } @@ -735,7 +775,7 @@ RSpec.describe Gitlab::Ci::Config do - local: #{local_location} rules: - if: $CI_PROJECT_ID == "#{project_id}" - image: ruby:2.7 + image: image:1.0 HEREDOC end @@ -763,7 +803,7 @@ RSpec.describe Gitlab::Ci::Config do - local: #{local_location} rules: - exists: "#{filename}" - image: ruby:2.7 + image: image:1.0 HEREDOC end diff --git a/spec/lib/gitlab/ci/parsers/security/common_spec.rb b/spec/lib/gitlab/ci/parsers/security/common_spec.rb index 1e96c717a4f..dfc5dec1481 100644 --- a/spec/lib/gitlab/ci/parsers/security/common_spec.rb +++ b/spec/lib/gitlab/ci/parsers/security/common_spec.rb @@ -4,6 +4,18 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Parsers::Security::Common do describe '#parse!' do + let_it_be(:scanner_data) do + { + scan: { + scanner: { + id: "gemnasium", + name: "Gemnasium", + version: "2.1.0" + } + } + } + end + where(vulnerability_finding_signatures_enabled: [true, false]) with_them do let_it_be(:pipeline) { create(:ci_pipeline) } @@ -30,7 +42,9 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do describe 'schema validation' do let(:validator_class) { Gitlab::Ci::Parsers::Security::Validators::SchemaValidator } - let(:parser) { described_class.new('{}', report, vulnerability_finding_signatures_enabled, validate: validate) } + let(:data) { {}.merge(scanner_data) } + let(:json_data) { data.to_json } + let(:parser) { described_class.new(json_data, report, vulnerability_finding_signatures_enabled, validate: validate) } subject(:parse_report) { parser.parse! } @@ -38,172 +52,138 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do allow(validator_class).to receive(:new).and_call_original end - context 'when show_report_validation_warnings is enabled' do - before do - stub_feature_flags(show_report_validation_warnings: true) - end - - context 'when the validate flag is set to `false`' do - let(:validate) { false } - let(:valid?) { false } - let(:errors) { ['foo'] } - - before do - allow_next_instance_of(validator_class) do |instance| - allow(instance).to receive(:valid?).and_return(valid?) - allow(instance).to receive(:errors).and_return(errors) - end - - allow(parser).to receive_messages(create_scanner: true, create_scan: true) - end - - it 'instantiates the validator with correct params' do - parse_report - - expect(validator_class).to have_received(:new).with(report.type, {}, report.version) - end - - context 'when the report data is not valid according to the schema' do - it 'adds warnings to the report' do - expect { parse_report }.to change { report.warnings }.from([]).to([{ message: 'foo', type: 'Schema' }]) - end - - it 'keeps the execution flow as normal' do - parse_report + context 'when the validate flag is set to `false`' do + let(:validate) { false } + let(:valid?) { false } + let(:errors) { ['foo'] } + let(:warnings) { ['bar'] } - expect(parser).to have_received(:create_scanner) - expect(parser).to have_received(:create_scan) - end + before do + allow_next_instance_of(validator_class) do |instance| + allow(instance).to receive(:valid?).and_return(valid?) + allow(instance).to receive(:errors).and_return(errors) + allow(instance).to receive(:warnings).and_return(warnings) end - context 'when the report data is valid according to the schema' do - let(:valid?) { true } - let(:errors) { [] } - - it 'does not add warnings to the report' do - expect { parse_report }.not_to change { report.errors } - end - - it 'keeps the execution flow as normal' do - parse_report - - expect(parser).to have_received(:create_scanner) - expect(parser).to have_received(:create_scan) - end - end + allow(parser).to receive_messages(create_scanner: true, create_scan: true) end - context 'when the validate flag is set to `true`' do - let(:validate) { true } - let(:valid?) { false } - let(:errors) { ['foo'] } + it 'instantiates the validator with correct params' do + parse_report - before do - allow_next_instance_of(validator_class) do |instance| - allow(instance).to receive(:valid?).and_return(valid?) - allow(instance).to receive(:errors).and_return(errors) - end + expect(validator_class).to have_received(:new).with( + report.type, + data.deep_stringify_keys, + report.version, + project: pipeline.project, + scanner: data.dig(:scan, :scanner).deep_stringify_keys + ) + end - allow(parser).to receive_messages(create_scanner: true, create_scan: true) + context 'when the report data is not valid according to the schema' do + it 'adds warnings to the report' do + expect { parse_report }.to change { report.warnings }.from([]).to( + [ + { message: 'foo', type: 'Schema' }, + { message: 'bar', type: 'Schema' } + ] + ) end - it 'instantiates the validator with correct params' do + it 'keeps the execution flow as normal' do parse_report - expect(validator_class).to have_received(:new).with(report.type, {}, report.version) + expect(parser).to have_received(:create_scanner) + expect(parser).to have_received(:create_scan) end + end - context 'when the report data is not valid according to the schema' do - it 'adds errors to the report' do - expect { parse_report }.to change { report.errors }.from([]).to([{ message: 'foo', type: 'Schema' }]) - end - - it 'does not try to create report entities' do - parse_report + context 'when the report data is valid according to the schema' do + let(:valid?) { true } + let(:errors) { [] } + let(:warnings) { [] } - expect(parser).not_to have_received(:create_scanner) - expect(parser).not_to have_received(:create_scan) - end + it 'does not add errors to the report' do + expect { parse_report }.not_to change { report.errors } end - context 'when the report data is valid according to the schema' do - let(:valid?) { true } - let(:errors) { [] } - - it 'does not add errors to the report' do - expect { parse_report }.not_to change { report.errors }.from([]) - end + it 'does not add warnings to the report' do + expect { parse_report }.not_to change { report.warnings } + end - it 'keeps the execution flow as normal' do - parse_report + it 'keeps the execution flow as normal' do + parse_report - expect(parser).to have_received(:create_scanner) - expect(parser).to have_received(:create_scan) - end + expect(parser).to have_received(:create_scanner) + expect(parser).to have_received(:create_scan) end end end - context 'when show_report_validation_warnings is disabled' do - before do - stub_feature_flags(show_report_validation_warnings: false) - end - - context 'when the validate flag is set as `false`' do - let(:validate) { false } + context 'when the validate flag is set to `true`' do + let(:validate) { true } + let(:valid?) { false } + let(:errors) { ['foo'] } + let(:warnings) { ['bar'] } - it 'does not run the validation logic' do - parse_report - - expect(validator_class).not_to have_received(:new) + before do + allow_next_instance_of(validator_class) do |instance| + allow(instance).to receive(:valid?).and_return(valid?) + allow(instance).to receive(:errors).and_return(errors) + allow(instance).to receive(:warnings).and_return(warnings) end + + allow(parser).to receive_messages(create_scanner: true, create_scan: true) end - context 'when the validate flag is set as `true`' do - let(:validate) { true } - let(:valid?) { false } + it 'instantiates the validator with correct params' do + parse_report - before do - allow_next_instance_of(validator_class) do |instance| - allow(instance).to receive(:valid?).and_return(valid?) - allow(instance).to receive(:errors).and_return(['foo']) - end + expect(validator_class).to have_received(:new).with( + report.type, + data.deep_stringify_keys, + report.version, + project: pipeline.project, + scanner: data.dig(:scan, :scanner).deep_stringify_keys + ) + end - allow(parser).to receive_messages(create_scanner: true, create_scan: true) + context 'when the report data is not valid according to the schema' do + it 'adds errors to the report' do + expect { parse_report }.to change { report.errors }.from([]).to( + [ + { message: 'foo', type: 'Schema' }, + { message: 'bar', type: 'Schema' } + ] + ) end - it 'instantiates the validator with correct params' do + it 'does not try to create report entities' do parse_report - expect(validator_class).to have_received(:new).with(report.type, {}, report.version) + expect(parser).not_to have_received(:create_scanner) + expect(parser).not_to have_received(:create_scan) end + end - context 'when the report data is not valid according to the schema' do - it 'adds errors to the report' do - expect { parse_report }.to change { report.errors }.from([]).to([{ message: 'foo', type: 'Schema' }]) - end - - it 'does not try to create report entities' do - parse_report + context 'when the report data is valid according to the schema' do + let(:valid?) { true } + let(:errors) { [] } + let(:warnings) { [] } - expect(parser).not_to have_received(:create_scanner) - expect(parser).not_to have_received(:create_scan) - end + it 'does not add errors to the report' do + expect { parse_report }.not_to change { report.errors }.from([]) end - context 'when the report data is valid according to the schema' do - let(:valid?) { true } - - it 'does not add errors to the report' do - expect { parse_report }.not_to change { report.errors }.from([]) - end + it 'does not add warnings to the report' do + expect { parse_report }.not_to change { report.warnings }.from([]) + end - it 'keeps the execution flow as normal' do - parse_report + it 'keeps the execution flow as normal' do + parse_report - expect(parser).to have_received(:create_scanner) - expect(parser).to have_received(:create_scan) - end + expect(parser).to have_received(:create_scanner) + expect(parser).to have_received(:create_scan) end end end 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 c83427b68ef..f6409c8b01f 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 @@ -3,6 +3,18 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do + let_it_be(:project) { create(:project) } + + let(:scanner) do + { + 'id' => 'gemnasium', + 'name' => 'Gemnasium', + 'version' => '2.1.0' + } + end + + let(:validator) { described_class.new(report_type, report_data, report_version, project: project, scanner: scanner) } + describe 'SUPPORTED_VERSIONS' do schema_path = Rails.root.join("lib", "gitlab", "ci", "parsers", "security", "validators", "schemas") @@ -47,48 +59,652 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do end end - using RSpec::Parameterized::TableSyntax + describe '#valid?' do + subject { validator.valid? } - where(:report_type, :report_version, :expected_errors, :valid_data) do - 'sast' | '10.0.0' | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] } - :sast | '10.0.0' | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] } - :secret_detection | '10.0.0' | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] } - end + context 'when given a supported schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last } - with_them do - let(:validator) { described_class.new(report_type, report_data, report_version) } + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end - describe '#valid?' do - subject { validator.valid? } + it { is_expected.to be_truthy } + end - context 'when given data is invalid according to the schema' do - let(:report_data) { {} } + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end it { is_expected.to be_falsey } + + it 'logs related information' do + expect(Gitlab::AppLogger).to receive(:info).with( + message: "security report schema validation problem", + security_report_type: report_type, + security_report_version: report_version, + project_id: project.id, + security_report_failure: 'schema_validation_fails', + security_report_scanner_id: 'gemnasium', + security_report_scanner_version: '2.1.0' + ) + + subject + end end + end + + context 'when given a deprecated schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last } - context 'when given data is valid according to the schema' do - let(:report_data) { valid_data } + context 'and the report passes schema validation' do + let(:report_data) do + { + 'version' => '10.0.0', + 'vulnerabilities' => [] + } + end it { is_expected.to be_truthy } + + it 'logs related information' do + expect(Gitlab::AppLogger).to receive(:info).with( + message: "security report schema validation problem", + security_report_type: report_type, + security_report_version: report_version, + project_id: project.id, + security_report_failure: 'using_deprecated_schema_version', + security_report_scanner_id: 'gemnasium', + security_report_scanner_version: '2.1.0' + ) + + subject + end end - context 'when no report_version is provided' do - let(:report_version) { nil } - let(:report_data) { valid_data } + context 'and the report does not pass schema validation' do + context 'and enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end - it 'does not fail' do - expect { subject }.not_to raise_error + it { is_expected.to be_falsey } + end + + context 'and enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end + + it { is_expected.to be_truthy } end end end - describe '#errors' do - let(:report_data) { { 'version' => '10.0.0' } } + context 'when given an unsupported schema version' do + let(:report_type) { :dast } + let(:report_version) { "12.37.0" } + + context 'if enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + it { is_expected.to be_falsey } + + it 'logs related information' do + expect(Gitlab::AppLogger).to receive(:info).with( + message: "security report schema validation problem", + security_report_type: report_type, + security_report_version: report_version, + project_id: project.id, + security_report_failure: 'using_unsupported_schema_version', + security_report_scanner_id: 'gemnasium', + security_report_scanner_version: '2.1.0' + ) + + subject + end + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + context 'and scanner information is empty' do + let(:scanner) { {} } + + it 'logs related information' do + expect(Gitlab::AppLogger).to receive(:info).with( + message: "security report schema validation problem", + security_report_type: report_type, + security_report_version: report_version, + project_id: project.id, + security_report_failure: 'schema_validation_fails', + security_report_scanner_id: nil, + security_report_scanner_version: nil + ) + + expect(Gitlab::AppLogger).to receive(:info).with( + message: "security report schema validation problem", + security_report_type: report_type, + security_report_version: report_version, + project_id: project.id, + security_report_failure: 'using_unsupported_schema_version', + security_report_scanner_id: nil, + security_report_scanner_version: nil + ) + + subject + end + end + + it { is_expected.to be_falsey } + end + end + + context 'if enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + it { is_expected.to be_truthy } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + it { is_expected.to be_truthy } + end + end + end + end - subject { validator.errors } + describe '#errors' do + subject { validator.errors } - it { is_expected.to eq(expected_errors) } + context 'when given a supported schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last } + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + context 'if enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: project) + end + + let(:expected_errors) do + [ + 'root is missing required keys: vulnerabilities' + ] + end + + it { is_expected.to match_array(expected_errors) } + end + + context 'if enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + end + end + + context 'when given a deprecated schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last } + + context 'and the report passes schema validation' do + let(:report_data) do + { + 'version' => '10.0.0', + 'vulnerabilities' => [] + } + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + + context 'and the report does not pass schema validation' do + context 'and enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end + + let(:expected_errors) do + [ + "property '/version' does not match pattern: ^[0-9]+\\.[0-9]+\\.[0-9]+$", + "root is missing required keys: vulnerabilities" + ] + end + + it { is_expected.to match_array(expected_errors) } + end + + context 'and enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + end + end + + context 'when given an unsupported schema version' do + let(:report_type) { :dast } + let(:report_version) { "12.37.0" } + + context 'if enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_errors) do + [ + "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1" + ] + end + + it { is_expected.to match_array(expected_errors) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + let(:expected_errors) do + [ + "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1", + "root is missing required keys: vulnerabilities" + ] + end + + it { is_expected.to match_array(expected_errors) } + end + end + + context 'if enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + end + end + end + + describe '#deprecation_warnings' do + subject { validator.deprecation_warnings } + + context 'when given a supported schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last } + + let(:expected_deprecation_warnings) { [] } + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + it { is_expected.to match_array(expected_deprecation_warnings) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + it { is_expected.to match_array(expected_deprecation_warnings) } + end + end + + context 'when given a deprecated schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last } + let(:expected_deprecation_warnings) do + [ + "Version V2.7.0 for report type dast has been deprecated, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1" + ] + end + + context 'and the report passes schema validation' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + it { is_expected.to match_array(expected_deprecation_warnings) } + end + + context 'and the report does not pass schema validation' do + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end + + it { is_expected.to match_array(expected_deprecation_warnings) } + end + end + + context 'when given an unsupported schema version' do + let(:report_type) { :dast } + let(:report_version) { "21.37.0" } + let(:expected_deprecation_warnings) { [] } + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + it { is_expected.to match_array(expected_deprecation_warnings) } + end + end + + describe '#warnings' do + subject { validator.warnings } + + context 'when given a supported schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last } + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + context 'if enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: project) + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + + context 'if enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + let(:expected_warnings) do + [ + 'root is missing required keys: vulnerabilities' + ] + end + + it { is_expected.to match_array(expected_warnings) } + end + end + end + + context 'when given a deprecated schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last } + + context 'and the report passes schema validation' do + let(:report_data) do + { + 'vulnerabilities' => [] + } + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + + context 'and the report does not pass schema validation' do + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end + + context 'and enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + + context 'and enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + let(:expected_warnings) do + [ + "property '/version' does not match pattern: ^[0-9]+\\.[0-9]+\\.[0-9]+$", + "root is missing required keys: vulnerabilities" + ] + end + + it { is_expected.to match_array(expected_warnings) } + end + end + end + + context 'when given an unsupported schema version' do + let(:report_type) { :dast } + let(:report_version) { "12.37.0" } + + context 'if enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + end + + context 'if enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_warnings) do + [ + "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1" + ] + end + + it { is_expected.to match_array(expected_warnings) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + let(:expected_warnings) do + [ + "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1", + "root is missing required keys: vulnerabilities" + ] + end + + it { is_expected.to match_array(expected_warnings) } + end + end end end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb new file mode 100644 index 00000000000..aa8aec2af4a --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :clean_gitlab_redis_rate_limiting do + let_it_be(:user) { create(:user) } + let_it_be(:namespace) { create(:namespace) } + let_it_be(:project, reload: true) { create(:project, namespace: namespace) } + + let(:save_incompleted) { false } + let(:throttle_message) do + 'Too many pipelines created in the last minute. Try again later.' + end + + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new( + project: project, + current_user: user, + save_incompleted: save_incompleted + ) + end + + let(:pipeline) { build(:ci_pipeline, project: project, source: source) } + let(:source) { 'push' } + let(:step) { described_class.new(pipeline, command) } + + def perform(count: 2) + count.times { step.perform! } + end + + context 'when the limit is exceeded' do + before do + allow(Gitlab::ApplicationRateLimiter).to receive(:rate_limits) + .and_return(pipelines_create: { threshold: 1, interval: 1.minute }) + + stub_feature_flags(ci_throttle_pipelines_creation_dry_run: false) + end + + it 'does not persist the pipeline' do + perform + + expect(pipeline).not_to be_persisted + expect(pipeline.errors.added?(:base, throttle_message)).to be_truthy + end + + it 'breaks the chain' do + perform + + expect(step.break?).to be_truthy + end + + it 'creates a log entry' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + class: described_class.name, + project_id: project.id, + subscription_plan: project.actual_plan_name, + commit_sha: command.sha + ) + ) + + perform + end + + context 'with child pipelines' do + let(:source) { 'parent_pipeline' } + + it 'does not break the chain' do + perform + + expect(step.break?).to be_falsey + end + + it 'does not invalidate the pipeline' do + perform + + expect(pipeline.errors).to be_empty + end + + it 'does not log anything' do + expect(Gitlab::AppJsonLogger).not_to receive(:info) + + perform + end + end + + context 'when saving incompleted pipelines' do + let(:save_incompleted) { true } + + it 'does not persist the pipeline' do + perform + + expect(pipeline).not_to be_persisted + expect(pipeline.errors.added?(:base, throttle_message)).to be_truthy + end + + it 'breaks the chain' do + perform + + expect(step.break?).to be_truthy + end + end + + context 'when ci_throttle_pipelines_creation is disabled' do + before do + stub_feature_flags(ci_throttle_pipelines_creation: false) + end + + it 'does not break the chain' do + perform + + expect(step.break?).to be_falsey + end + + it 'does not invalidate the pipeline' do + perform + + expect(pipeline.errors).to be_empty + end + + it 'does not log anything' do + expect(Gitlab::AppJsonLogger).not_to receive(:info) + + perform + end + end + + context 'when ci_throttle_pipelines_creation_dry_run is enabled' do + before do + stub_feature_flags(ci_throttle_pipelines_creation_dry_run: true) + end + + it 'does not break the chain' do + perform + + expect(step.break?).to be_falsey + end + + it 'does not invalidate the pipeline' do + perform + + expect(pipeline.errors).to be_empty + end + + it 'creates a log entry' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + class: described_class.name, + project_id: project.id, + subscription_plan: project.actual_plan_name, + commit_sha: command.sha + ) + ) + + perform + end + end + end + + context 'when the limit is not exceeded' do + it 'does not break the chain' do + perform + + expect(step.break?).to be_falsey + end + + it 'does not invalidate the pipeline' do + perform + + expect(pipeline.errors).to be_empty + end + + it 'does not log anything' do + expect(Gitlab::AppJsonLogger).not_to receive(:info) + + perform + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb index 8e0b032e68c..ddd0de69d79 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::TemplateUsage do %w(Template-1 Template-2).each do |expected_template| expect(Gitlab::UsageDataCounters::CiTemplateUniqueCounter).to( receive(:track_unique_project_event) - .with(project_id: project.id, template: expected_template, config_source: pipeline.config_source) + .with(project: project, template: expected_template, config_source: pipeline.config_source, user: user) ) end diff --git a/spec/lib/gitlab/ci/reports/security/report_spec.rb b/spec/lib/gitlab/ci/reports/security/report_spec.rb index 4dc1eca3859..ab0efb90901 100644 --- a/spec/lib/gitlab/ci/reports/security/report_spec.rb +++ b/spec/lib/gitlab/ci/reports/security/report_spec.rb @@ -184,6 +184,22 @@ RSpec.describe Gitlab::Ci::Reports::Security::Report do end end + describe 'warnings?' do + subject { report.warnings? } + + context 'when the report does not have any errors' do + it { is_expected.to be_falsey } + end + + context 'when the report has warnings' do + before do + report.add_warning('foo', 'bar') + end + + it { is_expected.to be_truthy } + end + end + describe '#primary_scanner_order_to' do let(:scanner_1) { build(:ci_reports_security_scanner) } let(:scanner_2) { build(:ci_reports_security_scanner) } diff --git a/spec/lib/gitlab/ci/reports/security/scanner_spec.rb b/spec/lib/gitlab/ci/reports/security/scanner_spec.rb index 99f5d4723d3..eb406e01b24 100644 --- a/spec/lib/gitlab/ci/reports/security/scanner_spec.rb +++ b/spec/lib/gitlab/ci/reports/security/scanner_spec.rb @@ -109,6 +109,7 @@ RSpec.describe Gitlab::Ci::Reports::Security::Scanner do { external_id: 'gemnasium-maven', name: 'foo', vendor: 'bar' } | { external_id: 'gemnasium-python', name: 'foo', vendor: 'bar' } | -1 { external_id: 'gemnasium-python', name: 'foo', vendor: 'bar' } | { external_id: 'bandit', name: 'foo', vendor: 'bar' } | 1 { external_id: 'bandit', name: 'foo', vendor: 'bar' } | { external_id: 'semgrep', name: 'foo', vendor: 'bar' } | -1 + { external_id: 'spotbugs', name: 'foo', vendor: 'bar' } | { external_id: 'semgrep', name: 'foo', vendor: 'bar' } | -1 { external_id: 'semgrep', name: 'foo', vendor: 'bar' } | { external_id: 'unknown', name: 'foo', vendor: 'bar' } | -1 { external_id: 'gemnasium', name: 'foo', vendor: 'bar' } | { external_id: 'gemnasium', name: 'foo', vendor: nil } | 1 end diff --git a/spec/lib/gitlab/ci/runner_releases_spec.rb b/spec/lib/gitlab/ci/runner_releases_spec.rb new file mode 100644 index 00000000000..9e4a8739c0f --- /dev/null +++ b/spec/lib/gitlab/ci/runner_releases_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::RunnerReleases do + subject { described_class.instance } + + describe '#releases' do + before do + subject.reset! + + stub_application_setting(public_runner_releases_url: 'the release API URL') + allow(Gitlab::HTTP).to receive(:try_get).with('the release API URL').once { mock_http_response(response) } + end + + def releases + subject.releases + end + + shared_examples 'requests that follow cache status' do |validity_period| + context "almost #{validity_period.inspect} later" do + let(:followup_request_interval) { validity_period - 0.001.seconds } + + it 'returns cached releases' do + releases + + travel followup_request_interval do + expect(Gitlab::HTTP).not_to receive(:try_get) + + expect(releases).to eq(expected_result) + end + end + end + + context "after #{validity_period.inspect}" do + let(:followup_request_interval) { validity_period + 1.second } + let(:followup_response) { (response || []) + [{ 'name' => 'v14.9.2' }] } + + it 'checks new releases' do + releases + + travel followup_request_interval do + expect(Gitlab::HTTP).to receive(:try_get).with('the release API URL').once { mock_http_response(followup_response) } + + expect(releases).to eq((expected_result || []) + [Gitlab::VersionInfo.new(14, 9, 2)]) + end + end + end + end + + context 'when response is nil' do + let(:response) { nil } + let(:expected_result) { nil } + + it 'returns nil' do + expect(releases).to be_nil + end + + it_behaves_like 'requests that follow cache status', 5.seconds + + it 'performs exponential backoff on requests', :aggregate_failures do + start_time = Time.now.utc.change(usec: 0) + + http_call_timestamp_offsets = [] + allow(Gitlab::HTTP).to receive(:try_get).with('the release API URL') do + http_call_timestamp_offsets << Time.now.utc - start_time + mock_http_response(response) + end + + # An initial HTTP request fails + travel_to(start_time) + subject.reset! + expect(releases).to be_nil + + # Successive failed requests result in HTTP requests only after specific backoff periods + backoff_periods = [5, 10, 20, 40, 80, 160, 320, 640, 1280, 2560, 3600].map(&:seconds) + backoff_periods.each do |period| + travel(period - 1.second) + expect(releases).to be_nil + + travel 1.second + expect(releases).to be_nil + end + + expect(http_call_timestamp_offsets).to eq([0, 5, 15, 35, 75, 155, 315, 635, 1275, 2555, 5115, 8715]) + + # Finally a successful HTTP request results in releases being returned + allow(Gitlab::HTTP).to receive(:try_get).with('the release API URL').once { mock_http_response([{ 'name' => 'v14.9.1' }]) } + travel 1.hour + expect(releases).not_to be_nil + end + end + + context 'when response is not nil' do + let(:response) { [{ 'name' => 'v14.9.1' }, { 'name' => 'v14.9.0' }] } + let(:expected_result) { [Gitlab::VersionInfo.new(14, 9, 0), Gitlab::VersionInfo.new(14, 9, 1)] } + + it 'returns parsed and sorted Gitlab::VersionInfo objects' do + expect(releases).to eq(expected_result) + end + + it_behaves_like 'requests that follow cache status', 1.day + end + + def mock_http_response(response) + http_response = instance_double(HTTParty::Response) + + allow(http_response).to receive(:success?).and_return(response.present?) + allow(http_response).to receive(:parsed_response).and_return(response) + + http_response + end + end +end diff --git a/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb new file mode 100644 index 00000000000..b430da376dd --- /dev/null +++ b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::RunnerUpgradeCheck do + include StubVersion + using RSpec::Parameterized::TableSyntax + + describe '#check_runner_upgrade_status' do + subject(:result) { described_class.instance.check_runner_upgrade_status(runner_version) } + + before do + runner_releases_double = instance_double(Gitlab::Ci::RunnerReleases) + + allow(Gitlab::Ci::RunnerReleases).to receive(:instance).and_return(runner_releases_double) + allow(runner_releases_double).to receive(:releases).and_return(available_runner_releases.map { |v| ::Gitlab::VersionInfo.parse(v) }) + end + + context 'with available_runner_releases configured up to 14.1.1' do + let(:available_runner_releases) { %w[13.9.0 13.9.1 13.9.2 13.10.0 13.10.1 14.0.0 14.0.1 14.0.2 14.1.0 14.1.1 14.1.1-rc3] } + + context 'with nil runner_version' do + let(:runner_version) { nil } + + it 'raises :unknown' do + is_expected.to eq(:unknown) + end + end + + context 'with invalid runner_version' do + let(:runner_version) { 'junk' } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + + context 'with Gitlab::VERSION set to 14.1.123' do + before do + stub_version('14.1.123', 'deadbeef') + + described_class.instance.reset! + end + + context 'with a runner_version that is too recent' do + let(:runner_version) { 'v14.2.0' } + + it 'returns :not_available' do + is_expected.to eq(:not_available) + end + end + end + + context 'with Gitlab::VERSION set to 14.0.123' do + before do + stub_version('14.0.123', 'deadbeef') + + described_class.instance.reset! + end + + context 'with valid params' do + where(:runner_version, :expected_result) do + 'v14.1.0-rc3' | :not_available # not available since the GitLab instance is still on 14.0.x + 'v14.1.0~beta.1574.gf6ea9389' | :not_available # suffixes are correctly handled + 'v14.1.0/1.1.0' | :not_available # suffixes are correctly handled + 'v14.1.0' | :not_available # not available since the GitLab instance is still on 14.0.x + 'v14.0.1' | :recommended # recommended upgrade since 14.0.2 is available + 'v14.0.2' | :not_available # not available since 14.0.2 is the latest 14.0.x release available + 'v13.10.1' | :available # available upgrade: 14.1.1 + 'v13.10.1~beta.1574.gf6ea9389' | :available # suffixes are correctly handled + 'v13.10.1/1.1.0' | :available # suffixes are correctly handled + 'v13.10.0' | :recommended # recommended upgrade since 13.10.1 is available + 'v13.9.2' | :recommended # recommended upgrade since backports are no longer released for this version + 'v13.9.0' | :recommended # recommended upgrade since backports are no longer released for this version + 'v13.8.1' | :recommended # recommended upgrade since build is too old (missing in records) + 'v11.4.1' | :recommended # recommended upgrade since build is too old (missing in records) + end + + with_them do + it 'returns symbol representing expected upgrade status' do + is_expected.to be_a(Symbol) + is_expected.to eq(expected_result) + end + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/status/build/manual_spec.rb b/spec/lib/gitlab/ci/status/build/manual_spec.rb index 78193055139..150705c1e36 100644 --- a/spec/lib/gitlab/ci/status/build/manual_spec.rb +++ b/spec/lib/gitlab/ci/status/build/manual_spec.rb @@ -3,15 +3,27 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Status::Build::Manual do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } + let_it_be(:job) { create(:ci_build, :manual) } subject do - build = create(:ci_build, :manual) - described_class.new(Gitlab::Ci::Status::Core.new(build, user)) + described_class.new(Gitlab::Ci::Status::Core.new(job, user)) end describe '#illustration' do it { expect(subject.illustration).to include(:image, :size, :title, :content) } + + context 'when the user can trigger the job' do + before do + job.project.add_maintainer(user) + end + + it { expect(subject.illustration[:content]).to match /This job requires manual intervention to start/ } + end + + context 'when the user can not trigger the job' do + it { expect(subject.illustration[:content]).to match /This job does not run automatically and must be started manually/ } + end end describe '.matches?' do diff --git a/spec/lib/gitlab/ci/templates/MATLAB_spec.rb b/spec/lib/gitlab/ci/templates/MATLAB_spec.rb new file mode 100644 index 00000000000..a12d69b67a6 --- /dev/null +++ b/spec/lib/gitlab/ci/templates/MATLAB_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'MATLAB.gitlab-ci.yml' do + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('MATLAB') } + + describe 'the created pipeline' do + let_it_be(:project) { create(:project, :auto_devops, :custom_repo, files: { 'README.md' => '' }) } + + let(:user) { project.first_owner } + let(:default_branch) { 'master' } + let(:pipeline_branch) { default_branch } + let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) } + let(:pipeline) { service.execute!(:push).payload } + let(:build_names) { pipeline.builds.pluck(:name) } + + before do + stub_ci_pipeline_yaml_file(template.content) + end + + it 'creates all jobs' do + expect(build_names).to include('command', 'test', 'test_artifacts_job') + end + end +end diff --git a/spec/lib/gitlab/ci/templates/templates_spec.rb b/spec/lib/gitlab/ci/templates/templates_spec.rb index cdda7e953d0..ca096fcecc4 100644 --- a/spec/lib/gitlab/ci/templates/templates_spec.rb +++ b/spec/lib/gitlab/ci/templates/templates_spec.rb @@ -23,7 +23,8 @@ RSpec.describe 'CI YML Templates' do exceptions = [ 'Security/DAST.gitlab-ci.yml', # DAST stage is defined inside AutoDevops yml 'Security/DAST-API.gitlab-ci.yml', # no auto-devops - 'Security/API-Fuzzing.gitlab-ci.yml' # no auto-devops + 'Security/API-Fuzzing.gitlab-ci.yml', # no auto-devops + 'ThemeKit.gitlab-ci.yml' ] context 'when including available templates in a CI YAML configuration' do diff --git a/spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb new file mode 100644 index 00000000000..4708108f404 --- /dev/null +++ b/spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'ThemeKit.gitlab-ci.yml' do + before do + allow(Gitlab::Template::GitlabCiYmlTemplate).to receive(:excluded_patterns).and_return([]) + end + + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('ThemeKit') } + + describe 'the created pipeline' do + let(:pipeline_ref) { project.default_branch_or_main } + let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) } + let(:user) { project.first_owner } + let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) } + let(:pipeline) { service.execute!(:push).payload } + let(:build_names) { pipeline.builds.pluck(:name) } + + before do + stub_ci_pipeline_yaml_file(template.content) + end + + context 'on the default branch' do + it 'only creates staging deploy', :aggregate_failures do + expect(pipeline.errors).to be_empty + expect(build_names).to include('staging') + expect(build_names).not_to include('production') + end + end + + context 'on a tag' do + let(:pipeline_ref) { '1.0' } + + before do + project.repository.add_tag(user, pipeline_ref, project.default_branch_or_main) + end + + it 'only creates a production deploy', :aggregate_failures do + expect(pipeline.errors).to be_empty + expect(build_names).to include('production') + expect(build_names).not_to include('staging') + end + end + + context 'outside of the default branch' do + let(:pipeline_ref) { 'patch-1' } + + before do + project.repository.create_branch(pipeline_ref, project.default_branch_or_main) + end + + it 'has no jobs' do + expect { pipeline }.to raise_error( + Ci::CreatePipelineService::CreateError, 'No stages / jobs for this pipeline.' + ) + end + end + end +end diff --git a/spec/lib/gitlab/ci/variables/builder_spec.rb b/spec/lib/gitlab/ci/variables/builder_spec.rb index 8552a06eab3..b9aa5f7c431 100644 --- a/spec/lib/gitlab/ci/variables/builder_spec.rb +++ b/spec/lib/gitlab/ci/variables/builder_spec.rb @@ -199,6 +199,20 @@ RSpec.describe Gitlab::Ci::Variables::Builder do 'O' => '15', 'P' => '15') end end + + context 'with schedule variables' do + let_it_be(:schedule) { create(:ci_pipeline_schedule, project: project) } + let_it_be(:schedule_variable) { create(:ci_pipeline_schedule_variable, pipeline_schedule: schedule) } + + before do + pipeline.update!(pipeline_schedule_id: schedule.id) + end + + it 'includes schedule variables' do + expect(subject.to_runner_variables) + .to include(a_hash_including(key: schedule_variable.key, value: schedule_variable.value)) + end + end end describe '#user_variables' do @@ -278,6 +292,14 @@ RSpec.describe Gitlab::Ci::Variables::Builder do end shared_examples "secret CI variables" do + let(:protected_variable_item) do + Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable) + end + + let(:unprotected_variable_item) do + Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable) + end + context 'when ref is branch' do context 'when ref is protected' do before do @@ -338,189 +360,255 @@ RSpec.describe Gitlab::Ci::Variables::Builder do let_it_be(:protected_variable) { create(:ci_instance_variable, protected: true) } let_it_be(:unprotected_variable) { create(:ci_instance_variable, protected: false) } - let(:protected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable) } - let(:unprotected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable) } - include_examples "secret CI variables" end describe '#secret_group_variables' do - subject { builder.secret_group_variables(ref: job.git_ref, environment: job.expanded_environment_name) } + subject { builder.secret_group_variables(environment: job.expanded_environment_name) } let_it_be(:protected_variable) { create(:ci_group_variable, protected: true, group: group) } let_it_be(:unprotected_variable) { create(:ci_group_variable, protected: false, group: group) } - context 'with ci_variables_builder_memoize_secret_variables disabled' do - before do - stub_feature_flags(ci_variables_builder_memoize_secret_variables: false) + include_examples "secret CI variables" + + context 'variables memoization' do + let_it_be(:scoped_variable) { create(:ci_group_variable, group: group, environment_scope: 'scoped') } + + let(:environment) { job.expanded_environment_name } + let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) } + + context 'with protected environments' do + it 'memoizes the result by environment' do + expect(pipeline.project) + .to receive(:protected_for?) + .with(pipeline.jobs_git_ref) + .once.and_return(true) + + expect_next_instance_of(described_class::Group) do |group_variables_builder| + expect(group_variables_builder) + .to receive(:secret_variables) + .with(environment: 'production', protected_ref: true) + .once + .and_call_original + end + + 2.times do + expect(builder.secret_group_variables(environment: 'production')) + .to contain_exactly(unprotected_variable_item, protected_variable_item) + end + end end - let(:protected_variable_item) { protected_variable } - let(:unprotected_variable_item) { unprotected_variable } + context 'with unprotected environments' do + it 'memoizes the result by environment' do + expect(pipeline.project) + .to receive(:protected_for?) + .with(pipeline.jobs_git_ref) + .once.and_return(false) + + expect_next_instance_of(described_class::Group) do |group_variables_builder| + expect(group_variables_builder) + .to receive(:secret_variables) + .with(environment: nil, protected_ref: false) + .once + .and_call_original + + expect(group_variables_builder) + .to receive(:secret_variables) + .with(environment: 'scoped', protected_ref: false) + .once + .and_call_original + end + + 2.times do + expect(builder.secret_group_variables(environment: nil)) + .to contain_exactly(unprotected_variable_item) - include_examples "secret CI variables" + expect(builder.secret_group_variables(environment: 'scoped')) + .to contain_exactly(unprotected_variable_item, scoped_variable_item) + end + end + end end + end - context 'with ci_variables_builder_memoize_secret_variables enabled' do - before do - stub_feature_flags(ci_variables_builder_memoize_secret_variables: true) - end + describe '#secret_project_variables' do + let_it_be(:protected_variable) { create(:ci_variable, protected: true, project: project) } + let_it_be(:unprotected_variable) { create(:ci_variable, protected: false, project: project) } - let(:protected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable) } - let(:unprotected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable) } + let(:environment) { job.expanded_environment_name } - include_examples "secret CI variables" + subject { builder.secret_project_variables(environment: environment) } - context 'variables memoization' do - let_it_be(:scoped_variable) { create(:ci_group_variable, group: group, environment_scope: 'scoped') } + include_examples "secret CI variables" - let(:ref) { job.git_ref } - let(:environment) { job.expanded_environment_name } - let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) } + context 'variables memoization' do + let_it_be(:scoped_variable) { create(:ci_variable, project: project, environment_scope: 'scoped') } - context 'with protected environments' do - it 'memoizes the result by environment' do - expect(pipeline.project) - .to receive(:protected_for?) - .with(pipeline.jobs_git_ref) - .once.and_return(true) + let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) } - expect_next_instance_of(described_class::Group) do |group_variables_builder| - expect(group_variables_builder) - .to receive(:secret_variables) - .with(environment: 'production', protected_ref: true) - .once - .and_call_original - end + context 'with protected environments' do + it 'memoizes the result by environment' do + expect(pipeline.project) + .to receive(:protected_for?) + .with(pipeline.jobs_git_ref) + .once.and_return(true) - 2.times do - expect(builder.secret_group_variables(ref: ref, environment: 'production')) - .to contain_exactly(unprotected_variable_item, protected_variable_item) - end + expect_next_instance_of(described_class::Project) do |project_variables_builder| + expect(project_variables_builder) + .to receive(:secret_variables) + .with(environment: 'production', protected_ref: true) + .once + .and_call_original + end + + 2.times do + expect(builder.secret_project_variables(environment: 'production')) + .to contain_exactly(unprotected_variable_item, protected_variable_item) end end + end - context 'with unprotected environments' do - it 'memoizes the result by environment' do - expect(pipeline.project) - .to receive(:protected_for?) - .with(pipeline.jobs_git_ref) - .once.and_return(false) - - expect_next_instance_of(described_class::Group) do |group_variables_builder| - expect(group_variables_builder) - .to receive(:secret_variables) - .with(environment: nil, protected_ref: false) - .once - .and_call_original - - expect(group_variables_builder) - .to receive(:secret_variables) - .with(environment: 'scoped', protected_ref: false) - .once - .and_call_original - end - - 2.times do - expect(builder.secret_group_variables(ref: 'other', environment: nil)) - .to contain_exactly(unprotected_variable_item) - - expect(builder.secret_group_variables(ref: 'other', environment: 'scoped')) - .to contain_exactly(unprotected_variable_item, scoped_variable_item) - end + context 'with unprotected environments' do + it 'memoizes the result by environment' do + expect(pipeline.project) + .to receive(:protected_for?) + .with(pipeline.jobs_git_ref) + .once.and_return(false) + + expect_next_instance_of(described_class::Project) do |project_variables_builder| + expect(project_variables_builder) + .to receive(:secret_variables) + .with(environment: nil, protected_ref: false) + .once + .and_call_original + + expect(project_variables_builder) + .to receive(:secret_variables) + .with(environment: 'scoped', protected_ref: false) + .once + .and_call_original + end + + 2.times do + expect(builder.secret_project_variables(environment: nil)) + .to contain_exactly(unprotected_variable_item) + + expect(builder.secret_project_variables(environment: 'scoped')) + .to contain_exactly(unprotected_variable_item, scoped_variable_item) end end end end end - describe '#secret_project_variables' do - let_it_be(:protected_variable) { create(:ci_variable, protected: true, project: project) } - let_it_be(:unprotected_variable) { create(:ci_variable, protected: false, project: project) } + describe '#config_variables' do + subject(:config_variables) { builder.config_variables } - let(:ref) { job.git_ref } - let(:environment) { job.expanded_environment_name } + context 'without project' do + before do + pipeline.update!(project_id: nil) + end + + it { expect(config_variables.size).to eq(0) } + end - subject { builder.secret_project_variables(ref: ref, environment: environment) } + context 'without repository' do + let(:project) { create(:project) } + let(:pipeline) { build(:ci_pipeline, ref: nil, sha: nil, project: project) } - context 'with ci_variables_builder_memoize_secret_variables disabled' do - before do - stub_feature_flags(ci_variables_builder_memoize_secret_variables: false) + it { expect(config_variables['CI_COMMIT_SHA']).to be_nil } + end + + context 'with protected variables' do + let_it_be(:instance_variable) do + create(:ci_instance_variable, :protected, key: 'instance_variable') + end + + let_it_be(:group_variable) do + create(:ci_group_variable, :protected, group: group, key: 'group_variable') end - let(:protected_variable_item) { protected_variable } - let(:unprotected_variable_item) { unprotected_variable } + let_it_be(:project_variable) do + create(:ci_variable, :protected, project: project, key: 'project_variable') + end - include_examples "secret CI variables" + it 'does not include protected variables' do + expect(config_variables[instance_variable.key]).to be_nil + expect(config_variables[group_variable.key]).to be_nil + expect(config_variables[project_variable.key]).to be_nil + end end - context 'with ci_variables_builder_memoize_secret_variables enabled' do - before do - stub_feature_flags(ci_variables_builder_memoize_secret_variables: true) + context 'with scoped variables' do + let_it_be(:scoped_group_variable) do + create(:ci_group_variable, + group: group, + key: 'group_variable', + value: 'scoped', + environment_scope: 'scoped') end - let(:protected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable) } - let(:unprotected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable) } + let_it_be(:group_variable) do + create(:ci_group_variable, + group: group, + key: 'group_variable', + value: 'unscoped') + end - include_examples "secret CI variables" + let_it_be(:scoped_project_variable) do + create(:ci_variable, + project: project, + key: 'project_variable', + value: 'scoped', + environment_scope: 'scoped') + end - context 'variables memoization' do - let_it_be(:scoped_variable) { create(:ci_variable, project: project, environment_scope: 'scoped') } + let_it_be(:project_variable) do + create(:ci_variable, + project: project, + key: 'project_variable', + value: 'unscoped') + end - let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) } + it 'does not include scoped variables' do + expect(config_variables.to_hash[group_variable.key]).to eq('unscoped') + expect(config_variables.to_hash[project_variable.key]).to eq('unscoped') + end + end - context 'with protected environments' do - it 'memoizes the result by environment' do - expect(pipeline.project) - .to receive(:protected_for?) - .with(pipeline.jobs_git_ref) - .once.and_return(true) + context 'variables ordering' do + def var(name, value) + { key: name, value: value.to_s, public: true, masked: false } + end - expect_next_instance_of(described_class::Project) do |project_variables_builder| - expect(project_variables_builder) - .to receive(:secret_variables) - .with(environment: 'production', protected_ref: true) - .once - .and_call_original - end + before do + allow(pipeline.project).to receive(:predefined_variables) { [var('A', 1), var('B', 1)] } + allow(pipeline).to receive(:predefined_variables) { [var('B', 2), var('C', 2)] } + allow(builder).to receive(:secret_instance_variables) { [var('C', 3), var('D', 3)] } + allow(builder).to receive(:secret_group_variables) { [var('D', 4), var('E', 4)] } + allow(builder).to receive(:secret_project_variables) { [var('E', 5), var('F', 5)] } + allow(pipeline).to receive(:variables) { [var('F', 6), var('G', 6)] } + allow(pipeline).to receive(:pipeline_schedule) { double(job_variables: [var('G', 7), var('H', 7)]) } + end - 2.times do - expect(builder.secret_project_variables(ref: ref, environment: 'production')) - .to contain_exactly(unprotected_variable_item, protected_variable_item) - end - end - end + it 'returns variables in order depending on resource hierarchy' do + expect(config_variables.to_runner_variables).to eq( + [var('A', 1), var('B', 1), + var('B', 2), var('C', 2), + var('C', 3), var('D', 3), + var('D', 4), var('E', 4), + var('E', 5), var('F', 5), + var('F', 6), var('G', 6), + var('G', 7), var('H', 7)]) + end - context 'with unprotected environments' do - it 'memoizes the result by environment' do - expect(pipeline.project) - .to receive(:protected_for?) - .with(pipeline.jobs_git_ref) - .once.and_return(false) - - expect_next_instance_of(described_class::Project) do |project_variables_builder| - expect(project_variables_builder) - .to receive(:secret_variables) - .with(environment: nil, protected_ref: false) - .once - .and_call_original - - expect(project_variables_builder) - .to receive(:secret_variables) - .with(environment: 'scoped', protected_ref: false) - .once - .and_call_original - end - - 2.times do - expect(builder.secret_project_variables(ref: 'other', environment: nil)) - .to contain_exactly(unprotected_variable_item) - - expect(builder.secret_project_variables(ref: 'other', environment: 'scoped')) - .to contain_exactly(unprotected_variable_item, scoped_variable_item) - end - end - end + it 'overrides duplicate keys depending on resource hierarchy' do + expect(config_variables.to_hash).to match( + 'A' => '1', 'B' => '2', + 'C' => '3', 'D' => '4', + 'E' => '5', 'F' => '6', + 'G' => '7', 'H' => '7') end end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index ebb5c91ebad..9b68ee2d6a2 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -842,7 +842,7 @@ module Gitlab describe "Image and service handling" do context "when extended docker configuration is used" do it "returns image and service when defined" do - config = YAML.dump({ image: { name: "ruby:2.7", entrypoint: ["/usr/local/bin/init", "run"] }, + config = YAML.dump({ image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] }, services: ["mysql", { name: "docker:dind", alias: "docker", entrypoint: ["/usr/local/bin/init", "run"], command: ["/usr/local/bin/init", "run"] }], @@ -860,7 +860,7 @@ module Gitlab options: { before_script: ["pwd"], script: ["rspec"], - image: { name: "ruby:2.7", entrypoint: ["/usr/local/bin/init", "run"] }, + image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] }, services: [{ name: "mysql" }, { name: "docker:dind", alias: "docker", entrypoint: ["/usr/local/bin/init", "run"], command: ["/usr/local/bin/init", "run"] }] @@ -874,10 +874,10 @@ module Gitlab end it "returns image and service when overridden for job" do - config = YAML.dump({ image: "ruby:2.7", + config = YAML.dump({ image: "image:1.0", services: ["mysql"], before_script: ["pwd"], - rspec: { image: { name: "ruby:3.0", entrypoint: ["/usr/local/bin/init", "run"] }, + rspec: { image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] }, services: [{ name: "postgresql", alias: "db-pg", entrypoint: ["/usr/local/bin/init", "run"], command: ["/usr/local/bin/init", "run"] }, "docker:dind"], @@ -894,7 +894,7 @@ module Gitlab options: { before_script: ["pwd"], script: ["rspec"], - image: { name: "ruby:3.0", entrypoint: ["/usr/local/bin/init", "run"] }, + image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] }, services: [{ name: "postgresql", alias: "db-pg", entrypoint: ["/usr/local/bin/init", "run"], command: ["/usr/local/bin/init", "run"] }, { name: "docker:dind" }] @@ -910,7 +910,7 @@ module Gitlab context "when etended docker configuration is not used" do it "returns image and service when defined" do - config = YAML.dump({ image: "ruby:2.7", + config = YAML.dump({ image: "image:1.0", services: ["mysql", "docker:dind"], before_script: ["pwd"], rspec: { script: "rspec" } }) @@ -926,7 +926,7 @@ module Gitlab options: { before_script: ["pwd"], script: ["rspec"], - image: { name: "ruby:2.7" }, + image: { name: "image:1.0" }, services: [{ name: "mysql" }, { name: "docker:dind" }] }, allow_failure: false, @@ -938,10 +938,10 @@ module Gitlab end it "returns image and service when overridden for job" do - config = YAML.dump({ image: "ruby:2.7", + config = YAML.dump({ image: "image:1.0", services: ["mysql"], before_script: ["pwd"], - rspec: { image: "ruby:3.0", services: ["postgresql", "docker:dind"], script: "rspec" } }) + rspec: { image: "image:1.0", services: ["postgresql", "docker:dind"], script: "rspec" } }) config_processor = Gitlab::Ci::YamlProcessor.new(config).execute @@ -954,7 +954,7 @@ module Gitlab options: { before_script: ["pwd"], script: ["rspec"], - image: { name: "ruby:3.0" }, + image: { name: "image:1.0" }, services: [{ name: "postgresql" }, { name: "docker:dind" }] }, allow_failure: false, @@ -1557,7 +1557,7 @@ module Gitlab describe "Artifacts" do it "returns artifacts when defined" do config = YAML.dump({ - image: "ruby:2.7", + image: "image:1.0", services: ["mysql"], before_script: ["pwd"], rspec: { @@ -1583,7 +1583,7 @@ module Gitlab options: { before_script: ["pwd"], script: ["rspec"], - image: { name: "ruby:2.7" }, + image: { name: "image:1.0" }, services: [{ name: "mysql" }], artifacts: { name: "custom_name", @@ -2327,7 +2327,7 @@ module Gitlab context 'when hidden job have a script definition' do let(:config) do YAML.dump({ - '.hidden_job' => { image: 'ruby:2.7', script: 'test' }, + '.hidden_job' => { image: 'image:1.0', script: 'test' }, 'normal_job' => { script: 'test' } }) end @@ -2338,7 +2338,7 @@ module Gitlab context "when hidden job doesn't have a script definition" do let(:config) do YAML.dump({ - '.hidden_job' => { image: 'ruby:2.7' }, + '.hidden_job' => { image: 'image:1.0' }, 'normal_job' => { script: 'test' } }) end diff --git a/spec/lib/gitlab/config/loader/yaml_spec.rb b/spec/lib/gitlab/config/loader/yaml_spec.rb index be568a8e5f9..66ea931a42c 100644 --- a/spec/lib/gitlab/config/loader/yaml_spec.rb +++ b/spec/lib/gitlab/config/loader/yaml_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do let(:yml) do <<~YAML - image: 'ruby:2.7' + image: 'image:1.0' texts: nested_key: 'value1' more_text: @@ -34,7 +34,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do end context 'when yaml syntax is correct' do - let(:yml) { 'image: ruby:2.7' } + let(:yml) { 'image: image:1.0' } describe '#valid?' do it 'returns true' do @@ -44,7 +44,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do describe '#load!' do it 'returns a valid hash' do - expect(loader.load!).to eq(image: 'ruby:2.7') + expect(loader.load!).to eq(image: 'image:1.0') end end end @@ -164,7 +164,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do describe '#load_raw!' do it 'loads keys as strings' do expect(loader.load_raw!).to eq( - 'image' => 'ruby:2.7', + 'image' => 'image:1.0', 'texts' => { 'nested_key' => 'value1', 'more_text' => { @@ -178,7 +178,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do describe '#load!' do it 'symbolizes keys' do expect(loader.load!).to eq( - image: 'ruby:2.7', + image: 'image:1.0', texts: { nested_key: 'value1', more_text: { diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb index 08d29f7842c..44e2cb21677 100644 --- a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb +++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb @@ -107,24 +107,8 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do stub_env('CUSTOMER_PORTAL_URL', customer_portal_url) end - context 'when in production' do - before do - allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production')) - end - - it 'does not add CUSTOMER_PORTAL_URL to CSP' do - expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid") - end - end - - context 'when in development' do - before do - allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development')) - end - - it 'adds CUSTOMER_PORTAL_URL to CSP' do - expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/rails/letter_opener/ https://customers.example.com http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid") - end + it 'adds CUSTOMER_PORTAL_URL to CSP' do + expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid #{customer_portal_url}") end end diff --git a/spec/lib/gitlab/data_builder/deployment_spec.rb b/spec/lib/gitlab/data_builder/deployment_spec.rb index ab8c8a51694..e8fe80f75cb 100644 --- a/spec/lib/gitlab/data_builder/deployment_spec.rb +++ b/spec/lib/gitlab/data_builder/deployment_spec.rb @@ -46,5 +46,42 @@ RSpec.describe Gitlab::DataBuilder::Deployment do expect(data[:deployable_url]).to be_nil end + + context 'when commit does not exist in the repository' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:deployment) { create(:deployment, project: project) } + + subject(:data) { described_class.build(deployment, Time.current) } + + before(:all) do + project.repository.remove + end + + it 'returns nil for commit_url' do + expect(data[:commit_url]).to be_nil + end + + it 'returns nil for commit_title' do + expect(data[:commit_title]).to be_nil + end + end + + context 'when deployed_by is nil' do + let_it_be(:deployment) { create(:deployment, user: nil, deployable: nil) } + + subject(:data) { described_class.build(deployment, Time.current) } + + before(:all) do + deployment.user = nil + end + + it 'returns nil for user' do + expect(data[:user]).to be_nil + end + + it 'returns nil for user_url' do + expect(data[:user_url]).to be_nil + end + end end end diff --git a/spec/lib/gitlab/data_builder/note_spec.rb b/spec/lib/gitlab/data_builder/note_spec.rb index 90ca5430526..3fa535dd800 100644 --- a/spec/lib/gitlab/data_builder/note_spec.rb +++ b/spec/lib/gitlab/data_builder/note_spec.rb @@ -8,18 +8,22 @@ RSpec.describe Gitlab::DataBuilder::Note do let(:data) { described_class.build(note, user) } let(:fixed_time) { Time.at(1425600000) } # Avoid time precision errors - before do - expect(data).to have_key(:object_attributes) - expect(data[:object_attributes]).to have_key(:url) - expect(data[:object_attributes][:url]) - .to eq(Gitlab::UrlBuilder.build(note)) - expect(data[:object_kind]).to eq('note') - expect(data[:user]).to eq(user.hook_attrs) + shared_examples 'includes general data' do + specify do + expect(data).to have_key(:object_attributes) + expect(data[:object_attributes]).to have_key(:url) + expect(data[:object_attributes][:url]) + .to eq(Gitlab::UrlBuilder.build(note)) + expect(data[:object_kind]).to eq('note') + expect(data[:user]).to eq(user.hook_attrs) + end end describe 'When asking for a note on commit' do let(:note) { create(:note_on_commit, project: project) } + it_behaves_like 'includes general data' + it 'returns the note and commit-specific data' do expect(data).to have_key(:commit) end @@ -31,6 +35,8 @@ RSpec.describe Gitlab::DataBuilder::Note do describe 'When asking for a note on commit diff' do let(:note) { create(:diff_note_on_commit, project: project) } + it_behaves_like 'includes general data' + it 'returns the note and commit-specific data' do expect(data).to have_key(:commit) end @@ -51,22 +57,21 @@ RSpec.describe Gitlab::DataBuilder::Note do create(:note_on_issue, noteable: issue, project: project) end + it_behaves_like 'includes general data' + it 'returns the note and issue-specific data' do - without_timestamps = lambda { |label| label.except('created_at', 'updated_at') } - hook_attrs = issue.reload.hook_attrs + expect_next_instance_of(Gitlab::HookData::IssueBuilder) do |issue_data_builder| + expect(issue_data_builder).to receive(:build).and_return('Issue data') + end - expect(data).to have_key(:issue) - expect(data[:issue].except('updated_at', 'labels')) - .to eq(hook_attrs.except('updated_at', 'labels')) - expect(data[:issue]['updated_at']) - .to be >= hook_attrs['updated_at'] - expect(data[:issue]['labels'].map(&without_timestamps)) - .to eq(hook_attrs['labels'].map(&without_timestamps)) + expect(data[:issue]).to eq('Issue data') end context 'with confidential issue' do let(:issue) { create(:issue, project: project, confidential: true) } + it_behaves_like 'includes general data' + it 'sets event_type to confidential_note' do expect(data[:event_type]).to eq('confidential_note') end @@ -77,10 +82,12 @@ RSpec.describe Gitlab::DataBuilder::Note do end describe 'When asking for a note on merge request' do + let(:label) { create(:label, project: project) } let(:merge_request) do - create(:merge_request, created_at: fixed_time, + create(:labeled_merge_request, created_at: fixed_time, updated_at: fixed_time, - source_project: project) + source_project: project, + labels: [label]) end let(:note) do @@ -88,12 +95,14 @@ RSpec.describe Gitlab::DataBuilder::Note do project: project) end - it 'returns the note and merge request data' do - expect(data).to have_key(:merge_request) - expect(data[:merge_request].except('updated_at')) - .to eq(merge_request.reload.hook_attrs.except('updated_at')) - expect(data[:merge_request]['updated_at']) - .to be >= merge_request.hook_attrs['updated_at'] + it_behaves_like 'includes general data' + + it 'returns the merge request data' do + expect_next_instance_of(Gitlab::HookData::MergeRequestBuilder) do |mr_data_builder| + expect(mr_data_builder).to receive(:build).and_return('MR data') + end + + expect(data[:merge_request]).to eq('MR data') end include_examples 'project hook data' @@ -101,9 +110,11 @@ RSpec.describe Gitlab::DataBuilder::Note do end describe 'When asking for a note on merge request diff' do + let(:label) { create(:label, project: project) } let(:merge_request) do - create(:merge_request, created_at: fixed_time, updated_at: fixed_time, - source_project: project) + create(:labeled_merge_request, created_at: fixed_time, updated_at: fixed_time, + source_project: project, + labels: [label]) end let(:note) do @@ -111,12 +122,14 @@ RSpec.describe Gitlab::DataBuilder::Note do project: project) end - it 'returns the note and merge request diff data' do - expect(data).to have_key(:merge_request) - expect(data[:merge_request].except('updated_at')) - .to eq(merge_request.reload.hook_attrs.except('updated_at')) - expect(data[:merge_request]['updated_at']) - .to be >= merge_request.hook_attrs['updated_at'] + it_behaves_like 'includes general data' + + it 'returns the merge request data' do + expect_next_instance_of(Gitlab::HookData::MergeRequestBuilder) do |mr_data_builder| + expect(mr_data_builder).to receive(:build).and_return('MR data') + end + + expect(data[:merge_request]).to eq('MR data') end include_examples 'project hook data' @@ -134,6 +147,8 @@ RSpec.describe Gitlab::DataBuilder::Note do project: project) end + it_behaves_like 'includes general data' + it 'returns the note and project snippet data' do expect(data).to have_key(:snippet) expect(data[:snippet].except('updated_at')) diff --git a/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb b/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb index 66983733411..6db3081ca7e 100644 --- a/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb @@ -10,7 +10,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchMetrics do expect(batch_metrics.timings).to be_empty expect(Gitlab::Metrics::System).to receive(:monotonic_time) - .exactly(6).times .and_return(0.0, 111.0, 200.0, 290.0, 300.0, 410.0) batch_metrics.time_operation(:my_label) do @@ -28,4 +27,33 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchMetrics do expect(batch_metrics.timings).to eq(my_label: [111.0, 110.0], my_other_label: [90.0]) end end + + describe '#instrument_operation' do + it 'tracks duration and affected rows' do + expect(batch_metrics.timings).to be_empty + expect(batch_metrics.affected_rows).to be_empty + + expect(Gitlab::Metrics::System).to receive(:monotonic_time) + .and_return(0.0, 111.0, 200.0, 290.0, 300.0, 410.0, 420.0, 450.0) + + batch_metrics.instrument_operation(:my_label) do + 3 + end + + batch_metrics.instrument_operation(:my_other_label) do + 42 + end + + batch_metrics.instrument_operation(:my_label) do + 2 + end + + batch_metrics.instrument_operation(:my_other_label) do + :not_an_integer + end + + expect(batch_metrics.timings).to eq(my_label: [111.0, 110.0], my_other_label: [90.0, 30.0]) + expect(batch_metrics.affected_rows).to eq(my_label: [3, 2], my_other_label: [42]) + end + end end diff --git a/spec/lib/gitlab/database/background_migration/batched_job_spec.rb b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb index 8c663ff9f8a..c39f6a78e93 100644 --- a/spec/lib/gitlab/database/background_migration/batched_job_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb @@ -21,7 +21,19 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d context 'when a job is running' do it 'logs the transition' do - expect(Gitlab::AppLogger).to receive(:info).with( { batched_job_id: job.id, message: 'BatchedJob transition', new_state: :running, previous_state: :failed } ) + expect(Gitlab::AppLogger).to receive(:info).with( + { + batched_job_id: job.id, + batched_migration_id: job.batched_background_migration_id, + exception_class: nil, + exception_message: nil, + job_arguments: job.batched_migration.job_arguments, + job_class_name: job.batched_migration.job_class_name, + message: 'BatchedJob transition', + new_state: :running, + previous_state: :failed + } + ) expect { job.run! }.to change(job, :started_at) end @@ -31,7 +43,19 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d let(:job) { create(:batched_background_migration_job, :running) } it 'logs the transition' do - expect(Gitlab::AppLogger).to receive(:info).with( { batched_job_id: job.id, message: 'BatchedJob transition', new_state: :succeeded, previous_state: :running } ) + expect(Gitlab::AppLogger).to receive(:info).with( + { + batched_job_id: job.id, + batched_migration_id: job.batched_background_migration_id, + exception_class: nil, + exception_message: nil, + job_arguments: job.batched_migration.job_arguments, + job_class_name: job.batched_migration.job_class_name, + message: 'BatchedJob transition', + new_state: :succeeded, + previous_state: :running + } + ) job.succeed! end @@ -89,7 +113,15 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d end it 'logs the error' do - expect(Gitlab::AppLogger).to receive(:error).with( { message: error_message, batched_job_id: job.id } ) + expect(Gitlab::AppLogger).to receive(:error).with( + { + batched_job_id: job.id, + batched_migration_id: job.batched_background_migration_id, + job_arguments: job.batched_migration.job_arguments, + job_class_name: job.batched_migration.job_class_name, + message: error_message + } + ) job.failure!(error: exception) end @@ -100,13 +132,32 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d let(:job) { create(:batched_background_migration_job, :running) } it 'logs the transition' do - expect(Gitlab::AppLogger).to receive(:info).with( { batched_job_id: job.id, message: 'BatchedJob transition', new_state: :failed, previous_state: :running } ) + expect(Gitlab::AppLogger).to receive(:info).with( + { + batched_job_id: job.id, + batched_migration_id: job.batched_background_migration_id, + exception_class: RuntimeError, + exception_message: 'error', + job_arguments: job.batched_migration.job_arguments, + job_class_name: job.batched_migration.job_class_name, + message: 'BatchedJob transition', + new_state: :failed, + previous_state: :running + } + ) - job.failure! + job.failure!(error: RuntimeError.new('error')) end it 'tracks the exception' do - expect(Gitlab::ErrorTracking).to receive(:track_exception).with(RuntimeError, { batched_job_id: job.id } ) + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + RuntimeError, + { + batched_job_id: job.id, + job_arguments: job.batched_migration.job_arguments, + job_class_name: job.batched_migration.job_class_name + } + ) job.failure!(error: RuntimeError.new) end @@ -130,13 +181,11 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d describe 'scopes' do let_it_be(:fixed_time) { Time.new(2021, 04, 27, 10, 00, 00, 00) } - let_it_be(:pending_job) { create(:batched_background_migration_job, :pending, updated_at: fixed_time) } - let_it_be(:running_job) { create(:batched_background_migration_job, :running, updated_at: fixed_time) } - let_it_be(:stuck_job) { create(:batched_background_migration_job, :pending, updated_at: fixed_time - described_class::STUCK_JOBS_TIMEOUT) } - let_it_be(:failed_job) { create(:batched_background_migration_job, :failed, attempts: 1) } - - let!(:max_attempts_failed_job) { create(:batched_background_migration_job, :failed, attempts: described_class::MAX_ATTEMPTS) } - let!(:succeeded_job) { create(:batched_background_migration_job, :succeeded) } + let_it_be(:pending_job) { create(:batched_background_migration_job, :pending, created_at: fixed_time - 2.days, updated_at: fixed_time) } + let_it_be(:running_job) { create(:batched_background_migration_job, :running, created_at: fixed_time - 2.days, updated_at: fixed_time) } + let_it_be(:stuck_job) { create(:batched_background_migration_job, :pending, created_at: fixed_time, updated_at: fixed_time - described_class::STUCK_JOBS_TIMEOUT) } + let_it_be(:failed_job) { create(:batched_background_migration_job, :failed, created_at: fixed_time, attempts: 1) } + let_it_be(:max_attempts_failed_job) { create(:batched_background_migration_job, :failed, created_at: fixed_time, attempts: described_class::MAX_ATTEMPTS) } before do travel_to fixed_time @@ -165,6 +214,12 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d expect(described_class.retriable).to contain_exactly(failed_job, stuck_job) end end + + describe '.created_since' do + it 'returns jobs since a given time' do + expect(described_class.created_since(fixed_time)).to contain_exactly(stuck_job, failed_job, max_attempts_failed_job) + end + end end describe 'delegated batched_migration attributes' do @@ -194,6 +249,12 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d expect(batched_job.migration_job_arguments).to eq(batched_migration.job_arguments) end end + + describe '#migration_job_class_name' do + it 'returns the migration job_class_name' do + expect(batched_job.migration_job_class_name).to eq(batched_migration.job_class_name) + end + end end describe '#can_split?' do diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb index 124d204cb62..f147e8204e6 100644 --- a/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb @@ -3,8 +3,16 @@ require 'spec_helper' RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do + let(:connection) { Gitlab::Database.database_base_models[:main].connection } let(:migration_wrapper) { double('test wrapper') } - let(:runner) { described_class.new(migration_wrapper) } + + let(:runner) { described_class.new(connection: connection, migration_wrapper: migration_wrapper) } + + around do |example| + Gitlab::Database::SharedModel.using_connection(connection) do + example.run + end + end describe '#run_migration_job' do shared_examples_for 'it has completed the migration' do @@ -86,6 +94,19 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do end end + context 'when the migration should stop' do + let(:migration) { create(:batched_background_migration, :active) } + + let!(:job) { create(:batched_background_migration_job, :failed, batched_migration: migration) } + + it 'changes the status to failure' do + expect(migration).to receive(:should_stop?).and_return(true) + expect(migration_wrapper).to receive(:perform).and_return(job) + + expect { runner.run_migration_job(migration) }.to change { migration.status_name }.from(:active).to(:failed) + end + end + context 'when the migration has previous jobs' do let!(:event1) { create(:event) } let!(:event2) { create(:event) } @@ -282,7 +303,9 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do end describe '#finalize' do - let(:migration_wrapper) { Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper.new } + let(:migration_wrapper) do + Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper.new(connection: connection) + end let(:migration_helpers) { ActiveRecord::Migration.new } let(:table_name) { :_test_batched_migrations_test_table } @@ -293,8 +316,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do let!(:batched_migration) do create( - :batched_background_migration, - status: migration_status, + :batched_background_migration, migration_status, max_value: 8, batch_size: 2, sub_batch_size: 1, @@ -339,7 +361,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do .with('CopyColumnUsingBackgroundMigrationJob', table_name, column_name, job_arguments) .and_return(batched_migration) - expect(batched_migration).to receive(:finalizing!).and_call_original + expect(batched_migration).to receive(:finalize!).and_call_original expect do runner.finalize( @@ -348,7 +370,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do column_name, job_arguments ) - end.to change { batched_migration.reload.status }.from('active').to('finished') + end.to change { batched_migration.reload.status_name }.from(:active).to(:finished) expect(batched_migration.batched_jobs).to all(be_succeeded) @@ -390,7 +412,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do expect(Gitlab::AppLogger).to receive(:warn) .with("Batched background migration for the given configuration is already finished: #{configuration}") - expect(batched_migration).not_to receive(:finalizing!) + expect(batched_migration).not_to receive(:finalize!) runner.finalize( batched_migration.job_class_name, @@ -417,7 +439,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do expect(Gitlab::AppLogger).to receive(:warn) .with("Could not find batched background migration for the given configuration: #{configuration}") - expect(batched_migration).not_to receive(:finalizing!) + expect(batched_migration).not_to receive(:finalize!) runner.finalize( batched_migration.job_class_name, @@ -431,8 +453,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do describe '.finalize' do context 'when the connection is passed' do - let(:connection) { double('connection') } - let(:table_name) { :_test_batched_migrations_test_table } let(:column_name) { :some_id } let(:job_arguments) { [:some, :other, :arguments] } 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 803123e8e34..7a433be0e2f 100644 --- a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb @@ -27,28 +27,46 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m it { is_expected.to validate_uniqueness_of(:job_arguments).scoped_to(:job_class_name, :table_name, :column_name) } context 'when there are failed jobs' do - let(:batched_migration) { create(:batched_background_migration, status: :active, total_tuple_count: 100) } + let(:batched_migration) { create(:batched_background_migration, :active, total_tuple_count: 100) } let!(:batched_job) { create(:batched_background_migration_job, :failed, batched_migration: batched_migration) } it 'raises an exception' do - expect { batched_migration.finished! }.to raise_error(ActiveRecord::RecordInvalid) + expect { batched_migration.finish! }.to raise_error(StateMachines::InvalidTransition) - expect(batched_migration.reload.status).to eql 'active' + expect(batched_migration.reload.status_name).to be :active end end context 'when the jobs are completed' do - let(:batched_migration) { create(:batched_background_migration, status: :active, total_tuple_count: 100) } + let(:batched_migration) { create(:batched_background_migration, :active, total_tuple_count: 100) } let!(:batched_job) { create(:batched_background_migration_job, :succeeded, batched_migration: batched_migration) } it 'finishes the migration' do - batched_migration.finished! + batched_migration.finish! - expect(batched_migration.status).to eql 'finished' + expect(batched_migration.status_name).to be :finished end end end + describe 'state machine' do + context 'when a migration is executed' do + let!(:batched_migration) { create(:batched_background_migration) } + + it 'updates the started_at' do + expect { batched_migration.execute! }.to change(batched_migration, :started_at).from(nil).to(Time) + end + end + end + + describe '.valid_status' do + valid_status = [:paused, :active, :finished, :failed, :finalizing] + + it 'returns valid status' do + expect(described_class.valid_status).to eq(valid_status) + end + end + describe '.queue_order' do let!(:migration1) { create(:batched_background_migration) } let!(:migration2) { create(:batched_background_migration) } @@ -61,12 +79,23 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m describe '.active_migration' do let!(:migration1) { create(:batched_background_migration, :finished) } - let!(:migration2) { create(:batched_background_migration, :active) } - let!(:migration3) { create(:batched_background_migration, :active) } - it 'returns the first active migration according to queue order' do - expect(described_class.active_migration).to eq(migration2) - create(:batched_background_migration_job, :succeeded, batched_migration: migration1, batch_size: 1000) + context 'without migrations on hold' do + let!(:migration2) { create(:batched_background_migration, :active) } + let!(:migration3) { create(:batched_background_migration, :active) } + + it 'returns the first active migration according to queue order' do + expect(described_class.active_migration).to eq(migration2) + end + end + + context 'with migrations are on hold' do + let!(:migration2) { create(:batched_background_migration, :active, on_hold_until: 10.minutes.from_now) } + let!(:migration3) { create(:batched_background_migration, :active, on_hold_until: 2.minutes.ago) } + + it 'returns the first active migration that is not on hold according to queue order' do + expect(described_class.active_migration).to eq(migration3) + end end end @@ -287,7 +316,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m it 'moves the status of the migration to active' do retry_failed_jobs - expect(batched_migration.status).to eql 'active' + expect(batched_migration.status_name).to be :active end it 'changes the number of attempts to 0' do @@ -301,8 +330,59 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m it 'moves the status of the migration to active' do retry_failed_jobs - expect(batched_migration.status).to eql 'active' + expect(batched_migration.status_name).to be :active + end + end + end + + describe '#should_stop?' do + subject(:should_stop?) { batched_migration.should_stop? } + + let(:batched_migration) { create(:batched_background_migration, started_at: started_at) } + + before do + stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MINIMUM_JOBS', 1) + end + + context 'when the started_at is nil' do + let(:started_at) { nil } + + it { expect(should_stop?).to be_falsey } + end + + context 'when the number of jobs is lesser than the MINIMUM_JOBS' do + let(:started_at) { Time.zone.now - 6.days } + + before do + stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MINIMUM_JOBS', 10) + stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MAXIMUM_FAILED_RATIO', 0.70) + create_list(:batched_background_migration_job, 1, :succeeded, batched_migration: batched_migration) + create_list(:batched_background_migration_job, 3, :failed, batched_migration: batched_migration) + end + + it { expect(should_stop?).to be_falsey } + end + + context 'when the calculated value is greater than the threshold' do + let(:started_at) { Time.zone.now - 6.days } + + before do + stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MAXIMUM_FAILED_RATIO', 0.70) + create_list(:batched_background_migration_job, 1, :succeeded, batched_migration: batched_migration) + create_list(:batched_background_migration_job, 3, :failed, batched_migration: batched_migration) + end + + it { expect(should_stop?).to be_truthy } + end + + context 'when the calculated value is lesser than the threshold' do + let(:started_at) { Time.zone.now - 6.days } + + before do + create_list(:batched_background_migration_job, 2, :succeeded, batched_migration: batched_migration) end + + it { expect(should_stop?).to be_falsey } end end @@ -449,6 +529,20 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m end end + describe '#hold!', :freeze_time do + subject { create(:batched_background_migration) } + + let(:time) { 5.minutes.from_now } + + it 'updates on_hold_until property' do + expect { subject.hold!(until_time: time) }.to change { subject.on_hold_until }.from(nil).to(time) + end + + it 'defaults to 10 minutes' do + expect { subject.hold! }.to change { subject.on_hold_until }.from(nil).to(10.minutes.from_now) + end + end + describe '.for_configuration' do let!(:migration) do create( diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb index d6c984c7adb..6a4ac317cad 100644 --- a/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb @@ -3,8 +3,10 @@ require 'spec_helper' RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '#perform' do - subject { described_class.new.perform(job_record) } + subject { described_class.new(connection: connection, metrics: metrics_tracker).perform(job_record) } + let(:connection) { Gitlab::Database.database_base_models[:main].connection } + let(:metrics_tracker) { instance_double('::Gitlab::Database::BackgroundMigration::PrometheusMetrics', track: nil) } let(:job_class) { Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob } let_it_be(:pause_ms) { 250 } @@ -19,6 +21,12 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' let(:job_instance) { double('job instance', batch_metrics: {}) } + around do |example| + Gitlab::Database::SharedModel.using_connection(connection) do + example.run + end + end + before do allow(job_class).to receive(:new).and_return(job_instance) end @@ -78,86 +86,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' end end - context 'reporting prometheus metrics' do - let(:labels) { job_record.batched_migration.prometheus_labels } - - before do - allow(job_instance).to receive(:perform) - end - - it 'reports batch_size' do - expect(described_class.metrics[:gauge_batch_size]).to receive(:set).with(labels, job_record.batch_size) - - subject - end - - it 'reports sub_batch_size' do - expect(described_class.metrics[:gauge_sub_batch_size]).to receive(:set).with(labels, job_record.sub_batch_size) - - subject - end - - it 'reports interval' do - expect(described_class.metrics[:gauge_interval]).to receive(:set).with(labels, job_record.batched_migration.interval) - - subject - end - - it 'reports updated tuples (currently based on batch_size)' do - expect(described_class.metrics[:counter_updated_tuples]).to receive(:increment).with(labels, job_record.batch_size) - - subject - end - - it 'reports migrated tuples' do - count = double - expect(job_record.batched_migration).to receive(:migrated_tuple_count).and_return(count) - expect(described_class.metrics[:gauge_migrated_tuples]).to receive(:set).with(labels, count) - - subject - end - - it 'reports summary of query timings' do - metrics = { 'timings' => { 'update_all' => [1, 2, 3, 4, 5] } } - - expect(job_instance).to receive(:batch_metrics).and_return(metrics) - - metrics['timings'].each do |key, timings| - summary_labels = labels.merge(operation: key) - timings.each do |timing| - expect(described_class.metrics[:histogram_timings]).to receive(:observe).with(summary_labels, timing) - end - end - - subject - end - - it 'reports job duration' do - freeze_time do - expect(Time).to receive(:current).and_return(Time.zone.now - 5.seconds).ordered - allow(Time).to receive(:current).and_call_original - - expect(described_class.metrics[:gauge_job_duration]).to receive(:set).with(labels, 5.seconds) - - subject - end - end - - it 'reports the total tuple count for the migration' do - expect(described_class.metrics[:gauge_total_tuple_count]).to receive(:set).with(labels, job_record.batched_migration.total_tuple_count) - - subject - end - - it 'reports last updated at timestamp' do - freeze_time do - expect(described_class.metrics[:gauge_last_update_time]).to receive(:set).with(labels, Time.current.to_i) - - subject - end - end - end - context 'when the migration job does not raise an error' do it 'marks the tracking record as succeeded' do expect(job_instance).to receive(:perform).with(1, 10, 'events', 'id', 1, pause_ms, 'id', 'other_id') @@ -171,6 +99,13 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' expect(reloaded_job_record.finished_at).to eq(Time.current) end end + + it 'tracks metrics of the execution' do + expect(job_instance).to receive(:perform) + expect(metrics_tracker).to receive(:track).with(job_record) + + subject + end end context 'when the migration job raises an error' do @@ -189,6 +124,13 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' expect(reloaded_job_record.finished_at).to eq(Time.current) end end + + it 'tracks metrics of the execution' do + expect(job_instance).to receive(:perform).and_raise(error_class) + expect(metrics_tracker).to receive(:track).with(job_record) + + expect { subject }.to raise_error(error_class) + end end it_behaves_like 'an error is raised', RuntimeError.new('Something broke!') @@ -203,7 +145,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' stub_const('Gitlab::BackgroundMigration::Foo', migration_class) end - let(:connection) { double(:connection) } let(:active_migration) { create(:batched_background_migration, :active, job_class_name: 'Foo') } let!(:job_record) { create(:batched_background_migration_job, batched_migration: active_migration) } @@ -212,12 +153,11 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' expect(job_instance).to receive(:perform) - described_class.new(connection: connection).perform(job_record) + subject end end context 'when the batched background migration inherits from BaseJob' do - let(:connection) { double(:connection) } let(:active_migration) { create(:batched_background_migration, :active, job_class_name: 'Foo') } let!(:job_record) { create(:batched_background_migration_job, batched_migration: active_migration) } @@ -232,7 +172,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' expect(job_instance).to receive(:perform) - described_class.new(connection: connection).perform(job_record) + subject end end end diff --git a/spec/lib/gitlab/database/background_migration/prometheus_metrics_spec.rb b/spec/lib/gitlab/database/background_migration/prometheus_metrics_spec.rb new file mode 100644 index 00000000000..1f256de35ec --- /dev/null +++ b/spec/lib/gitlab/database/background_migration/prometheus_metrics_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::BackgroundMigration::PrometheusMetrics, :prometheus do + describe '#track' do + let(:job_record) do + build(:batched_background_migration_job, :succeeded, + started_at: Time.current - 2.minutes, + finished_at: Time.current - 1.minute, + updated_at: Time.current, + metrics: { 'timings' => { 'update_all' => [0.05, 0.2, 0.4, 0.9, 4] } }) + end + + let(:labels) { job_record.batched_migration.prometheus_labels } + + subject(:track_job_record_metrics) { described_class.new.track(job_record) } + + it 'reports batch_size' do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_batch_size)).to eq(job_record.batch_size) + end + + it 'reports sub_batch_size' do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_sub_batch_size)).to eq(job_record.sub_batch_size) + end + + it 'reports interval' do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_interval)).to eq(job_record.batched_migration.interval) + end + + it 'reports job duration' do + freeze_time do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_job_duration)).to eq(1.minute) + end + end + + it 'increments updated tuples (currently based on batch_size)' do + expect(described_class.metrics[:counter_updated_tuples]).to receive(:increment) + .with(labels, job_record.batch_size) + .twice + .and_call_original + + track_job_record_metrics + + expect(metric_for_job_by_name(:counter_updated_tuples)).to eq(job_record.batch_size) + + described_class.new.track(job_record) + + expect(metric_for_job_by_name(:counter_updated_tuples)).to eq(job_record.batch_size * 2) + end + + it 'reports migrated tuples' do + expect(job_record.batched_migration).to receive(:migrated_tuple_count).and_return(20) + + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_migrated_tuples)).to eq(20) + end + + it 'reports the total tuple count for the migration' do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_total_tuple_count)).to eq(job_record.batched_migration.total_tuple_count) + end + + it 'reports last updated at timestamp' do + freeze_time do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_last_update_time)).to eq(Time.current.to_i) + end + end + + it 'reports summary of query timings' do + summary_labels = labels.merge(operation: 'update_all') + + job_record.metrics['timings']['update_all'].each do |timing| + expect(described_class.metrics[:histogram_timings]).to receive(:observe) + .with(summary_labels, timing) + .and_call_original + end + + track_job_record_metrics + + expect(metric_for_job_by_name(:histogram_timings, job_labels: summary_labels)) + .to eq({ 0.1 => 1.0, 0.25 => 2.0, 0.5 => 3.0, 1 => 4.0, 5 => 5.0 }) + end + + context 'when the tracking record does not having timing metrics' do + before do + job_record.metrics = {} + end + + it 'does not attempt to report query timings' do + summary_labels = labels.merge(operation: 'update_all') + + expect(described_class.metrics[:histogram_timings]).not_to receive(:observe) + + track_job_record_metrics + + expect(metric_for_job_by_name(:histogram_timings, job_labels: summary_labels)) + .to eq({ 0.1 => 0.0, 0.25 => 0.0, 0.5 => 0.0, 1 => 0.0, 5 => 0.0 }) + end + end + + def metric_for_job_by_name(name, job_labels: labels) + described_class.metrics[name].values[job_labels].get + end + end +end diff --git a/spec/lib/gitlab/database/consistency_checker_spec.rb b/spec/lib/gitlab/database/consistency_checker_spec.rb new file mode 100644 index 00000000000..2ff79d20786 --- /dev/null +++ b/spec/lib/gitlab/database/consistency_checker_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::ConsistencyChecker do + let(:batch_size) { 10 } + let(:max_batches) { 4 } + let(:max_runtime) { described_class::MAX_RUNTIME } + let(:metrics_counter) { Gitlab::Metrics.registry.get(:consistency_checks) } + + subject(:consistency_checker) do + described_class.new( + source_model: Namespace, + target_model: Ci::NamespaceMirror, + source_columns: %w[id traversal_ids], + target_columns: %w[namespace_id traversal_ids] + ) + end + + before do + stub_const("#{described_class.name}::BATCH_SIZE", batch_size) + stub_const("#{described_class.name}::MAX_BATCHES", max_batches) + redis_shared_state_cleanup! # For Prometheus Counters + end + + after do + Gitlab::Metrics.reset_registry! + end + + describe '#over_time_limit?' do + before do + allow(consistency_checker).to receive(:start_time).and_return(0) + end + + it 'returns true only if the running time has exceeded MAX_RUNTIME' do + allow(consistency_checker).to receive(:monotonic_time).and_return(0, max_runtime - 1, max_runtime + 1) + expect(consistency_checker.monotonic_time).to eq(0) + expect(consistency_checker.send(:over_time_limit?)).to eq(false) + expect(consistency_checker.send(:over_time_limit?)).to eq(true) + end + end + + describe '#execute' do + context 'when empty tables' do + it 'returns an empty response' do + expected_result = { matches: 0, mismatches: 0, batches: 0, mismatches_details: [], next_start_id: nil } + expect(consistency_checker.execute(start_id: 1)).to eq(expected_result) + end + end + + context 'when the tables contain matching items' do + before do + create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects + end + + it 'does not process more than MAX_BATCHES' do + max_batches = 3 + stub_const("#{described_class.name}::MAX_BATCHES", max_batches) + result = consistency_checker.execute(start_id: Namespace.minimum(:id)) + expect(result[:batches]).to eq(max_batches) + expect(result[:matches]).to eq(max_batches * batch_size) + end + + it 'doesn not exceed the MAX_RUNTIME' do + allow(consistency_checker).to receive(:monotonic_time).and_return(0, max_runtime - 1, max_runtime + 1) + result = consistency_checker.execute(start_id: Namespace.minimum(:id)) + expect(result[:batches]).to eq(1) + expect(result[:matches]).to eq(1 * batch_size) + end + + it 'returns the correct number of matches and batches checked' do + expected_result = { + next_start_id: Namespace.minimum(:id) + described_class::MAX_BATCHES * described_class::BATCH_SIZE, + batches: max_batches, + matches: max_batches * batch_size, + mismatches: 0, + mismatches_details: [] + } + expect(consistency_checker.execute(start_id: Namespace.minimum(:id))).to eq(expected_result) + end + + it 'returns the min_id as the next_start_id if the check reaches the last element' do + expect(Gitlab::Metrics).to receive(:counter).at_most(:once) + .with(:consistency_checks, "Consistency Check Results") + .and_call_original + + # Starting from the 5th last element + start_id = Namespace.all.order(id: :desc).limit(5).pluck(:id).last + expected_result = { + next_start_id: Namespace.first.id, + batches: 1, + matches: 5, + mismatches: 0, + mismatches_details: [] + } + expect(consistency_checker.execute(start_id: start_id)).to eq(expected_result) + + expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(0) + expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(5) + end + end + + context 'when some items are missing from the first table' do + let(:missing_namespace) { Namespace.all.order(:id).limit(2).last } + + before do + create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects + missing_namespace.delete + end + + it 'reports the missing elements' do + expected_result = { + next_start_id: Namespace.first.id + described_class::MAX_BATCHES * described_class::BATCH_SIZE, + batches: max_batches, + matches: 39, + mismatches: 1, + mismatches_details: [{ + id: missing_namespace.id, + source_table: nil, + target_table: [missing_namespace.traversal_ids] + }] + } + expect(consistency_checker.execute(start_id: Namespace.first.id)).to eq(expected_result) + + expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(1) + expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(39) + end + end + + context 'when some items are missing from the second table' do + let(:missing_ci_namespace_mirror) { Ci::NamespaceMirror.all.order(:id).limit(2).last } + + before do + create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects + missing_ci_namespace_mirror.delete + end + + it 'reports the missing elements' do + expected_result = { + next_start_id: Namespace.first.id + described_class::MAX_BATCHES * described_class::BATCH_SIZE, + batches: 4, + matches: 39, + mismatches: 1, + mismatches_details: [{ + id: missing_ci_namespace_mirror.namespace_id, + source_table: [missing_ci_namespace_mirror.traversal_ids], + target_table: nil + }] + } + expect(consistency_checker.execute(start_id: Namespace.first.id)).to eq(expected_result) + + expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(1) + expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(39) + end + end + + context 'when elements are different between the two tables' do + let(:different_namespaces) { Namespace.order(:id).limit(max_batches * batch_size).sample(3).sort_by(&:id) } + + before do + create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects + + different_namespaces.each do |namespace| + namespace.update_attribute(:traversal_ids, []) + end + end + + it 'reports the difference between the two tables' do + expected_result = { + next_start_id: Namespace.first.id + described_class::MAX_BATCHES * described_class::BATCH_SIZE, + batches: 4, + matches: 37, + mismatches: 3, + mismatches_details: different_namespaces.map do |namespace| + { + id: namespace.id, + source_table: [[]], + target_table: [[namespace.id]] # old traversal_ids of the namespace + } + end + } + expect(consistency_checker.execute(start_id: Namespace.first.id)).to eq(expected_result) + + expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(3) + expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(37) + end + end + end +end diff --git a/spec/lib/gitlab/database/each_database_spec.rb b/spec/lib/gitlab/database/each_database_spec.rb index d46c1ca8681..191f7017b4c 100644 --- a/spec/lib/gitlab/database/each_database_spec.rb +++ b/spec/lib/gitlab/database/each_database_spec.rb @@ -58,6 +58,15 @@ RSpec.describe Gitlab::Database::EachDatabase do end end end + + context 'when shared connections are not included' do + it 'only yields the unshared connections' do + expect(Gitlab::Database).to receive(:db_config_share_with).twice.and_return(nil, 'main') + + expect { |b| described_class.each_database_connection(include_shared: false, &b) } + .to yield_successive_args([ActiveRecord::Base.connection, 'main']) + end + end end describe '.each_model_connection' do diff --git a/spec/lib/gitlab/database/load_balancing/setup_spec.rb b/spec/lib/gitlab/database/load_balancing/setup_spec.rb index 4d565ce137a..c44637b8d06 100644 --- a/spec/lib/gitlab/database/load_balancing/setup_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/setup_spec.rb @@ -10,7 +10,6 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do expect(setup).to receive(:configure_connection) expect(setup).to receive(:setup_connection_proxy) expect(setup).to receive(:setup_service_discovery) - expect(setup).to receive(:setup_feature_flag_to_model_load_balancing) setup.setup end @@ -120,120 +119,46 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do end end - describe '#setup_feature_flag_to_model_load_balancing', :reestablished_active_record_base do + context 'uses correct base models', :reestablished_active_record_base do using RSpec::Parameterized::TableSyntax where do { - "with model LB enabled it picks a dedicated CI connection" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: 'true', + "it picks a dedicated CI connection" => { env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, request_store_active: false, - ff_use_model_load_balancing: nil, ff_force_no_sharing_primary_model: false, expectations: { main: { read: 'main_replica', write: 'main' }, ci: { read: 'ci_replica', write: 'ci' } } }, - "with model LB enabled and re-use of primary connection it uses CI connection for reads" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: 'true', + "with re-use of primary connection it uses CI connection for reads" => { env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main', request_store_active: false, - ff_use_model_load_balancing: nil, ff_force_no_sharing_primary_model: false, expectations: { main: { read: 'main_replica', write: 'main' }, ci: { read: 'ci_replica', write: 'main' } } }, - "with model LB disabled it fallbacks to use main" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: 'false', - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, - request_store_active: false, - ff_use_model_load_balancing: nil, - ff_force_no_sharing_primary_model: false, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'main_replica', write: 'main' } - } - }, - "with model LB disabled, but re-use configured it fallbacks to use main" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: 'false', + "with re-use and FF force_no_sharing_primary_model enabled with RequestStore it sticks FF and uses CI connection for reads and writes" => { env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main', - request_store_active: false, - ff_use_model_load_balancing: nil, - ff_force_no_sharing_primary_model: false, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'main_replica', write: 'main' } - } - }, - "with FF use_model_load_balancing disabled without RequestStore it uses main" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, - request_store_active: false, - ff_use_model_load_balancing: false, - ff_force_no_sharing_primary_model: false, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'main_replica', write: 'main' } - } - }, - "with FF use_model_load_balancing enabled without RequestStore sticking of FF does not work, so it fallbacks to use main" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, - request_store_active: false, - ff_use_model_load_balancing: true, - ff_force_no_sharing_primary_model: false, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'main_replica', write: 'main' } - } - }, - "with FF use_model_load_balancing disabled with RequestStore it uses main" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, - request_store_active: true, - ff_use_model_load_balancing: false, - ff_force_no_sharing_primary_model: false, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'main_replica', write: 'main' } - } - }, - "with FF use_model_load_balancing enabled with RequestStore it sticks FF and uses CI connection" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, request_store_active: true, - ff_use_model_load_balancing: true, - ff_force_no_sharing_primary_model: false, + ff_force_no_sharing_primary_model: true, expectations: { main: { read: 'main_replica', write: 'main' }, ci: { read: 'ci_replica', write: 'ci' } } }, - "with re-use and ff_use_model_load_balancing enabled and FF force_no_sharing_primary_model disabled with RequestStore it sticks FF and uses CI connection for reads" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, + "with re-use and FF force_no_sharing_primary_model enabled without RequestStore it doesn't use FF and uses CI connection for reads only" => { env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main', request_store_active: true, - ff_use_model_load_balancing: true, ff_force_no_sharing_primary_model: false, expectations: { main: { read: 'main_replica', write: 'main' }, ci: { read: 'ci_replica', write: 'main' } } - }, - "with re-use and ff_use_model_load_balancing enabled and FF force_no_sharing_primary_model enabled with RequestStore it sticks FF and uses CI connection for reads" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main', - request_store_active: true, - ff_use_model_load_balancing: true, - ff_force_no_sharing_primary_model: true, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'ci_replica', write: 'ci' } - } } } end @@ -285,9 +210,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do end end - stub_env('GITLAB_USE_MODEL_LOAD_BALANCING', env_GITLAB_USE_MODEL_LOAD_BALANCING) stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci) - stub_feature_flags(use_model_load_balancing: ff_use_model_load_balancing) # Make load balancer to force init with a dedicated replicas connections models.each do |_, model| diff --git a/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb b/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb index ad9a3a6e257..e7b5bad8626 100644 --- a/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb @@ -240,7 +240,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a end def software_license_class - Class.new(ActiveRecord::Base) do + Class.new(Gitlab::Database::Migration[2.0]::MigrationRecord) do self.table_name = 'software_licenses' end end @@ -272,7 +272,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a end def ci_instance_variables_class - Class.new(ActiveRecord::Base) do + Class.new(Gitlab::Database::Migration[2.0]::MigrationRecord) do self.table_name = 'ci_instance_variables' end end @@ -303,7 +303,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a end def detached_partitions_class - Class.new(ActiveRecord::Base) do + Class.new(Gitlab::Database::Migration[2.0]::MigrationRecord) do self.table_name = 'detached_partitions' end end @@ -496,11 +496,16 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a Gitlab::Database.database_base_models.each do |db_config_name, model| context "for db_config_name=#{db_config_name}" do around do |example| + verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + with_reestablished_active_record_base do reconfigure_db_connection(model: ActiveRecord::Base, config_model: model) example.run end + ensure + ActiveRecord::Migration.verbose = verbose_was end before do @@ -543,8 +548,15 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a expect { ignore_error(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas::DDLNotAllowedError) { migration_class.migrate(:down) } }.not_to raise_error when :skipped - expect { migration_class.migrate(:up) }.to raise_error(Gitlab::Database::MigrationHelpers::RestrictGitlabSchema::MigrationSkippedError) - expect { migration_class.migrate(:down) }.to raise_error(Gitlab::Database::MigrationHelpers::RestrictGitlabSchema::MigrationSkippedError) + expect_next_instance_of(migration_class) do |migration_object| + expect(migration_object).to receive(:migration_skipped).and_call_original + expect(migration_object).not_to receive(:up) + expect(migration_object).not_to receive(:down) + expect(migration_object).not_to receive(:change) + end + + migration_class.migrate(:up) + migration_class.migrate(:down) 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 acf775b3538..5c054795697 100644 --- a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb @@ -96,6 +96,12 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do expect(new_record_1.reload).to have_attributes(status: 1, original: 'updated', renamed: 'updated') expect(new_record_2.reload).to have_attributes(status: 1, original: nil, renamed: nil) end + + it 'requires the helper to run in ddl mode' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!) + + migration.public_send(operation, :_test_table, :original, :renamed) + end end describe '#rename_column_concurrently' do diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 9505da8fd12..798eee0de3e 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -1390,6 +1390,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end it 'reverses the operations of cleanup_concurrent_column_type_change' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!) + expect(model).to receive(:check_trigger_permissions!).with(:users) expect(model).to receive(:create_column_from).with( @@ -1415,6 +1417,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end it 'passes the type_cast_function, batch_column_name and limit' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!) + expect(model).to receive(:column_exists?).with(:users, :other_batch_column).and_return(true) expect(model).to receive(:check_trigger_permissions!).with(:users) @@ -2096,7 +2100,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end end - let(:migration_relation) { Gitlab::Database::BackgroundMigration::BatchedMigration.active } + let(:migration_relation) { Gitlab::Database::BackgroundMigration::BatchedMigration.with_status(:active) } before do model.initialize_conversion_of_integer_to_bigint(table, columns) @@ -2218,7 +2222,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do subject(:ensure_batched_background_migration_is_finished) { model.ensure_batched_background_migration_is_finished(**configuration) } it 'raises an error when migration exists and is not marked as finished' do - create(:batched_background_migration, configuration.merge(status: :active)) + create(:batched_background_migration, :active, configuration) expect { ensure_batched_background_migration_is_finished } .to raise_error "Expected batched background migration for the given configuration to be marked as 'finished', but it is 'active':" \ @@ -2234,7 +2238,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end it 'does not raise error when migration exists and is marked as finished' do - create(:batched_background_migration, configuration.merge(status: :finished)) + create(:batched_background_migration, :finished, configuration) expect { ensure_batched_background_migration_is_finished } .not_to raise_error @@ -2422,7 +2426,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do def setup namespace = namespaces.create!(name: 'foo', path: 'foo', type: Namespaces::UserNamespace.sti_name) - projects.create!(namespace_id: namespace.id) + project_namespace = namespaces.create!(name: 'project-foo', path: 'project-foo', type: 'Project', parent_id: namespace.id, visibility_level: 20) + projects.create!(namespace_id: namespace.id, project_namespace_id: project_namespace.id) end it 'generates iids properly for models created after the migration' do diff --git a/spec/lib/gitlab/database/migration_spec.rb b/spec/lib/gitlab/database/migration_spec.rb index 287e738c24e..18bbc6c1dd3 100644 --- a/spec/lib/gitlab/database/migration_spec.rb +++ b/spec/lib/gitlab/database/migration_spec.rb @@ -32,7 +32,7 @@ RSpec.describe Gitlab::Database::Migration 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) + expect(described_class[described_class.current_version]).to be < ActiveRecord::Migration::Current end end diff --git a/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb index 37efff165c7..f9347a174c4 100644 --- a/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb @@ -75,7 +75,7 @@ RSpec.describe Gitlab::Database::Migrations::BatchedBackgroundMigrationHelpers d max_batch_size: 10000, sub_batch_size: 10, job_arguments: %w[], - status: 'active', + status_name: :active, total_tuple_count: pgclass_info.cardinality_estimate) end diff --git a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb index fd8303c379c..c31244060ec 100644 --- a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb +++ b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb @@ -11,6 +11,10 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do describe '#observe' do subject { described_class.new(result_dir: result_dir) } + def load_observation(result_dir, migration_name) + Gitlab::Json.parse(File.read(File.join(result_dir, migration_name, described_class::STATS_FILENAME))) + end + let(:migration_name) { 'test' } let(:migration_version) { '12345' } @@ -87,7 +91,7 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do end context 'retrieving observations' do - subject { instance.observations.first } + subject { load_observation(result_dir, migration_name) } before do observe @@ -98,10 +102,10 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do end it 'records a valid observation', :aggregate_failures do - expect(subject.walltime).not_to be_nil - expect(subject.success).to be_falsey - expect(subject.version).to eq(migration_version) - expect(subject.name).to eq(migration_name) + expect(subject['walltime']).not_to be_nil + expect(subject['success']).to be_falsey + expect(subject['version']).to eq(migration_version) + expect(subject['name']).to eq(migration_name) end end end @@ -113,11 +117,18 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do let(:migration1) { double('migration1', call: nil) } let(:migration2) { double('migration2', call: nil) } + let(:migration_name_2) { 'other_migration' } + let(:migration_version_2) { '98765' } + it 'records observations for all migrations' do subject.observe(version: migration_version, name: migration_name, connection: connection) {} - subject.observe(version: migration_version, name: migration_name, connection: connection) { raise 'something went wrong' } rescue nil + subject.observe(version: migration_version_2, name: migration_name_2, connection: connection) { raise 'something went wrong' } rescue nil + + expect { load_observation(result_dir, migration_name) }.not_to raise_error + expect { load_observation(result_dir, migration_name_2) }.not_to raise_error - expect(subject.observations.size).to eq(2) + # Each observation is a subdirectory of the result_dir, so here we check that we didn't record an extra one + expect(Pathname(result_dir).children.map { |d| d.basename.to_s }).to contain_exactly(migration_name, migration_name_2) end end end diff --git a/spec/lib/gitlab/database/migrations/runner_spec.rb b/spec/lib/gitlab/database/migrations/runner_spec.rb index 84482e6b450..8b1ccf05eb1 100644 --- a/spec/lib/gitlab/database/migrations/runner_spec.rb +++ b/spec/lib/gitlab/database/migrations/runner_spec.rb @@ -124,4 +124,16 @@ RSpec.describe Gitlab::Database::Migrations::Runner do expect(metadata).to match('version' => described_class::SCHEMA_VERSION) end end + + describe '.background_migrations' do + it 'is a TestBackgroundRunner' do + expect(described_class.background_migrations).to be_a(Gitlab::Database::Migrations::TestBackgroundRunner) + end + + it 'is configured with a result dir of /background_migrations' do + runner = described_class.background_migrations + + expect(runner.result_dir).to eq(described_class::BASE_RESULT_DIR.join( 'background_migrations')) + end + end end diff --git a/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb b/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb index c6fe88a7c2d..9407efad91f 100644 --- a/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb +++ b/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb @@ -11,11 +11,17 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do Sidekiq::Testing.disable! { ex.run } end + let(:result_dir) { Dir.mktmpdir } + + after do + FileUtils.rm_rf(result_dir) + end + context 'without jobs to run' do it 'returns immediately' do - runner = described_class.new + runner = described_class.new(result_dir: result_dir) expect(runner).not_to receive(:run_job) - described_class.new.run_jobs(for_duration: 1.second) + described_class.new(result_dir: result_dir).run_jobs(for_duration: 1.second) end end @@ -30,7 +36,7 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do context 'finding pending background jobs' do it 'finds all the migrations' do - expect(described_class.new.traditional_background_migrations.to_a.size).to eq(5) + expect(described_class.new(result_dir: result_dir).traditional_background_migrations.to_a.size).to eq(5) end end @@ -53,12 +59,28 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do end end + def expect_recorded_migration_runs(migrations_to_runs) + migrations_to_runs.each do |migration, runs| + path = File.join(result_dir, migration.name.demodulize) + num_subdirs = Pathname(path).children.count(&:directory?) + expect(num_subdirs).to eq(runs) + end + end + + def expect_migration_runs(migrations_to_run_counts) + expect_migration_call_counts(migrations_to_run_counts) + + yield + + expect_recorded_migration_runs(migrations_to_run_counts) + end + it 'runs the migration class correctly' do calls = [] define_background_migration(migration_name) do |i| calls << i end - described_class.new.run_jobs(for_duration: 1.second) # Any time would work here as we do not advance time + described_class.new(result_dir: result_dir).run_jobs(for_duration: 1.second) # Any time would work here as we do not advance time expect(calls).to contain_exactly(1, 2, 3, 4, 5) end @@ -67,9 +89,9 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do travel(1.minute) end - expect_migration_call_counts(migration => 3) - - described_class.new.run_jobs(for_duration: 3.minutes) + expect_migration_runs(migration => 3) do + described_class.new(result_dir: result_dir).run_jobs(for_duration: 3.minutes) + end end context 'with multiple migrations to run' do @@ -90,12 +112,12 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do travel(2.minutes) end - expect_migration_call_counts( + expect_migration_runs( migration => 2, # 1 minute jobs for 90 seconds, can finish the first and start the second other_migration => 1 # 2 minute jobs for 90 seconds, past deadline after a single job - ) - - described_class.new.run_jobs(for_duration: 3.minutes) + ) do + described_class.new(result_dir: result_dir).run_jobs(for_duration: 3.minutes) + end end it 'does not give leftover time to extra migrations' do @@ -107,12 +129,13 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do other_migration = define_background_migration(other_migration_name) do travel(1.minute) end - expect_migration_call_counts( + + expect_migration_runs( migration => 5, other_migration => 2 - ) - - described_class.new.run_jobs(for_duration: 3.minutes) + ) do + described_class.new(result_dir: result_dir).run_jobs(for_duration: 3.minutes) + end end end end diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb index 4f1d6302331..1026b4370a5 100644 --- a/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb +++ b/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb @@ -125,6 +125,17 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe expect_table_partitioned_by(partitioned_table, [partition_column]) end + it 'requires the migration helper to be run in DDL mode' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!) + + migration.partition_table_by_date source_table, partition_column, min_date: min_date, max_date: max_date + + expect(connection.table_exists?(partitioned_table)).to be(true) + expect(connection.primary_key(partitioned_table)).to eq(new_primary_key) + + expect_table_partitioned_by(partitioned_table, [partition_column]) + end + it 'changes the primary key datatype to bigint' do migration.partition_table_by_date source_table, partition_column, min_date: min_date, max_date: max_date @@ -191,6 +202,8 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe end it 'creates a partition spanning over each month from the first record' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:with_suppressed).and_yield + migration.partition_table_by_date source_table, partition_column, max_date: max_date expect_range_partitions_for(partitioned_table, { @@ -206,6 +219,8 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe context 'without data' do it 'creates the catchall partition plus two actual partition' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:with_suppressed).and_yield + migration.partition_table_by_date source_table, partition_column, max_date: max_date expect_range_partitions_for(partitioned_table, { @@ -536,6 +551,16 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe migration.finalize_backfilling_partitioned_table source_table end + + it 'requires the migration helper to execute in DML mode' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_dml_mode!) + + expect(Gitlab::BackgroundMigration).to receive(:steal) + .with(described_class::MIGRATION_CLASS_NAME) + .and_yield(background_job) + + migration.finalize_backfilling_partitioned_table source_table + end end context 'when there is missed data' do @@ -627,6 +652,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe allow(backfill).to receive(:perform).and_return(1) end + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:with_suppressed).and_yield expect(migration).to receive(:disable_statement_timeout).and_call_original expect(migration).to receive(:execute).with("VACUUM FREEZE ANALYZE #{partitioned_table}") diff --git a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb index 86e74cf5177..b8c1ecd9089 100644 --- a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb +++ b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana process_sql(ActiveRecord::Base, "SELECT 1 FROM projects") end - context 'properly observes all queries', :add_ci_connection do + context 'properly observes all queries', :add_ci_connection, :request_store do using RSpec::Parameterized::TableSyntax where do @@ -28,7 +28,8 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana expectations: { gitlab_schemas: "gitlab_main", db_config_name: "main" - } + }, + setup: nil }, "for query accessing gitlab_ci and gitlab_main" => { model: ApplicationRecord, @@ -36,7 +37,8 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana expectations: { gitlab_schemas: "gitlab_ci,gitlab_main", db_config_name: "main" - } + }, + setup: nil }, "for query accessing gitlab_ci and gitlab_main the gitlab_schemas is always ordered" => { model: ApplicationRecord, @@ -44,7 +46,8 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana expectations: { gitlab_schemas: "gitlab_ci,gitlab_main", db_config_name: "main" - } + }, + setup: nil }, "for query accessing CI database" => { model: Ci::ApplicationRecord, @@ -53,6 +56,62 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana gitlab_schemas: "gitlab_ci", db_config_name: "ci" } + }, + "for query accessing CI database with re-use and disabled sharing" => { + model: Ci::ApplicationRecord, + sql: "SELECT 1 FROM ci_builds", + expectations: { + gitlab_schemas: "gitlab_ci", + db_config_name: "ci", + ci_dedicated_primary_connection: true + }, + setup: ->(_) do + skip_if_multiple_databases_not_setup + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', 'main') + stub_feature_flags(force_no_sharing_primary_model: true) + end + }, + "for query accessing CI database with re-use and enabled sharing" => { + model: Ci::ApplicationRecord, + sql: "SELECT 1 FROM ci_builds", + expectations: { + gitlab_schemas: "gitlab_ci", + db_config_name: "ci", + ci_dedicated_primary_connection: false + }, + setup: ->(_) do + skip_if_multiple_databases_not_setup + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', 'main') + stub_feature_flags(force_no_sharing_primary_model: false) + end + }, + "for query accessing CI database without re-use and disabled sharing" => { + model: Ci::ApplicationRecord, + sql: "SELECT 1 FROM ci_builds", + expectations: { + gitlab_schemas: "gitlab_ci", + db_config_name: "ci", + ci_dedicated_primary_connection: true + }, + setup: ->(_) do + skip_if_multiple_databases_not_setup + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', nil) + stub_feature_flags(force_no_sharing_primary_model: true) + end + }, + "for query accessing CI database without re-use and enabled sharing" => { + model: Ci::ApplicationRecord, + sql: "SELECT 1 FROM ci_builds", + expectations: { + gitlab_schemas: "gitlab_ci", + db_config_name: "ci", + ci_dedicated_primary_connection: true + }, + setup: ->(_) do + skip_if_multiple_databases_not_setup + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', nil) + stub_feature_flags(force_no_sharing_primary_model: false) + end } } end @@ -63,8 +122,15 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana end it do + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', nil) + + instance_eval(&setup) if setup + + allow(::Ci::ApplicationRecord.load_balancer).to receive(:configuration) + .and_return(Gitlab::Database::LoadBalancing::Configuration.for_model(::Ci::ApplicationRecord)) + expect(described_class.schemas_metrics).to receive(:increment) - .with(expectations).and_call_original + .with({ ci_dedicated_primary_connection: anything }.merge(expectations)).and_call_original process_sql(model, sql) end diff --git a/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb b/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb index e76718fe48a..34670696787 100644 --- a/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb +++ b/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb @@ -74,8 +74,28 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do end describe '#notify_start' do - context 'additional tag is nil' do - subject { described_class.new(api_key, api_url, nil).notify_start(action) } + context 'when Grafana is configured using application settings' do + subject { described_class.new.notify_start(action) } + + let(:payload) do + { + time: (action.action_start.utc.to_f * 1000).to_i, + tags: ['reindex', additional_tag, action.index.tablename, action.index.name], + text: "Started reindexing of #{action.index.name} on #{action.index.tablename}" + } + end + + before do + stub_application_setting(database_grafana_api_key: api_key) + stub_application_setting(database_grafana_api_url: api_url) + stub_application_setting(database_grafana_tag: additional_tag) + end + + it_behaves_like 'interacting with Grafana annotations API' + end + + context 'when there is no additional tag' do + subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: '').notify_start(action) } let(:payload) do { @@ -88,8 +108,8 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do it_behaves_like 'interacting with Grafana annotations API' end - context 'additional tag is not nil' do - subject { described_class.new(api_key, api_url, additional_tag).notify_start(action) } + context 'additional tag is provided' do + subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: additional_tag).notify_start(action) } let(:payload) do { @@ -104,8 +124,30 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do end describe '#notify_end' do - context 'additional tag is nil' do - subject { described_class.new(api_key, api_url, nil).notify_end(action) } + context 'when Grafana is configured using application settings' do + subject { described_class.new.notify_end(action) } + + let(:payload) do + { + time: (action.action_start.utc.to_f * 1000).to_i, + tags: ['reindex', additional_tag, action.index.tablename, action.index.name], + text: "Finished reindexing of #{action.index.name} on #{action.index.tablename} (#{action.state})", + timeEnd: (action.action_end.utc.to_f * 1000).to_i, + isRegion: true + } + end + + before do + stub_application_setting(database_grafana_api_key: api_key) + stub_application_setting(database_grafana_api_url: api_url) + stub_application_setting(database_grafana_tag: additional_tag) + end + + it_behaves_like 'interacting with Grafana annotations API' + end + + context 'when there is no additional tag' do + subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: '').notify_end(action) } let(:payload) do { @@ -120,8 +162,8 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do it_behaves_like 'interacting with Grafana annotations API' end - context 'additional tag is not nil' do - subject { described_class.new(api_key, api_url, additional_tag).notify_end(action) } + context 'additional tag is provided' do + subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: additional_tag).notify_end(action) } let(:payload) do { diff --git a/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb b/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb index 7caee414719..0bea348e6b4 100644 --- a/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb +++ b/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb @@ -68,8 +68,8 @@ RSpec.describe Gitlab::Database::SchemaCacheWithRenamedTable do describe 'when the table behind a model is actually a view' do let(:group) { create(:group) } - let(:project_attributes) { attributes_for(:project, namespace_id: group.id).except(:creator) } - let(:record) { old_model.create!(project_attributes) } + let(:attrs) { attributes_for(:project, namespace_id: group.id, project_namespace_id: group.id).except(:creator) } + let(:record) { old_model.create!(attrs) } it 'can persist records' do expect(record.reload.attributes).to eq(new_model.find(record.id).attributes) diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index c58dba213ee..ac8616f84a7 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -185,16 +185,6 @@ RSpec.describe Gitlab::Database do end end - describe '.nulls_last_order' do - it { expect(described_class.nulls_last_order('column', 'ASC')).to eq 'column ASC NULLS LAST'} - it { expect(described_class.nulls_last_order('column', 'DESC')).to eq 'column DESC NULLS LAST'} - end - - describe '.nulls_first_order' do - it { expect(described_class.nulls_first_order('column', 'ASC')).to eq 'column ASC NULLS FIRST'} - it { expect(described_class.nulls_first_order('column', 'DESC')).to eq 'column DESC NULLS FIRST'} - end - describe '.db_config_for_connection' do context 'when the regular connection is used' do it 'returns db_config' do @@ -245,15 +235,32 @@ RSpec.describe Gitlab::Database do end end + describe '.db_config_names' do + let(:expected) { %w[foo bar] } + + it 'includes only main by default' do + allow(::ActiveRecord::Base).to receive(:configurations).and_return( + double(configs_for: %w[foo bar].map { |x| double(name: x) }) + ) + + expect(described_class.db_config_names).to eq(expected) + end + + it 'excludes geo when that is included' do + allow(::ActiveRecord::Base).to receive(:configurations).and_return( + double(configs_for: %w[foo bar geo].map { |x| double(name: x) }) + ) + + expect(described_class.db_config_names).to eq(expected) + end + end + describe '.gitlab_schemas_for_connection' do it 'does raise exception for invalid connection' do expect { described_class.gitlab_schemas_for_connection(:invalid) }.to raise_error /key not found: "unknown"/ end it 'does return a valid schema depending on a base model used', :request_store do - # This is currently required as otherwise the `Ci::Build.connection` == `Project.connection` - # ENV due to lib/gitlab/database/load_balancing/setup.rb:93 - stub_env('GITLAB_USE_MODEL_LOAD_BALANCING', '1') # FF due to lib/gitlab/database/load_balancing/configuration.rb:92 stub_feature_flags(force_no_sharing_primary_model: true) @@ -268,6 +275,47 @@ RSpec.describe Gitlab::Database do expect(described_class.gitlab_schemas_for_connection(ActiveRecord::Base.connection)).to include(:gitlab_ci, :gitlab_shared) end end + + context "when there's CI connection", :request_store do + before do + skip_if_multiple_databases_not_setup + + # FF due to lib/gitlab/database/load_balancing/configuration.rb:92 + # Requires usage of `:request_store` + stub_feature_flags(force_no_sharing_primary_model: true) + end + + context 'when CI uses database_tasks: false does indicate that ci: is subset of main:' do + before do + allow(Ci::ApplicationRecord.connection_db_config).to receive(:database_tasks?).and_return(false) + end + + it 'does return gitlab_ci when accessing via main: connection' do + expect(described_class.gitlab_schemas_for_connection(Project.connection)).to include(:gitlab_ci, :gitlab_main, :gitlab_shared) + end + + it 'does not return gitlab_main when accessing via ci: connection' do + expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).to include(:gitlab_ci, :gitlab_shared) + expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).not_to include(:gitlab_main) + end + end + + context 'when CI uses database_tasks: true does indicate that ci: has own database' do + before do + allow(Ci::ApplicationRecord.connection_db_config).to receive(:database_tasks?).and_return(true) + end + + it 'does not return gitlab_ci when accessing via main: connection' do + expect(described_class.gitlab_schemas_for_connection(Project.connection)).to include(:gitlab_main, :gitlab_shared) + expect(described_class.gitlab_schemas_for_connection(Project.connection)).not_to include(:gitlab_ci) + end + + it 'does not return gitlab_main when accessing via ci: connection' do + expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).to include(:gitlab_ci, :gitlab_shared) + expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).not_to include(:gitlab_main) + end + end + end end describe '#true_value' do diff --git a/spec/lib/gitlab/diff/custom_diff_spec.rb b/spec/lib/gitlab/diff/custom_diff_spec.rb index 246508d2e1e..77d2a6cbcd6 100644 --- a/spec/lib/gitlab/diff/custom_diff_spec.rb +++ b/spec/lib/gitlab/diff/custom_diff_spec.rb @@ -34,6 +34,59 @@ RSpec.describe Gitlab::Diff::CustomDiff do expect(described_class.transformed_for_diff?(blob)).to be_falsey end end + + context 'timeout' do + subject { described_class.preprocess_before_diff(ipynb_blob.path, nil, ipynb_blob) } + + it 'falls back to nil on timeout' do + allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + expect(Timeout).to receive(:timeout).and_raise(Timeout::Error) + + expect(subject).to be_nil + end + + context 'when in foreground' do + it 'utilizes timeout for web' do + expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_FOREGROUND).and_call_original + + expect(subject).not_to include('cells') + end + + it 'increments metrics' do + counter = Gitlab::Metrics.counter(:ipynb_semantic_diff_timeouts_total, 'desc') + + expect(Timeout).to receive(:timeout).and_raise(Timeout::Error) + expect { subject }.to change { counter.get(source: described_class::FOREGROUND_EXECUTION) }.by(1) + end + end + + context 'when in background' do + before do + allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) + end + + it 'utilizes longer timeout for sidekiq' do + expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_BACKGROUND).and_call_original + + expect(subject).not_to include('cells') + end + + it 'increments metrics' do + counter = Gitlab::Metrics.counter(:ipynb_semantic_diff_timeouts_total, 'desc') + + expect(Timeout).to receive(:timeout).and_raise(Timeout::Error) + expect { subject }.to change { counter.get(source: described_class::BACKGROUND_EXECUTION) }.by(1) + end + end + end + + context 'when invalid ipynb' do + it 'returns nil' do + expect(ipynb_blob).to receive(:data).and_return('invalid ipynb') + + expect(described_class.preprocess_before_diff(ipynb_blob.path, nil, ipynb_blob)).to be_nil + end + end end describe '#transformed_blob_data' do diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb index f2212ec9b09..0d7a183bb11 100644 --- a/spec/lib/gitlab/diff/file_spec.rb +++ b/spec/lib/gitlab/diff/file_spec.rb @@ -51,6 +51,54 @@ RSpec.describe Gitlab::Diff::File do project.commit(branch_name).diffs.diff_files.first end + describe '#initialize' do + let(:commit) { project.commit("532c837") } + + context 'when file is ipynb' do + let(:ipynb_semantic_diff) { false } + let(:rendered_diffs_viewer) { false } + + before do + stub_feature_flags(ipynb_semantic_diff: ipynb_semantic_diff, rendered_diffs_viewer: rendered_diffs_viewer) + end + + context 'when ipynb_semantic_diff is off, and rendered_viewer is off' do + it 'does not generate notebook diffs' do + expect(Gitlab::Diff::CustomDiff).not_to receive(:preprocess_before_diff) + expect(diff_file.rendered).to be_nil + end + end + + context 'when ipynb_semantic_diff is off, and rendered_viewer is on' do + let(:rendered_diffs_viewer) { true } + + it 'does not generate rendered diff' do + expect(Gitlab::Diff::CustomDiff).not_to receive(:preprocess_before_diff) + expect(diff_file.rendered).to be_nil + end + end + + context 'when ipynb_semantic_diff is on, and rendered_viewer is off' do + let(:ipynb_semantic_diff) { true } + + it 'transforms using custom diff CustomDiff' do + expect(Gitlab::Diff::CustomDiff).to receive(:preprocess_before_diff).and_call_original + expect(diff_file.rendered).to be_nil + end + end + + context 'when ipynb_semantic_diff is on, and rendered_viewer is on' do + let(:ipynb_semantic_diff) { true } + let(:rendered_diffs_viewer) { true } + + it 'transforms diff using NotebookDiffFile' do + expect(Gitlab::Diff::CustomDiff).not_to receive(:preprocess_before_diff) + expect(diff_file.rendered).not_to be_nil + end + end + end + end + describe '#has_renderable?' do context 'file is ipynb' do let(:commit) { project.commit("532c837") } @@ -66,14 +114,58 @@ RSpec.describe Gitlab::Diff::File do it 'does not have renderable viewer' do expect(diff_file.has_renderable?).to be_falsey end + + it 'does not create a Notebook DiffFile' do + expect(diff_file.rendered).to be_nil + + expect(::Gitlab::Diff::Rendered::Notebook::DiffFile).not_to receive(:new) + end end end describe '#rendered' do - let(:commit) { project.commit("532c837") } + context 'when not ipynb' do + it 'is nil' do + expect(diff_file.rendered).to be_nil + end + end + + context 'when ipynb' do + let(:commit) { project.commit("532c837") } + + it 'creates a NotebookDiffFile for rendering' do + expect(diff_file.rendered).to be_kind_of(Gitlab::Diff::Rendered::Notebook::DiffFile) + end + + context 'when too large' do + it 'is nil' do + expect(diff).to receive(:too_large?).and_return(true) + + expect(diff_file.rendered).to be_nil + end + end + + context 'when not modified' do + it 'is nil' do + expect(diff_file).to receive(:modified_file?).and_return(false) + + expect(diff_file.rendered).to be_nil + end + end + + context 'when semantic ipynb is off' do + before do + stub_feature_flags(ipynb_semantic_diff: false) + end + + it 'returns nil' do + expect(diff_file).not_to receive(:modified_file?) + expect(diff_file).not_to receive(:ipynb?) + expect(diff).not_to receive(:too_large?) - it 'creates a NotebookDiffFile for rendering' do - expect(diff_file.rendered).to be_kind_of(Gitlab::Diff::Rendered::Notebook::DiffFile) + expect(diff_file.rendered).to be_nil + end + end end end diff --git a/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb b/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb index 15edbc22460..89b284feee0 100644 --- a/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb +++ b/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb @@ -63,6 +63,28 @@ RSpec.describe Gitlab::Diff::Rendered::Notebook::DiffFile do expect(nb_file.diff).to be_nil end end + + context 'timeout' do + it 'utilizes timeout for web' do + expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_FOREGROUND).and_call_original + + nb_file.diff + end + + it 'falls back to nil on timeout' do + allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + expect(Timeout).to receive(:timeout).and_raise(Timeout::Error) + + expect(nb_file.diff).to be_nil + end + + it 'utilizes longer timeout for sidekiq' do + allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) + expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_BACKGROUND).and_call_original + + nb_file.diff + end + end end describe '#has_renderable?' 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 913e197708f..8d008986464 100644 --- a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb @@ -477,20 +477,6 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do end end - context 'when there is a reply-to address and a from address' do - let(:email_raw) { email_fixture('emails/service_desk_reply_to_and_from.eml') } - - it 'shows both from and reply-to addresses in the issue header' do - setup_attachment - - expect { receiver.execute }.to change { Issue.count }.by(1) - - new_issue = Issue.last - - expect(new_issue.external_author).to eq('finn@adventuretime.ooo (reply to: marceline@adventuretime.ooo)') - end - end - context 'when service desk is not enabled for project' do before do allow(Gitlab::ServiceDesk).to receive(:enabled?).and_return(false) diff --git a/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb index 0521123f1ef..8bd873cf008 100644 --- a/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb +++ b/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb @@ -100,7 +100,6 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing::Base do :trial | true :team | true :experience | true - :invite_team | false end with_them do diff --git a/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb deleted file mode 100644 index 8319560f594..00000000000 --- a/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Email::Message::InProductMarketing::InviteTeam do - let_it_be(:group) { build(:group) } - let_it_be(:user) { build(:user) } - - let(:series) { 0 } - - subject(:message) { described_class.new(group: group, user: user, series: series) } - - describe 'initialize' do - context 'when series is valid' do - it 'does not raise error' do - expect { subject }.not_to raise_error(ArgumentError) - end - end - - context 'when series is invalid' do - let(:series) { 1 } - - it 'raises error' do - expect { subject }.to raise_error(ArgumentError) - end - end - end - - it 'contains the correct message', :aggregate_failures do - expect(message.subject_line).to eq 'Invite your teammates to GitLab' - expect(message.tagline).to be_empty - expect(message.title).to eq 'GitLab is better with teammates to help out!' - expect(message.subtitle).to be_empty - expect(message.body_line1).to eq 'Invite your teammates today and build better code together. You can even assign tasks to new teammates such as setting up CI/CD, to help get projects up and running.' - expect(message.body_line2).to be_empty - expect(message.cta_text).to eq 'Invite your teammates to help' - expect(message.logo_path).to eq 'mailers/in_product_marketing/team-0.png' - end -end diff --git a/spec/lib/gitlab/email/message/in_product_marketing_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing_spec.rb index 594df7440bb..40351bef8b9 100644 --- a/spec/lib/gitlab/email/message/in_product_marketing_spec.rb +++ b/spec/lib/gitlab/email/message/in_product_marketing_spec.rb @@ -18,7 +18,6 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing do :trial | described_class::Trial :team | described_class::Team :experience | described_class::Experience - :invite_team | described_class::InviteTeam end with_them do diff --git a/spec/lib/gitlab/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb index 98170ef437c..4c1fbb93c13 100644 --- a/spec/lib/gitlab/encoding_helper_spec.rb +++ b/spec/lib/gitlab/encoding_helper_spec.rb @@ -265,4 +265,14 @@ RSpec.describe Gitlab::EncodingHelper do end end end + + describe '#unquote_path' do + it do + expect(described_class.unquote_path('unquoted')).to eq('unquoted') + expect(described_class.unquote_path('"quoted"')).to eq('quoted') + expect(described_class.unquote_path('"\\311\\240\\304\\253\\305\\247\\305\\200\\310\\247\\306\\200"')).to eq('ɠīŧŀȧƀ') + expect(described_class.unquote_path('"\\\\303\\\\251"')).to eq('\303\251') + expect(described_class.unquote_path('"\a\b\e\f\n\r\t\v\""')).to eq("\a\b\e\f\n\r\t\v\"") + end + end end diff --git a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb index 5b78acc3b1d..f878f02f410 100644 --- a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb +++ b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb @@ -67,7 +67,7 @@ RSpec.describe Gitlab::Gfm::UploadsRewriter do it 'does not rewrite plain links as embedded' do embedded_link = image_uploader.markdown_link - plain_image_link = embedded_link.sub(/\A!/, "") + plain_image_link = embedded_link.delete_prefix('!') text = "#{plain_image_link} and #{embedded_link}" moved_text = described_class.new(text, old_project, user).rewrite(new_project) diff --git a/spec/lib/gitlab/git/blame_spec.rb b/spec/lib/gitlab/git/blame_spec.rb index 495cb16ebab..7dd7460b142 100644 --- a/spec/lib/gitlab/git/blame_spec.rb +++ b/spec/lib/gitlab/git/blame_spec.rb @@ -4,71 +4,81 @@ require "spec_helper" RSpec.describe Gitlab::Git::Blame, :seed_helper do let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } - let(:blame) do - Gitlab::Git::Blame.new(repository, SeedRepo::Commit::ID, "CONTRIBUTING.md") + + let(:sha) { SeedRepo::Commit::ID } + let(:path) { 'CONTRIBUTING.md' } + let(:range) { nil } + + subject(:blame) { Gitlab::Git::Blame.new(repository, sha, path, range: range) } + + let(:result) do + [].tap do |data| + blame.each do |commit, line, previous_path| + data << { commit: commit, line: line, previous_path: previous_path } + end + end end describe 'blaming a file' do - context "each count" do - it do - data = [] - blame.each do |commit, line| - data << { - commit: commit, - line: line - } - end + it 'has the right number of lines' do + expect(result.size).to eq(95) + expect(result.first[:commit]).to be_kind_of(Gitlab::Git::Commit) + expect(result.first[:line]).to eq("# Contribute to GitLab") + expect(result.first[:line]).to be_utf8 + end - expect(data.size).to eq(95) - expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit) - expect(data.first[:line]).to eq("# Contribute to GitLab") - expect(data.first[:line]).to be_utf8 + context 'blaming a range' do + let(:range) { 2..4 } + + it 'only returns the range' do + expect(result.size).to eq(range.size) + expect(result.map {|r| r[:line] }).to eq(['', 'This guide details how contribute to GitLab.', '']) end end context "ISO-8859 encoding" do - let(:blame) do - Gitlab::Git::Blame.new(repository, SeedRepo::EncodingCommit::ID, "encoding/iso8859.txt") - end + let(:sha) { SeedRepo::EncodingCommit::ID } + let(:path) { 'encoding/iso8859.txt' } it 'converts to UTF-8' do - data = [] - blame.each do |commit, line| - data << { - commit: commit, - line: line - } - end - - expect(data.size).to eq(1) - expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit) - expect(data.first[:line]).to eq("Ä ü") - expect(data.first[:line]).to be_utf8 + expect(result.size).to eq(1) + expect(result.first[:commit]).to be_kind_of(Gitlab::Git::Commit) + expect(result.first[:line]).to eq("Ä ü") + expect(result.first[:line]).to be_utf8 end end context "unknown encoding" do - let(:blame) do - Gitlab::Git::Blame.new(repository, SeedRepo::EncodingCommit::ID, "encoding/iso8859.txt") - end + let(:sha) { SeedRepo::EncodingCommit::ID } + let(:path) { 'encoding/iso8859.txt' } it 'converts to UTF-8' do expect_next_instance_of(CharlockHolmes::EncodingDetector) do |detector| expect(detector).to receive(:detect).and_return(nil) end - data = [] - blame.each do |commit, line| - data << { - commit: commit, - line: line - } - end + expect(result.size).to eq(1) + expect(result.first[:commit]).to be_kind_of(Gitlab::Git::Commit) + expect(result.first[:line]).to eq(" ") + expect(result.first[:line]).to be_utf8 + end + end + + context "renamed file" do + let(:project) { create(:project, :repository) } + let(:repository) { project.repository.raw_repository } + let(:commit) { project.commit('blame-on-renamed') } + let(:sha) { commit.id } + let(:path) { 'files/plain_text/renamed' } + + it 'includes the previous path' do + expect(result.size).to eq(5) - expect(data.size).to eq(1) - expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit) - expect(data.first[:line]).to eq(" ") - expect(data.first[:line]).to be_utf8 + expect(result[0]).to include(line: 'Initial commit', previous_path: nil) + expect(result[1]).to include(line: 'Initial commit', previous_path: nil) + expect(result[2]).to include(line: 'Renamed as "filename"', previous_path: 'files/plain_text/initial-commit') + expect(result[3]).to include(line: 'Renamed as renamed', previous_path: 'files/plain_text/"filename"') + expect(result[4]).to include(line: 'Last edit, no rename', previous_path: path) end end end diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb index 17bb83d0f2f..46f544797bb 100644 --- a/spec/lib/gitlab/git/diff_spec.rb +++ b/spec/lib/gitlab/git/diff_spec.rb @@ -161,6 +161,52 @@ EOT expect(diff).not_to have_binary_notice end end + + context 'when diff contains invalid characters' do + let(:bad_string) { [0xae].pack("C*") } + let(:bad_string_two) { [0x89].pack("C*") } + + let(:diff) { described_class.new(@raw_diff_hash.merge({ diff: bad_string })) } + let(:diff_two) { described_class.new(@raw_diff_hash.merge({ diff: bad_string_two })) } + + context 'when replace_invalid_utf8_chars is true' do + it 'will convert invalid characters and not cause an encoding error' do + expect(diff.diff).to include(Gitlab::EncodingHelper::UNICODE_REPLACEMENT_CHARACTER) + expect(diff_two.diff).to include(Gitlab::EncodingHelper::UNICODE_REPLACEMENT_CHARACTER) + + expect { Oj.dump(diff) }.not_to raise_error(EncodingError) + expect { Oj.dump(diff_two) }.not_to raise_error(EncodingError) + end + + context 'when the diff is binary' do + let(:project) { create(:project, :repository) } + + it 'will not try to replace characters' do + expect(Gitlab::EncodingHelper).not_to receive(:encode_utf8_with_replacement_character?) + expect(binary_diff(project).diff).not_to be_empty + end + end + + context 'when convert_diff_to_utf8_with_replacement_symbol feature flag is disabled' do + before do + stub_feature_flags(convert_diff_to_utf8_with_replacement_symbol: false) + end + + it 'will not try to convert invalid characters' do + expect(Gitlab::EncodingHelper).not_to receive(:encode_utf8_with_replacement_character?) + end + end + end + + context 'when replace_invalid_utf8_chars is false' do + let(:not_replaced_diff) { described_class.new(@raw_diff_hash.merge({ diff: bad_string, replace_invalid_utf8_chars: false }) ) } + let(:not_replaced_diff_two) { described_class.new(@raw_diff_hash.merge({ diff: bad_string_two, replace_invalid_utf8_chars: false }) ) } + + it 'will not try to convert invalid characters' do + expect(Gitlab::EncodingHelper).not_to receive(:encode_utf8_with_replacement_character?) + end + end + end end describe 'straight diffs' do @@ -255,12 +301,11 @@ EOT let(:project) { create(:project, :repository) } it 'fake binary message when it detects binary' do - # Rugged will not detect this as binary, but we can fake it diff_message = "Binary files files/images/icn-time-tracking.pdf and files/images/icn-time-tracking.pdf differ\n" - binary_diff = described_class.between(project.repository, 'add-pdf-text-binary', 'add-pdf-text-binary^').first - expect(binary_diff.diff).not_to be_empty - expect(binary_diff.json_safe_diff).to eq(diff_message) + diff = binary_diff(project) + expect(diff.diff).not_to be_empty + expect(diff.json_safe_diff).to eq(diff_message) end it 'leave non-binary diffs as-is' do @@ -374,4 +419,9 @@ EOT expect(diff.line_count).to eq(0) end end + + def binary_diff(project) + # rugged will not detect this as binary, but we can fake it + described_class.between(project.repository, 'add-pdf-text-binary', 'add-pdf-text-binary^').first + end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index ae6ca728573..47688c4b3e6 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -2448,7 +2448,7 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do it 'delegates to Gitaly' do expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |svc| - expect(svc).to receive(:import_repository).with(url).and_return(nil) + expect(svc).to receive(:import_repository).with(url, http_authorization_header: '', mirror: false).and_return(nil) end repository.import_repository(url) diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb index 8d9ab5db886..50a0f20e775 100644 --- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb @@ -563,4 +563,39 @@ RSpec.describe Gitlab::GitalyClient::CommitService do expect(response).not_to have_key 'nonexistent' end end + + describe '#raw_blame' do + let(:project) { create(:project, :test_repo) } + let(:revision) { 'blame-on-renamed' } + let(:path) { 'files/plain_text/renamed' } + + let(:blame_headers) do + [ + '405a45736a75e439bb059e638afaa9a3c2eeda79 1 1 2', + '405a45736a75e439bb059e638afaa9a3c2eeda79 2 2', + 'bed1d1610ebab382830ee888288bf939c43873bb 3 3 1', + '3685515c40444faf92774e72835e1f9c0e809672 4 4 1', + '32c33da59f8a1a9f90bdeda570337888b00b244d 5 5 1' + ] + end + + subject(:blame) { client.raw_blame(revision, path, range: range).split("\n") } + + context 'without a range' do + let(:range) { nil } + + it 'blames a whole file' do + is_expected.to include(*blame_headers) + end + end + + context 'with a range' do + let(:range) { '3,4' } + + it 'blames part of a file' do + is_expected.to include(blame_headers[2], blame_headers[3]) + is_expected.not_to include(blame_headers[0], blame_headers[1], blame_headers[4]) + end + end + end end 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 1c7b35ed928..6eb92cdeab9 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 @@ -98,9 +98,9 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNotesImporter do .to receive(:each_object_to_import) .and_yield(github_comment) - expect(Gitlab::GithubImport::ImportDiffNoteWorker) - .to receive(:perform_async) - .with(project.id, an_instance_of(Hash), an_instance_of(String)) + expect(Gitlab::GithubImport::ImportDiffNoteWorker).to receive(:bulk_perform_in).with(1.second, [ + [project.id, an_instance_of(Hash), an_instance_of(String)] + ], batch_size: 1000, batch_delay: 1.minute) waiter = importer.parallel_import diff --git a/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb b/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb index 2c2b6a2aff0..6b807bdf098 100644 --- a/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb @@ -91,9 +91,9 @@ RSpec.describe Gitlab::GithubImport::Importer::IssuesImporter do .to receive(:each_object_to_import) .and_yield(github_issue) - expect(Gitlab::GithubImport::ImportIssueWorker) - .to receive(:perform_async) - .with(project.id, an_instance_of(Hash), an_instance_of(String)) + expect(Gitlab::GithubImport::ImportIssueWorker).to receive(:bulk_perform_in).with(1.second, [ + [project.id, an_instance_of(Hash), an_instance_of(String)] + ], batch_size: 1000, batch_delay: 1.minute) waiter = importer.parallel_import diff --git a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb index a2c7d51214a..6dfd4424342 100644 --- a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb @@ -118,9 +118,9 @@ RSpec.describe Gitlab::GithubImport::Importer::LfsObjectsImporter do expect(service).to receive(:execute).and_return([lfs_download_object]) end - expect(Gitlab::GithubImport::ImportLfsObjectWorker) - .to receive(:perform_async) - .with(project.id, an_instance_of(Hash), an_instance_of(String)) + expect(Gitlab::GithubImport::ImportLfsObjectWorker).to receive(:bulk_perform_in).with(1.second, [ + [project.id, an_instance_of(Hash), an_instance_of(String)] + ], batch_size: 1000, batch_delay: 1.minute) waiter = importer.parallel_import diff --git a/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb index 3782dab5ee3..3b4fe652da8 100644 --- a/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb @@ -84,9 +84,9 @@ RSpec.describe Gitlab::GithubImport::Importer::NotesImporter do .to receive(:each_object_to_import) .and_yield(github_comment) - expect(Gitlab::GithubImport::ImportNoteWorker) - .to receive(:perform_async) - .with(project.id, an_instance_of(Hash), an_instance_of(String)) + expect(Gitlab::GithubImport::ImportNoteWorker).to receive(:bulk_perform_in).with(1.second, [ + [project.id, an_instance_of(Hash), an_instance_of(String)] + ], batch_size: 1000, batch_delay: 1.minute) waiter = importer.parallel_import diff --git a/spec/lib/gitlab/github_import/object_counter_spec.rb b/spec/lib/gitlab/github_import/object_counter_spec.rb index c9e4ac67061..e522f74416c 100644 --- a/spec/lib/gitlab/github_import/object_counter_spec.rb +++ b/spec/lib/gitlab/github_import/object_counter_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::GithubImport::ObjectCounter, :clean_gitlab_redis_cache do - let_it_be(:project) { create(:project) } + let_it_be(:project) { create(:project, :import_started, import_type: 'github') } it 'validates the operation being incremented' do expect { described_class.increment(project, :issue, :unknown) } @@ -49,4 +49,12 @@ RSpec.describe Gitlab::GithubImport::ObjectCounter, :clean_gitlab_redis_cache do 'imported' => {} }) end + + it 'expires etag cache of relevant realtime change endpoints on increment' do + expect_next_instance_of(Gitlab::EtagCaching::Store) do |instance| + expect(instance).to receive(:touch).with(Gitlab::Routing.url_helpers.realtime_changes_import_github_path(format: :json)) + end + + described_class.increment(project, :issue, :fetched) + end end diff --git a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb index 6a19afbc60d..200898f8f03 100644 --- a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb +++ b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb @@ -22,10 +22,6 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do def collection_method :issues end - - def parallel_import_batch - { size: 10, delay: 1.minute } - end end end @@ -261,7 +257,7 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do let(:repr_class) { double(:representation) } let(:worker_class) { double(:worker) } let(:object) { double(:object) } - let(:batch_size) { 200 } + let(:batch_size) { 1000 } let(:batch_delay) { 1.minute } before do @@ -281,7 +277,6 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do context 'with multiple objects' do before do - allow(importer).to receive(:parallel_import_batch) { { size: batch_size, delay: batch_delay } } expect(importer).to receive(:each_object_to_import).and_yield(object).and_yield(object).and_yield(object) end @@ -296,9 +291,9 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do end end - context 'when FF is disabled' do + context 'when distribute_github_parallel_import feature flag is disabled' do before do - stub_feature_flags(spread_parallel_import: false) + stub_feature_flags(distribute_github_parallel_import: false) end it 'imports data in parallel' do diff --git a/spec/lib/gitlab/gon_helper_spec.rb b/spec/lib/gitlab/gon_helper_spec.rb index 047873d8237..28cb9125af1 100644 --- a/spec/lib/gitlab/gon_helper_spec.rb +++ b/spec/lib/gitlab/gon_helper_spec.rb @@ -64,6 +64,34 @@ RSpec.describe Gitlab::GonHelper do end end + describe '#push_force_frontend_feature_flag' do + let(:gon) { class_double('Gon') } + + before do + skip_feature_flags_yaml_validation + + allow(helper) + .to receive(:gon) + .and_return(gon) + end + + it 'pushes a feature flag to the frontend with the provided value' do + expect(gon) + .to receive(:push) + .with({ features: { 'myFeatureFlag' => true } }, true) + + helper.push_force_frontend_feature_flag(:my_feature_flag, true) + end + + it 'pushes a disabled feature flag if provided value is nil' do + expect(gon) + .to receive(:push) + .with({ features: { 'myFeatureFlag' => false } }, true) + + helper.push_force_frontend_feature_flag(:my_feature_flag, nil) + end + end + describe '#default_avatar_url' do it 'returns an absolute URL' do url = helper.default_avatar_url diff --git a/spec/lib/gitlab/graphql/known_operations_spec.rb b/spec/lib/gitlab/graphql/known_operations_spec.rb index 411c0876f82..3ebfefbb43c 100644 --- a/spec/lib/gitlab/graphql/known_operations_spec.rb +++ b/spec/lib/gitlab/graphql/known_operations_spec.rb @@ -19,7 +19,7 @@ RSpec.describe Gitlab::Graphql::KnownOperations do describe "#from_query" do where(:query_string, :expected) do - "query { helloWorld }" | described_class::ANONYMOUS + "query { helloWorld }" | described_class::UNKNOWN "query fuzzyyy { helloWorld }" | described_class::UNKNOWN "query foo { helloWorld }" | described_class::Operation.new("foo") end @@ -35,13 +35,13 @@ RSpec.describe Gitlab::Graphql::KnownOperations do describe "#operations" do it "returns array of known operations" do - expect(subject.operations.map(&:name)).to match_array(%w(anonymous unknown foo bar)) + expect(subject.operations.map(&:name)).to match_array(%w(unknown foo bar)) end end describe "Operation#to_caller_id" do where(:query_string, :expected) do - "query { helloWorld }" | "graphql:#{described_class::ANONYMOUS.name}" + "query { helloWorld }" | "graphql:#{described_class::UNKNOWN.name}" "query foo { helloWorld }" | "graphql:foo" end diff --git a/spec/lib/gitlab/graphql/pagination/active_record_array_connection_spec.rb b/spec/lib/gitlab/graphql/pagination/active_record_array_connection_spec.rb new file mode 100644 index 00000000000..320c6b52308 --- /dev/null +++ b/spec/lib/gitlab/graphql/pagination/active_record_array_connection_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Graphql::Pagination::ActiveRecordArrayConnection do + using RSpec::Parameterized::TableSyntax + + let_it_be(:items) { create_list(:package_build_info, 3) } + + let_it_be(:context) do + GraphQL::Query::Context.new( + query: GraphQL::Query.new(GitlabSchema, document: nil, context: {}, variables: {}), + values: {}, + object: nil + ) + end + + let(:first) { nil } + let(:last) { nil } + let(:after) { nil } + let(:before) { nil } + let(:max_page_size) { nil } + + let(:connection) do + described_class.new( + items, + context: context, + first: first, + last: last, + after: after, + before: before, + max_page_size: max_page_size + ) + end + + it_behaves_like 'a connection with collection methods' + + it_behaves_like 'a redactable connection' do + let(:unwanted) { items[1] } + end + + describe '#nodes' do + subject { connection.nodes } + + it { is_expected.to match_array(items) } + + context 'with first set' do + let(:first) { 2 } + + it { is_expected.to match_array([items[0], items[1]]) } + end + + context 'with last set' do + let(:last) { 2 } + + it { is_expected.to match_array([items[1], items[2]]) } + end + end + + describe '#next_page?' do + subject { connection.next_page? } + + where(:before, :first, :max_page_size, :result) do + nil | nil | nil | false + 1 | nil | nil | true + nil | 1 | nil | true + nil | 10 | nil | false + nil | 1 | 1 | true + nil | 1 | 10 | true + nil | 10 | 10 | false + end + + with_them do + it { is_expected.to eq(result) } + end + end + + describe '#previous_page?' do + subject { connection.previous_page? } + + where(:after, :last, :max_page_size, :result) do + nil | nil | nil | false + 1 | nil | nil | true + nil | 1 | nil | true + nil | 10 | nil | false + nil | 1 | 1 | true + nil | 1 | 10 | true + nil | 10 | 10 | false + end + + with_them do + it { is_expected.to eq(result) } + end + end + + describe '#cursor_for' do + let(:item) { items[0] } + let(:expected_result) do + GitlabSchema.cursor_encoder.encode( + Gitlab::Json.dump(id: item.id.to_s), + nonce: true + ) + end + + subject { connection.cursor_for(item) } + + it { is_expected.to eq(expected_result) } + + context 'with a BatchLoader::GraphQL item' do + let_it_be(:user) { create(:user) } + + let(:item) { ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::User, user.id).find } + let(:expected_result) do + GitlabSchema.cursor_encoder.encode( + Gitlab::Json.dump(id: user.id.to_s), + nonce: true + ) + end + + it { is_expected.to eq(expected_result) } + end + end + + describe '#dup' do + subject { connection.dup } + + it 'properly handles items duplication' do + connection2 = subject + + connection2 << create(:package_build_info) + + expect(connection.items).not_to eq(connection2.items) + end + end +end diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb index 0741088c915..86e7d4e344c 100644 --- a/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb @@ -19,8 +19,8 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'last_repository_check_at', column_expression: Project.arel_table[:last_repository_check_at], - order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :asc), - reversed_order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :desc), + order_expression: Project.arel_table[:last_repository_check_at].asc.nulls_last, + reversed_order_expression: Project.arel_table[:last_repository_check_at].desc.nulls_last, order_direction: :asc, nullable: :nulls_last, distinct: false) @@ -30,8 +30,8 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'last_repository_check_at', column_expression: Project.arel_table[:last_repository_check_at], - order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :desc), - reversed_order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :asc), + order_expression: Project.arel_table[:last_repository_check_at].desc.nulls_last, + reversed_order_expression: Project.arel_table[:last_repository_check_at].asc.nulls_last, order_direction: :desc, nullable: :nulls_last, distinct: false) @@ -256,11 +256,6 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do end end - # rubocop: disable RSpec/EmptyExampleGroup - context 'when ordering uses LOWER' do - end - # rubocop: enable RSpec/EmptyExampleGroup - context 'when ordering by similarity' do let_it_be(:project1) { create(:project, name: 'test') } let_it_be(:project2) { create(:project, name: 'testing') } diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb index b511a294f97..f31ec6c09fd 100644 --- a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb @@ -77,6 +77,17 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do expect(decoded_cursor(cursor)).to eq('id' => project.id.to_s) end + context 'when SimpleOrderBuilder cannot build keyset paginated query' do + it 'increments the `old_keyset_pagination_usage` counter', :prometheus do + expect(Gitlab::Pagination::Keyset::SimpleOrderBuilder).to receive(:build).and_return([false, nil]) + + decoded_cursor(cursor) + + counter = Gitlab::Metrics.registry.get(:old_keyset_pagination_usage) + expect(counter.get(model: 'Project')).to eq(1) + end + end + context 'when an order is specified' do let(:nodes) { Project.order(:updated_at) } @@ -222,91 +233,97 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do end end - context 'when multiple orders with nil values are defined' do - let!(:project1) { create(:project, last_repository_check_at: 10.days.ago) } # Asc: project5 Desc: project3 - let!(:project2) { create(:project, last_repository_check_at: nil) } # Asc: project1 Desc: project1 - let!(:project3) { create(:project, last_repository_check_at: 5.days.ago) } # Asc: project3 Desc: project5 - let!(:project4) { create(:project, last_repository_check_at: nil) } # Asc: project2 Desc: project2 - let!(:project5) { create(:project, last_repository_check_at: 20.days.ago) } # Asc: project4 Desc: project4 + context 'when ordering uses LOWER' do + let!(:project1) { create(:project, name: 'A') } # Asc: project1 Desc: project4 + let!(:project2) { create(:project, name: 'c') } # Asc: project5 Desc: project2 + let!(:project3) { create(:project, name: 'b') } # Asc: project3 Desc: project3 + let!(:project4) { create(:project, name: 'd') } # Asc: project2 Desc: project5 + let!(:project5) { create(:project, name: 'a') } # Asc: project4 Desc: project1 context 'when ascending' do let(:nodes) do - Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :asc).order(id: :asc) + Project.order(Arel::Table.new(:projects)['name'].lower.asc).order(id: :asc) end - let(:ascending_nodes) { [project5, project1, project3, project2, project4] } + let(:ascending_nodes) { [project1, project5, project3, project2, project4] } it_behaves_like 'nodes are in ascending order' - - context 'when before cursor value is NULL' do - let(:arguments) { { before: encoded_cursor(project4) } } - - it 'returns all projects before the cursor' do - expect(subject.sliced_nodes).to eq([project5, project1, project3, project2]) - end - end - - context 'when after cursor value is NULL' do - let(:arguments) { { after: encoded_cursor(project2) } } - - it 'returns all projects after the cursor' do - expect(subject.sliced_nodes).to eq([project4]) - end - end end context 'when descending' do let(:nodes) do - Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :desc).order(id: :asc) + Project.order(Arel::Table.new(:projects)['name'].lower.desc).order(id: :desc) end - let(:descending_nodes) { [project3, project1, project5, project2, project4] } + let(:descending_nodes) { [project4, project2, project3, project5, project1] } it_behaves_like 'nodes are in descending order' + end + end - context 'when before cursor value is NULL' do - let(:arguments) { { before: encoded_cursor(project4) } } + context 'NULLS order' do + using RSpec::Parameterized::TableSyntax - it 'returns all projects before the cursor' do - expect(subject.sliced_nodes).to eq([project3, project1, project5, project2]) - end - end + let_it_be(:issue1) { create(:issue, relative_position: nil) } + let_it_be(:issue2) { create(:issue, relative_position: 100) } + let_it_be(:issue3) { create(:issue, relative_position: 200) } + let_it_be(:issue4) { create(:issue, relative_position: nil) } + let_it_be(:issue5) { create(:issue, relative_position: 300) } + + context 'when ascending NULLS LAST (ties broken by id DESC implicitly)' do + let(:ascending_nodes) { [issue2, issue3, issue5, issue4, issue1] } - context 'when after cursor value is NULL' do - let(:arguments) { { after: encoded_cursor(project2) } } + where(:nodes) do + [ + lazy { Issue.order(Issue.arel_table[:relative_position].asc.nulls_last) } + ] + end - it 'returns all projects after the cursor' do - expect(subject.sliced_nodes).to eq([project4]) - end + with_them do + it_behaves_like 'nodes are in ascending order' end end - end - context 'when ordering uses LOWER' do - let!(:project1) { create(:project, name: 'A') } # Asc: project1 Desc: project4 - let!(:project2) { create(:project, name: 'c') } # Asc: project5 Desc: project2 - let!(:project3) { create(:project, name: 'b') } # Asc: project3 Desc: project3 - let!(:project4) { create(:project, name: 'd') } # Asc: project2 Desc: project5 - let!(:project5) { create(:project, name: 'a') } # Asc: project4 Desc: project1 + context 'when descending NULLS LAST (ties broken by id DESC implicitly)' do + let(:descending_nodes) { [issue5, issue3, issue2, issue4, issue1] } - context 'when ascending' do - let(:nodes) do - Project.order(Arel::Table.new(:projects)['name'].lower.asc).order(id: :asc) + where(:nodes) do + [ + lazy { Issue.order(Issue.arel_table[:relative_position].desc.nulls_last) } +] end - let(:ascending_nodes) { [project1, project5, project3, project2, project4] } - - it_behaves_like 'nodes are in ascending order' + with_them do + it_behaves_like 'nodes are in descending order' + end end - context 'when descending' do - let(:nodes) do - Project.order(Arel::Table.new(:projects)['name'].lower.desc).order(id: :desc) + context 'when ascending NULLS FIRST with a tie breaker' do + let(:ascending_nodes) { [issue1, issue4, issue2, issue3, issue5] } + + where(:nodes) do + [ + lazy { Issue.order(Issue.arel_table[:relative_position].asc.nulls_first).order(id: :asc) } +] end - let(:descending_nodes) { [project4, project2, project3, project5, project1] } + with_them do + it_behaves_like 'nodes are in ascending order' + end + end - it_behaves_like 'nodes are in descending order' + context 'when descending NULLS FIRST with a tie breaker' do + let(:descending_nodes) { [issue1, issue4, issue5, issue3, issue2] } + + where(:nodes) do + [ + lazy { Issue.order(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :asc) } +] + end + + with_them do + it_behaves_like 'nodes are in descending order' + end end end diff --git a/spec/lib/gitlab/hook_data/issuable_builder_spec.rb b/spec/lib/gitlab/hook_data/issuable_builder_spec.rb index f5ee8eba8bc..676396697fb 100644 --- a/spec/lib/gitlab/hook_data/issuable_builder_spec.rb +++ b/spec/lib/gitlab/hook_data/issuable_builder_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Gitlab::HookData::IssuableBuilder do let_it_be(:user) { create(:user) } # This shared example requires a `builder` and `user` variable - shared_examples 'issuable hook data' do |kind| + shared_examples 'issuable hook data' do |kind, hook_data_issuable_builder_class| let(:data) { builder.build(user: user) } include_examples 'project hook data' do @@ -20,7 +20,7 @@ RSpec.describe Gitlab::HookData::IssuableBuilder do expect(data[:object_kind]).to eq(kind) expect(data[:user]).to eq(user.hook_attrs) expect(data[:project]).to eq(builder.issuable.project.hook_attrs) - expect(data[:object_attributes]).to eq(builder.issuable.hook_attrs) + expect(data[:object_attributes]).to eq(hook_data_issuable_builder_class.new(issuable).build) expect(data[:changes]).to eq({}) expect(data[:repository]).to eq(builder.issuable.project.hook_attrs.slice(:name, :url, :description, :homepage)) end @@ -95,12 +95,12 @@ RSpec.describe Gitlab::HookData::IssuableBuilder do end describe '#build' do - it_behaves_like 'issuable hook data', 'issue' do + it_behaves_like 'issuable hook data', 'issue', Gitlab::HookData::IssueBuilder do let(:issuable) { create(:issue, description: 'A description') } let(:builder) { described_class.new(issuable) } end - it_behaves_like 'issuable hook data', 'merge_request' do + it_behaves_like 'issuable hook data', 'merge_request', Gitlab::HookData::MergeRequestBuilder do let(:issuable) { create(:merge_request, description: 'A description') } let(:builder) { described_class.new(issuable) } end diff --git a/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb b/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb index ddd681f75f0..771fc0218e2 100644 --- a/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb +++ b/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb @@ -62,6 +62,7 @@ RSpec.describe Gitlab::HookData::MergeRequestBuilder do expect(data).to include(:human_time_estimate) expect(data).to include(:human_total_time_spent) expect(data).to include(:human_time_change) + expect(data).to include(:labels) end context 'when the MR has an image in the description' do diff --git a/spec/lib/gitlab/http_connection_adapter_spec.rb b/spec/lib/gitlab/http_connection_adapter_spec.rb index e9e517f1fe6..cde8376febd 100644 --- a/spec/lib/gitlab/http_connection_adapter_spec.rb +++ b/spec/lib/gitlab/http_connection_adapter_spec.rb @@ -27,16 +27,6 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do end end - context 'with header_read_timeout_buffered_io feature disabled' do - before do - stub_feature_flags(header_read_timeout_buffered_io: false) - end - - it 'uses the regular Net::HTTP class' do - expect(connection).to be_a(Net::HTTP) - end - end - context 'when local requests are allowed' do let(:options) { { allow_local_requests: true } } diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 29a19e4cafd..730f9035293 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -665,6 +665,7 @@ protected_environments: - project - group - deploy_access_levels +- approval_rules deploy_access_levels: - protected_environment - user diff --git a/spec/lib/gitlab/import_export/command_line_util_spec.rb b/spec/lib/gitlab/import_export/command_line_util_spec.rb index 59d97357045..f47f1ab58a8 100644 --- a/spec/lib/gitlab/import_export/command_line_util_spec.rb +++ b/spec/lib/gitlab/import_export/command_line_util_spec.rb @@ -114,7 +114,7 @@ RSpec.describe Gitlab::ImportExport::CommandLineUtil do end end - %w[MOVED_PERMANENTLY FOUND TEMPORARY_REDIRECT].each do |code| + %w[MOVED_PERMANENTLY FOUND SEE_OTHER TEMPORARY_REDIRECT].each do |code| context "with a redirect status code #{code}" do let(:status) { HTTP::Status.const_get(code, false) } diff --git a/spec/lib/gitlab/import_export/duration_measuring_spec.rb b/spec/lib/gitlab/import_export/duration_measuring_spec.rb new file mode 100644 index 00000000000..cf8b6060741 --- /dev/null +++ b/spec/lib/gitlab/import_export/duration_measuring_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::ImportExport::DurationMeasuring do + subject do + Class.new do + include Gitlab::ImportExport::DurationMeasuring + + def test + with_duration_measuring do + 'test' + end + end + end.new + end + + it 'measures method execution duration' do + subject.test + + expect(subject.duration_s).not_to be_nil + end + + describe '#with_duration_measuring' do + it 'yields control' do + expect { |block| subject.with_duration_measuring(&block) }.to yield_control + end + + it 'returns result of the yielded block' do + return_value = 'return_value' + + expect(subject.with_duration_measuring { return_value }).to eq(return_value) + end + end +end diff --git a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb index ba1cccf87ce..03f522ae490 100644 --- a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb +++ b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb @@ -115,7 +115,7 @@ RSpec.describe Gitlab::ImportExport::Json::StreamingSerializer do end it 'orders exported issues by custom column(relative_position)' do - expected_issues = exportable.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')).order(id: :desc).map(&:to_json) + expected_issues = exportable.issues.reorder(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :desc).map(&:to_json) expect(json_writer).to receive(:write_relation_array).with(exportable_path, :issues, expected_issues) diff --git a/spec/lib/gitlab/import_export/version_checker_spec.rb b/spec/lib/gitlab/import_export/version_checker_spec.rb index 8b39330656f..9e69e04b17c 100644 --- a/spec/lib/gitlab/import_export/version_checker_spec.rb +++ b/spec/lib/gitlab/import_export/version_checker_spec.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true require 'spec_helper' -include ImportExport::CommonUtil RSpec.describe Gitlab::ImportExport::VersionChecker do + include ImportExport::CommonUtil + let!(:shared) { Gitlab::ImportExport::Shared.new(nil) } describe 'bundle a project Git repo' do diff --git a/spec/lib/gitlab/insecure_key_fingerprint_spec.rb b/spec/lib/gitlab/insecure_key_fingerprint_spec.rb index 2f3489edcd8..3a281574563 100644 --- a/spec/lib/gitlab/insecure_key_fingerprint_spec.rb +++ b/spec/lib/gitlab/insecure_key_fingerprint_spec.rb @@ -10,16 +10,9 @@ RSpec.describe Gitlab::InsecureKeyFingerprint do 'Jw0=' end - let(:fingerprint) { "3f:a2:ee:de:b5:de:53:c3:aa:2f:9c:45:24:4c:47:7b" } let(:fingerprint_sha256) { "MQHWhS9nhzUezUdD42ytxubZoBKrZLbyBZzxCkmnxXc" } - describe "#fingerprint" do - it "generates the key's fingerprint" do - expect(described_class.new(key.split[1]).fingerprint_md5).to eq(fingerprint) - end - end - - describe "#fingerprint" do + describe '#fingerprint_sha256' do it "generates the key's fingerprint" do expect(described_class.new(key.split[1]).fingerprint_sha256).to eq(fingerprint_sha256) end diff --git a/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb b/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb index 02cc2eba4da..68f1c214cef 100644 --- a/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb +++ b/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::LegacyGithubImport::ProjectCreator do let(:namespace) { create(:group) } let(:repo) do - OpenStruct.new( + ActiveSupport::InheritableOptions.new( login: 'vim', name: 'vim', full_name: 'asd/vim', @@ -21,7 +21,7 @@ RSpec.describe Gitlab::LegacyGithubImport::ProjectCreator do namespace.add_owner(user) expect_next_instance_of(Project) do |project| - expect(project).to receive(:add_import_job) + allow(project).to receive(:add_import_job) end end diff --git a/spec/lib/gitlab/metrics/rails_slis_spec.rb b/spec/lib/gitlab/metrics/rails_slis_spec.rb index 0c77dc9f582..2ba06316507 100644 --- a/spec/lib/gitlab/metrics/rails_slis_spec.rb +++ b/spec/lib/gitlab/metrics/rails_slis_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Gitlab::Metrics::RailsSlis do end allow(Gitlab::RequestEndpoints).to receive(:all_api_endpoints).and_return([api_route]) - allow(Gitlab::RequestEndpoints).to receive(:all_controller_actions).and_return([[ProjectsController, 'show']]) + allow(Gitlab::RequestEndpoints).to receive(:all_controller_actions).and_return([[ProjectsController, 'index']]) allow(Gitlab::Graphql::KnownOperations).to receive(:default).and_return(Gitlab::Graphql::KnownOperations.new(%w(foo bar))) end @@ -22,13 +22,13 @@ RSpec.describe Gitlab::Metrics::RailsSlis do request_urgency: :default }, { - endpoint_id: "ProjectsController#show", + endpoint_id: "ProjectsController#index", feature_category: :projects, request_urgency: :default } ] - possible_graphql_labels = ['graphql:foo', 'graphql:bar', 'graphql:unknown', 'graphql:anonymous'].map do |endpoint_id| + possible_graphql_labels = ['graphql:foo', 'graphql:bar', 'graphql:unknown'].map do |endpoint_id| { endpoint_id: endpoint_id, feature_category: nil, diff --git a/spec/lib/gitlab/omniauth_initializer_spec.rb b/spec/lib/gitlab/omniauth_initializer_spec.rb index 8b959cf787f..c91b14a33ba 100644 --- a/spec/lib/gitlab/omniauth_initializer_spec.rb +++ b/spec/lib/gitlab/omniauth_initializer_spec.rb @@ -309,4 +309,16 @@ RSpec.describe Gitlab::OmniauthInitializer do subject.execute([conf]) end end + + describe '.full_host' do + subject { described_class.full_host.call({}) } + + let(:base_url) { 'http://localhost/test' } + + before do + allow(Settings).to receive(:gitlab).and_return({ 'base_url' => base_url }) + end + + it { is_expected.to eq(base_url) } + end end diff --git a/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb index 69384e0c501..778244677ef 100644 --- a/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb @@ -140,8 +140,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition 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_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last, + reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first, order_direction: :desc, nullable: :nulls_last, # null values are always last distinct: false @@ -161,8 +161,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition 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_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last, + reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first, order_direction: :desc, nullable: true, distinct: false @@ -175,8 +175,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition 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_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last, + reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first, order_direction: :desc, nullable: :nulls_last, distinct: true @@ -191,8 +191,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition 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_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last, + reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first, order_direction: :desc, nullable: :nulls_last, # null values are always last distinct: false diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb index 58db22e5a9c..9f2ac9a953d 100644 --- a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb @@ -24,12 +24,12 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder 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_1, created_at: two_weeks_ago, relative_position: nil), 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_2, created_at: two_weeks_ago, relative_position: nil), + create(:issue, project: project_3, created_at: four_weeks_ago, relative_position: nil), create(:issue, project: project_4, created_at: five_weeks_ago, relative_position: 10), - create(:issue, project: project_5, created_at: four_weeks_ago) + create(:issue, project: project_5, created_at: four_weeks_ago, relative_position: nil) ] end @@ -121,8 +121,8 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder 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_expression: Issue.arel_table[:relative_position].desc.nulls_last, + reversed_order_expression: Issue.arel_table[:relative_position].asc.nulls_first, order_direction: :desc, nullable: :nulls_last, distinct: false @@ -155,6 +155,31 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder it_behaves_like 'correct ordering examples' end + + context 'with condition "relative_position IS NULL"' do + let(:base_scope) { Issue.where(relative_position: nil) } + let(:scope) { base_scope.order(order) } + + 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.merge(base_scope.dup).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 end context 'when ordering by issues.created_at DESC, issues.id ASC' do @@ -239,7 +264,7 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder end it 'raises error when unsupported scope is passed' do - scope = Issue.order(Issue.arel_table[:id].lower.desc) + scope = Issue.order(Arel::Nodes::NamedFunction.new('UPPER', [Issue.arel_table[:id]])) options = { scope: scope, diff --git a/spec/lib/gitlab/pagination/keyset/iterator_spec.rb b/spec/lib/gitlab/pagination/keyset/iterator_spec.rb index 09cbca2c1cb..d62d20d2d2c 100644 --- a/spec/lib/gitlab/pagination/keyset/iterator_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/iterator_spec.rb @@ -19,8 +19,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: column, column_expression: klass.arel_table[column], - order_expression: ::Gitlab::Database.nulls_order(column, direction, nulls_position), - reversed_order_expression: ::Gitlab::Database.nulls_order(column, reverse_direction, reverse_nulls_position), + order_expression: klass.arel_table[column].public_send(direction).public_send(nulls_position), # rubocop:disable GitlabSecurity/PublicSend + reversed_order_expression: klass.arel_table[column].public_send(reverse_direction).public_send(reverse_nulls_position), # rubocop:disable GitlabSecurity/PublicSend order_direction: direction, nullable: nulls_position, distinct: false @@ -99,7 +99,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) } - expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')).order(id: :asc).pluck(:relative_position, :id)) + expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].asc.nulls_last).order(id: :asc).pluck(:relative_position, :id)) end end @@ -111,7 +111,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) } - expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')).order(id: :desc).pluck(:relative_position, :id)) + expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :desc).pluck(:relative_position, :id)) end end @@ -123,7 +123,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) } - expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'ASC')).order(id: :asc).pluck(:relative_position, :id)) + expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].asc.nulls_first).order(id: :asc).pluck(:relative_position, :id)) end end @@ -136,7 +136,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) } - expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_last_order('relative_position', 'DESC')).order(id: :desc).pluck(:relative_position, :id)) + expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].desc.nulls_last).order(id: :desc).pluck(:relative_position, :id)) end end diff --git a/spec/lib/gitlab/pagination/keyset/order_spec.rb b/spec/lib/gitlab/pagination/keyset/order_spec.rb index 1bed8e542a2..abbb3a21cd4 100644 --- a/spec/lib/gitlab/pagination/keyset/order_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/order_spec.rb @@ -262,8 +262,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'year', column_expression: table['year'], - order_expression: Gitlab::Database.nulls_last_order('year', :asc), - reversed_order_expression: Gitlab::Database.nulls_first_order('year', :desc), + order_expression: table[:year].asc.nulls_last, + reversed_order_expression: table[:year].desc.nulls_first, order_direction: :asc, nullable: :nulls_last, distinct: false @@ -271,8 +271,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'month', column_expression: table['month'], - order_expression: Gitlab::Database.nulls_last_order('month', :asc), - reversed_order_expression: Gitlab::Database.nulls_first_order('month', :desc), + order_expression: table[:month].asc.nulls_last, + reversed_order_expression: table[:month].desc.nulls_first, order_direction: :asc, nullable: :nulls_last, distinct: false @@ -328,8 +328,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'year', column_expression: table['year'], - order_expression: Gitlab::Database.nulls_first_order('year', :asc), - reversed_order_expression: Gitlab::Database.nulls_last_order('year', :desc), + order_expression: table[:year].asc.nulls_first, + reversed_order_expression: table[:year].desc.nulls_last, order_direction: :asc, nullable: :nulls_first, distinct: false @@ -337,9 +337,9 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'month', column_expression: table['month'], - order_expression: Gitlab::Database.nulls_first_order('month', :asc), + order_expression: table[:month].asc.nulls_first, order_direction: :asc, - reversed_order_expression: Gitlab::Database.nulls_last_order('month', :desc), + reversed_order_expression: table[:month].desc.nulls_last, nullable: :nulls_first, distinct: false ), @@ -441,6 +441,47 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do end end + context 'when ordering by the named function LOWER' do + let(:order) do + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'title', + column_expression: Arel::Nodes::NamedFunction.new("LOWER", [table['title'].desc]), + order_expression: table['title'].lower.desc, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + column_expression: table['id'], + order_expression: table['id'].desc, + nullable: :not_nullable, + distinct: true + ) + ]) + end + + let(:table_data) do + <<-SQL + VALUES (1, 'A') + SQL + end + + let(:query) do + <<-SQL + SELECT id, title + FROM (#{table_data}) my_table (id, title) + ORDER BY #{order}; + SQL + end + + subject { run_query(query) } + + it "uses downcased value for encoding and decoding a cursor" do + expect(order.cursor_attributes_for_node(subject.first)['title']).to eq("a") + end + end + context 'when the passed cursor values do not match with the order definition' do let(:order) do Gitlab::Pagination::Keyset::Order.build([ diff --git a/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb b/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb index 5af86cb2dc0..4f1d380ab0a 100644 --- a/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do let(:ordered_scope) { described_class.build(scope).first } let(:order_object) { Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(ordered_scope) } + let(:column_definition) { order_object.column_definitions.first } subject(:sql_with_order) { ordered_scope.to_sql } @@ -16,11 +17,25 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do end it 'sets the column definition distinct and not nullable' do - column_definition = order_object.column_definitions.first - expect(column_definition).to be_not_nullable expect(column_definition).to be_distinct end + + context "when the order scope's model uses default_scope" do + let(:scope) do + model = Class.new(ApplicationRecord) do + self.table_name = 'events' + + default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope + end + + model.reorder(nil) + end + + it 'orders by primary key' do + expect(sql_with_order).to end_with('ORDER BY "events"."id" DESC') + end + end end context 'when primary key order present' do @@ -39,8 +54,6 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do end it 'sets the column definition for created_at non-distinct and nullable' do - column_definition = order_object.column_definitions.first - expect(column_definition.attribute_name).to eq('created_at') expect(column_definition.nullable?).to eq(true) # be_nullable calls non_null? method for some reason expect(column_definition).not_to be_distinct @@ -59,14 +72,80 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do let(:scope) { Project.where(id: [1, 2, 3]).order(namespace_id: :asc, id: :asc) } it 'sets the column definition for namespace_id non-distinct and non-nullable' do - column_definition = order_object.column_definitions.first - expect(column_definition.attribute_name).to eq('namespace_id') expect(column_definition).to be_not_nullable expect(column_definition).not_to be_distinct end end + context 'when ordering by a column with the lower named function' do + let(:scope) { Project.where(id: [1, 2, 3]).order(Project.arel_table[:name].lower.desc) } + + it 'sets the column definition for name' do + expect(column_definition.attribute_name).to eq('name') + expect(column_definition.column_expression.expressions.first.name).to eq('name') + expect(column_definition.column_expression.name).to eq('LOWER') + end + + it 'adds extra primary key order as tie-breaker' do + expect(sql_with_order).to end_with('ORDER BY LOWER("projects"."name") DESC, "projects"."id" DESC') + end + end + + context "NULLS order given as as an Arel literal" do + context 'when NULLS LAST order is given without a tie-breaker' do + let(:scope) { Project.order(Project.arel_table[:created_at].asc.nulls_last) } + + it 'sets the column definition for created_at appropriately' do + expect(column_definition.attribute_name).to eq('created_at') + end + + it 'orders by primary key' do + expect(sql_with_order) + .to end_with('ORDER BY "projects"."created_at" ASC NULLS LAST, "projects"."id" DESC') + end + end + + context 'when NULLS FIRST order is given with a tie-breaker' do + let(:scope) { Issue.order(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :asc) } + + it 'sets the column definition for created_at appropriately' do + expect(column_definition.attribute_name).to eq('relative_position') + end + + it 'orders by the given primary key' do + expect(sql_with_order) + .to end_with('ORDER BY "issues"."relative_position" DESC NULLS FIRST, "issues"."id" ASC') + end + end + end + + context "NULLS order given as as an Arel node" do + context 'when NULLS LAST order is given without a tie-breaker' do + let(:scope) { Project.order(Project.arel_table[:created_at].asc.nulls_last) } + + it 'sets the column definition for created_at appropriately' do + expect(column_definition.attribute_name).to eq('created_at') + end + + it 'orders by primary key' do + expect(sql_with_order).to end_with('ORDER BY "projects"."created_at" ASC NULLS LAST, "projects"."id" DESC') + end + end + + context 'when NULLS FIRST order is given with a tie-breaker' do + let(:scope) { Issue.order(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :asc) } + + it 'sets the column definition for created_at appropriately' do + expect(column_definition.attribute_name).to eq('relative_position') + end + + it 'orders by the given primary key' do + expect(sql_with_order).to end_with('ORDER BY "issues"."relative_position" DESC NULLS FIRST, "issues"."id" ASC') + end + end + end + context 'return :unable_to_order symbol when order cannot be built' do subject(:success) { described_class.build(scope).last } @@ -76,10 +155,20 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do it { is_expected.to eq(false) } end - context 'when NULLS LAST order is given' do - let(:scope) { Project.order(::Gitlab::Database.nulls_last_order('created_at', 'ASC')) } + context 'when an invalid NULLS order is given' do + using RSpec::Parameterized::TableSyntax - it { is_expected.to eq(false) } + where(:scope) do + [ + lazy { Project.order(Arel.sql('projects.updated_at created_at Asc Nulls Last')) }, + lazy { Project.order(Arel.sql('projects.created_at ZZZ NULLS FIRST')) }, + lazy { Project.order(Arel.sql('projects.relative_position ASC NULLS LAST')) } + ] + end + + with_them do + it { is_expected.to eq(false) } + end end context 'when more than 2 columns are given for the order' do diff --git a/spec/lib/gitlab/pagination/offset_pagination_spec.rb b/spec/lib/gitlab/pagination/offset_pagination_spec.rb index f8d50fbc517..ebbd207cc11 100644 --- a/spec/lib/gitlab/pagination/offset_pagination_spec.rb +++ b/spec/lib/gitlab/pagination/offset_pagination_spec.rb @@ -66,70 +66,50 @@ RSpec.describe Gitlab::Pagination::OffsetPagination do let(:query) { base_query.merge(page: 1, per_page: 2) } - context 'when the api_kaminari_count_with_limit feature flag is unset' do - it_behaves_like 'paginated response' - it_behaves_like 'response with pagination headers' - end - - context 'when the api_kaminari_count_with_limit feature flag is disabled' do + context 'when resources count is less than MAX_COUNT_LIMIT' do before do - stub_feature_flags(api_kaminari_count_with_limit: false) + stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 4) end it_behaves_like 'paginated response' it_behaves_like 'response with pagination headers' end - context 'when the api_kaminari_count_with_limit feature flag is enabled' do + context 'when resources count is more than MAX_COUNT_LIMIT' do before do - stub_feature_flags(api_kaminari_count_with_limit: true) - end - - context 'when resources count is less than MAX_COUNT_LIMIT' do - before do - stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 4) - end - - it_behaves_like 'paginated response' - it_behaves_like 'response with pagination headers' + stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 2) end - context 'when resources count is more than MAX_COUNT_LIMIT' do - before do - stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_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 + it_behaves_like 'paginated response' - it 'does not return the total headers when excluding them' do + 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', '') - paginator.paginate(resource, exclude_total_headers: true) + 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 + it 'does not return the total headers when excluding them' do + expect_no_header('X-Total') + expect_no_header('X-Total-Pages') + expect_header('X-Per-Page', '2') + expect_header('X-Page', '1') + + paginator.paginate(resource, exclude_total_headers: true) + end + context 'when resource already paginated' do let(:resource) { Project.all.page(1).per(1) } diff --git a/spec/lib/gitlab/patch/legacy_database_config_spec.rb b/spec/lib/gitlab/patch/database_config_spec.rb index b87e16f31ae..d6f36ab86d5 100644 --- a/spec/lib/gitlab/patch/legacy_database_config_spec.rb +++ b/spec/lib/gitlab/patch/database_config_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Patch::LegacyDatabaseConfig do +RSpec.describe Gitlab::Patch::DatabaseConfig do it 'module is included' do expect(Rails::Application::Configuration).to include(described_class) end diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index 9876387512b..e5fa7538515 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -557,7 +557,7 @@ RSpec.describe Gitlab::PathRegex do end it 'does not match other non-word characters' do - expect(subject.match('ruby:2.7.0')[0]).to eq('ruby') + expect(subject.match('image:1.0.0')[0]).to eq('image') end end diff --git a/spec/lib/gitlab/project_template_spec.rb b/spec/lib/gitlab/project_template_spec.rb index 05417e721c7..0ef52b63bc6 100644 --- a/spec/lib/gitlab/project_template_spec.rb +++ b/spec/lib/gitlab/project_template_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Gitlab::ProjectTemplate do expected = %w[ rails spring express iosswift dotnetcore android gomicro gatsby hugo jekyll plainhtml gitbook - hexo sse_middleman gitpod_spring_petclinic nfhugo + hexo middleman gitpod_spring_petclinic nfhugo nfjekyll nfplainhtml nfgitbook nfhexo salesforcedx serverless_framework tencent_serverless_framework jsonnet cluster_management kotlin_native_linux diff --git a/spec/lib/gitlab/quick_actions/command_definition_spec.rb b/spec/lib/gitlab/quick_actions/command_definition_spec.rb index 73629ce3da2..8362c07baca 100644 --- a/spec/lib/gitlab/quick_actions/command_definition_spec.rb +++ b/spec/lib/gitlab/quick_actions/command_definition_spec.rb @@ -26,7 +26,7 @@ RSpec.describe Gitlab::QuickActions::CommandDefinition do describe "#noop?" do context "when the command has an action block" do before do - subject.action_block = proc { } + subject.action_block = proc {} end it "returns false" do @@ -42,7 +42,7 @@ RSpec.describe Gitlab::QuickActions::CommandDefinition do end describe "#available?" do - let(:opts) { OpenStruct.new(go: false) } + let(:opts) { ActiveSupport::InheritableOptions.new(go: false) } context "when the command has a condition block" do before do @@ -104,7 +104,8 @@ RSpec.describe Gitlab::QuickActions::CommandDefinition do end describe "#execute" do - let(:context) { OpenStruct.new(run: false, commands_executed_count: nil) } + let(:fake_context) { Struct.new(:run, :commands_executed_count, :received_arg) } + let(:context) { fake_context.new(false, nil, nil) } context "when the command is a noop" do it "doesn't execute the command" do diff --git a/spec/lib/gitlab/search_context/builder_spec.rb b/spec/lib/gitlab/search_context/builder_spec.rb index 079477115bb..a09115f3f21 100644 --- a/spec/lib/gitlab/search_context/builder_spec.rb +++ b/spec/lib/gitlab/search_context/builder_spec.rb @@ -43,7 +43,6 @@ RSpec.describe Gitlab::SearchContext::Builder, type: :controller do def be_search_context(project: nil, group: nil, snippets: [], ref: nil) group = project ? project.group : group snippets.compact! - ref = ref have_attributes( project: project, diff --git a/spec/lib/gitlab/security/scan_configuration_spec.rb b/spec/lib/gitlab/security/scan_configuration_spec.rb index 2e8a11dfda3..1760796c5a0 100644 --- a/spec/lib/gitlab/security/scan_configuration_spec.rb +++ b/spec/lib/gitlab/security/scan_configuration_spec.rb @@ -47,6 +47,16 @@ RSpec.describe ::Gitlab::Security::ScanConfiguration do it { is_expected.to be_nil } end + describe '#meta_info_path' do + subject { scan.meta_info_path } + + let(:configured) { true } + let(:available) { true } + let(:type) { :dast } + + it { is_expected.to be_nil } + end + describe '#can_enable_by_merge_request?' do subject { scan.can_enable_by_merge_request? } diff --git a/spec/lib/gitlab/seeder_spec.rb b/spec/lib/gitlab/seeder_spec.rb index 71d0a41ef98..a22d47cbfb3 100644 --- a/spec/lib/gitlab/seeder_spec.rb +++ b/spec/lib/gitlab/seeder_spec.rb @@ -3,6 +3,24 @@ require 'spec_helper' RSpec.describe Gitlab::Seeder do + describe Namespace do + subject { described_class } + + it 'has not_mass_generated scope' do + expect { Namespace.not_mass_generated }.to raise_error(NoMethodError) + + Gitlab::Seeder.quiet do + expect { Namespace.not_mass_generated }.not_to raise_error + end + end + + it 'includes NamespaceSeed module' do + Gitlab::Seeder.quiet do + is_expected.to include_module(Gitlab::Seeder::NamespaceSeed) + end + end + end + describe '.quiet' do let(:database_base_models) do { @@ -50,4 +68,13 @@ RSpec.describe Gitlab::Seeder do notification_service.new_note(note) end end + + describe '.log_message' do + it 'prepends timestamp to the logged message' do + freeze_time do + message = "some message." + expect { described_class.log_message(message) }.to output(/#{Time.current}: #{message}/).to_stdout + end + end + end end diff --git a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb index 3fbd207c2e1..ffa92126cc9 100644 --- a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb @@ -292,7 +292,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do if category feature_category category else - feature_category_not_owned! + feature_category :not_owned end def perform diff --git a/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb b/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb index b9a13fd697e..3baa0c6f967 100644 --- a/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do 'TestNotOwnedWithContextWorker' end - feature_category_not_owned! + feature_category :not_owned end end diff --git a/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb b/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb index 377ff6fd166..05b328e55d3 100644 --- a/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb @@ -29,7 +29,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Server do "NotOwnedWorker" end - feature_category_not_owned! + feature_category :not_owned end end diff --git a/spec/lib/gitlab/ssh_public_key_spec.rb b/spec/lib/gitlab/ssh_public_key_spec.rb index cf5d2c3b455..422b6f925a1 100644 --- a/spec/lib/gitlab/ssh_public_key_spec.rb +++ b/spec/lib/gitlab/ssh_public_key_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::SSHPublicKey, lib: true do +RSpec.describe Gitlab::SSHPublicKey, lib: true, fips_mode: false do let(:key) { attributes_for(:rsa_key_2048)[:key] } let(:public_key) { described_class.new(key) } @@ -19,6 +19,17 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do it { expect(described_class.technology(name).name).to eq(name) } it { expect(described_class.technology(name.to_s).name).to eq(name) } end + + context 'FIPS mode', :fips_mode do + where(:name) do + [:rsa, :ecdsa, :ed25519, :ecdsa_sk, :ed25519_sk] + end + + with_them do + it { expect(described_class.technology(name).name).to eq(name) } + it { expect(described_class.technology(name.to_s).name).to eq(name) } + end + end end describe '.supported_types' do @@ -27,6 +38,14 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do [:rsa, :dsa, :ecdsa, :ed25519, :ecdsa_sk, :ed25519_sk] ) end + + context 'FIPS mode', :fips_mode do + it 'returns array with the names of supported technologies' do + expect(described_class.supported_types).to eq( + [:rsa, :dsa, :ecdsa, :ed25519, :ecdsa_sk, :ed25519_sk] + ) + end + end end describe '.supported_sizes(name)' do @@ -45,6 +64,24 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do it { expect(described_class.supported_sizes(name)).to eq(sizes) } it { expect(described_class.supported_sizes(name.to_s)).to eq(sizes) } end + + context 'FIPS mode', :fips_mode do + where(:name, :sizes) do + [ + [:rsa, [3072, 4096]], + [:dsa, []], + [:ecdsa, [256, 384, 521]], + [:ed25519, [256]], + [:ecdsa_sk, [256]], + [:ed25519_sk, [256]] + ] + end + + with_them do + it { expect(described_class.supported_sizes(name)).to eq(sizes) } + it { expect(described_class.supported_sizes(name.to_s)).to eq(sizes) } + end + end end describe '.supported_algorithms' do @@ -60,6 +97,21 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do ) ) end + + context 'FIPS mode', :fips_mode do + it 'returns all supported algorithms' do + expect(described_class.supported_algorithms).to eq( + %w( + ssh-rsa + ssh-dss + ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521 + ssh-ed25519 + sk-ecdsa-sha2-nistp256@openssh.com + sk-ssh-ed25519@openssh.com + ) + ) + end + end end describe '.supported_algorithms_for_name' do @@ -80,6 +132,26 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do expect(described_class.supported_algorithms_for_name(name.to_s)).to eq(algorithms) end end + + context 'FIPS mode', :fips_mode do + where(:name, :algorithms) do + [ + [:rsa, %w(ssh-rsa)], + [:dsa, %w(ssh-dss)], + [:ecdsa, %w(ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521)], + [:ed25519, %w(ssh-ed25519)], + [:ecdsa_sk, %w(sk-ecdsa-sha2-nistp256@openssh.com)], + [:ed25519_sk, %w(sk-ssh-ed25519@openssh.com)] + ] + end + + with_them do + it "returns all supported algorithms for #{params[:name]}" do + expect(described_class.supported_algorithms_for_name(name)).to eq(algorithms) + expect(described_class.supported_algorithms_for_name(name.to_s)).to eq(algorithms) + end + end + end end describe '.sanitize(key_content)' do diff --git a/spec/lib/gitlab/suggestions/commit_message_spec.rb b/spec/lib/gitlab/suggestions/commit_message_spec.rb index 965960f0c3e..dcadc206715 100644 --- a/spec/lib/gitlab/suggestions/commit_message_spec.rb +++ b/spec/lib/gitlab/suggestions/commit_message_spec.rb @@ -3,7 +3,10 @@ require 'spec_helper' RSpec.describe Gitlab::Suggestions::CommitMessage do - def create_suggestion(file_path, new_line, to_content) + include ProjectForksHelper + using RSpec::Parameterized::TableSyntax + + def create_suggestion(merge_request, file_path, new_line, to_content) position = Gitlab::Diff::Position.new(old_path: file_path, new_path: file_path, old_line: nil, @@ -29,69 +32,111 @@ RSpec.describe Gitlab::Suggestions::CommitMessage do create(:project, :repository, path: 'project-1', name: 'Project_1') end - let_it_be(:merge_request) do + let_it_be(:forked_project) { fork_project(project, nil, repository: true) } + + let_it_be(:merge_request_same_project) do create(:merge_request, source_project: project, target_project: project) end - let_it_be(:suggestion_set) do - suggestion1 = create_suggestion('files/ruby/popen.rb', 9, '*** SUGGESTION 1 ***') - suggestion2 = create_suggestion('files/ruby/popen.rb', 13, '*** SUGGESTION 2 ***') - suggestion3 = create_suggestion('files/ruby/regex.rb', 22, '*** SUGGESTION 3 ***') + let_it_be(:merge_request_from_fork) do + create(:merge_request, source_project: forked_project, target_project: project) + end + + let_it_be(:suggestion_set_same_project) do + suggestion1 = create_suggestion(merge_request_same_project, 'files/ruby/popen.rb', 9, '*** SUGGESTION 1 ***') + suggestion2 = create_suggestion(merge_request_same_project, 'files/ruby/popen.rb', 13, '*** SUGGESTION 2 ***') + suggestion3 = create_suggestion(merge_request_same_project, 'files/ruby/regex.rb', 22, '*** SUGGESTION 3 ***') + + Gitlab::Suggestions::SuggestionSet.new([suggestion1, suggestion2, suggestion3]) + end + + let_it_be(:suggestion_set_forked_project) do + suggestion1 = create_suggestion(merge_request_from_fork, 'files/ruby/popen.rb', 9, '*** SUGGESTION 1 ***') + suggestion2 = create_suggestion(merge_request_from_fork, 'files/ruby/popen.rb', 13, '*** SUGGESTION 2 ***') + suggestion3 = create_suggestion(merge_request_from_fork, 'files/ruby/regex.rb', 22, '*** SUGGESTION 3 ***') Gitlab::Suggestions::SuggestionSet.new([suggestion1, suggestion2, suggestion3]) end describe '#message' do - before do - # Updating the suggestion_commit_message on a project shared across specs - # avoids recreating the repository for each spec. - project.update!(suggestion_commit_message: message) - end + where(:suggestion_set) { [ref(:suggestion_set_same_project), ref(:suggestion_set_forked_project)] } + + with_them do + before do + # Updating the suggestion_commit_message on a project shared across specs + # avoids recreating the repository for each spec. + project.update!(suggestion_commit_message: message) + forked_project.update!(suggestion_commit_message: fork_message) + end + + let(:fork_message) { nil } - context 'when a custom commit message is not specified' do - let(:expected_message) { 'Apply 3 suggestion(s) to 2 file(s)' } + context 'when a custom commit message is not specified' do + let(:expected_message) { 'Apply 3 suggestion(s) to 2 file(s)' } - context 'and is nil' do - let(:message) { nil } + context 'and is nil' do + let(:message) { nil } - it 'uses the default commit message' do - expect(described_class - .new(user, suggestion_set) - .message).to eq(expected_message) + it 'uses the default commit message' do + expect(described_class + .new(user, suggestion_set) + .message).to eq(expected_message) + end end - end - context 'and is an empty string' do - let(:message) { '' } + context 'and is an empty string' do + let(:message) { '' } - it 'uses the default commit message' do - expect(described_class - .new(user, suggestion_set) - .message).to eq(expected_message) + it 'uses the default commit message' do + expect(described_class + .new(user, suggestion_set) + .message).to eq(expected_message) + end end - end - end - context 'when a custom commit message is specified' do - let(:message) { "i'm a project message. a user's custom message takes precedence over me :(" } - let(:custom_message) { "hello there! i'm a cool custom commit message." } + context 'when a custom commit message is specified for forked project' do + let(:message) { nil } + let(:fork_message) { "I'm a sad message that will not be used :(" } - it 'shows the custom commit message' do - expect(Gitlab::Suggestions::CommitMessage - .new(user, suggestion_set, custom_message) - .message).to eq(custom_message) + it 'uses the default commit message' do + expect(described_class + .new(user, suggestion_set) + .message).to eq(expected_message) + end + end end - end - context 'is specified and includes all placeholders' do - let(:message) do - '*** %{branch_name} %{files_count} %{file_paths} %{project_name} %{project_path} %{user_full_name} %{username} %{suggestions_count} ***' + context 'when a custom commit message is specified' do + let(:message) { "i'm a project message. a user's custom message takes precedence over me :(" } + let(:custom_message) { "hello there! i'm a cool custom commit message." } + + it 'shows the custom commit message' do + expect(Gitlab::Suggestions::CommitMessage + .new(user, suggestion_set, custom_message) + .message).to eq(custom_message) + end end - it 'generates a custom commit message' do - expect(Gitlab::Suggestions::CommitMessage - .new(user, suggestion_set) - .message).to eq('*** master 2 files/ruby/popen.rb, files/ruby/regex.rb Project_1 project-1 Test User test.user 3 ***') + context 'is specified and includes all placeholders' do + let(:message) do + '*** %{branch_name} %{files_count} %{file_paths} %{project_name} %{project_path} %{user_full_name} %{username} %{suggestions_count} ***' + end + + it 'generates a custom commit message' do + expect(Gitlab::Suggestions::CommitMessage + .new(user, suggestion_set) + .message).to eq('*** master 2 files/ruby/popen.rb, files/ruby/regex.rb Project_1 project-1 Test User test.user 3 ***') + end + + context 'when a custom commit message is specified for forked project' do + let(:fork_message) { "I'm a sad message that will not be used :(" } + + it 'uses the target project commit message' do + expect(Gitlab::Suggestions::CommitMessage + .new(user, suggestion_set) + .message).to eq('*** master 2 files/ruby/popen.rb, files/ruby/regex.rb Project_1 project-1 Test User test.user 3 ***') + end + end end end end diff --git a/spec/lib/gitlab/suggestions/suggestion_set_spec.rb b/spec/lib/gitlab/suggestions/suggestion_set_spec.rb index 54d79a9d4ba..469646986e1 100644 --- a/spec/lib/gitlab/suggestions/suggestion_set_spec.rb +++ b/spec/lib/gitlab/suggestions/suggestion_set_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' RSpec.describe Gitlab::Suggestions::SuggestionSet do + include ProjectForksHelper + using RSpec::Parameterized::TableSyntax + def create_suggestion(file_path, new_line, to_content) position = Gitlab::Diff::Position.new(old_path: file_path, new_path: file_path, @@ -24,86 +27,99 @@ RSpec.describe Gitlab::Suggestions::SuggestionSet do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project, :repository) } + let_it_be(:forked_project) { fork_project(project, nil, repository: true) } - let_it_be(:merge_request) do + let_it_be(:merge_request_same_project) do create(:merge_request, source_project: project, target_project: project) end - let_it_be(:suggestion) { create(:suggestion)} - - let_it_be(:suggestion2) do - create_suggestion('files/ruby/popen.rb', 13, "*** SUGGESTION 2 ***") - end - - let_it_be(:suggestion3) do - create_suggestion('files/ruby/regex.rb', 22, "*** SUGGESTION 3 ***") + let_it_be(:merge_request_from_fork) do + create(:merge_request, source_project: forked_project, target_project: project) end - let_it_be(:unappliable_suggestion) { create(:suggestion, :unappliable) } + where(:merge_request) { [ref(:merge_request_same_project), ref(:merge_request_from_fork)] } + with_them do + let(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } + let(:suggestion) { create(:suggestion, note: note) } - let(:suggestion_set) { described_class.new([suggestion]) } - - describe '#project' do - it 'returns the project associated with the suggestions' do - expected_project = suggestion.project + let(:suggestion2) do + create_suggestion('files/ruby/popen.rb', 13, "*** SUGGESTION 2 ***") + end - expect(suggestion_set.project).to be(expected_project) + let(:suggestion3) do + create_suggestion('files/ruby/regex.rb', 22, "*** SUGGESTION 3 ***") end - end - describe '#branch' do - it 'returns the branch associated with the suggestions' do - expected_branch = suggestion.branch + let(:unappliable_suggestion) { create(:suggestion, :unappliable) } + + let(:suggestion_set) { described_class.new([suggestion]) } - expect(suggestion_set.branch).to be(expected_branch) + describe '#source_project' do + it 'returns the source project associated with the suggestions' do + expect(suggestion_set.source_project).to be(merge_request.source_project) + end end - end - describe '#valid?' do - it 'returns true if no errors are found' do - expect(suggestion_set.valid?).to be(true) + describe '#target_project' do + it 'returns the target project associated with the suggestions' do + expect(suggestion_set.target_project).to be(project) + end end - it 'returns false if an error is found' do - suggestion_set = described_class.new([unappliable_suggestion]) + describe '#branch' do + it 'returns the branch associated with the suggestions' do + expected_branch = suggestion.branch - expect(suggestion_set.valid?).to be(false) + expect(suggestion_set.branch).to be(expected_branch) + end end - end - describe '#error_message' do - it 'returns an error message if an error is found' do - suggestion_set = described_class.new([unappliable_suggestion]) + describe '#valid?' do + it 'returns true if no errors are found' do + expect(suggestion_set.valid?).to be(true) + end - expect(suggestion_set.error_message).to be_a(String) + it 'returns false if an error is found' do + suggestion_set = described_class.new([unappliable_suggestion]) + + expect(suggestion_set.valid?).to be(false) + end end - it 'returns nil if no errors are found' do - expect(suggestion_set.error_message).to be(nil) + describe '#error_message' do + it 'returns an error message if an error is found' do + suggestion_set = described_class.new([unappliable_suggestion]) + + expect(suggestion_set.error_message).to be_a(String) + end + + it 'returns nil if no errors are found' do + expect(suggestion_set.error_message).to be(nil) + end end - end - describe '#actions' do - it 'returns an array of hashes with proper key/value pairs' do - first_action = suggestion_set.actions.first + describe '#actions' do + it 'returns an array of hashes with proper key/value pairs' do + first_action = suggestion_set.actions.first - file_suggestion = suggestion_set.send(:suggestions_per_file).first + file_suggestion = suggestion_set.send(:suggestions_per_file).first - expect(first_action[:action]).to be('update') - expect(first_action[:file_path]).to eq(file_suggestion.file_path) - expect(first_action[:content]).to eq(file_suggestion.new_content) + expect(first_action[:action]).to be('update') + expect(first_action[:file_path]).to eq(file_suggestion.file_path) + expect(first_action[:content]).to eq(file_suggestion.new_content) + end end - end - describe '#file_paths' do - it 'returns an array of unique file paths associated with the suggestions' do - suggestion_set = described_class.new([suggestion, suggestion2, suggestion3]) + describe '#file_paths' do + it 'returns an array of unique file paths associated with the suggestions' do + suggestion_set = described_class.new([suggestion, suggestion2, suggestion3]) - expected_paths = %w(files/ruby/popen.rb files/ruby/regex.rb) + expected_paths = %w(files/ruby/popen.rb files/ruby/regex.rb) - actual_paths = suggestion_set.file_paths + actual_paths = suggestion_set.file_paths - expect(actual_paths.sort).to eq(expected_paths) + expect(actual_paths.sort).to eq(expected_paths) + end end end end diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb index cd83971aef9..cc973be8be9 100644 --- a/spec/lib/gitlab/tracking_spec.rb +++ b/spec/lib/gitlab/tracking_spec.rb @@ -149,4 +149,42 @@ RSpec.describe Gitlab::Tracking do described_class.event(nil, 'some_action') end end + + describe '.definition' do + let(:namespace) { create(:namespace) } + + let_it_be(:definition_action) { 'definition_action' } + let_it_be(:definition_category) { 'definition_category' } + let_it_be(:label_description) { 'definition label description' } + let_it_be(:test_definition) {{ 'category': definition_category, 'action': definition_action }} + + before do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:event) + end + allow_next_instance_of(Gitlab::Tracking::Destinations::Snowplow) do |instance| + allow(instance).to receive(:event) + end + allow(YAML).to receive(:load_file).with(Rails.root.join('config/events/filename.yml')).and_return(test_definition) + end + + it 'dispatchs the data to .event' do + project = build_stubbed(:project) + user = build_stubbed(:user) + + expect(described_class).to receive(:event) do |category, action, args| + expect(category).to eq(definition_category) + expect(action).to eq(definition_action) + expect(args[:label]).to eq('label') + expect(args[:property]).to eq('...') + expect(args[:project]).to eq(project) + expect(args[:user]).to eq(user) + expect(args[:namespace]).to eq(namespace) + expect(args[:extra_key_1]).to eq('extra value 1') + end + + described_class.definition('filename', category: nil, action: nil, label: 'label', property: '...', + project: project, user: user, namespace: namespace, extra_key_1: 'extra value 1') + end + end end diff --git a/spec/lib/gitlab/url_sanitizer_spec.rb b/spec/lib/gitlab/url_sanitizer_spec.rb index aba4ca109a9..0ffbf5f81e7 100644 --- a/spec/lib/gitlab/url_sanitizer_spec.rb +++ b/spec/lib/gitlab/url_sanitizer_spec.rb @@ -139,6 +139,23 @@ RSpec.describe Gitlab::UrlSanitizer do it { is_expected.to eq(credentials) } end end + + context 'with mixed credentials' do + where(:url, :credentials, :result) do + 'http://a@example.com' | { password: 'd' } | { user: 'a', password: 'd' } + 'http://a:b@example.com' | { password: 'd' } | { user: 'a', password: 'd' } + 'http://:b@example.com' | { password: 'd' } | { user: nil, password: 'd' } + 'http://a@example.com' | { user: 'c' } | { user: 'c', password: nil } + 'http://a:b@example.com' | { user: 'c' } | { user: 'c', password: 'b' } + 'http://a:b@example.com' | { user: '' } | { user: 'a', password: 'b' } + end + + with_them do + subject { described_class.new(url, credentials: credentials).credentials } + + it { is_expected.to eq(result) } + end + end end describe '#user' do diff --git a/spec/lib/gitlab/usage/service_ping_report_spec.rb b/spec/lib/gitlab/usage/service_ping_report_spec.rb index 1f62ddd0bbb..b6119ab52ec 100644 --- a/spec/lib/gitlab/usage/service_ping_report_spec.rb +++ b/spec/lib/gitlab/usage/service_ping_report_spec.rb @@ -7,119 +7,86 @@ RSpec.describe Gitlab::Usage::ServicePingReport, :use_clean_rails_memory_store_c let(:usage_data) { { uuid: "1111", counts: { issue: 0 } } } - context 'when feature merge_service_ping_instrumented_metrics enabled' do - before do - stub_feature_flags(merge_service_ping_instrumented_metrics: true) + before do + allow_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor) do |instance| + allow(instance).to receive(:missing_key_paths).and_return([]) + end - allow_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor) do |instance| - allow(instance).to receive(:missing_key_paths).and_return([]) - end + allow_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload) do |instance| + allow(instance).to receive(:build).and_return({}) + end + end - allow_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload) do |instance| - allow(instance).to receive(:build).and_return({}) - end + context 'all_metrics_values' do + it 'generates the service ping when there are no missing values' do + expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0 } }) end - context 'all_metrics_values' do - it 'generates the service ping when there are no missing values' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0 } }) + it 'generates the service ping with the missing values' do + expect_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor, usage_data) do |instance| + expect(instance).to receive(:missing_instrumented_metrics_key_paths).and_return(['counts.boards']) end - it 'generates the service ping with the missing values' do - expect_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor, usage_data) do |instance| - expect(instance).to receive(:missing_instrumented_metrics_key_paths).and_return(['counts.boards']) - end - - expect_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload, ['counts.boards'], :with_value) do |instance| - expect(instance).to receive(:build).and_return({ counts: { boards: 1 } }) - end - - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0, boards: 1 } }) + expect_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload, ['counts.boards'], :with_value) do |instance| + expect(instance).to receive(:build).and_return({ counts: { boards: 1 } }) end - end - - context 'for output: :metrics_queries' do - it 'generates the service ping' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - described_class.for(output: :metrics_queries) - end + expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0, boards: 1 } }) end + end - context 'for output: :non_sql_metrics_values' do - it 'generates the service ping' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) + context 'for output: :metrics_queries' do + it 'generates the service ping' do + expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - described_class.for(output: :non_sql_metrics_values) - end + described_class.for(output: :metrics_queries) end + end - context 'when using cached' do - context 'for cached: true' do - let(:new_usage_data) { { uuid: "1112" } } - - it 'caches the values' do - allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) + context 'for output: :non_sql_metrics_values' do + it 'generates the service ping' do + expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) - expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(usage_data) + described_class.for(output: :non_sql_metrics_values) + end + end - expect(Rails.cache.fetch('usage_data')).to eq(usage_data) - end + context 'when using cached' do + context 'for cached: true' do + let(:new_usage_data) { { uuid: "1112" } } - it 'writes to cache and returns fresh data' do - allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) + it 'caches the values' do + allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data) - expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(new_usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) + expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(usage_data) - expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data) - end + expect(Rails.cache.fetch('usage_data')).to eq(usage_data) end - context 'when no caching' do - let(:new_usage_data) { { uuid: "1112" } } - - it 'returns fresh data' do - allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) - - expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data) - - expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data) - end - end - end - end + it 'writes to cache and returns fresh data' do + allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) - context 'when feature merge_service_ping_instrumented_metrics disabled' do - before do - stub_feature_flags(merge_service_ping_instrumented_metrics: false) - end + expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data) + expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(new_usage_data) - context 'all_metrics_values' do - it 'generates the service ping when there are no missing values' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0 } }) + expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data) end end - context 'for output: :metrics_queries' do - it 'generates the service ping' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) + context 'when no caching' do + let(:new_usage_data) { { uuid: "1112" } } - described_class.for(output: :metrics_queries) - end - end + it 'returns fresh data' do + allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) - context 'for output: :non_sql_metrics_values' do - it 'generates the service ping' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data) - described_class.for(output: :non_sql_metrics_values) + expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data) end 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 222198a58ac..6a37bfd106d 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 @@ -5,30 +5,52 @@ require 'spec_helper' RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do describe '.track_unique_project_event' do using RSpec::Parameterized::TableSyntax + include SnowplowHelpers - let(:project_id) { 1 } + let(:project) { build(:project) } + let(:user) { build(:user) } shared_examples 'tracks template' do + let(:subject) { described_class.track_unique_project_event(project: project, template: template_path, config_source: config_source, user: user) } + it "has an event defined for template" do expect do - described_class.track_unique_project_event( - project_id: project_id, - template: template_path, - config_source: config_source - ) + subject end.not_to raise_error end it "tracks template" do expanded_template_name = described_class.expand_template_name(template_path) expected_template_event_name = described_class.ci_template_event_name(expanded_template_name, config_source) - expect(Gitlab::UsageDataCounters::HLLRedisCounter).to(receive(:track_event)).with(expected_template_event_name, values: project_id) + expect(Gitlab::UsageDataCounters::HLLRedisCounter).to(receive(:track_event)).with(expected_template_event_name, values: project.id) + + subject + end + + context 'Snowplow' do + it 'event is not tracked if FF is disabled' do + stub_feature_flags(route_hll_to_snowplow: false) + + subject - described_class.track_unique_project_event(project_id: project_id, template: template_path, config_source: config_source) + expect_no_snowplow_event + end + + it 'tracks event' do + subject + + expect_snowplow_event( + category: described_class.to_s, + action: 'ci_templates_unique', + namespace: project.namespace, + user: user, + project: project + ) + end end end - context 'with explicit includes' do + context 'with explicit includes', :snowplow do let(:config_source) { :repository_source } (described_class.ci_templates - ['Verify/Browser-Performance.latest.gitlab-ci.yml', 'Verify/Browser-Performance.gitlab-ci.yml']).each do |template| @@ -40,7 +62,7 @@ RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do end end - context 'with implicit includes' do + context 'with implicit includes', :snowplow do let(:config_source) { :auto_devops_source } [ @@ -60,7 +82,7 @@ RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do it 'expands short template names' do expect do - described_class.track_unique_project_event(project_id: 1, template: 'Dependency-Scanning.gitlab-ci.yml', config_source: :repository_source) + described_class.track_unique_project_event(project: project, template: 'Dependency-Scanning.gitlab-ci.yml', config_source: :repository_source, user: user) end.not_to raise_error end end diff --git a/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb new file mode 100644 index 00000000000..d6eb67e5c35 --- /dev/null +++ b/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter, :clean_gitlab_redis_shared_state do # rubocop:disable RSpec/FilePath + let(:user1) { build(:user, id: 1) } + let(:user2) { build(:user, id: 2) } + let(:time) { Time.current } + let(:action) { described_class::GITLAB_CLI_API_REQUEST_ACTION } + let(:user_agent) { { user_agent: 'GLab - GitLab CLI' } } + + context 'when tracking a gitlab cli request' do + it_behaves_like 'a request from an extension' + end +end diff --git a/spec/lib/gitlab/usage_data_queries_spec.rb b/spec/lib/gitlab/usage_data_queries_spec.rb index c3ac9d7db90..88322e1b971 100644 --- a/spec/lib/gitlab/usage_data_queries_spec.rb +++ b/spec/lib/gitlab/usage_data_queries_spec.rb @@ -34,14 +34,14 @@ RSpec.describe Gitlab::UsageDataQueries do describe '.redis_usage_data' do subject(:redis_usage_data) { described_class.redis_usage_data { 42 } } - it 'returns a class for redis_usage_data with a counter call' do + it 'returns a stringified class for redis_usage_data with a counter call' do expect(described_class.redis_usage_data(Gitlab::UsageDataCounters::WikiPageCounter)) - .to eq(redis_usage_data_counter: Gitlab::UsageDataCounters::WikiPageCounter) + .to eq(redis_usage_data_counter: "Gitlab::UsageDataCounters::WikiPageCounter") end - it 'returns a stringified block for redis_usage_data with a block' do + it 'returns a placeholder string for redis_usage_data with a block' do is_expected.to include(:redis_usage_data_block) - expect(redis_usage_data[:redis_usage_data_block]).to start_with('#<Proc:') + expect(redis_usage_data[:redis_usage_data_block]).to eq('non-SQL usage data block') end end @@ -53,8 +53,8 @@ RSpec.describe Gitlab::UsageDataQueries do .to eq(alt_usage_data_value: 1) end - it 'returns a stringified block for alt_usage_data with a block' do - expect(alt_usage_data[:alt_usage_data_block]).to start_with('#<Proc:') + it 'returns a placeholder string for alt_usage_data with a block' do + expect(alt_usage_data[:alt_usage_data_block]).to eq('non-SQL usage data block') end end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 958df7baf72..8a919a0a72e 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -166,7 +166,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(described_class.usage_activity_by_stage_create({})).to include( deploy_keys: 2, keys: 2, - merge_requests: 2, projects_with_disable_overriding_approvers_per_merge_request: 2, projects_without_disable_overriding_approvers_per_merge_request: 6, remote_mirrors: 2, @@ -175,7 +174,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(described_class.usage_activity_by_stage_create(described_class.monthly_time_range_db_params)).to include( deploy_keys: 1, keys: 1, - merge_requests: 1, projects_with_disable_overriding_approvers_per_merge_request: 1, projects_without_disable_overriding_approvers_per_merge_request: 3, remote_mirrors: 1, @@ -507,10 +505,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end it 'gathers usage counts', :aggregate_failures do - stub_feature_flags(merge_service_ping_instrumented_metrics: false) - count_data = subject[:counts] - expect(count_data[:boards]).to eq(1) expect(count_data[:projects]).to eq(4) expect(count_data.keys).to include(*UsageDataHelpers::COUNTS_KEYS) expect(UsageDataHelpers::COUNTS_KEYS - count_data.keys).to be_empty diff --git a/spec/lib/gitlab/utils/delegator_override/validator_spec.rb b/spec/lib/gitlab/utils/delegator_override/validator_spec.rb index 4cd1b18de82..a58bc65c708 100644 --- a/spec/lib/gitlab/utils/delegator_override/validator_spec.rb +++ b/spec/lib/gitlab/utils/delegator_override/validator_spec.rb @@ -47,6 +47,15 @@ RSpec.describe Gitlab::Utils::DelegatorOverride::Validator do expect(validator.target_classes).to contain_exactly(target_class) end + + it 'adds all descendants of the target' do + child_class1 = Class.new(target_class) + child_class2 = Class.new(target_class) + grandchild_class = Class.new(child_class2) + validator.add_target(target_class) + + expect(validator.target_classes).to contain_exactly(target_class, child_class1, child_class2, grandchild_class) + end end describe '#expand_on_ancestors' do diff --git a/spec/lib/gitlab/view/presenter/base_spec.rb b/spec/lib/gitlab/view/presenter/base_spec.rb index a7083bd2722..afb44c0d298 100644 --- a/spec/lib/gitlab/view/presenter/base_spec.rb +++ b/spec/lib/gitlab/view/presenter/base_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Gitlab::View::Presenter::Base do let(:project) { double(:project) } let(:presenter_class) do - Struct.new(:subject).include(described_class) + Struct.new(:__subject__).include(described_class) end describe '.presenter?' do @@ -17,17 +17,24 @@ RSpec.describe Gitlab::View::Presenter::Base do end describe '.presents' do - it 'exposes #subject with the given keyword' do - presenter_class.presents(Object, as: :foo) - presenter = presenter_class.new(project) - - expect(presenter.foo).to eq(project) - end - it 'raises an error when symbol is passed' do expect { presenter_class.presents(:foo) }.to raise_error(ArgumentError) end + context 'when the presenter class specifies a custom keyword' do + subject(:presenter) { presenter_class.new(project) } + + before do + presenter_class.class_eval do + presents Object, as: :foo + end + end + + it 'exposes the subject with the given keyword' do + expect(presenter.foo).to be(project) + end + end + context 'when the presenter class inherits Presenter::Delegated' do let(:presenter_class) do Class.new(::Gitlab::View::Presenter::Delegated) do @@ -50,13 +57,22 @@ RSpec.describe Gitlab::View::Presenter::Base do end it 'does not set the delegator target' do - expect(presenter_class).not_to receive(:delegator_target).with(Object) + expect(presenter_class).not_to receive(:delegator_target) presenter_class.presents(Object, as: :foo) end end end + describe '#__subject__' do + it 'returns the subject' do + subject = double + presenter = presenter_class.new(subject) + + expect(presenter.__subject__).to be(subject) + end + end + describe '#can?' do context 'user is not allowed' do it 'returns false' do diff --git a/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb b/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb index f8c4a28ed45..7d96adf95e8 100644 --- a/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb +++ b/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb @@ -132,7 +132,7 @@ RSpec.describe Gitlab::WebIde::Config::Entry::Terminal do { before_script: %w[ls pwd], script: 'sleep 100', tags: ['webide'], - image: 'ruby:3.0', + image: 'image:1.0', services: ['mysql'], variables: { KEY: 'value' } } end @@ -143,7 +143,7 @@ RSpec.describe Gitlab::WebIde::Config::Entry::Terminal do tag_list: ['webide'], job_variables: [{ key: 'KEY', value: 'value', public: true }], options: { - image: { name: "ruby:3.0" }, + image: { name: "image:1.0" }, services: [{ name: "mysql" }], before_script: %w[ls pwd], script: ['sleep 100'] diff --git a/spec/lib/gitlab/web_ide/config_spec.rb b/spec/lib/gitlab/web_ide/config_spec.rb index 7ee9d40410c..11ea6150719 100644 --- a/spec/lib/gitlab/web_ide/config_spec.rb +++ b/spec/lib/gitlab/web_ide/config_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Gitlab::WebIde::Config do let(:yml) do <<-EOS terminal: - image: ruby:2.7 + image: image:1.0 before_script: - gem install rspec EOS @@ -21,7 +21,7 @@ RSpec.describe Gitlab::WebIde::Config do it 'returns hash created from string' do hash = { terminal: { - image: 'ruby:2.7', + image: 'image:1.0', before_script: ['gem install rspec'] } } diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index 3bab9aec454..91ab0a53c6c 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -448,6 +448,14 @@ RSpec.describe Gitlab::Workhorse do end end + describe '.detect_content_type' do + subject { described_class.detect_content_type } + + it 'returns array setting detect content type in workhorse' do + expect(subject).to eq(%w[Gitlab-Workhorse-Detect-Content-Type true]) + end + end + describe '.send_git_blob' do include FakeBlobHelpers diff --git a/spec/lib/mattermost/session_spec.rb b/spec/lib/mattermost/session_spec.rb index e2e1b4c28c7..2158076e4b5 100644 --- a/spec/lib/mattermost/session_spec.rb +++ b/spec/lib/mattermost/session_spec.rb @@ -35,6 +35,12 @@ RSpec.describe Mattermost::Session, type: :request do it 'makes a request to the oauth uri' do expect { subject.with_session }.to raise_error(::Mattermost::NoSessionError) end + + it 'returns nill on calling a non exisitng method on request' do + return_value = subject.request.method_missing("non_existing_method", "something") do + end + expect(return_value).to be(nil) + end end context 'with oauth_uri' do diff --git a/spec/lib/prometheus/cleanup_multiproc_dir_service_spec.rb b/spec/lib/prometheus/cleanup_multiproc_dir_service_spec.rb index db77a5d42d8..bdf9673a53f 100644 --- a/spec/lib/prometheus/cleanup_multiproc_dir_service_spec.rb +++ b/spec/lib/prometheus/cleanup_multiproc_dir_service_spec.rb @@ -1,32 +1,27 @@ # frozen_string_literal: true -require 'spec_helper' +require 'fast_spec_helper' RSpec.describe Prometheus::CleanupMultiprocDirService do - describe '.call' do - subject { described_class.new.execute } - + describe '#execute' do let(:metrics_multiproc_dir) { Dir.mktmpdir } let(:metrics_file_path) { File.join(metrics_multiproc_dir, 'counter_puma_master-0.db') } + subject(:service) { described_class.new(metrics_dir_arg).execute } + before do FileUtils.touch(metrics_file_path) end after do - FileUtils.rm_r(metrics_multiproc_dir) + FileUtils.rm_rf(metrics_multiproc_dir) end context 'when `multiprocess_files_dir` is defined' do - before do - expect(Prometheus::Client.configuration) - .to receive(:multiprocess_files_dir) - .and_return(metrics_multiproc_dir) - .at_least(:once) - end + let(:metrics_dir_arg) { metrics_multiproc_dir } it 'removes old metrics' do - expect { subject } + expect { service } .to change { File.exist?(metrics_file_path) } .from(true) .to(false) @@ -34,15 +29,10 @@ RSpec.describe Prometheus::CleanupMultiprocDirService do end context 'when `multiprocess_files_dir` is not defined' do - before do - expect(Prometheus::Client.configuration) - .to receive(:multiprocess_files_dir) - .and_return(nil) - .at_least(:once) - end + let(:metrics_dir_arg) { nil } it 'does not remove any files' do - expect { subject } + expect { service } .not_to change { File.exist?(metrics_file_path) } .from(true) end diff --git a/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb b/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb index b68af6fb8ab..5f67ee11970 100644 --- a/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb +++ b/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb @@ -28,6 +28,20 @@ RSpec.describe Sidebars::Groups::Menus::GroupInformationMenu do end end + describe '#sprite_icon' do + subject { described_class.new(context).sprite_icon } + + context 'when group is a root group' do + specify { is_expected.to eq 'group'} + end + + context 'when group is a child group' do + let(:group) { build(:group, parent: root_group) } + + specify { is_expected.to eq 'subgroup'} + end + end + describe '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/infrastructure_menu_spec.rb b/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb index 8a6b0e4e95d..81114f5a0b3 100644 --- a/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb @@ -112,6 +112,38 @@ RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu do let(:item_id) { :google_cloud } it_behaves_like 'access rights checks' + + context 'when feature flag is turned off globally' do + before do + stub_feature_flags(incubation_5mp_google_cloud: false) + end + + it { is_expected.to be_nil } + + context 'when feature flag is enabled for specific project' do + before do + stub_feature_flags(incubation_5mp_google_cloud: project) + end + + it_behaves_like 'access rights checks' + end + + context 'when feature flag is enabled for specific group' do + before do + stub_feature_flags(incubation_5mp_google_cloud: project.group) + end + + it_behaves_like 'access rights checks' + end + + context 'when feature flag is enabled for specific project' do + before do + stub_feature_flags(incubation_5mp_google_cloud: user) + end + + it_behaves_like 'access rights checks' + end + end end end end diff --git a/spec/lib/sidebars/projects/panel_spec.rb b/spec/lib/sidebars/projects/panel_spec.rb index 7e69a2dfe52..ff253eedd08 100644 --- a/spec/lib/sidebars/projects/panel_spec.rb +++ b/spec/lib/sidebars/projects/panel_spec.rb @@ -17,16 +17,40 @@ RSpec.describe Sidebars::Projects::Panel do subject { described_class.new(context).instance_variable_get(:@menus) } context 'when integration is present and active' do - let_it_be(:confluence) { create(:confluence_integration, active: true) } + context 'confluence only' do + let_it_be(:confluence) { create(:confluence_integration, active: true) } - let(:project) { confluence.project } + let(:project) { confluence.project } - it 'contains Confluence menu item' do - expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::ConfluenceMenu) }).not_to be_nil + it 'contains Confluence menu item' do + expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::ConfluenceMenu) }).not_to be_nil + end + + it 'does not contain Wiki menu item' do + expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::WikiMenu) }).to be_nil + end end - it 'does not contain Wiki menu item' do - expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::WikiMenu) }).to be_nil + context 'shimo only' do + let_it_be(:shimo) { create(:shimo_integration, active: true) } + + let(:project) { shimo.project } + + it 'contains Shimo menu item' do + expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::ShimoMenu) }).not_to be_nil + end + end + + context 'confluence & shimo' do + let_it_be(:confluence) { create(:confluence_integration, active: true) } + let_it_be(:shimo) { create(:shimo_integration, active: true) } + + let(:project) { confluence.project } + + it 'contains Confluence menu item, not Shimo' do + expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::ConfluenceMenu) }).not_to be_nil + expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::ShimoMenu) }).to be_nil + end end end diff --git a/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb b/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb index 2c996635c36..7c9fbe152cc 100644 --- a/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb +++ b/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb @@ -71,7 +71,31 @@ RSpec.describe SystemCheck::App::GitUserDefaultSSHConfigCheck do end end + describe '#show_error' do + subject(:show_error) { described_class.new.show_error } + + before do + stub_user + stub_home_dir + stub_ssh_file(forbidden_file) + end + + it 'outputs error information' do + expected = %r{ + Try\ fixing\ it:\s+ + mkdir\ ~/gitlab-check-backup-(.+)\s+ + sudo\ mv\ (.+)\s+ + For\ more\ information\ see:\s+ + doc/user/ssh\.md\#overriding-ssh-settings-on-the-gitlab-server\s+ + Please\ fix\ the\ error\ above\ and\ rerun\ the\ checks + }x + + expect { show_error }.to output(expected).to_stdout + end + end + def stub_user + allow(File).to receive(:expand_path).and_call_original allow(File).to receive(:expand_path).with("~#{username}").and_return(home_dir) end diff --git a/spec/mailers/emails/in_product_marketing_spec.rb b/spec/mailers/emails/in_product_marketing_spec.rb index 720e6f101a8..e62719f4283 100644 --- a/spec/mailers/emails/in_product_marketing_spec.rb +++ b/spec/mailers/emails/in_product_marketing_spec.rb @@ -69,7 +69,6 @@ RSpec.describe Emails::InProductMarketing do :team_short | 0 :trial_short | 0 :admin_verify | 0 - :invite_team | 0 end with_them do @@ -99,11 +98,7 @@ RSpec.describe Emails::InProductMarketing do is_expected.not_to have_body_text(CGI.unescapeHTML(message.invite_link)) end - if track == :invite_team - is_expected.not_to have_body_text(/This is email \d of \d/) - else - is_expected.to have_body_text(message.progress) - end + is_expected.to have_body_text(message.progress) end end end diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 87776457473..f4483f7e8f5 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -416,4 +416,27 @@ RSpec.describe Emails::Profile do is_expected.to have_body_text /#{profile_two_factor_auth_path}/ end end + + describe 'added a new email address' do + let_it_be(:user) { create(:user) } + let_it_be(:email) { create(:email, user: user) } + + subject { Notify.new_email_address_added_email(user, email) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like 'a user cannot unsubscribe through footer link' + + it 'is sent to the user' do + is_expected.to deliver_to user.email + end + + it 'has the correct subject' do + is_expected.to have_subject /^New email address added$/i + end + + it 'includes a link to the email address page' do + is_expected.to have_body_text /#{profile_emails_path}/ + end + end end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 978118ed1b1..b6ad66f41b5 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -317,6 +317,9 @@ RSpec.describe Notify do end context 'for merge requests' do + let(:push_user) { create(:user) } + let(:commit_limit) { NotificationService::NEW_COMMIT_EMAIL_DISPLAY_LIMIT } + describe 'that are new' do subject { described_class.new_merge_request_email(merge_request.assignee_ids.first, merge_request.id) } @@ -457,12 +460,6 @@ RSpec.describe Notify do end shared_examples 'a push to an existing merge request' do - let(:push_user) { create(:user) } - - subject do - described_class.push_to_merge_request_email(recipient.id, merge_request.id, push_user.id, new_commits: merge_request.commits, existing_commits: existing_commits) - end - it_behaves_like 'a multiple recipients email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do let(:model) { merge_request } @@ -487,16 +484,136 @@ RSpec.describe Notify do end end - describe 'that have new commits' do - let(:existing_commits) { [] } + shared_examples 'shows the compare url between first and last commits' do |count| + it 'shows the compare url between first and last commits' do + commit_id_1 = existing_commits.first[:short_id] + commit_id_2 = existing_commits.last[:short_id] + + is_expected.to have_link("#{commit_id_1}...#{commit_id_2}", href: project_compare_url(project, from: commit_id_1, to: commit_id_2)) + is_expected.to have_body_text("#{count} commits from branch `#{merge_request.target_branch}`") + end + end + + shared_examples 'shows new commit urls' do |count| + it 'shows new commit urls' do + displayed_new_commits.each do |commit| + is_expected.to have_link(commit[:short_id], href: project_commit_url(project, commit[:short_id])) + is_expected.to have_body_text(commit[:title]) + end + end + + it 'does not show hidden new commit urls' do + hidden_new_commits.each do |commit| + is_expected.not_to have_link(commit[:short_id], href: project_commit_url(project, commit[:short_id])) + is_expected.not_to have_body_text(commit[:title]) + end + end + end + + describe 'that have no new commits' do + subject do + described_class.push_to_merge_request_email(recipient.id, merge_request.id, push_user.id, new_commits: [], total_new_commits_count: 0, existing_commits: [], total_existing_commits_count: 0) + end it_behaves_like 'a push to an existing merge request' end + describe 'that have fewer than the commit truncation limit' do + let(:new_commits) { merge_request.commits } + let(:displayed_new_commits) { new_commits } + let(:hidden_new_commits) { [] } + + subject do + described_class.push_to_merge_request_email( + recipient.id, merge_request.id, push_user.id, + new_commits: new_commits, total_new_commits_count: new_commits.length, + existing_commits: [], total_existing_commits_count: 0 + ) + end + + it_behaves_like 'a push to an existing merge request' + it_behaves_like 'shows new commit urls' + end + + describe 'that have more than the commit truncation limit' do + let(:new_commits) do + Array.new(commit_limit + 10) do |i| + { + short_id: SecureRandom.hex(4), + title: "This is commit #{i}" + } + end + end + + let(:displayed_new_commits) { new_commits.first(commit_limit) } + let(:hidden_new_commits) { new_commits.last(10) } + + subject do + described_class.push_to_merge_request_email( + recipient.id, merge_request.id, push_user.id, + new_commits: displayed_new_commits, total_new_commits_count: commit_limit + 10, + existing_commits: [], total_existing_commits_count: 0 + ) + end + + it_behaves_like 'a push to an existing merge request' + it_behaves_like 'shows new commit urls' + + it 'shows "and more" message' do + is_expected.to have_body_text("And 10 more") + end + end + describe 'that have new commits on top of an existing one' do let(:existing_commits) { [merge_request.commits.first] } + subject do + described_class.push_to_merge_request_email( + recipient.id, merge_request.id, push_user.id, + new_commits: merge_request.commits, total_new_commits_count: merge_request.commits.length, + existing_commits: existing_commits, total_existing_commits_count: existing_commits.length + ) + end + + it_behaves_like 'a push to an existing merge request' + + it 'shows the existing commit' do + commit_id = existing_commits.first.short_id + is_expected.to have_link(commit_id, href: project_commit_url(project, commit_id)) + is_expected.to have_body_text("1 commit from branch `#{merge_request.target_branch}`") + end + end + + describe 'that have new commits on top of two existing ones' do + let(:existing_commits) { [merge_request.commits.first, merge_request.commits.second] } + + subject do + described_class.push_to_merge_request_email( + recipient.id, merge_request.id, push_user.id, + new_commits: merge_request.commits, total_new_commits_count: merge_request.commits.length, + existing_commits: existing_commits, total_existing_commits_count: existing_commits.length + ) + end + + it_behaves_like 'a push to an existing merge request' + it_behaves_like 'shows the compare url between first and last commits', 2 + end + + describe 'that have new commits on top of more than two existing ones' do + let(:existing_commits) do + [merge_request.commits.first] + [double(:commit)] * 3 + [merge_request.commits.second] + end + + subject do + described_class.push_to_merge_request_email( + recipient.id, merge_request.id, push_user.id, + new_commits: merge_request.commits, total_new_commits_count: merge_request.commits.length, + existing_commits: existing_commits, total_existing_commits_count: existing_commits.length + ) + end + it_behaves_like 'a push to an existing merge request' + it_behaves_like 'shows the compare url between first and last commits', 5 end end @@ -2064,14 +2181,46 @@ RSpec.describe Notify do context 'when diff note' do let!(:notes) { create_list(:diff_note_on_merge_request, 3, review: review, project: project, author: review.author, noteable: merge_request) } - it 'links to notes' do + it 'links to notes and discussions', :aggregate_failures do + reply_note = create(:diff_note_on_merge_request, review: review, project: project, author: review.author, noteable: merge_request, in_reply_to: notes.first) + review.notes.each do |note| # Text part expect(subject.text_part.body.raw_source).to include( project_merge_request_url(project, merge_request, anchor: "note_#{note.id}") ) + + if note == reply_note + expect(subject.text_part.body.raw_source).to include("commented on a discussion on #{note.discussion.file_path}") + else + expect(subject.text_part.body.raw_source).to include("started a new discussion on #{note.discussion.file_path}") + end end end + + it 'includes only one link to the highlighted_diff_email' do + expect(subject.html_part.body.raw_source).to include('assets/mailers/highlighted_diff_email').once + end + + it 'avoids N+1 cached queries when rendering html', :use_sql_query_cache, :request_store do + control_count = ActiveRecord::QueryRecorder.new(query_recorder_debug: true, skip_cached: false) do + subject.html_part + end + + create_list(:diff_note_on_merge_request, 3, review: review, project: project, author: review.author, noteable: merge_request) + + expect { described_class.new_review_email(recipient.id, review.id).html_part }.not_to exceed_all_query_limit(control_count) + end + + it 'avoids N+1 cached queries when rendering text', :use_sql_query_cache, :request_store do + control_count = ActiveRecord::QueryRecorder.new(query_recorder_debug: true, skip_cached: false) do + subject.text_part + end + + create_list(:diff_note_on_merge_request, 3, review: review, project: project, author: review.author, noteable: merge_request) + + expect { described_class.new_review_email(recipient.id, review.id).text_part }.not_to exceed_all_query_limit(control_count) + end end it 'contains review author name' do diff --git a/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb b/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb index 598da495195..ed18820ec8d 100644 --- a/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb +++ b/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb @@ -36,7 +36,6 @@ RSpec.describe CopyAdoptionSnapshotNamespace, :migration, schema: 20210430124630 runner_configured: true, pipeline_succeeded: true, deploy_succeeded: true, - security_scan_succeeded: true, end_time: Time.zone.now.end_of_month } diff --git a/spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb b/spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb index a17fee6bab2..791c0595f0e 100644 --- a/spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb +++ b/spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb @@ -10,27 +10,10 @@ RSpec.describe BackfillIncidentIssueEscalationStatuses do let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } let(:project) { projects.create!(namespace_id: namespace.id) } - before do - stub_const("#{described_class.name}::BATCH_SIZE", 1) - end - - it 'schedules jobs for incident issues' do - issue_1 = issues.create!(project_id: project.id) # non-incident issue - incident_1 = issues.create!(project_id: project.id, issue_type: 1) - incident_2 = issues.create!(project_id: project.id, issue_type: 1) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! + # Backfill removed - see db/migrate/20220321234317_remove_all_issuable_escalation_statuses.rb. + it 'does nothing' do + issues.create!(project_id: project.id, issue_type: 1) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration( - 2.minutes, issue_1.id, issue_1.id) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration( - 4.minutes, incident_1.id, incident_1.id) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration( - 6.minutes, incident_2.id, incident_2.id) - expect(BackgroundMigrationWorker.jobs.size).to eq(3) - end - end + expect { migrate! }.not_to change { BackgroundMigrationWorker.jobs.size } end end diff --git a/spec/migrations/20220204095121_backfill_namespace_statistics_with_dependency_proxy_size_spec.rb b/spec/migrations/20220204095121_backfill_namespace_statistics_with_dependency_proxy_size_spec.rb new file mode 100644 index 00000000000..39398fa058d --- /dev/null +++ b/spec/migrations/20220204095121_backfill_namespace_statistics_with_dependency_proxy_size_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe BackfillNamespaceStatisticsWithDependencyProxySize do + let_it_be(:groups) { table(:namespaces) } + let_it_be(:group1) { groups.create!(id: 10, name: 'test1', path: 'test1', type: 'Group') } + let_it_be(:group2) { groups.create!(id: 20, name: 'test2', path: 'test2', type: 'Group') } + let_it_be(:group3) { groups.create!(id: 30, name: 'test3', path: 'test3', type: 'Group') } + let_it_be(:group4) { groups.create!(id: 40, name: 'test4', path: 'test4', type: 'Group') } + + let_it_be(:dependency_proxy_blobs) { table(:dependency_proxy_blobs) } + let_it_be(:dependency_proxy_manifests) { table(:dependency_proxy_manifests) } + + let_it_be(:group1_manifest) { create_manifest(10, 10) } + let_it_be(:group2_manifest) { create_manifest(20, 20) } + let_it_be(:group3_manifest) { create_manifest(30, 30) } + + let_it_be(:group1_blob) { create_blob(10, 10) } + let_it_be(:group2_blob) { create_blob(20, 20) } + let_it_be(:group3_blob) { create_blob(30, 30) } + + describe '#up' do + it 'correctly schedules background migrations' do + stub_const("#{described_class}::BATCH_SIZE", 2) + + Sidekiq::Testing.fake! do + freeze_time do + migrate! + + aggregate_failures do + expect(described_class::MIGRATION) + .to be_scheduled_migration([10, 30], ['dependency_proxy_size']) + + expect(described_class::MIGRATION) + .to be_scheduled_delayed_migration(2.minutes, [20], ['dependency_proxy_size']) + + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + end + end + end + end + end + + def create_manifest(group_id, size) + dependency_proxy_manifests.create!( + group_id: group_id, + size: size, + file_name: 'test-file', + file: 'test', + digest: 'abc123' + ) + end + + def create_blob(group_id, size) + dependency_proxy_blobs.create!( + group_id: group_id, + size: size, + file_name: 'test-file', + file: 'test' + ) + end +end diff --git a/spec/migrations/20220223124428_schedule_merge_topics_with_same_name_spec.rb b/spec/migrations/20220223124428_schedule_merge_topics_with_same_name_spec.rb new file mode 100644 index 00000000000..d9f6729475c --- /dev/null +++ b/spec/migrations/20220223124428_schedule_merge_topics_with_same_name_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe ScheduleMergeTopicsWithSameName do + let(:topics) { table(:topics) } + + describe '#up' do + before do + stub_const("#{described_class}::BATCH_SIZE", 2) + + topics.create!(name: 'topic1') + topics.create!(name: 'Topic2') + topics.create!(name: 'Topic3') + topics.create!(name: 'Topic4') + topics.create!(name: 'topic2') + topics.create!(name: 'topic3') + topics.create!(name: 'topic4') + topics.create!(name: 'TOPIC2') + topics.create!(name: 'topic5') + end + + it 'schedules MergeTopicsWithSameName background jobs', :aggregate_failures do + Sidekiq::Testing.fake! do + freeze_time do + migrate! + + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, %w[topic2 topic3]) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, %w[topic4]) + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + end + end + end + end +end diff --git a/spec/migrations/20220315171129_cleanup_draft_data_from_faulty_regex_spec.rb b/spec/migrations/20220315171129_cleanup_draft_data_from_faulty_regex_spec.rb new file mode 100644 index 00000000000..925f1e573be --- /dev/null +++ b/spec/migrations/20220315171129_cleanup_draft_data_from_faulty_regex_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe CleanupDraftDataFromFaultyRegex do + let(:merge_requests) { table(:merge_requests) } + + let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') } + let!(:project) { table(:projects).create!(namespace_id: namespace.id) } + + let(:default_mr_values) do + { + target_project_id: project.id, + draft: true, + source_branch: 'master', + target_branch: 'feature' + } + end + + let!(:known_good_1) { merge_requests.create!(default_mr_values.merge(title: "Draft: Test Title")) } + let!(:known_good_2) { merge_requests.create!(default_mr_values.merge(title: "WIP: Test Title")) } + let!(:known_bad_1) { merge_requests.create!(default_mr_values.merge(title: "Known bad title drafts")) } + let!(:known_bad_2) { merge_requests.create!(default_mr_values.merge(title: "Known bad title wip")) } + + describe '#up' do + it 'schedules CleanupDraftDataFromFaultyRegex background jobs filtering for eligble MRs' do + stub_const("#{described_class}::BATCH_SIZE", 2) + allow(Gitlab).to receive(:com?).and_return(true) + + freeze_time do + migrate! + + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, known_bad_1.id, known_bad_2.id) + + expect(BackgroundMigrationWorker.jobs.size).to eq(1) + end + end + end +end diff --git a/spec/migrations/20220316202640_populate_container_repositories_migration_plan_spec.rb b/spec/migrations/20220316202640_populate_container_repositories_migration_plan_spec.rb new file mode 100644 index 00000000000..7b5c8254163 --- /dev/null +++ b/spec/migrations/20220316202640_populate_container_repositories_migration_plan_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe PopulateContainerRepositoriesMigrationPlan, :aggregate_failures do + let_it_be(:namespaces) { table(:namespaces) } + let_it_be(:projects) { table(:projects) } + let_it_be(:container_repositories) { table(:container_repositories) } + + let!(:namespace) { namespaces.create!(id: 1, name: 'namespace', path: 'namespace') } + let!(:project) { projects.create!(id: 1, name: 'project', path: 'project', namespace_id: 1) } + let!(:container_repository1) { container_repositories.create!(name: 'container_repository1', project_id: 1) } + let!(:container_repository2) { container_repositories.create!(name: 'container_repository2', project_id: 1) } + let!(:container_repository3) { container_repositories.create!(name: 'container_repository3', project_id: 1) } + + before do + stub_const("#{described_class.name}::BATCH_SIZE", 2) + end + + it 'schedules jobs for container_repositories to populate migration_state' do + Sidekiq::Testing.fake! do + freeze_time do + migrate! + + expect(described_class::MIGRATION).to be_scheduled_delayed_migration( + 2.minutes, container_repository1.id, container_repository2.id) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration( + 4.minutes, container_repository3.id, container_repository3.id) + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + end + end + end +end diff --git a/spec/migrations/20220321234317_remove_all_issuable_escalation_statuses_spec.rb b/spec/migrations/20220321234317_remove_all_issuable_escalation_statuses_spec.rb new file mode 100644 index 00000000000..44e20df1130 --- /dev/null +++ b/spec/migrations/20220321234317_remove_all_issuable_escalation_statuses_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe RemoveAllIssuableEscalationStatuses do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:issues) { table(:issues) } + let(:statuses) { table(:incident_management_issuable_escalation_statuses) } + let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } + let(:project) { projects.create!(namespace_id: namespace.id) } + + it 'removes all escalation status records' do + issue = issues.create!(project_id: project.id, issue_type: 1) + statuses.create!(issue_id: issue.id) + + expect { migrate! }.to change(statuses, :count).from(1).to(0) + end +end diff --git a/spec/migrations/20220322132242_update_pages_onboarding_state_spec.rb b/spec/migrations/20220322132242_update_pages_onboarding_state_spec.rb new file mode 100644 index 00000000000..fbd5fe546fa --- /dev/null +++ b/spec/migrations/20220322132242_update_pages_onboarding_state_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true +require 'spec_helper' +require_migration! + +RSpec.describe UpdatePagesOnboardingState do + let(:migration) { described_class.new } + let!(:namespaces) { table(:namespaces) } + let!(:projects) { table(:projects) } + let!(:project_pages_metadata) { table(:project_pages_metadata) } + + let!(:namespace1) { namespaces.create!(name: 'foo', path: 'foo') } + let!(:namespace2) { namespaces.create!(name: 'bar', path: 'bar') } + let!(:project1) { projects.create!(namespace_id: namespace1.id) } + let!(:project2) { projects.create!(namespace_id: namespace2.id) } + let!(:pages_metadata1) do + project_pages_metadata.create!( + project_id: project1.id, + deployed: true, + onboarding_complete: false + ) + end + + let!(:pages_metadata2) do + project_pages_metadata.create!( + project_id: project2.id, + deployed: false, + onboarding_complete: false + ) + end + + describe '#up' do + before do + migration.up + end + + it 'sets the onboarding_complete attribute to the value of deployed' do + expect(pages_metadata1.reload.onboarding_complete).to eq(true) + expect(pages_metadata2.reload.onboarding_complete).to eq(false) + end + end + + describe '#down' do + before do + migration.up + migration.down + end + + it 'sets all onboarding_complete attributes to false' do + expect(pages_metadata1.reload.onboarding_complete).to eq(false) + expect(pages_metadata2.reload.onboarding_complete).to eq(false) + end + end +end diff --git a/spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb b/spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb new file mode 100644 index 00000000000..38db6d51e7e --- /dev/null +++ b/spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe MigrateShimoConfluenceServiceCategory, :migration do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:integrations) { table(:integrations) } + + before do + namespace = namespaces.create!(name: 'test', path: 'test') + projects.create!(id: 1, namespace_id: namespace.id, name: 'gitlab', path: 'gitlab') + integrations.create!(id: 1, active: true, type_new: "Integrations::SlackSlashCommands", + category: 'chat', project_id: 1) + integrations.create!(id: 3, active: true, type_new: "Integrations::Confluence", category: 'common', project_id: 1) + integrations.create!(id: 5, active: true, type_new: "Integrations::Shimo", category: 'common', project_id: 1) + end + + describe '#up' do + it 'correctly schedules background migrations', :aggregate_failures do + stub_const("#{described_class.name}::BATCH_SIZE", 2) + + Sidekiq::Testing.fake! do + freeze_time do + migrate! + + expect(described_class::MIGRATION).to be_scheduled_migration(3, 5) + expect(BackgroundMigrationWorker.jobs.size).to eq(1) + end + end + end + end +end diff --git a/spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb b/spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb new file mode 100644 index 00000000000..13884007af2 --- /dev/null +++ b/spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true +require 'spec_helper' + +require_migration! + +RSpec.describe RemoveLeftoverCiJobArtifactDeletions do + let(:deleted_records) { table(:loose_foreign_keys_deleted_records) } + + target_table_name = Ci::JobArtifact.table_name + + let(:pending_record1) do + deleted_records.create!( + id: 1, + fully_qualified_table_name: "public.#{target_table_name}", + primary_key_value: 1, + status: 1 + ) + end + + let(:pending_record2) do + deleted_records.create!( + id: 2, + fully_qualified_table_name: "public.#{target_table_name}", + primary_key_value: 2, + status: 1 + ) + end + + let(:other_pending_record1) do + deleted_records.create!( + id: 3, + fully_qualified_table_name: 'public.projects', + primary_key_value: 1, + status: 1 + ) + end + + let(:other_pending_record2) do + deleted_records.create!( + id: 4, + fully_qualified_table_name: 'public.ci_builds', + primary_key_value: 1, + status: 1 + ) + end + + let(:processed_record1) do + deleted_records.create!( + id: 5, + fully_qualified_table_name: 'public.external_pull_requests', + primary_key_value: 3, + status: 2 + ) + end + + let(:other_processed_record1) do + deleted_records.create!( + id: 6, + fully_qualified_table_name: 'public.ci_builds', + primary_key_value: 2, + status: 2 + ) + end + + let!(:persisted_ids_before) do + [ + pending_record1, + pending_record2, + other_pending_record1, + other_pending_record2, + processed_record1, + other_processed_record1 + ].map(&:id).sort + end + + let!(:persisted_ids_after) do + [ + other_pending_record1, + other_pending_record2, + processed_record1, + other_processed_record1 + ].map(&:id).sort + end + + def all_ids + deleted_records.all.map(&:id).sort + end + + it 'deletes pending external_pull_requests records' do + expect { migrate! }.to change { all_ids }.from(persisted_ids_before).to(persisted_ids_after) + end +end diff --git a/spec/migrations/20220412143552_consume_remaining_encrypt_integration_property_jobs_spec.rb b/spec/migrations/20220412143552_consume_remaining_encrypt_integration_property_jobs_spec.rb new file mode 100644 index 00000000000..4a1b68a5a85 --- /dev/null +++ b/spec/migrations/20220412143552_consume_remaining_encrypt_integration_property_jobs_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe ConsumeRemainingEncryptIntegrationPropertyJobs, :migration do + subject(:migration) { described_class.new } + + let(:integrations) { table(:integrations) } + let(:bg_migration_class) { ::Gitlab::BackgroundMigration::EncryptIntegrationProperties } + let(:bg_migration) { instance_double(bg_migration_class) } + + before do + stub_const("#{described_class.name}::BATCH_SIZE", 2) + end + + it 'performs remaining background migrations', :aggregate_failures do + # Already migrated + integrations.create!(properties: some_props, encrypted_properties: 'abc') + integrations.create!(properties: some_props, encrypted_properties: 'def') + integrations.create!(properties: some_props, encrypted_properties: 'xyz') + # update required + record1 = integrations.create!(properties: some_props) + record2 = integrations.create!(properties: some_props) + record3 = integrations.create!(properties: some_props) + # No update required + integrations.create!(properties: nil) + integrations.create!(properties: nil) + + expect(Gitlab::BackgroundMigration).to receive(:steal).with(bg_migration_class.name.demodulize) + expect(bg_migration_class).to receive(:new).twice.and_return(bg_migration) + expect(bg_migration).to receive(:perform).with(record1.id, record2.id) + expect(bg_migration).to receive(:perform).with(record3.id, record3.id) + + migrate! + end + + def some_props + { iid: generate(:iid), url: generate(:url), username: generate(:username) }.to_json + end +end diff --git a/spec/migrations/add_epics_relative_position_spec.rb b/spec/migrations/add_epics_relative_position_spec.rb new file mode 100644 index 00000000000..f3b7dd1727b --- /dev/null +++ b/spec/migrations/add_epics_relative_position_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe AddEpicsRelativePosition, :migration do + let(:groups) { table(:namespaces) } + let(:epics) { table(:epics) } + let(:users) { table(:users) } + let(:user) { users.create!(name: 'user', email: 'email@example.org', projects_limit: 100) } + let(:group) { groups.create!(name: 'gitlab', path: 'gitlab-org', type: 'Group') } + + let!(:epic1) { epics.create!(title: 'epic 1', title_html: 'epic 1', author_id: user.id, group_id: group.id, iid: 1) } + let!(:epic2) { epics.create!(title: 'epic 2', title_html: 'epic 2', author_id: user.id, group_id: group.id, iid: 2) } + let!(:epic3) { epics.create!(title: 'epic 3', title_html: 'epic 3', author_id: user.id, group_id: group.id, iid: 3) } + + it 'does nothing if epics table contains relative_position' do + expect { migrate! }.not_to change { epics.pluck(:relative_position) } + end + + it 'adds relative_position if missing and backfills it with ID value', :aggregate_failures do + ActiveRecord::Base.connection.execute('ALTER TABLE epics DROP relative_position') + + migrate! + + expect(epics.pluck(:relative_position)).to match_array([epic1.id * 500, epic2.id * 500, epic3.id * 500]) + end +end diff --git a/spec/migrations/backfill_group_features_spec.rb b/spec/migrations/backfill_group_features_spec.rb new file mode 100644 index 00000000000..922d54f43be --- /dev/null +++ b/spec/migrations/backfill_group_features_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe BackfillGroupFeatures, :migration do + let(:migration) { described_class::MIGRATION } + + describe '#up' do + it 'schedules background jobs for each batch of namespaces' do + migrate! + + expect(migration).to have_scheduled_batched_migration( + table_name: :namespaces, + column_name: :id, + job_arguments: [described_class::BATCH_SIZE], + interval: described_class::INTERVAL, + batch_size: described_class::BATCH_SIZE + ) + end + end + + describe '#down' do + it 'deletes all batched migration records' do + migrate! + schema_migrate_down! + + expect(migration).not_to have_scheduled_batched_migration + end + end +end diff --git a/spec/migrations/backfill_namespace_id_for_project_routes_spec.rb b/spec/migrations/backfill_namespace_id_for_project_routes_spec.rb new file mode 100644 index 00000000000..28edd17731f --- /dev/null +++ b/spec/migrations/backfill_namespace_id_for_project_routes_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe BackfillNamespaceIdForProjectRoutes, :migration do + let(:migration) { described_class::MIGRATION } + + describe '#up' do + it 'schedules background jobs for each batch of group members' do + migrate! + + expect(migration).to have_scheduled_batched_migration( + table_name: :routes, + column_name: :id, + interval: described_class::INTERVAL + ) + end + end + + describe '#down' do + it 'deletes all batched migration records' do + migrate! + schema_migrate_down! + + expect(migration).not_to have_scheduled_batched_migration + end + end +end diff --git a/spec/migrations/backfill_work_item_type_id_on_issues_spec.rb b/spec/migrations/backfill_work_item_type_id_on_issues_spec.rb new file mode 100644 index 00000000000..6798b0cc7e8 --- /dev/null +++ b/spec/migrations/backfill_work_item_type_id_on_issues_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe BackfillWorkItemTypeIdOnIssues, :migration do + let_it_be(:migration) { described_class::MIGRATION } + let_it_be(:interval) { 2.minutes } + let_it_be(:issue_type_enum) { { issue: 0, incident: 1, test_case: 2, requirement: 3, task: 4 } } + let_it_be(:base_work_item_type_ids) do + table(:work_item_types).where(namespace_id: nil).order(:base_type).each_with_object({}) do |type, hash| + hash[type.base_type] = type.id + end + end + + describe '#up' do + it 'correctly schedules background migrations' do + Sidekiq::Testing.fake! do + freeze_time do + migrate! + + scheduled_migrations = Gitlab::Database::BackgroundMigration::BatchedMigration.where(job_class_name: migration) + work_item_types = table(:work_item_types).where(namespace_id: nil) + + expect(scheduled_migrations.count).to eq(work_item_types.count) + + [:issue, :incident, :test_case, :requirement, :task].each do |issue_type| + expect(migration).to have_scheduled_batched_migration( + table_name: :issues, + column_name: :id, + job_arguments: [issue_type_enum[issue_type], base_work_item_type_ids[issue_type_enum[issue_type]]], + interval: interval, + batch_size: described_class::BATCH_SIZE, + max_batch_size: described_class::MAX_BATCH_SIZE, + sub_batch_size: described_class::SUB_BATCH_SIZE, + batch_class_name: described_class::BATCH_CLASS_NAME + ) + end + end + end + end + end + + describe '#down' do + it 'deletes all batched migration records' do + migrate! + schema_migrate_down! + + expect(migration).not_to have_scheduled_batched_migration + end + end +end diff --git a/spec/migrations/cleanup_after_fixing_issue_when_admin_changed_primary_email_spec.rb b/spec/migrations/cleanup_after_fixing_issue_when_admin_changed_primary_email_spec.rb new file mode 100644 index 00000000000..eda57545c7a --- /dev/null +++ b/spec/migrations/cleanup_after_fixing_issue_when_admin_changed_primary_email_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe CleanupAfterFixingIssueWhenAdminChangedPrimaryEmail, :sidekiq do + let(:migration) { described_class.new } + let(:users) { table(:users) } + let(:emails) { table(:emails) } + + let!(:user_1) { users.create!(name: 'confirmed-user-1', email: 'confirmed-1@example.com', confirmed_at: 3.days.ago, projects_limit: 100) } + let!(:user_2) { users.create!(name: 'confirmed-user-2', email: 'confirmed-2@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + let!(:user_3) { users.create!(name: 'confirmed-user-3', email: 'confirmed-3@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + let!(:user_4) { users.create!(name: 'unconfirmed-user', email: 'unconfirmed@example.com', confirmed_at: nil, projects_limit: 100) } + + let!(:email_1) { emails.create!(email: 'confirmed-1@example.com', user_id: user_1.id, confirmed_at: 1.day.ago) } + let!(:email_2) { emails.create!(email: 'other_2@example.com', user_id: user_2.id, confirmed_at: 1.day.ago) } + + before do + stub_const("#{described_class.name}::BATCH_SIZE", 2) + end + + it 'adds the primary email to emails for leftover confirmed users that do not have their primary email in the emails table', :aggregate_failures do + original_email_1_confirmed_at = email_1.reload.confirmed_at + + expect { migration.up }.to change { emails.count }.by(2) + + expect(emails.find_by(user_id: user_2.id, email: 'confirmed-2@example.com').confirmed_at).to eq(user_2.reload.confirmed_at) + expect(emails.find_by(user_id: user_3.id, email: 'confirmed-3@example.com').confirmed_at).to eq(user_3.reload.confirmed_at) + expect(email_1.reload.confirmed_at).to eq(original_email_1_confirmed_at) + + expect(emails.exists?(user_id: user_4.id)).to be(false) + end + + it 'continues in case of errors with one email' do + allow(Email).to receive(:create) { raise 'boom!' } + + expect { migration.up }.not_to raise_error + end +end diff --git a/spec/migrations/finalize_project_namespaces_backfill_spec.rb b/spec/migrations/finalize_project_namespaces_backfill_spec.rb new file mode 100644 index 00000000000..3d0b0ec13fe --- /dev/null +++ b/spec/migrations/finalize_project_namespaces_backfill_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe FinalizeProjectNamespacesBackfill, :migration do + let(:batched_migrations) { table(:batched_background_migrations) } + + let_it_be(:migration) { described_class::MIGRATION } + + describe '#up' do + shared_examples 'raises migration not finished exception' do + it 'raises exception' do + expect { migrate! }.to raise_error(/Expected batched background migration for the given configuration to be marked as 'finished'/) + end + end + + context 'when project namespace backfilling migration is missing' do + it 'warns migration not found' do + expect(Gitlab::AppLogger) + .to receive(:warn).with(/Could not find batched background migration for the given configuration:/) + + migrate! + end + end + + context 'with backfilling migration present' do + let!(:project_namespace_backfill) do + batched_migrations.create!( + job_class_name: 'ProjectNamespaces::BackfillProjectNamespaces', + table_name: :projects, + column_name: :id, + job_arguments: [nil, 'up'], + interval: 2.minutes, + min_value: 1, + max_value: 2, + batch_size: 1000, + sub_batch_size: 200, + status: 3 # finished + ) + end + + context 'when project namespace backfilling migration finished successfully' do + it 'does not raise exception' do + expect { migrate! }.not_to raise_error(/Expected batched background migration for the given configuration to be marked as 'finished'/) + end + end + + context 'when project namespace backfilling migration is paused' do + using RSpec::Parameterized::TableSyntax + + where(:status, :description) do + 0 | 'paused' + 1 | 'active' + 4 | 'failed' + 5 | 'finalizing' + end + + with_them do + before do + project_namespace_backfill.update!(status: status) + end + + it_behaves_like 'raises migration not finished exception' + end + end + end + end +end diff --git a/spec/migrations/finalize_traversal_ids_background_migrations_spec.rb b/spec/migrations/finalize_traversal_ids_background_migrations_spec.rb new file mode 100644 index 00000000000..74d6447e6a7 --- /dev/null +++ b/spec/migrations/finalize_traversal_ids_background_migrations_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('finalize_traversal_ids_background_migrations') + +RSpec.describe FinalizeTraversalIdsBackgroundMigrations, :migration do + shared_context 'incomplete background migration' do + before do + # Jobs enqueued in Sidekiq. + Sidekiq::Testing.disable! do + BackgroundMigrationWorker.perform_in(10, job_class_name, [1, 2, 100]) + BackgroundMigrationWorker.perform_in(20, job_class_name, [3, 4, 100]) + end + + # Jobs tracked in the database. + # table(:background_migration_jobs).create!( + Gitlab::Database::BackgroundMigrationJob.create!( + class_name: job_class_name, + arguments: [5, 6, 100], + status: Gitlab::Database::BackgroundMigrationJob.statuses['pending'] + ) + # table(:background_migration_jobs).create!( + Gitlab::Database::BackgroundMigrationJob.create!( + class_name: job_class_name, + arguments: [7, 8, 100], + status: Gitlab::Database::BackgroundMigrationJob.statuses['succeeded'] + ) + end + end + + context 'BackfillNamespaceTraversalIdsRoots background migration' do + let(:job_class_name) { 'BackfillNamespaceTraversalIdsRoots' } + + include_context 'incomplete background migration' + + before do + migrate! + end + + it_behaves_like( + 'finalized tracked background migration', + Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsRoots + ) + end + + context 'BackfillNamespaceTraversalIdsChildren background migration' do + let(:job_class_name) { 'BackfillNamespaceTraversalIdsChildren' } + + include_context 'incomplete background migration' + + before do + migrate! + end + + it_behaves_like( + 'finalized tracked background migration', + Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsChildren + ) + end +end diff --git a/spec/migrations/fix_and_backfill_project_namespaces_for_projects_with_duplicate_name_spec.rb b/spec/migrations/fix_and_backfill_project_namespaces_for_projects_with_duplicate_name_spec.rb new file mode 100644 index 00000000000..44a2220b2ad --- /dev/null +++ b/spec/migrations/fix_and_backfill_project_namespaces_for_projects_with_duplicate_name_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe FixAndBackfillProjectNamespacesForProjectsWithDuplicateName, :migration do + let(:projects) { table(:projects) } + let(:namespaces) { table(:namespaces) } + + let!(:group) { namespaces.create!(name: 'group1', path: 'group1', type: 'Group') } + let!(:project_namespace) { namespaces.create!(name: 'project2', path: 'project2', type: 'Project') } + let!(:project1) { projects.create!(name: 'project1', path: 'project1', project_namespace_id: nil, namespace_id: group.id, visibility_level: 20) } + let!(:project2) { projects.create!(name: 'project2', path: 'project2', project_namespace_id: project_namespace.id, namespace_id: group.id, visibility_level: 20) } + let!(:project3) { projects.create!(name: 'project3', path: 'project3', project_namespace_id: nil, namespace_id: group.id, visibility_level: 20) } + let!(:project4) { projects.create!(name: 'project4', path: 'project4', project_namespace_id: nil, namespace_id: group.id, visibility_level: 20) } + + 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, project1.id, project4.id) + expect(BackgroundMigrationWorker.jobs.size).to eq 1 + end + end + end + + context 'in batches' do + before do + stub_const('FixAndBackfillProjectNamespacesForProjectsWithDuplicateName::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, project1.id, project3.id) + expect(migration).to be_scheduled_delayed_migration(4.minutes, project4.id, project4.id) + expect(BackgroundMigrationWorker.jobs.size).to eq 2 + end + end + end + end + end +end diff --git a/spec/migrations/remove_wiki_notes_spec.rb b/spec/migrations/remove_wiki_notes_spec.rb new file mode 100644 index 00000000000..2ffebdee106 --- /dev/null +++ b/spec/migrations/remove_wiki_notes_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe RemoveWikiNotes, :migration do + let(:notes) { table(:notes) } + + it 'removes all wiki notes' do + notes.create!(id: 97, note: 'Wiki note', noteable_type: 'Wiki') + notes.create!(id: 98, note: 'Commit note', noteable_type: 'Commit') + notes.create!(id: 110, note: 'Issue note', noteable_type: 'Issue') + notes.create!(id: 242, note: 'MergeRequest note', noteable_type: 'MergeRequest') + + expect(notes.where(noteable_type: 'Wiki').size).to eq(1) + + expect { migrate! }.to change { notes.count }.by(-1) + + expect(notes.where(noteable_type: 'Wiki').size).to eq(0) + end + + context 'when not staging nor com' do + it 'does not remove notes' do + allow(::Gitlab).to receive(:com?).and_return(false) + allow(::Gitlab).to receive(:dev_or_test_env?).and_return(false) + allow(::Gitlab).to receive(:staging?).and_return(false) + + notes.create!(id: 97, note: 'Wiki note', noteable_type: 'Wiki') + + expect { migrate! }.not_to change { notes.count } + end + end +end diff --git a/spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb b/spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb new file mode 100644 index 00000000000..5e22fc06973 --- /dev/null +++ b/spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe ReplaceWorkItemTypeBackfillNextBatchStrategy, :migration do + describe '#up' do + it 'sets the new strategy for existing migrations' do + migrations = create_migrations(described_class::OLD_STRATEGY_CLASS, 2) + + expect do + migrate! + + migrations.each(&:reload) + end.to change { migrations.pluck(:batch_class_name).uniq }.from([described_class::OLD_STRATEGY_CLASS]) + .to([described_class::NEW_STRATEGY_CLASS]) + end + end + + describe '#down' do + it 'sets the old strategy for existing migrations' do + migrations = create_migrations(described_class::NEW_STRATEGY_CLASS, 2) + + expect do + migrate! + schema_migrate_down! + + migrations.each(&:reload) + end.to change { migrations.pluck(:batch_class_name).uniq }.from([described_class::NEW_STRATEGY_CLASS]) + .to([described_class::OLD_STRATEGY_CLASS]) + end + end + + def create_migrations(batch_class_name, count) + Array.new(2) { |index| create_background_migration(batch_class_name, [index]) } + end + + def create_background_migration(batch_class_name, job_arguments) + migrations_table = table(:batched_background_migrations) + + migrations_table.create!( + batch_class_name: batch_class_name, + job_class_name: described_class::JOB_CLASS_NAME, + max_value: 10, + batch_size: 5, + sub_batch_size: 1, + interval: 2.minutes, + table_name: :issues, + column_name: :id, + total_tuple_count: 10_000, + pause_ms: 100, + job_arguments: job_arguments + ) + end +end diff --git a/spec/models/alert_management/metric_image_spec.rb b/spec/models/alert_management/metric_image_spec.rb new file mode 100644 index 00000000000..dedbd6e501e --- /dev/null +++ b/spec/models/alert_management/metric_image_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AlertManagement::MetricImage do + subject { build(:alert_metric_image) } + + describe 'associations' do + it { is_expected.to belong_to(:alert) } + end + + describe 'validations' do + it { is_expected.to be_valid } + it { is_expected.to validate_presence_of(:file) } + it { is_expected.to validate_length_of(:url).is_at_most(255) } + it { is_expected.to validate_length_of(:url_text).is_at_most(128) } + end + + describe '.available_for?' do + subject { described_class.available_for?(issue.project) } + + let_it_be_with_refind(:issue) { create(:issue) } + + it { is_expected.to eq(true) } + end +end diff --git a/spec/models/analytics/cycle_analytics/aggregation_spec.rb b/spec/models/analytics/cycle_analytics/aggregation_spec.rb index 4bf737df56a..6071e4b3d21 100644 --- a/spec/models/analytics/cycle_analytics/aggregation_spec.rb +++ b/spec/models/analytics/cycle_analytics/aggregation_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Analytics::CycleAnalytics::Aggregation, type: :model do it { is_expected.not_to validate_presence_of(:group) } it { is_expected.not_to validate_presence_of(:enabled) } - %i[incremental_runtimes_in_seconds incremental_processed_records last_full_run_runtimes_in_seconds last_full_run_processed_records].each do |column| + %i[incremental_runtimes_in_seconds incremental_processed_records full_runtimes_in_seconds full_processed_records].each do |column| it "validates the array length of #{column}" do record = described_class.new(column => [1] * 11) @@ -20,6 +20,81 @@ RSpec.describe Analytics::CycleAnalytics::Aggregation, type: :model do end end + describe 'attribute updater methods' do + subject(:aggregation) { build(:cycle_analytics_aggregation) } + + describe '#cursor_for' do + it 'returns empty cursors' do + aggregation.last_full_issues_id = nil + aggregation.last_full_issues_updated_at = nil + + expect(aggregation.cursor_for(:full, Issue)).to eq({}) + end + + context 'when cursor is not empty' do + it 'returns the cursor values' do + current_time = Time.current + + aggregation.last_full_issues_id = 1111 + aggregation.last_full_issues_updated_at = current_time + + expect(aggregation.cursor_for(:full, Issue)).to eq({ id: 1111, updated_at: current_time }) + end + end + end + + describe '#refresh_last_run' do + it 'updates the run_at column' do + freeze_time do + aggregation.refresh_last_run(:incremental) + + expect(aggregation.last_incremental_run_at).to eq(Time.current) + end + end + end + + describe '#reset_full_run_cursors' do + it 'resets all full run cursors to nil' do + aggregation.last_full_issues_id = 111 + aggregation.last_full_issues_updated_at = Time.current + aggregation.last_full_merge_requests_id = 111 + aggregation.last_full_merge_requests_updated_at = Time.current + + aggregation.reset_full_run_cursors + + expect(aggregation).to have_attributes( + last_full_issues_id: nil, + last_full_issues_updated_at: nil, + last_full_merge_requests_id: nil, + last_full_merge_requests_updated_at: nil + ) + end + end + + describe '#set_cursor' do + it 'sets the cursor values for the given mode' do + aggregation.set_cursor(:full, Issue, { id: 2222, updated_at: nil }) + + expect(aggregation).to have_attributes( + last_full_issues_id: 2222, + last_full_issues_updated_at: nil + ) + end + end + + describe '#set_stats' do + it 'appends stats to the runtime and processed_records attributes' do + aggregation.set_stats(:full, 10, 20) + aggregation.set_stats(:full, 20, 30) + + expect(aggregation).to have_attributes( + full_runtimes_in_seconds: [10, 20], + full_processed_records: [20, 30] + ) + end + end + end + describe '#safe_create_for_group' do let_it_be(:group) { create(:group) } let_it_be(:subgroup) { create(:group, parent: group) } diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 70331e8d78a..541fa1ac77a 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -512,12 +512,8 @@ RSpec.describe ApplicationSetting do end context 'key restrictions' do - it 'supports all key types' do - expect(described_class::SUPPORTED_KEY_TYPES).to eq(Gitlab::SSHPublicKey.supported_types) - end - it 'does not allow all key types to be disabled' do - described_class::SUPPORTED_KEY_TYPES.each do |type| + Gitlab::SSHPublicKey.supported_types.each do |type| setting["#{type}_key_restriction"] = described_class::FORBIDDEN_KEY_VALUE end @@ -526,15 +522,23 @@ RSpec.describe ApplicationSetting do end where(:type) do - described_class::SUPPORTED_KEY_TYPES + Gitlab::SSHPublicKey.supported_types end with_them do let(:field) { :"#{type}_key_restriction" } - it { is_expected.to validate_presence_of(field) } - it { is_expected.to allow_value(*KeyRestrictionValidator.supported_key_restrictions(type)).for(field) } - it { is_expected.not_to allow_value(128).for(field) } + shared_examples 'key validations' do + it { is_expected.to validate_presence_of(field) } + it { is_expected.to allow_value(*KeyRestrictionValidator.supported_key_restrictions(type)).for(field) } + it { is_expected.not_to allow_value(128).for(field) } + end + + it_behaves_like 'key validations' + + context 'FIPS mode', :fips_mode do + it_behaves_like 'key validations' + end end end @@ -1306,4 +1310,31 @@ RSpec.describe ApplicationSetting do end end end + + describe '#database_grafana_api_key' do + it 'is encrypted' do + subject.database_grafana_api_key = 'somesecret' + + aggregate_failures do + expect(subject.encrypted_database_grafana_api_key).to be_present + expect(subject.encrypted_database_grafana_api_key_iv).to be_present + expect(subject.encrypted_database_grafana_api_key).not_to eq(subject.database_grafana_api_key) + end + end + end + + context "inactive project deletion" do + it "validates that inactive_projects_send_warning_email_after_months is less than inactive_projects_delete_after_months" do + subject[:inactive_projects_delete_after_months] = 3 + subject[:inactive_projects_send_warning_email_after_months] = 6 + + expect(subject).to be_invalid + end + + it { is_expected.to validate_numericality_of(:inactive_projects_send_warning_email_after_months).is_greater_than(0) } + + it { is_expected.to validate_numericality_of(:inactive_projects_delete_after_months).is_greater_than(0) } + + it { is_expected.to validate_numericality_of(:inactive_projects_min_size_mb).is_greater_than_or_equal_to(0) } + end end diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb index ebd1441f901..4da19267b1c 100644 --- a/spec/models/award_emoji_spec.rb +++ b/spec/models/award_emoji_spec.rb @@ -58,6 +58,43 @@ RSpec.describe AwardEmoji do end end end + + context 'custom emoji' do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:emoji) { create(:custom_emoji, name: 'partyparrot', namespace: group) } + + before do + group.add_maintainer(user) + end + + %i[issue merge_request note_on_issue snippet].each do |awardable_type| + let_it_be(:project) { create(:project, namespace: group) } + let(:awardable) { create(awardable_type, project: project) } + + it "is accepted on #{awardable_type}" do + new_award = build(:award_emoji, user: user, awardable: awardable, name: emoji.name) + + expect(new_award).to be_valid + end + end + + it 'is accepted on subgroup issue' do + subgroup = create(:group, parent: group) + project = create(:project, namespace: subgroup) + issue = create(:issue, project: project) + new_award = build(:award_emoji, user: user, awardable: issue, name: emoji.name) + + expect(new_award).to be_valid + end + + it 'is not supported on personal snippet (yet)' do + snippet = create(:personal_snippet) + new_award = build(:award_emoji, user: snippet.author, awardable: snippet, name: 'null') + + expect(new_award).not_to be_valid + end + end end describe 'scopes' do @@ -210,4 +247,47 @@ RSpec.describe AwardEmoji do end end end + + describe '#url' do + let_it_be(:custom_emoji) { create(:custom_emoji) } + let_it_be(:project) { create(:project, namespace: custom_emoji.group) } + let_it_be(:issue) { create(:issue, project: project) } + + def build_award(name) + build(:award_emoji, awardable: issue, name: name) + end + + it 'is nil for built-in emoji' do + new_award = build_award('tada') + + count = ActiveRecord::QueryRecorder.new do + expect(new_award.url).to be_nil + end.count + expect(count).to be_zero + end + + it 'is nil for unrecognized emoji' do + new_award = build_award('null') + + expect(new_award.url).to be_nil + end + + it 'is set for custom emoji' do + new_award = build_award(custom_emoji.name) + + expect(new_award.url).to eq(custom_emoji.url) + end + + context 'feature flag disabled' do + before do + stub_feature_flags(custom_emoji: false) + end + + it 'does not query' do + new_award = build_award(custom_emoji.name) + + expect(ActiveRecord::QueryRecorder.new { new_award.url }.count).to be_zero + end + end + end end diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 6eba9ca63b0..9c153f36d8b 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -229,6 +229,20 @@ RSpec.describe Blob do end end + describe '#executable?' do + it 'is true for executables' do + executable_blob = fake_blob(path: 'file', mode: '100755') + + expect(executable_blob.executable?).to eq true + end + + it 'is false for non-executables' do + non_executable_blob = fake_blob(path: 'file', mode: '100655') + + expect(non_executable_blob.executable?).to eq false + end + end + describe '#extension' do it 'returns the extension' do blob = fake_blob(path: 'file.md') diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb index 90a06b80f9c..775cccd2aec 100644 --- a/spec/models/board_spec.rb +++ b/spec/models/board_spec.rb @@ -21,10 +21,12 @@ RSpec.describe Board do end describe '#order_by_name_asc' do + # rubocop:disable RSpec/VariableName let!(:board_B) { create(:board, project: project, name: 'B') } let!(:board_C) { create(:board, project: project, name: 'C') } let!(:board_a) { create(:board, project: project, name: 'a') } let!(:board_A) { create(:board, project: project, name: 'A') } + # rubocop:enable RSpec/VariableName it 'returns in case-insensitive alphabetical order and then by ascending id' do expect(project.boards.order_by_name_asc).to eq [board_a, board_A, board_B, board_C] @@ -32,10 +34,12 @@ RSpec.describe Board do end describe '#first_board' do + # rubocop:disable RSpec/VariableName let!(:board_B) { create(:board, project: project, name: 'B') } let!(:board_C) { create(:board, project: project, name: 'C') } let!(:board_a) { create(:board, project: project, name: 'a') } let!(:board_A) { create(:board, project: project, name: 'A') } + # rubocop:enable RSpec/VariableName it 'return the first case-insensitive alphabetical board as a relation' do expect(project.boards.first_board).to eq [board_a] diff --git a/spec/models/bulk_import_spec.rb b/spec/models/bulk_import_spec.rb index ea002a7b174..3430da43f62 100644 --- a/spec/models/bulk_import_spec.rb +++ b/spec/models/bulk_import_spec.rb @@ -3,6 +3,13 @@ require 'spec_helper' RSpec.describe BulkImport, type: :model do + let_it_be(:created_bulk_import) { create(:bulk_import, :created) } + let_it_be(:started_bulk_import) { create(:bulk_import, :started) } + let_it_be(:finished_bulk_import) { create(:bulk_import, :finished) } + let_it_be(:failed_bulk_import) { create(:bulk_import, :failed) } + let_it_be(:stale_created_bulk_import) { create(:bulk_import, :created, created_at: 3.days.ago) } + let_it_be(:stale_started_bulk_import) { create(:bulk_import, :started, created_at: 3.days.ago) } + describe 'associations' do it { is_expected.to belong_to(:user).required } it { is_expected.to have_one(:configuration) } @@ -16,9 +23,15 @@ RSpec.describe BulkImport, type: :model do it { is_expected.to define_enum_for(:source_type).with_values(%i[gitlab]) } end + describe '.stale scope' do + subject { described_class.stale } + + it { is_expected.to contain_exactly(stale_created_bulk_import, stale_started_bulk_import) } + end + describe '.all_human_statuses' do it 'returns all human readable entity statuses' do - expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed') + expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed', 'timeout') end end diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb index e5bbac62dcc..6f6a7c9bcd8 100644 --- a/spec/models/bulk_imports/entity_spec.rb +++ b/spec/models/bulk_imports/entity_spec.rb @@ -151,7 +151,7 @@ RSpec.describe BulkImports::Entity, type: :model do describe '.all_human_statuses' do it 'returns all human readable entity statuses' do - expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed') + expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed', 'timeout') end end @@ -179,7 +179,7 @@ RSpec.describe BulkImports::Entity, type: :model do entity = create(:bulk_import_entity, :group_entity) entity.create_pipeline_trackers! - expect(entity.trackers.count).to eq(BulkImports::Groups::Stage.new(entity.bulk_import).pipelines.count) + expect(entity.trackers.count).to eq(BulkImports::Groups::Stage.new(entity).pipelines.count) expect(entity.trackers.map(&:pipeline_name)).to include(BulkImports::Groups::Pipelines::GroupPipeline.to_s) end end @@ -189,7 +189,7 @@ RSpec.describe BulkImports::Entity, type: :model do entity = create(:bulk_import_entity, :project_entity) entity.create_pipeline_trackers! - expect(entity.trackers.count).to eq(BulkImports::Projects::Stage.new(entity.bulk_import).pipelines.count) + expect(entity.trackers.count).to eq(BulkImports::Projects::Stage.new(entity).pipelines.count) expect(entity.trackers.map(&:pipeline_name)).to include(BulkImports::Projects::Pipelines::ProjectPipeline.to_s) end end diff --git a/spec/models/bulk_imports/export_status_spec.rb b/spec/models/bulk_imports/export_status_spec.rb index f945ad12244..79ed6b39358 100644 --- a/spec/models/bulk_imports/export_status_spec.rb +++ b/spec/models/bulk_imports/export_status_spec.rb @@ -13,6 +13,10 @@ RSpec.describe BulkImports::ExportStatus do double(parsed_response: [{ 'relation' => 'labels', 'status' => status, 'error' => 'error!' }]) end + let(:invalid_response_double) do + double(parsed_response: [{ 'relation' => 'not_a_real_relation', 'status' => status, 'error' => 'error!' }]) + end + subject { described_class.new(tracker, relation) } before do @@ -36,6 +40,18 @@ RSpec.describe BulkImports::ExportStatus do it 'returns false' do expect(subject.started?).to eq(false) end + + context 'when returned relation is invalid' do + before do + allow_next_instance_of(BulkImports::Clients::HTTP) do |client| + allow(client).to receive(:get).and_return(invalid_response_double) + end + end + + it 'returns false' do + expect(subject.started?).to eq(false) + end + end end end @@ -63,7 +79,7 @@ RSpec.describe BulkImports::ExportStatus do it 'returns true' do expect(subject.failed?).to eq(true) - expect(subject.error).to eq('Empty export status response') + expect(subject.error).to eq('Empty relation export status') end end end diff --git a/spec/models/bulk_imports/tracker_spec.rb b/spec/models/bulk_imports/tracker_spec.rb index a72b628e329..0b6f692a477 100644 --- a/spec/models/bulk_imports/tracker_spec.rb +++ b/spec/models/bulk_imports/tracker_spec.rb @@ -66,8 +66,8 @@ RSpec.describe BulkImports::Tracker, type: :model do describe '#pipeline_class' do it 'returns the pipeline class' do - bulk_import = create(:bulk_import) - pipeline_class = BulkImports::Groups::Stage.new(bulk_import).pipelines.first[1] + entity = create(:bulk_import_entity) + pipeline_class = BulkImports::Groups::Stage.new(entity).pipelines.first[1] tracker = create(:bulk_import_tracker, pipeline_name: pipeline_class) expect(tracker.pipeline_class).to eq(pipeline_class) diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb index 7c3c02a5ab7..5ee560c4925 100644 --- a/spec/models/ci/bridge_spec.rb +++ b/spec/models/ci/bridge_spec.rb @@ -30,6 +30,12 @@ RSpec.describe Ci::Bridge do expect(bridge).to have_one(:downstream_pipeline) end + describe '#retryable?' do + it 'returns false' do + expect(bridge.retryable?).to eq(false) + end + end + describe '#tags' do it 'only has a bridge tag' do expect(bridge.tags).to eq [:bridge] @@ -282,6 +288,26 @@ RSpec.describe Ci::Bridge do ) end end + + context 'when the pipeline runs from a pipeline schedule' do + let(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project ) } + let(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule) } + + let(:options) do + { trigger: { project: 'my/project', forward: { pipeline_variables: true } } } + end + + before do + pipeline_schedule.variables.create!(key: 'schedule_var_key', value: 'schedule var value') + end + + it 'adds the schedule variable' do + expect(bridge.downstream_variables).to contain_exactly( + { key: 'BRIDGE', value: 'cross' }, + { key: 'schedule_var_key', value: 'schedule var value' } + ) + end + end end end diff --git a/spec/models/ci/build_dependencies_spec.rb b/spec/models/ci/build_dependencies_spec.rb index cd330324840..91048cae064 100644 --- a/spec/models/ci/build_dependencies_spec.rb +++ b/spec/models/ci/build_dependencies_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Ci::BuildDependencies do end let!(:build) { create(:ci_build, pipeline: pipeline, name: 'build', stage_idx: 0, stage: 'build') } - let!(:rspec_test) { create(:ci_build, pipeline: pipeline, name: 'rspec', stage_idx: 1, stage: 'test') } + let!(:rspec_test) { create(:ci_build, :success, pipeline: pipeline, name: 'rspec', stage_idx: 1, stage: 'test') } let!(:rubocop_test) { create(:ci_build, pipeline: pipeline, name: 'rubocop', stage_idx: 1, stage: 'test') } let!(:staging) { create(:ci_build, pipeline: pipeline, name: 'staging', stage_idx: 2, stage: 'deploy') } @@ -48,7 +48,7 @@ RSpec.describe Ci::BuildDependencies do project.add_developer(user) end - let!(:retried_job) { Ci::Build.retry(rspec_test, user) } + let!(:retried_job) { Ci::RetryJobService.new(rspec_test.project, user).execute(rspec_test)[:job] } it 'contains the retried job instead of the original one' do is_expected.to contain_exactly(build, retried_job, rubocop_test) diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 240b932638a..fd87a388442 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1585,6 +1585,31 @@ RSpec.describe Ci::Build do it { is_expected.to eq('review/x') } end + + context 'when environment name uses a nested variable' do + let(:yaml_variables) do + [ + { key: 'ENVIRONMENT_NAME', value: '${CI_COMMIT_REF_NAME}' } + ] + end + + let(:build) do + create(:ci_build, + ref: 'master', + yaml_variables: yaml_variables, + environment: 'review/$ENVIRONMENT_NAME') + end + + it { is_expected.to eq('review/master') } + + context 'when the FF ci_expand_environment_name_and_url is disabled' do + before do + stub_feature_flags(ci_expand_environment_name_and_url: false) + end + + it { is_expected.to eq('review/${CI_COMMIT_REF_NAME}') } + end + end end describe '#expanded_kubernetes_namespace' do @@ -1951,90 +1976,6 @@ RSpec.describe Ci::Build do end end - describe '#retryable?' do - subject { build } - - context 'when build is retryable' do - context 'when build is successful' do - before do - build.success! - end - - it { is_expected.to be_retryable } - end - - context 'when build is failed' do - before do - build.drop! - end - - it { is_expected.to be_retryable } - end - - context 'when build is canceled' do - before do - build.cancel! - end - - it { is_expected.to be_retryable } - end - end - - context 'when build is not retryable' do - context 'when build is running' do - before do - build.run! - end - - it { is_expected.not_to be_retryable } - end - - context 'when build is skipped' do - before do - build.skip! - end - - it { is_expected.not_to be_retryable } - end - - context 'when build is degenerated' do - before do - build.degenerate! - end - - it { is_expected.not_to be_retryable } - end - - context 'when a canceled build has been retried already' do - before do - project.add_developer(user) - build.cancel! - described_class.retry(build, user) - end - - it { is_expected.not_to be_retryable } - end - - context 'when deployment is rejected' do - before do - build.drop!(:deployment_rejected) - end - - it { is_expected.not_to be_retryable } - end - - context 'when build is waiting for deployment approval' do - subject { build_stubbed(:ci_build, :manual, environment: 'production') } - - before do - create(:deployment, :blocked, deployable: subject) - end - - it { is_expected.not_to be_retryable } - end - end - end - describe '#action?' do before do build.update!(when: value) @@ -2308,7 +2249,7 @@ RSpec.describe Ci::Build do describe '#options' do let(:options) do { - image: "ruby:2.7", + image: "image:1.0", services: ["postgres"], script: ["ls -a"] } @@ -2319,7 +2260,7 @@ RSpec.describe Ci::Build do end it 'allows to access with symbolized keys' do - expect(build.options[:image]).to eq('ruby:2.7') + expect(build.options[:image]).to eq('image:1.0') end it 'rejects access with string keys' do @@ -2358,24 +2299,12 @@ RSpec.describe Ci::Build do end context 'when build is retried' do - let!(:new_build) { described_class.retry(build, user) } + let!(:new_build) { Ci::RetryJobService.new(project, user).execute(build)[:job] } it 'does not return any of them' do is_expected.not_to include(build, new_build) end end - - context 'when other build is retried' do - let!(:retried_build) { described_class.retry(other_build, user) } - - before do - retried_build.success - end - - it 'returns a retried build' do - is_expected.to contain_exactly(retried_build) - end - end end describe '#other_scheduled_actions' do @@ -3962,8 +3891,9 @@ RSpec.describe Ci::Build do subject { create(:ci_build, :running, options: { script: ["ls -al"], retry: 3 }, project: project, user: user) } it 'retries build and assigns the same user to it' do - expect(described_class).to receive(:retry) - .with(subject, user) + expect_next_instance_of(::Ci::RetryJobService) do |service| + expect(service).to receive(:execute).with(subject) + end subject.drop! end @@ -3977,10 +3907,10 @@ RSpec.describe Ci::Build do end context 'when retry service raises Gitlab::Access::AccessDeniedError exception' do - let(:retry_service) { Ci::RetryBuildService.new(subject.project, subject.user) } + let(:retry_service) { Ci::RetryJobService.new(subject.project, subject.user) } before do - allow_any_instance_of(Ci::RetryBuildService) + allow_any_instance_of(Ci::RetryJobService) .to receive(:execute) .with(subject) .and_raise(Gitlab::Access::AccessDeniedError) diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index bd0397e0396..24c318d0218 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -279,6 +279,15 @@ RSpec.describe Ci::JobArtifact do end end + describe '.order_expired_asc' do + let_it_be(:first_artifact) { create(:ci_job_artifact, expire_at: 2.days.ago) } + let_it_be(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) } + + it 'returns ordered artifacts' do + expect(described_class.order_expired_asc).to eq([first_artifact, second_artifact]) + end + end + describe '.for_project' do it 'returns artifacts only for given project(s)', :aggregate_failures do artifact1 = create(:ci_job_artifact) @@ -700,10 +709,6 @@ RSpec.describe Ci::JobArtifact do MSG end - it_behaves_like 'it has loose foreign keys' do - let(:factory_name) { :ci_job_artifact } - end - context 'loose foreign key on ci_job_artifacts.project_id' do it_behaves_like 'cleanup by a loose foreign key' do let!(:parent) { create(:project) } diff --git a/spec/models/ci/namespace_mirror_spec.rb b/spec/models/ci/namespace_mirror_spec.rb index 38471f15849..9b4e86916b8 100644 --- a/spec/models/ci/namespace_mirror_spec.rb +++ b/spec/models/ci/namespace_mirror_spec.rb @@ -44,6 +44,53 @@ RSpec.describe Ci::NamespaceMirror do end end + describe '.contains_traversal_ids' do + let!(:other_group1) { create(:group) } + let!(:other_group2) { create(:group, parent: other_group1) } + let!(:other_group3) { create(:group, parent: other_group2) } + let!(:other_group4) { create(:group) } + + subject(:result) { described_class.contains_traversal_ids(all_traversal_ids) } + + context 'when passing a top-level group' do + let(:all_traversal_ids) do + [ + [other_group1.id] + ] + end + + it 'returns only itself and children of that group' do + expect(result.map(&:namespace)).to contain_exactly(other_group1, other_group2, other_group3) + end + end + + context 'when passing many levels of groups' do + let(:all_traversal_ids) do + [ + [other_group2.parent_id, other_group2.id], + [other_group3.parent_id, other_group3.id], + [other_group4.id] + ] + end + + it 'returns only the asked group' do + expect(result.map(&:namespace)).to contain_exactly(other_group2, other_group3, other_group4) + end + end + + context 'when passing invalid data ' do + let(:all_traversal_ids) do + [ + ["; UPDATE"] + ] + end + + it 'data is properly sanitised' do + expect(result.to_sql).to include "((traversal_ids[1])) IN (('; UPDATE'))" + end + end + end + describe '.by_namespace_id' do subject(:result) { described_class.by_namespace_id(group2.id) } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 294ec07ee3e..45b51d5bf44 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -1146,6 +1146,50 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end end end + + describe 'variable CI_GITLAB_FIPS_MODE' do + context 'when FIPS flag is enabled' do + before do + allow(Gitlab::FIPS).to receive(:enabled?).and_return(true) + end + + it "is included with value 'true'" do + expect(subject.to_hash).to include('CI_GITLAB_FIPS_MODE' => 'true') + end + end + + context 'when FIPS flag is disabled' do + before do + allow(Gitlab::FIPS).to receive(:enabled?).and_return(false) + end + + it 'is not included' do + expect(subject.to_hash).not_to have_key('CI_GITLAB_FIPS_MODE') + end + end + end + + context 'without a commit' do + let(:pipeline) { build(:ci_empty_pipeline, :created, sha: nil) } + + it 'does not expose commit variables' do + expect(subject.to_hash.keys) + .not_to include( + 'CI_COMMIT_SHA', + 'CI_COMMIT_SHORT_SHA', + 'CI_COMMIT_BEFORE_SHA', + 'CI_COMMIT_REF_NAME', + 'CI_COMMIT_REF_SLUG', + 'CI_COMMIT_BRANCH', + 'CI_COMMIT_TAG', + 'CI_COMMIT_MESSAGE', + 'CI_COMMIT_TITLE', + 'CI_COMMIT_DESCRIPTION', + 'CI_COMMIT_REF_PROTECTED', + 'CI_COMMIT_TIMESTAMP', + 'CI_COMMIT_AUTHOR') + end + end end describe '#protected_ref?' do @@ -1663,7 +1707,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do expect(upstream_pipeline.reload).to be_failed Sidekiq::Testing.inline! do - new_job = Ci::Build.retry(job, project.users.first) + new_job = Ci::RetryJobService.new(project, project.users.first).execute(job)[:job] expect(downstream_pipeline.reload).to be_running expect(upstream_pipeline.reload).to be_running @@ -1684,7 +1728,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do expect(upstream_pipeline.reload).to be_success Sidekiq::Testing.inline! do - new_job = Ci::Build.retry(job, project.users.first) + new_job = Ci::RetryJobService.new(project, project.users.first).execute(job)[:job] expect(downstream_pipeline.reload).to be_running expect(upstream_pipeline.reload).to be_running @@ -1715,7 +1759,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do expect(upstream_of_upstream_pipeline.reload).to be_failed Sidekiq::Testing.inline! do - new_job = Ci::Build.retry(job, project.users.first) + new_job = Ci::RetryJobService.new(project, project.users.first).execute(job)[:job] expect(downstream_pipeline.reload).to be_running expect(upstream_pipeline.reload).to be_running @@ -2583,8 +2627,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do build.drop project.add_developer(user) - - Ci::Build.retry(build, user) + ::Ci::RetryJobService.new(project, user).execute(build)[:job] end # We are changing a state: created > failed > running @@ -4688,7 +4731,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do project.add_developer(user) retried_build.cancel! - ::Ci::Build.retry(retried_build, user) + Ci::RetryJobService.new(project, user).execute(retried_build)[:job] end it 'does not include retried builds' do @@ -4714,6 +4757,24 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end end + describe '#has_expired_test_reports?' do + subject { pipeline_with_test_report.has_expired_test_reports? } + + let(:pipeline_with_test_report) { create(:ci_pipeline, :with_test_reports) } + + context 'when artifacts are not expired' do + it { is_expected.to be_falsey } + end + + context 'when artifacts are expired' do + before do + pipeline_with_test_report.job_artifacts.first.update!(expire_at: Date.yesterday) + end + + it { is_expected.to be_truthy } + end + end + it_behaves_like 'it has loose foreign keys' do let(:factory_name) { :ci_pipeline } end diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb index ac1a8247aaa..71fef3c1b5b 100644 --- a/spec/models/ci/processable_spec.rb +++ b/spec/models/ci/processable_spec.rb @@ -14,6 +14,100 @@ RSpec.describe Ci::Processable do it { is_expected.to delegate_method(:legacy_detached_merge_request_pipeline?).to(:pipeline) } end + describe '#retryable' do + shared_examples_for 'retryable processable' do + context 'when processable is successful' do + before do + processable.success! + end + + it { is_expected.to be_retryable } + end + + context 'when processable is failed' do + before do + processable.drop! + end + + it { is_expected.to be_retryable } + end + + context 'when processable is canceled' do + before do + processable.cancel! + end + + it { is_expected.to be_retryable } + end + end + + shared_examples_for 'non-retryable processable' do + context 'when processable is skipped' do + before do + processable.skip! + end + + it { is_expected.not_to be_retryable } + end + + context 'when processable is degenerated' do + before do + processable.degenerate! + end + + it { is_expected.not_to be_retryable } + end + + context 'when a canceled processable has been retried already' do + before do + project.add_developer(create(:user)) + processable.cancel! + processable.update!(retried: true) + end + + it { is_expected.not_to be_retryable } + end + end + + context 'when the processable is a build' do + subject(:processable) { create(:ci_build, pipeline: pipeline) } + + context 'when the processable is retryable' do + it_behaves_like 'retryable processable' + + context 'when deployment is rejected' do + before do + processable.drop!(:deployment_rejected) + end + + it { is_expected.not_to be_retryable } + end + + context 'when build is waiting for deployment approval' do + subject { build_stubbed(:ci_build, :manual, environment: 'production') } + + before do + create(:deployment, :blocked, deployable: subject) + end + + it { is_expected.not_to be_retryable } + end + end + + context 'when the processable is non-retryable' do + it_behaves_like 'non-retryable processable' + + context 'when processable is running' do + before do + processable.run! + end + + it { is_expected.not_to be_retryable } + end + end + end + end + describe '#aggregated_needs_names' do let(:with_aggregated_needs) { pipeline.processables.select_with_aggregated_needs(project) } diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 42187c3ef99..05b7bc39a74 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -134,28 +134,28 @@ RSpec.describe Ci::Runner do end context 'cost factors validations' do - it 'dissalows :private_projects_minutes_cost_factor being nil' do + it 'disallows :private_projects_minutes_cost_factor being nil' do runner = build(:ci_runner, private_projects_minutes_cost_factor: nil) expect(runner).to be_invalid expect(runner.errors.full_messages).to include('Private projects minutes cost factor needs to be non-negative') end - it 'dissalows :public_projects_minutes_cost_factor being nil' do + it 'disallows :public_projects_minutes_cost_factor being nil' do runner = build(:ci_runner, public_projects_minutes_cost_factor: nil) expect(runner).to be_invalid expect(runner.errors.full_messages).to include('Public projects minutes cost factor needs to be non-negative') end - it 'dissalows :private_projects_minutes_cost_factor being negative' do + it 'disallows :private_projects_minutes_cost_factor being negative' do runner = build(:ci_runner, private_projects_minutes_cost_factor: -1.1) expect(runner).to be_invalid expect(runner.errors.full_messages).to include('Private projects minutes cost factor needs to be non-negative') end - it 'dissalows :public_projects_minutes_cost_factor being negative' do + it 'disallows :public_projects_minutes_cost_factor being negative' do runner = build(:ci_runner, public_projects_minutes_cost_factor: -2.2) expect(runner).to be_invalid diff --git a/spec/models/ci/secure_file_spec.rb b/spec/models/ci/secure_file_spec.rb index 4382385aaf5..f92db3fe8db 100644 --- a/spec/models/ci/secure_file_spec.rb +++ b/spec/models/ci/secure_file_spec.rb @@ -3,14 +3,14 @@ require 'spec_helper' RSpec.describe Ci::SecureFile do - let(:sample_file) { fixture_file('ci_secure_files/upload-keystore.jks') } - - subject { create(:ci_secure_file) } - before do stub_ci_secure_file_object_storage end + let(:sample_file) { fixture_file('ci_secure_files/upload-keystore.jks') } + + subject { create(:ci_secure_file, file: CarrierWaveStringFile.new(sample_file)) } + it { is_expected.to be_a FileStoreMounter } it { is_expected.to belong_to(:project).required } @@ -27,6 +27,26 @@ RSpec.describe Ci::SecureFile do it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:permissions) } it { is_expected.to validate_presence_of(:project_id) } + context 'unique filename' do + let_it_be(:project1) { create(:project) } + + it 'ensures the file name is unique within a given project' do + file1 = create(:ci_secure_file, project: project1, name: 'file1') + expect do + create(:ci_secure_file, project: project1, name: 'file1') + end.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Name has already been taken') + + expect(project1.secure_files.where(name: 'file1').count).to be 1 + expect(project1.secure_files.find_by(name: 'file1').id).to eq(file1.id) + end + + it 'allows duplicate file names in different projects' do + create(:ci_secure_file, project: project1) + expect do + create(:ci_secure_file, project: create(:project)) + end.not_to raise_error + end + end end describe '#permissions' do @@ -37,8 +57,6 @@ RSpec.describe Ci::SecureFile do describe '#checksum' do it 'computes SHA256 checksum on the file before encrypted' do - subject.file = CarrierWaveStringFile.new(sample_file) - subject.save! expect(subject.checksum).to eq(Digest::SHA256.hexdigest(sample_file)) end end @@ -51,8 +69,6 @@ RSpec.describe Ci::SecureFile do describe '#file' do it 'returns the saved file' do - subject.file = CarrierWaveStringFile.new(sample_file) - subject.save! expect(Base64.encode64(subject.file.read)).to eq(Base64.encode64(sample_file)) end end diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb index f279e779de5..f10e0cc8fa7 100644 --- a/spec/models/clusters/agent_spec.rb +++ b/spec/models/clusters/agent_spec.rb @@ -117,6 +117,23 @@ RSpec.describe Clusters::Agent do end end + describe '#last_used_agent_tokens' do + let_it_be(:agent) { create(:cluster_agent) } + + subject { agent.last_used_agent_tokens } + + context 'agent has no tokens' do + it { is_expected.to be_empty } + end + + context 'agent has active and inactive tokens' do + let!(:active_token) { create(:cluster_agent_token, agent: agent, last_used_at: 1.minute.ago) } + let!(:inactive_token) { create(:cluster_agent_token, agent: agent, last_used_at: 2.hours.ago) } + + it { is_expected.to contain_exactly(active_token, inactive_token) } + end + end + describe '#activity_event_deletion_cutoff' do let_it_be(:agent) { create(:cluster_agent) } let_it_be(:event1) { create(:agent_activity_event, agent: agent, recorded_at: 1.hour.ago) } diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 86ee159b97e..155e0fbb0e9 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -994,4 +994,11 @@ RSpec.describe CommitStatus do let!(:model) { create(:ci_build, project: parent) } end end + + context 'loose foreign key on ci_builds.runner_id' do + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:ci_runner) } + let!(:model) { create(:ci_build, runner: parent) } + end + end end diff --git a/spec/models/concerns/approvable_base_spec.rb b/spec/models/concerns/approvable_base_spec.rb index 79053e98db7..2bf6a98a64d 100644 --- a/spec/models/concerns/approvable_base_spec.rb +++ b/spec/models/concerns/approvable_base_spec.rb @@ -36,7 +36,7 @@ RSpec.describe ApprovableBase do subject { merge_request.can_be_approved_by?(user) } before do - merge_request.project.add_developer(user) + merge_request.project.add_developer(user) if user end it 'returns true' do @@ -64,7 +64,7 @@ RSpec.describe ApprovableBase do subject { merge_request.can_be_unapproved_by?(user) } before do - merge_request.project.add_developer(user) + merge_request.project.add_developer(user) if user end it 'returns false' do diff --git a/spec/models/concerns/batch_nullify_dependent_associations_spec.rb b/spec/models/concerns/batch_nullify_dependent_associations_spec.rb new file mode 100644 index 00000000000..933464f301a --- /dev/null +++ b/spec/models/concerns/batch_nullify_dependent_associations_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BatchNullifyDependentAssociations do + before do + test_user = Class.new(ActiveRecord::Base) do + self.table_name = 'users' + + has_many :closed_issues, foreign_key: :closed_by_id, class_name: 'Issue', dependent: :nullify + has_many :issues, foreign_key: :author_id, class_name: 'Issue', dependent: :nullify + has_many :updated_issues, foreign_key: :updated_by_id, class_name: 'Issue' + + include BatchNullifyDependentAssociations + end + + stub_const('TestUser', test_user) + end + + describe '.dependent_associations_to_nullify' do + it 'returns only associations with `dependent: :nullify` associations' do + expect(TestUser.dependent_associations_to_nullify.map(&:name)).to match_array([:closed_issues, :issues]) + end + end + + describe '#nullify_dependent_associations_in_batches' do + let_it_be(:user) { create(:user) } + let_it_be(:updated_by_issue) { create(:issue, updated_by: user) } + + before do + create(:issue, closed_by: user) + create(:issue, closed_by: user) + end + + it 'nullifies multiple settings' do + expect do + test_user = TestUser.find(user.id) + test_user.nullify_dependent_associations_in_batches + end.to change { Issue.where(closed_by_id: user.id).count }.by(-2) + end + + it 'excludes associations' do + expect do + test_user = TestUser.find(user.id) + test_user.nullify_dependent_associations_in_batches(exclude: [:closed_issues]) + end.not_to change { Issue.where(closed_by_id: user.id).count } + end + end +end diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb index 453b6f7f29a..bf104fe1b30 100644 --- a/spec/models/concerns/featurable_spec.rb +++ b/spec/models/concerns/featurable_spec.rb @@ -3,171 +3,101 @@ require 'spec_helper' RSpec.describe Featurable do - let_it_be(:user) { create(:user) } + let!(:klass) do + Class.new(ApplicationRecord) do + include Featurable - let(:project) { create(:project) } - let(:feature_class) { subject.class } - let(:features) { feature_class::FEATURES } + self.table_name = 'project_features' - subject { project.project_feature } + set_available_features %i(feature1 feature2 feature3) - describe '.quoted_access_level_column' do - it 'returns the table name and quoted column name for a feature' do - expected = '"project_features"."issues_access_level"' - - expect(feature_class.quoted_access_level_column(:issues)).to eq(expected) - end - end + def feature1_access_level + Featurable::DISABLED + end - describe '.access_level_attribute' do - it { expect(feature_class.access_level_attribute(:wiki)).to eq :wiki_access_level } + def feature2_access_level + Featurable::ENABLED + end - it 'raises error for unspecified feature' do - expect { feature_class.access_level_attribute(:unknown) } - .to raise_error(ArgumentError, /invalid feature: unknown/) + def feature3_access_level + Featurable::PRIVATE + end end end - describe '.set_available_features' do - let!(:klass) do - Class.new(ApplicationRecord) do - include Featurable + subject { klass.new } - self.table_name = 'project_features' - - set_available_features %i(feature1 feature2) + describe '.set_available_features' do + it { expect(klass.available_features).to match_array [:feature1, :feature2, :feature3] } + end - def feature1_access_level - Featurable::DISABLED - end + describe '#*_enabled?' do + it { expect(subject.feature1_enabled?).to be_falsey } + it { expect(subject.feature2_enabled?).to be_truthy } + end - def feature2_access_level - Featurable::ENABLED - end - end + describe '.quoted_access_level_column' do + it 'returns the table name and quoted column name for a feature' do + expect(klass.quoted_access_level_column(:feature1)).to eq('"project_features"."feature1_access_level"') end - - let!(:instance) { klass.new } - - it { expect(klass.available_features).to eq [:feature1, :feature2] } - it { expect(instance.feature1_enabled?).to be_falsey } - it { expect(instance.feature2_enabled?).to be_truthy } end - describe '.available_features' do - it { expect(feature_class.available_features).to include(*features) } + describe '.access_level_attribute' do + it { expect(klass.access_level_attribute(:feature1)).to eq :feature1_access_level } + + it 'raises error for unspecified feature' do + expect { klass.access_level_attribute(:unknown) } + .to raise_error(ArgumentError, /invalid feature: unknown/) + end end describe '#access_level' do it 'returns access level' do - expect(subject.access_level(:wiki)).to eq(subject.wiki_access_level) + expect(subject.access_level(:feature1)).to eq(subject.feature1_access_level) end end describe '#feature_available?' do - let(:features) { %w(issues wiki builds merge_requests snippets repository pages metrics_dashboard) } - context 'when features are disabled' do - it "returns false" do - update_all_project_features(project, features, ProjectFeature::DISABLED) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" - end + it 'returns false' do + expect(subject.feature_available?(:feature1)).to eq(false) end end context 'when features are enabled only for team members' do - it "returns false when user is not a team member" do - update_all_project_features(project, features, ProjectFeature::PRIVATE) + let_it_be(:user) { create(:user) } - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" - end + before do + expect(subject).to receive(:member?).and_call_original end - it "returns true when user is a team member" do - project.add_developer(user) - - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" + context 'when user is not present' do + it 'returns false' do + expect(subject.feature_available?(:feature3)).to eq(false) end end - it "returns true when user is a member of project group" do - group = create(:group) - project = create(:project, namespace: group) - group.add_developer(user) + context 'when user can read all resources' do + it 'returns true' do + allow(user).to receive(:can_read_all_resources?).and_return(true) - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" + expect(subject.feature_available?(:feature3, user)).to eq(true) end end - context 'when admin mode is enabled', :enable_admin_mode do - it "returns true if user is an admin" do - user.update_attribute(:admin, true) - - update_all_project_features(project, features, ProjectFeature::PRIVATE) + context 'when user cannot read all resources' do + it 'raises NotImplementedError exception' do + expect(subject).to receive(:resource_member?).and_call_original - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" - end - end - end - - context 'when admin mode is disabled' do - it "returns false when user is an admin" do - user.update_attribute(:admin, true) - - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" - end + expect { subject.feature_available?(:feature3, user) }.to raise_error(NotImplementedError) end end end context 'when feature is enabled for everyone' do - it "returns true" do - expect(project.feature_available?(:issues, user)).to eq(true) + it 'returns true' do + expect(subject.feature_available?(:feature2)).to eq(true) end end end - - describe '#*_enabled?' do - let(:features) { %w(wiki builds merge_requests) } - - it "returns false when feature is disabled" do - update_all_project_features(project, features, ProjectFeature::DISABLED) - - features.each do |feature| - expect(project.public_send("#{feature}_enabled?")).to eq(false), "#{feature} failed" - end - end - - it "returns true when feature is enabled only for team members" do - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" - end - end - - it "returns true when feature is enabled for everyone" do - features.each do |feature| - expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" - end - end - end - - def update_all_project_features(project, features, value) - project_feature_attributes = features.to_h { |f| ["#{f}_access_level", value] } - project.project_feature.update!(project_feature_attributes) - end end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index e3c0e3a7a2b..b38135fc0b2 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -625,6 +625,16 @@ RSpec.describe Issuable do end end + describe "#labels_hook_attrs" do + let(:project) { create(:project) } + let(:label) { create(:label) } + let(:issue) { create(:labeled_issue, project: project, labels: [label]) } + + it "returns a list of label hook attributes" do + expect(issue.labels_hook_attrs).to match_array([label.hook_attrs]) + end + end + describe '.labels_hash' do let(:feature_label) { create(:label, title: 'Feature') } let(:second_label) { create(:label, title: 'Second Label') } diff --git a/spec/models/concerns/sensitive_serializable_hash_spec.rb b/spec/models/concerns/sensitive_serializable_hash_spec.rb index 923f9e80c1f..c864ecb4eec 100644 --- a/spec/models/concerns/sensitive_serializable_hash_spec.rb +++ b/spec/models/concerns/sensitive_serializable_hash_spec.rb @@ -30,16 +30,6 @@ RSpec.describe SensitiveSerializableHash do expect(model.serializable_hash(unsafe_serialization_hash: true)).to include('super_secret') end end - - context 'when prevent_sensitive_fields_from_serializable_hash feature flag is disabled' do - before do - stub_feature_flags(prevent_sensitive_fields_from_serializable_hash: false) - end - - it 'includes the field in serializable_hash' do - expect(model.serializable_hash).to include('super_secret') - end - end end describe '#serializable_hash' do diff --git a/spec/models/concerns/taskable_spec.rb b/spec/models/concerns/taskable_spec.rb index 6b41174a046..140f6cda51c 100644 --- a/spec/models/concerns/taskable_spec.rb +++ b/spec/models/concerns/taskable_spec.rb @@ -13,6 +13,10 @@ RSpec.describe Taskable do - [x] Second item * [x] First item * [ ] Second item + + [ ] No-break space (U+00A0) + + [ ] Figure space (U+2007) + + [ ] Narrow no-break space (U+202F) + + [ ] Thin space (U+2009) MARKDOWN end @@ -21,7 +25,11 @@ RSpec.describe Taskable do TaskList::Item.new('- [ ]', 'First item'), TaskList::Item.new('- [x]', 'Second item'), TaskList::Item.new('* [x]', 'First item'), - TaskList::Item.new('* [ ]', 'Second item') + TaskList::Item.new('* [ ]', 'Second item'), + TaskList::Item.new('+ [ ]', 'No-break space (U+00A0)'), + TaskList::Item.new('+ [ ]', 'Figure space (U+2007)'), + TaskList::Item.new('+ [ ]', 'Narrow no-break space (U+202F)'), + TaskList::Item.new('+ [ ]', 'Thin space (U+2009)') ] end diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb index c8d86edc55f..2ea042fb767 100644 --- a/spec/models/container_repository_spec.rb +++ b/spec/models/container_repository_spec.rb @@ -122,6 +122,27 @@ RSpec.describe ContainerRepository, :aggregate_failures do expect(repository).to be_import_aborted end end + + context 'already imported' do + it 'finishes the import' do + expect(repository).to receive(:migration_pre_import).and_return(:already_imported) + + expect { subject } + .to change { repository.reload.migration_state }.to('import_done') + .and change { repository.reload.migration_skipped_reason }.to('native_import') + end + end + + context 'non-existing repository' do + it 'finishes the import' do + expect(repository).to receive(:migration_pre_import).and_return(:not_found) + + expect { subject } + .to change { repository.reload.migration_state }.to('import_done') + .and change { repository.migration_skipped_reason }.to('not_found') + .and change { repository.migration_import_done_at }.from(nil) + end + end end shared_examples 'transitioning to importing', skip_import_success: true do @@ -151,6 +172,16 @@ RSpec.describe ContainerRepository, :aggregate_failures do expect(repository).to be_import_aborted end end + + context 'already imported' do + it 'finishes the import' do + expect(repository).to receive(:migration_import).and_return(:already_imported) + + expect { subject } + .to change { repository.reload.migration_state }.to('import_done') + .and change { repository.reload.migration_skipped_reason }.to('native_import') + end + end end shared_examples 'transitioning out of import_aborted' do @@ -193,7 +224,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do end end - it_behaves_like 'transitioning from allowed states', %w[default] + it_behaves_like 'transitioning from allowed states', %w[default pre_importing importing import_aborted] it_behaves_like 'transitioning to pre_importing' end @@ -208,7 +239,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do end end - it_behaves_like 'transitioning from allowed states', %w[import_aborted] + it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted] it_behaves_like 'transitioning to pre_importing' it_behaves_like 'transitioning out of import_aborted' end @@ -218,7 +249,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do subject { repository.finish_pre_import } - it_behaves_like 'transitioning from allowed states', %w[pre_importing import_aborted] + it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted] it 'sets migration_pre_import_done_at' do expect { subject }.to change { repository.reload.migration_pre_import_done_at } @@ -238,7 +269,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do end end - it_behaves_like 'transitioning from allowed states', %w[pre_import_done] + it_behaves_like 'transitioning from allowed states', %w[pre_import_done pre_importing importing import_aborted] it_behaves_like 'transitioning to importing' end @@ -253,7 +284,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do end end - it_behaves_like 'transitioning from allowed states', %w[import_aborted] + it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted] it_behaves_like 'transitioning to importing' it_behaves_like 'no action when feature flag is disabled' end @@ -263,7 +294,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do subject { repository.finish_import } - it_behaves_like 'transitioning from allowed states', %w[importing import_aborted] + it_behaves_like 'transitioning from allowed states', %w[default pre_importing importing import_aborted] it_behaves_like 'queueing the next import' it 'sets migration_import_done_at and queues the next import' do @@ -302,6 +333,19 @@ RSpec.describe ContainerRepository, :aggregate_failures do expect(repository.migration_aborted_in_state).to eq('importing') expect(repository).to be_import_aborted end + + context 'above the max retry limit' do + before do + stub_application_setting(container_registry_import_max_retries: 1) + end + + it 'skips the migration' do + expect { subject }.to change { repository.migration_skipped_at } + + expect(repository.reload).to be_import_skipped + expect(repository.migration_skipped_reason).to eq('too_many_retries') + end + end end describe '#skip_import' do @@ -309,7 +353,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do subject { repository.skip_import(reason: :too_many_retries) } - it_behaves_like 'transitioning from allowed states', ContainerRepository::ABORTABLE_MIGRATION_STATES + it_behaves_like 'transitioning from allowed states', ContainerRepository::SKIPPABLE_MIGRATION_STATES it 'sets migration_skipped_at and migration_skipped_reason' do expect { subject }.to change { repository.reload.migration_skipped_at } @@ -334,7 +378,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do end end - it_behaves_like 'transitioning from allowed states', %w[pre_importing import_aborted] + it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted] it_behaves_like 'transitioning to importing' end end @@ -391,7 +435,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do describe '#retry_aborted_migration' do subject { repository.retry_aborted_migration } - shared_examples 'no action' do + context 'when migration_state is not aborted' do it 'does nothing' do expect { subject }.not_to change { repository.reload.migration_state } @@ -399,104 +443,45 @@ RSpec.describe ContainerRepository, :aggregate_failures do end end - shared_examples 'retrying the pre_import' do - it 'retries the pre_import' do - expect(repository).to receive(:migration_pre_import).and_return(:ok) - - expect { subject }.to change { repository.reload.migration_state }.to('pre_importing') - end - end - - shared_examples 'retrying the import' do - it 'retries the import' do - expect(repository).to receive(:migration_import).and_return(:ok) - - expect { subject }.to change { repository.reload.migration_state }.to('importing') - end - end - - context 'when migration_state is not aborted' do - it_behaves_like 'no action' - end - context 'when migration_state is aborted' do before do repository.abort_import allow(repository.gitlab_api_client) - .to receive(:import_status).with(repository.path).and_return(client_response) + .to receive(:import_status).with(repository.path).and_return(status) end - context 'native response' do - let(:client_response) { 'native' } - - it 'raises an error' do - expect { subject }.to raise_error(described_class::NativeImportError) - end - end + it_behaves_like 'reconciling migration_state' do + context 'error response' do + let(:status) { 'error' } - context 'import_in_progress response' do - let(:client_response) { 'import_in_progress' } - - it_behaves_like 'no action' - end - - context 'import_complete response' do - let(:client_response) { 'import_complete' } - - it 'finishes the import' do - expect { subject }.to change { repository.reload.migration_state }.to('import_done') - end - end - - context 'import_failed response' do - let(:client_response) { 'import_failed' } - - it_behaves_like 'retrying the import' - end - - context 'pre_import_in_progress response' do - let(:client_response) { 'pre_import_in_progress' } - - it_behaves_like 'no action' - end + context 'migration_pre_import_done_at is NULL' do + it_behaves_like 'retrying the pre_import' + end - context 'pre_import_complete response' do - let(:client_response) { 'pre_import_complete' } + context 'migration_pre_import_done_at is not NULL' do + before do + repository.update_columns( + migration_pre_import_started_at: 5.minutes.ago, + migration_pre_import_done_at: Time.zone.now + ) + end - it 'finishes the pre_import and starts the import' do - expect(repository).to receive(:finish_pre_import).and_call_original - expect(repository).to receive(:migration_import).and_return(:ok) - - expect { subject }.to change { repository.reload.migration_state }.to('importing') + it_behaves_like 'retrying the import' + end end end + end + end - context 'pre_import_failed response' do - let(:client_response) { 'pre_import_failed' } - - it_behaves_like 'retrying the pre_import' - end - - context 'error response' do - let(:client_response) { 'error' } - - context 'migration_pre_import_done_at is NULL' do - it_behaves_like 'retrying the pre_import' - end - - context 'migration_pre_import_done_at is not NULL' do - before do - repository.update_columns( - migration_pre_import_started_at: 5.minutes.ago, - migration_pre_import_done_at: Time.zone.now - ) - end + describe '#reconcile_import_status' do + subject { repository.reconcile_import_status(status) } - it_behaves_like 'retrying the import' - end - end + before do + repository.abort_import end + + it_behaves_like 'reconciling migration_state' end describe '#tag' do @@ -667,7 +652,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do context 'supports gitlab api on .com with a recent repository' do before do expect(repository.gitlab_api_client).to receive(:supports_gitlab_api?).and_return(true) - expect(repository.gitlab_api_client).to receive(:repository_details).with(repository.path, with_size: true).and_return(response) + expect(repository.gitlab_api_client).to receive(:repository_details).with(repository.path, sizing: :self).and_return(response) end context 'with a size_bytes field' do @@ -722,12 +707,12 @@ RSpec.describe ContainerRepository, :aggregate_failures do end context 'registry migration' do - shared_examples 'handling the migration step' do |step| - let(:client_response) { :foobar } + before do + allow(repository.gitlab_api_client).to receive(:supports_gitlab_api?).and_return(true) + end - before do - allow(repository.gitlab_api_client).to receive(:supports_gitlab_api?).and_return(true) - end + shared_examples 'gitlab migration client request' do |step| + let(:client_response) { :foobar } it 'returns the same response as the client' do expect(repository.gitlab_api_client) @@ -746,6 +731,10 @@ RSpec.describe ContainerRepository, :aggregate_failures do expect(subject).to eq(:error) end end + end + + shared_examples 'handling the migration step' do |step| + it_behaves_like 'gitlab migration client request', step context 'too many imports' do it 'raises an error when it receives too_many_imports as a response' do @@ -767,6 +756,67 @@ RSpec.describe ContainerRepository, :aggregate_failures do it_behaves_like 'handling the migration step', :import_repository end + + describe '#migration_cancel' do + subject { repository.migration_cancel } + + it_behaves_like 'gitlab migration client request', :cancel_repository_import + end + + describe '#force_migration_cancel' do + subject { repository.force_migration_cancel } + + shared_examples 'returning the same response as the client' do + it 'returns the same response' do + expect(repository.gitlab_api_client) + .to receive(:cancel_repository_import).with(repository.path, force: true).and_return(client_response) + + expect(subject).to eq(client_response) + end + end + + context 'successful cancellation' do + let(:client_response) { { status: :ok } } + + it_behaves_like 'returning the same response as the client' + + it 'skips the migration' do + expect(repository.gitlab_api_client) + .to receive(:cancel_repository_import).with(repository.path, force: true).and_return(client_response) + + expect { subject }.to change { repository.reload.migration_state }.to('import_skipped') + .and change { repository.migration_skipped_reason }.to('migration_forced_canceled') + .and change { repository.migration_skipped_at } + end + end + + context 'failed cancellation' do + let(:client_response) { { status: :error } } + + it_behaves_like 'returning the same response as the client' + + it 'does not skip the migration' do + expect(repository.gitlab_api_client) + .to receive(:cancel_repository_import).with(repository.path, force: true).and_return(client_response) + + expect { subject }.to not_change { repository.reload.migration_state } + .and not_change { repository.migration_skipped_reason } + .and not_change { repository.migration_skipped_at } + end + end + + context 'when the gitlab_api feature is not supported' do + before do + allow(repository.gitlab_api_client).to receive(:supports_gitlab_api?).and_return(false) + end + + it 'returns :error' do + expect(repository.gitlab_api_client).not_to receive(:cancel_repository_import) + + expect(subject).to eq(:error) + end + end + end end describe '.build_from_path' do @@ -1081,6 +1131,43 @@ RSpec.describe ContainerRepository, :aggregate_failures do end end + describe '.all_migrated?' do + let_it_be(:project) { create(:project) } + + subject { project.container_repositories.all_migrated? } + + context 'with no repositories' do + it { is_expected.to be_truthy } + end + + context 'with only recent repositories' do + let_it_be(:container_repository1) { create(:container_repository, project: project) } + let_it_be_with_reload(:container_repository2) { create(:container_repository, project: project) } + + it { is_expected.to be_truthy } + + context 'with one old non migrated repository' do + before do + container_repository2.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.months) + end + + it { is_expected.to be_falsey } + end + + context 'with one old migrated repository' do + before do + container_repository2.update!( + created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.months, + migration_state: 'import_done', + migration_import_done_at: Time.zone.now + ) + end + + it { is_expected.to be_truthy } + end + end + end + describe '.with_enabled_policy' do let_it_be(:repository) { create(:container_repository) } let_it_be(:repository2) { create(:container_repository) } @@ -1168,6 +1255,17 @@ RSpec.describe ContainerRepository, :aggregate_failures do end end + context 'not found response' do + let(:response) { :not_found } + + it 'completes the migration' do + expect(subject).to eq(false) + + expect(container_repository).to be_import_done + expect(container_repository.reload.migration_skipped_reason).to eq('not_found') + end + end + context 'other response' do let(:response) { :error } @@ -1185,6 +1283,30 @@ RSpec.describe ContainerRepository, :aggregate_failures do end end + describe '#retried_too_many_times?' do + subject { repository.retried_too_many_times? } + + before do + stub_application_setting(container_registry_import_max_retries: 3) + end + + context 'migration_retries_count is equal or greater than max_retries' do + before do + repository.update_column(:migration_retries_count, 3) + end + + it { is_expected.to eq(true) } + end + + context 'migration_retries_count is lower than max_retries' do + before do + repository.update_column(:migration_retries_count, 2) + end + + it { is_expected.to eq(false) } + end + end + context 'with repositories' do let_it_be_with_reload(:repository) { create(:container_repository, :cleanup_unscheduled) } let_it_be(:other_repository) { create(:container_repository, :cleanup_unscheduled) } @@ -1241,11 +1363,12 @@ RSpec.describe ContainerRepository, :aggregate_failures do let_it_be(:import_done_repository) { create(:container_repository, :import_done, migration_pre_import_done_at: 3.days.ago, migration_import_done_at: 2.days.ago) } let_it_be(:import_aborted_repository) { create(:container_repository, :import_aborted, migration_pre_import_done_at: 5.days.ago, migration_aborted_at: 1.day.ago) } let_it_be(:pre_import_done_repository) { create(:container_repository, :pre_import_done, migration_pre_import_done_at: 1.hour.ago) } + let_it_be(:import_skipped_repository) { create(:container_repository, :import_skipped, migration_skipped_at: 90.minutes.ago) } subject { described_class.recently_done_migration_step } it 'returns completed imports by done_at date' do - expect(subject.to_a).to eq([pre_import_done_repository, import_aborted_repository, import_done_repository]) + expect(subject.to_a).to eq([pre_import_done_repository, import_skipped_repository, import_aborted_repository, import_done_repository]) end end @@ -1255,7 +1378,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do subject { described_class.ready_for_import } before do - stub_application_setting(container_registry_import_target_plan: root_group.actual_plan_name) + stub_application_setting(container_registry_import_target_plan: valid_container_repository.migration_plan) end it 'works' do @@ -1266,13 +1389,15 @@ RSpec.describe ContainerRepository, :aggregate_failures do describe '#last_import_step_done_at' do let_it_be(:aborted_at) { Time.zone.now - 1.hour } let_it_be(:pre_import_done_at) { Time.zone.now - 2.hours } + let_it_be(:skipped_at) { Time.zone.now - 3.hours } subject { repository.last_import_step_done_at } before do repository.update_columns( migration_pre_import_done_at: pre_import_done_at, - migration_aborted_at: aborted_at + migration_aborted_at: aborted_at, + migration_skipped_at: skipped_at ) end diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb index 01252a58681..15655d08556 100644 --- a/spec/models/custom_emoji_spec.rb +++ b/spec/models/custom_emoji_spec.rb @@ -38,7 +38,7 @@ RSpec.describe CustomEmoji do new_emoji = build(:custom_emoji, name: old_emoji.name, namespace: old_emoji.namespace, group: group) expect(new_emoji).not_to be_valid - expect(new_emoji.errors.messages).to eq(creator: ["can't be blank"], name: ["has already been taken"]) + expect(new_emoji.errors.messages).to eq(name: ["has already been taken"]) end it 'disallows non http and https file value' do diff --git a/spec/models/customer_relations/contact_spec.rb b/spec/models/customer_relations/contact_spec.rb index 18896962261..86f868b269e 100644 --- a/spec/models/customer_relations/contact_spec.rb +++ b/spec/models/customer_relations/contact_spec.rb @@ -25,7 +25,7 @@ RSpec.describe CustomerRelations::Contact, type: :model do 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 { is_expected.to validate_uniqueness_of(:email).scoped_to(:group_id) } + it { is_expected.to validate_uniqueness_of(:email).case_insensitive.scoped_to(:group_id) } it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email end @@ -87,6 +87,15 @@ RSpec.describe CustomerRelations::Contact, type: :model do too_many_emails = described_class::MAX_PLUCK + 1 expect { described_class.find_ids_by_emails(group, Array(0..too_many_emails)) }.to raise_error(ArgumentError) end + + it 'finds contacts regardless of email casing' do + new_contact = create(:contact, group: group, email: "UPPERCASE@example.com") + emails = [group_contacts[0].email.downcase, group_contacts[1].email.upcase, new_contact.email] + + contact_ids = described_class.find_ids_by_emails(group, emails) + + expect(contact_ids).to contain_exactly(group_contacts[0].id, group_contacts[1].id, new_contact.id) + end end describe '#self.exists_for_group?' do @@ -104,4 +113,33 @@ RSpec.describe CustomerRelations::Contact, type: :model do end end end + + describe '#self.move_to_root_group' do + let!(:old_root_group) { create(:group) } + let!(:contacts) { create_list(:contact, 4, group: old_root_group) } + let!(:project) { create(:project, group: old_root_group) } + let!(:issue) { create(:issue, project: project) } + let!(:issue_contact1) { create(:issue_customer_relations_contact, issue: issue, contact: contacts[0]) } + let!(:issue_contact2) { create(:issue_customer_relations_contact, issue: issue, contact: contacts[1]) } + let!(:new_root_group) { create(:group) } + let!(:dupe_contact1) { create(:contact, group: new_root_group, email: contacts[1].email) } + let!(:dupe_contact2) { create(:contact, group: new_root_group, email: contacts[3].email.upcase) } + + before do + old_root_group.update!(parent: new_root_group) + CustomerRelations::Contact.move_to_root_group(old_root_group) + end + + it 'moves contacts with unique emails and deletes the rest' do + expect(contacts[0].reload.group_id).to eq(new_root_group.id) + expect(contacts[2].reload.group_id).to eq(new_root_group.id) + expect { contacts[1].reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { contacts[3].reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'updates issue_contact.contact_id for dupes and leaves the rest untouched' do + expect(issue_contact1.reload.contact_id).to eq(contacts[0].id) + expect(issue_contact2.reload.contact_id).to eq(dupe_contact1.id) + end + end end diff --git a/spec/models/customer_relations/issue_contact_spec.rb b/spec/models/customer_relations/issue_contact_spec.rb index f1fb574f86f..221378d26b2 100644 --- a/spec/models/customer_relations/issue_contact_spec.rb +++ b/spec/models/customer_relations/issue_contact_spec.rb @@ -92,4 +92,16 @@ RSpec.describe CustomerRelations::IssueContact do expect { described_class.delete_for_project(project.id) }.to change { described_class.count }.by(-3) end end + + describe '.delete_for_group' do + let(:project_for_root_group) { create(:project, group: group) } + + it 'destroys all issue_contacts for projects in group and subgroups' do + create_list(:issue_customer_relations_contact, 2, :for_issue, issue: create(:issue, project: project)) + create_list(:issue_customer_relations_contact, 2, :for_issue, issue: create(:issue, project: project_for_root_group)) + create(:issue_customer_relations_contact) + + expect { described_class.delete_for_group(group) }.to change { described_class.count }.by(-4) + end + end end diff --git a/spec/models/customer_relations/organization_spec.rb b/spec/models/customer_relations/organization_spec.rb index 9fe754b7605..06ba9c5b7ad 100644 --- a/spec/models/customer_relations/organization_spec.rb +++ b/spec/models/customer_relations/organization_spec.rb @@ -50,4 +50,32 @@ RSpec.describe CustomerRelations::Organization, type: :model do expect(described_class.find_by_name(group.id, 'TEST')).to eq([organiztion1]) end end + + describe '#self.move_to_root_group' do + let!(:old_root_group) { create(:group) } + let!(:organizations) { create_list(:organization, 4, group: old_root_group) } + let!(:new_root_group) { create(:group) } + let!(:contact1) { create(:contact, group: new_root_group, organization: organizations[0]) } + let!(:contact2) { create(:contact, group: new_root_group, organization: organizations[1]) } + + let!(:dupe_organization1) { create(:organization, group: new_root_group, name: organizations[1].name) } + let!(:dupe_organization2) { create(:organization, group: new_root_group, name: organizations[3].name.upcase) } + + before do + old_root_group.update!(parent: new_root_group) + CustomerRelations::Organization.move_to_root_group(old_root_group) + end + + it 'moves organizations with unique names and deletes the rest' do + expect(organizations[0].reload.group_id).to eq(new_root_group.id) + expect(organizations[2].reload.group_id).to eq(new_root_group.id) + expect { organizations[1].reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { organizations[3].reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'updates contact.organization_id for dupes and leaves the rest untouched' do + expect(contact1.reload.organization_id).to eq(organizations[0].id) + expect(contact2.reload.organization_id).to eq(dupe_organization1.id) + end + end end diff --git a/spec/models/deploy_token_spec.rb b/spec/models/deploy_token_spec.rb index 88451307efb..c48f1fab3c6 100644 --- a/spec/models/deploy_token_spec.rb +++ b/spec/models/deploy_token_spec.rb @@ -9,6 +9,7 @@ RSpec.describe DeployToken do it { is_expected.to have_many(:projects).through(:project_deploy_tokens) } it { is_expected.to have_many :group_deploy_tokens } it { is_expected.to have_many(:groups).through(:group_deploy_tokens) } + it { is_expected.to belong_to(:user).with_foreign_key('creator_id') } it_behaves_like 'having unique enum values' diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 47c246d12cc..705b9b4cc65 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -524,6 +524,16 @@ RSpec.describe Deployment do is_expected.to contain_exactly(deployment1, deployment2, deployment3, deployment4, deployment5) end + + it 'has a corresponding database index' do + index = ApplicationRecord.connection.indexes('deployments').find do |i| + i.name == 'index_deployments_for_visible_scope' + end + + scope_values = described_class::VISIBLE_STATUSES.map { |s| described_class.statuses[s] }.to_s + + expect(index.where).to include(scope_values) + end end describe 'upcoming' do @@ -1055,6 +1065,40 @@ RSpec.describe Deployment do end end + describe '#tier_in_yaml' do + context 'when deployable is nil' do + before do + subject.deployable = nil + end + + it 'returns nil' do + expect(subject.tier_in_yaml).to be_nil + end + end + + context 'when deployable is present' do + context 'when tier is specified' do + let(:deployable) { create(:ci_build, :success, :environment_with_deployment_tier) } + + before do + subject.deployable = deployable + end + + it 'returns the tier' do + expect(subject.tier_in_yaml).to eq('testing') + end + + context 'when tier is not specified' do + let(:deployable) { create(:ci_build, :success) } + + it 'returns nil' do + expect(subject.tier_in_yaml).to be_nil + end + end + end + end + end + describe '.fast_destroy_all' do it 'cleans path_refs for destroyed environments' do project = create(:project, :repository) diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 6144593395c..b42e73e6d93 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -23,7 +23,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do it { is_expected.to have_one(:upcoming_deployment) } it { is_expected.to have_one(:latest_opened_most_severe_alert) } - it { is_expected.to delegate_method(:stop_action).to(:last_deployment) } it { is_expected.to delegate_method(:manual_actions).to(:last_deployment) } it { is_expected.to validate_presence_of(:name) } @@ -349,15 +348,28 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end describe '.with_deployment' do - subject { described_class.with_deployment(sha) } + subject { described_class.with_deployment(sha, status: status) } let(:environment) { create(:environment, project: project) } let(:sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' } + let(:status) { nil } context 'when deployment has the specified sha' do let!(:deployment) { create(:deployment, environment: environment, sha: sha) } it { is_expected.to eq([environment]) } + + context 'with success status filter' do + let(:status) { :success } + + it { is_expected.to be_empty } + end + + context 'with created status filter' do + let(:status) { :created } + + it { is_expected.to contain_exactly(environment) } + end end context 'when deployment does not have the specified sha' do @@ -459,8 +471,8 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end end - describe '#stop_action_available?' do - subject { environment.stop_action_available? } + describe '#stop_actions_available?' do + subject { environment.stop_actions_available? } context 'when no other actions' do it { is_expected.to be_falsey } @@ -499,10 +511,10 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end end - describe '#stop_with_action!' do + describe '#stop_with_actions!' do let(:user) { create(:user) } - subject { environment.stop_with_action!(user) } + subject { environment.stop_with_actions!(user) } before do expect(environment).to receive(:available?).and_call_original @@ -515,9 +527,10 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end it do - subject + actions = subject expect(environment).to be_stopped + expect(actions).to match_array([]) end end @@ -536,18 +549,18 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do context 'when matching action is defined' do let(:pipeline) { create(:ci_pipeline, project: project) } - let(:build) { create(:ci_build, pipeline: pipeline) } + let(:build_a) { create(:ci_build, pipeline: pipeline) } - let!(:deployment) do + before do create(:deployment, :success, - environment: environment, - deployable: build, - on_stop: 'close_app') + environment: environment, + deployable: build_a, + on_stop: 'close_app_a') end context 'when user is not allowed to stop environment' do let!(:close_action) do - create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') + create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a') end it 'raises an exception' do @@ -565,31 +578,200 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do context 'when action did not yet finish' do let!(:close_action) do - create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') + create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a') end it 'returns the same action' do - expect(subject).to eq(close_action) - expect(subject.user).to eq(user) + action = subject.first + expect(action).to eq(close_action) + expect(action.user).to eq(user) end end context 'if action did finish' do let!(:close_action) do create(:ci_build, :manual, :success, - pipeline: pipeline, name: 'close_app') + pipeline: pipeline, name: 'close_app_a') end it 'returns a new action of the same type' do - expect(subject).to be_persisted - expect(subject.name).to eq(close_action.name) - expect(subject.user).to eq(user) + action = subject.first + + expect(action).to be_persisted + expect(action.name).to eq(close_action.name) + expect(action.user).to eq(user) + end + end + + context 'close action does not raise ActiveRecord::StaleObjectError' do + let!(:close_action) do + create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a') + end + + before do + # preload the build + environment.stop_actions + + # Update record as the other process. This makes `environment.stop_action` stale. + close_action.drop! end + + it 'successfully plays the build even if the build was a stale object' do + # Since build is droped. + expect(close_action.processed).to be_falsey + + # it encounters the StaleObjectError at first, but reloads the object and runs `build.play` + expect { subject }.not_to raise_error(ActiveRecord::StaleObjectError) + + # Now the build should be processed. + expect(close_action.reload.processed).to be_truthy + end + end + end + end + + context 'when there are more then one stop action for the environment' do + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:build_a) { create(:ci_build, pipeline: pipeline) } + let(:build_b) { create(:ci_build, pipeline: pipeline) } + + let!(:close_actions) do + [ + create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a'), + create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_b') + ] + end + + before do + project.add_developer(user) + + create(:deployment, :success, + environment: environment, + deployable: build_a, + finished_at: 5.minutes.ago, + on_stop: 'close_app_a') + + create(:deployment, :success, + environment: environment, + deployable: build_b, + finished_at: 1.second.ago, + on_stop: 'close_app_b') + end + + it 'returns the same actions' do + actions = subject + + expect(actions.count).to eq(close_actions.count) + expect(actions.pluck(:id)).to match_array(close_actions.pluck(:id)) + expect(actions.pluck(:user)).to match_array(close_actions.pluck(:user)) + end + + context 'when there are failed deployment jobs' do + before do + create(:ci_build, pipeline: pipeline, name: 'close_app_c') + + create(:deployment, :failed, + environment: environment, + deployable: create(:ci_build, pipeline: pipeline), + on_stop: 'close_app_c') + end + + it 'returns only stop actions from successful deployment jobs' do + actions = subject + + expect(actions).to match_array(close_actions) + expect(actions.count).to eq(environment.successful_deployments.count) + end + end + + context 'when the feature is disabled' do + before do + stub_feature_flags(environment_multiple_stop_actions: false) + end + + it 'returns the last deployment job stop action' do + stop_actions = subject + + expect(stop_actions.first).to eq(close_actions[1]) + expect(stop_actions.count).to eq(1) end end end end + describe '#stop_actions' do + subject { environment.stop_actions } + + context 'when there are no deployments and builds' do + it 'returns empty array' do + is_expected.to match_array([]) + end + end + + context 'when there are multiple deployments with actions' do + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline) } + let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline) } + let!(:ci_build_c) { create(:ci_build, :manual, project: project, pipeline: pipeline, name: 'close_app_a') } + let!(:ci_build_d) { create(:ci_build, :manual, project: project, pipeline: pipeline, name: 'close_app_b') } + + let!(:deployment_a) do + create(:deployment, + :success, project: project, environment: environment, deployable: ci_build_a, on_stop: 'close_app_a') + end + + let!(:deployment_b) do + create(:deployment, + :success, project: project, environment: environment, deployable: ci_build_b, on_stop: 'close_app_b') + end + + before do + # Create failed deployment without stop_action. + build = create(:ci_build, project: project, pipeline: pipeline) + create(:deployment, :failed, project: project, environment: environment, deployable: build) + end + + it 'returns only the stop actions' do + expect(subject.pluck(:id)).to contain_exactly(ci_build_c.id, ci_build_d.id) + end + end + end + + describe '#last_deployment_group' do + subject { environment.last_deployment_group } + + context 'when there are no deployments and builds' do + it do + is_expected.to eq(Deployment.none) + end + end + + context 'when there are deployments for multiple pipelines' 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) } + let(:ci_build_c) { create(:ci_build, project: project, pipeline: pipeline_a) } + let(:ci_build_d) { create(:ci_build, project: project, pipeline: pipeline_a) } + + # Successful deployments for pipeline_a + let!(:deployment_a) { create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a) } + let!(:deployment_b) { create(:deployment, :success, project: project, environment: environment, deployable: ci_build_c) } + + before do + # Failed deployment for pipeline_a + create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_d) + + # Failed deployment for pipeline_b + create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b) + end + + it 'returns the successful deployment jobs for the last deployment pipeline' do + expect(subject.pluck(:id)).to contain_exactly(deployment_a.id, deployment_b.id) + end + end + end + describe 'recently_updated_on_branch?' do subject { environment.recently_updated_on_branch?('feature') } @@ -698,10 +880,29 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do context 'when there is a deployment record with success status' do let!(:deployment) { create(:deployment, :success, environment: environment) } + let!(:old_deployment) { create(:deployment, :success, environment: environment, finished_at: 2.days.ago) } it 'returns the latest successful deployment' do is_expected.to eq(deployment) end + + context 'env_last_deployment_by_finished_at feature flag' do + it 'when enabled it returns the deployment with the latest finished_at' do + stub_feature_flags(env_last_deployment_by_finished_at: true) + + expect(old_deployment.finished_at < deployment.finished_at).to be_truthy + + is_expected.to eq(deployment) + end + + it 'when disabled it returns the deployment with the highest id' do + stub_feature_flags(env_last_deployment_by_finished_at: false) + + expect(old_deployment.finished_at < deployment.finished_at).to be_truthy + + is_expected.to eq(old_deployment) + end + end end end end @@ -728,6 +929,26 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end end + describe '#last_deployment_pipeline' do + subject { environment.last_deployment_pipeline } + + 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 'does not join across databases' do + with_cross_joins_prevented do + expect(subject.id).to eq(pipeline_a.id) + end + end + end + describe '#last_visible_deployment' do subject { environment.last_visible_deployment } diff --git a/spec/models/environment_status_spec.rb b/spec/models/environment_status_spec.rb index 1db1171401c..a9aa5698ebb 100644 --- a/spec/models/environment_status_spec.rb +++ b/spec/models/environment_status_spec.rb @@ -34,6 +34,13 @@ RSpec.describe EnvironmentStatus do subject { environment_status.deployment } it { is_expected.to eq(deployment) } + + context 'multiple deployments' do + it { + new_deployment = create(:deployment, :succeed, environment: deployment.environment, sha: deployment.sha ) + is_expected.to eq(new_deployment) + } + end end # $ git diff --stat pages-deploy-target...pages-deploy 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 d700eb5eaf7..2939a40a84f 100644 --- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb +++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb @@ -8,6 +8,8 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do let_it_be(:project) { create(:project) } + let(:sentry_client) { instance_double(ErrorTracking::SentryClient) } + subject(:setting) { build(:project_error_tracking_setting, project: project) } describe 'Associations' do @@ -48,7 +50,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do expect(subject.errors.messages[:project]).to include('is a required field') end - context 'presence validations' do + describe 'presence validations' do using RSpec::Parameterized::TableSyntax valid_api_url = 'http://example.com/api/0/projects/org-slug/proj-slug/' @@ -83,12 +85,12 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do describe 'after_save :create_client_key!' do subject { build(:project_error_tracking_setting, :integrated, project: project) } - context 'no client key yet' do + context 'without client key' do it 'creates a new client key' do expect { subject.save! }.to change { ErrorTracking::ClientKey.count }.by(1) end - context 'sentry backend' do + context 'with sentry backend' do before do subject.integrated = false end @@ -98,7 +100,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end end - context 'feature disabled' do + context 'when feature disabled' do before do subject.enabled = false end @@ -109,7 +111,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end end - context 'client key already exists' do + context 'when client key already exists' do let!(:client_key) { create(:error_tracking_client_key, project: project) } it 'does not create a new client key' do @@ -122,13 +124,13 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do describe '.extract_sentry_external_url' do subject { described_class.extract_sentry_external_url(sentry_url) } - describe 'when passing a URL' do + context 'when passing a URL' do let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } it { is_expected.to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project') } end - describe 'when passing nil' do + context 'when passing nil' do let(:sentry_url) { nil } it { is_expected.to be_nil } @@ -159,23 +161,15 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do describe '#list_sentry_issues' do let(:issues) { [:list, :of, :issues] } - - let(:opts) do - { issue_status: 'unresolved', limit: 10 } - end - - let(:result) do - subject.list_sentry_issues(**opts) - end + let(:result) { subject.list_sentry_issues(**opts) } + let(:opts) { { issue_status: 'unresolved', limit: 10 } } context 'when cached' do - let(:sentry_client) { spy(:sentry_client) } - before do stub_reactive_cache(subject, issues, opts) synchronous_reactive_cache(subject) - expect(subject).to receive(:sentry_client).and_return(sentry_client) + allow(subject).to receive(:sentry_client).and_return(sentry_client) end it 'returns cached issues' do @@ -195,8 +189,6 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end context 'when sentry client raises ErrorTracking::SentryClient::Error' do - let(:sentry_client) { spy(:sentry_client) } - before do synchronous_reactive_cache(subject) @@ -214,14 +206,13 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end context 'when sentry client raises ErrorTracking::SentryClient::MissingKeysError' do - let(:sentry_client) { spy(:sentry_client) } - before do synchronous_reactive_cache(subject) allow(subject).to receive(:sentry_client).and_return(sentry_client) allow(sentry_client).to receive(:list_issues).with(opts) - .and_raise(ErrorTracking::SentryClient::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"') + .and_raise(ErrorTracking::SentryClient::MissingKeysError, + 'Sentry API response is missing keys. key not found: "id"') end it 'returns error' do @@ -233,8 +224,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end context 'when sentry client raises ErrorTracking::SentryClient::ResponseInvalidSizeError' do - let(:sentry_client) { spy(:sentry_client) } - let(:error_msg) {"Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}."} + let(:error_msg) { "Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}." } before do synchronous_reactive_cache(subject) @@ -253,8 +243,6 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end context 'when sentry client raises StandardError' do - let(:sentry_client) { spy(:sentry_client) } - before do synchronous_reactive_cache(subject) @@ -270,7 +258,6 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do describe '#list_sentry_projects' do let(:projects) { [:list, :of, :projects] } - let(:sentry_client) { spy(:sentry_client) } it 'calls sentry client' do expect(subject).to receive(:sentry_client).and_return(sentry_client) @@ -284,19 +271,17 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do describe '#issue_details' do let(:issue) { build(:error_tracking_sentry_detailed_error) } - let(:sentry_client) { double('sentry_client', issue_details: issue) } let(:commit_id) { issue.first_release_version } - - let(:result) do - subject.issue_details - end + let(:result) { subject.issue_details(opts) } + let(:opts) { { issue_id: 1 } } context 'when cached' do before do stub_reactive_cache(subject, issue, {}) synchronous_reactive_cache(subject) - expect(subject).to receive(:sentry_client).and_return(sentry_client) + allow(subject).to receive(:sentry_client).and_return(sentry_client) + allow(sentry_client).to receive(:issue_details).with(opts).and_return(issue) end it { expect(result).to eq(issue: issue) } @@ -314,15 +299,15 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end context 'when repo commit matches first relase version' do - let(:commit) { double('commit', id: commit_id) } - let(:repository) { double('repository', commit: commit) } + let(:commit) { instance_double(Commit, id: commit_id) } + let(:repository) { instance_double(Repository, commit: commit) } before do - expect(project).to receive(:repository).and_return(repository) + allow(project).to receive(:repository).and_return(repository) end it { expect(result[:issue].gitlab_commit).to eq(commit_id) } - it { expect(result[:issue].gitlab_commit_path).to eq("/#{project.namespace.path}/#{project.path}/-/commit/#{commit_id}") } + it { expect(result[:issue].gitlab_commit_path).to eq(project_commit_path(project, commit_id)) } end end @@ -333,19 +318,15 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end describe '#update_issue' do - let(:opts) do - { status: 'resolved' } - end + let(:result) { subject.update_issue(**opts) } + let(:opts) { { issue_id: 1, params: {} } } - let(:result) do - subject.update_issue(**opts) + before do + allow(subject).to receive(:sentry_client).and_return(sentry_client) end - let(:sentry_client) { spy(:sentry_client) } - - context 'successful call to sentry' do + context 'when sentry response is successful' do before do - allow(subject).to receive(:sentry_client).and_return(sentry_client) allow(sentry_client).to receive(:update_issue).with(opts).and_return(true) end @@ -354,9 +335,8 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end end - context 'sentry raises an error' do + context 'when sentry raises an error' do before do - allow(subject).to receive(:sentry_client).and_return(sentry_client) allow(sentry_client).to receive(:update_issue).with(opts).and_raise(StandardError) end @@ -366,7 +346,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end end - context 'slugs' do + describe 'slugs' do shared_examples_for 'slug from api_url' do |method, slug| context 'when api_url is correct' do before do @@ -393,9 +373,9 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do it_behaves_like 'slug from api_url', :organization_slug, 'org-slug' end - context 'names from api_url' do + describe 'names from api_url' do shared_examples_for 'name from api_url' do |name, titleized_slug| - context 'name is present in DB' do + context 'when name is present in DB' do it 'returns name from DB' do subject[name] = 'Sentry name' subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug' @@ -404,7 +384,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end end - context 'name is null in DB' do + context 'when name is null in DB' do it 'titleizes and returns slug from api_url' do subject[name] = nil subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug' diff --git a/spec/models/group_group_link_spec.rb b/spec/models/group_group_link_spec.rb index 034a5c1dfc6..72c700e7981 100644 --- a/spec/models/group_group_link_spec.rb +++ b/spec/models/group_group_link_spec.rb @@ -29,6 +29,49 @@ RSpec.describe GroupGroupLink do ]) end end + + describe '.distinct_on_shared_with_group_id_with_group_access' do + let_it_be(:sub_shared_group) { create(:group, parent: shared_group) } + let_it_be(:other_group) { create(:group) } + + let_it_be(:group_group_link_2) do + create( + :group_group_link, + shared_group: shared_group, + shared_with_group: other_group, + group_access: Gitlab::Access::GUEST + ) + end + + let_it_be(:group_group_link_3) do + create( + :group_group_link, + shared_group: sub_shared_group, + shared_with_group: group, + group_access: Gitlab::Access::GUEST + ) + end + + let_it_be(:group_group_link_4) do + create( + :group_group_link, + shared_group: sub_shared_group, + shared_with_group: other_group, + group_access: Gitlab::Access::DEVELOPER + ) + end + + it 'returns only one group link per group (with max group access)' do + distinct_group_group_links = described_class.distinct_on_shared_with_group_id_with_group_access + + expect(described_class.all.count).to eq(4) + expect(distinct_group_group_links.count).to eq(2) + expect(distinct_group_group_links).to include(group_group_link) + expect(distinct_group_group_links).not_to include(group_group_link_2) + expect(distinct_group_group_links).not_to include(group_group_link_3) + expect(distinct_group_group_links).to include(group_group_link_4) + end + end end describe 'validation' do @@ -57,4 +100,9 @@ RSpec.describe GroupGroupLink do group_group_link.human_access end end + + describe 'search by group name' do + it { expect(described_class.search(group.name)).to eq([group_group_link]) } + it { expect(described_class.search('not-a-group-name')).to be_empty } + end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 45a2c134077..0ca1fe1c8a6 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -41,6 +41,7 @@ RSpec.describe Group do it { is_expected.to have_many(:contacts).class_name('CustomerRelations::Contact') } it { is_expected.to have_many(:organizations).class_name('CustomerRelations::Organization') } it { is_expected.to have_one(:crm_settings) } + it { is_expected.to have_one(:group_feature) } describe '#members & #requesters' do let(:requester) { create(:user) } @@ -293,6 +294,23 @@ RSpec.describe Group do end end + it_behaves_like 'a BulkUsersByEmailLoad model' + + context 'after initialized' do + it 'has a group_feature' do + expect(described_class.new.group_feature).to be_present + end + end + + context 'when creating a new project' do + let_it_be(:group) { create(:group) } + + it 'automatically creates the groups feature for the group' do + expect(group.group_feature).to be_an_instance_of(Groups::FeatureSetting) + expect(group.group_feature).to be_persisted + end + end + context 'traversal_ids on create' do context 'default traversal_ids' do let(:group) { build(:group) } @@ -533,6 +551,10 @@ RSpec.describe Group do describe '#ancestors_upto' do it { expect(group.ancestors_upto.to_sql).not_to include "WITH ORDINALITY" } end + + describe '.shortest_traversal_ids_prefixes' do + it { expect { described_class.shortest_traversal_ids_prefixes }.to raise_error /Feature not supported since the `:use_traversal_ids` is disabled/ } + end end context 'linear' do @@ -574,6 +596,90 @@ RSpec.describe Group do it { expect(group.ancestors_upto.to_sql).to include "WITH ORDINALITY" } end + describe '.shortest_traversal_ids_prefixes' do + subject { filter.shortest_traversal_ids_prefixes } + + context 'for many top-level namespaces' do + let!(:top_level_groups) { create_list(:group, 4) } + + context 'when querying all groups' do + let(:filter) { described_class.id_in(top_level_groups) } + + it "returns all traversal_ids" do + is_expected.to contain_exactly( + *top_level_groups.map { |group| [group.id] } + ) + end + end + + context 'when querying selected groups' do + let(:filter) { described_class.id_in(top_level_groups.first) } + + it "returns only a selected traversal_ids" do + is_expected.to contain_exactly([top_level_groups.first.id]) + end + end + end + + context 'for namespace hierarchy' do + let!(:group_a) { create(:group) } + let!(:group_a_sub_1) { create(:group, parent: group_a) } + let!(:group_a_sub_2) { create(:group, parent: group_a) } + let!(:group_b) { create(:group) } + let!(:group_b_sub_1) { create(:group, parent: group_b) } + let!(:group_c) { create(:group) } + + context 'when querying all groups' do + let(:filter) { described_class.id_in([group_a, group_a_sub_1, group_a_sub_2, group_b, group_b_sub_1, group_c]) } + + it 'returns only shortest prefixes of top-level groups' do + is_expected.to contain_exactly( + [group_a.id], + [group_b.id], + [group_c.id] + ) + end + end + + context 'when sub-group is reparented' do + let(:filter) { described_class.id_in([group_b_sub_1, group_c]) } + + before do + group_b_sub_1.update!(parent: group_c) + end + + it 'returns a proper shortest prefix of a new group' do + is_expected.to contain_exactly( + [group_c.id] + ) + end + end + + context 'when querying sub-groups' do + let(:filter) { described_class.id_in([group_a_sub_1, group_b_sub_1, group_c]) } + + it 'returns sub-groups as they are shortest prefixes' do + is_expected.to contain_exactly( + [group_a.id, group_a_sub_1.id], + [group_b.id, group_b_sub_1.id], + [group_c.id] + ) + end + end + + context 'when querying group and sub-group of this group' do + let(:filter) { described_class.id_in([group_a, group_a_sub_1, group_c]) } + + it 'returns parent groups as this contains all sub-groups' do + is_expected.to contain_exactly( + [group_a.id], + [group_c.id] + ) + end + end + end + end + context 'when project namespace exists in the group' do let!(:project) { create(:project, group: group) } let!(:project_namespace) { project.project_namespace } @@ -737,11 +843,23 @@ RSpec.describe Group do describe '#add_user' do let(:user) { create(:user) } - before do + it 'adds the user with a blocking refresh by default' do + expect_next_instance_of(GroupMember) do |member| + expect(member).to receive(:refresh_member_authorized_projects).with(blocking: true) + end + group.add_user(user, GroupMember::MAINTAINER) + + expect(group.group_members.maintainers.map(&:user)).to include(user) end - it { expect(group.group_members.maintainers.map(&:user)).to include(user) } + it 'passes the blocking refresh value to member' do + expect_next_instance_of(GroupMember) do |member| + expect(member).to receive(:refresh_member_authorized_projects).with(blocking: false) + end + + group.add_user(user, GroupMember::MAINTAINER, blocking_refresh: false) + end end describe '#add_users' do @@ -3246,4 +3364,57 @@ RSpec.describe Group do it_behaves_like 'no effective expiration interval' end end + + describe '#work_items_feature_flag_enabled?' do + it_behaves_like 'checks self and root ancestor feature flag' do + let(:feature_flag) { :work_items } + let(:feature_flag_method) { :work_items_feature_flag_enabled? } + end + end + + describe 'group shares' do + let!(:sub_group) { create(:group, parent: group) } + let!(:sub_sub_group) { create(:group, parent: sub_group) } + let!(:shared_group_1) { create(:group) } + let!(:shared_group_2) { create(:group) } + let!(:shared_group_3) { create(:group) } + + before do + group.shared_with_groups << shared_group_1 + sub_group.shared_with_groups << shared_group_2 + sub_sub_group.shared_with_groups << shared_group_3 + end + + describe '#shared_with_group_links.of_ancestors' do + using RSpec::Parameterized::TableSyntax + + where(:subject_group, :result) do + ref(:group) | [] + ref(:sub_group) | lazy { [shared_group_1].map(&:id) } + ref(:sub_sub_group) | lazy { [shared_group_1, shared_group_2].map(&:id) } + end + + with_them do + it 'returns correct group shares' do + expect(subject_group.shared_with_group_links.of_ancestors.map(&:shared_with_group_id)).to match_array(result) + end + end + end + + describe '#shared_with_group_links.of_ancestors_and_self' do + using RSpec::Parameterized::TableSyntax + + where(:subject_group, :result) do + ref(:group) | lazy { [shared_group_1].map(&:id) } + ref(:sub_group) | lazy { [shared_group_1, shared_group_2].map(&:id) } + ref(:sub_sub_group) | lazy { [shared_group_1, shared_group_2, shared_group_3].map(&:id) } + end + + with_them do + it 'returns correct group shares' do + expect(subject_group.shared_with_group_links.of_ancestors_and_self.map(&:shared_with_group_id)).to match_array(result) + end + end + end + end end diff --git a/spec/models/groups/feature_setting_spec.rb b/spec/models/groups/feature_setting_spec.rb new file mode 100644 index 00000000000..f1e66744b90 --- /dev/null +++ b/spec/models/groups/feature_setting_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::FeatureSetting do + describe 'associations' do + it { is_expected.to belong_to(:group) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:group) } + end +end diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb index 48d8ba975b6..0f596d3908d 100644 --- a/spec/models/integration_spec.rb +++ b/spec/models/integration_spec.rb @@ -60,6 +60,17 @@ RSpec.describe Integration do end describe 'Scopes' do + describe '.third_party_wikis' do + let!(:integration1) { create(:jira_integration) } + let!(:integration2) { create(:redmine_integration) } + let!(:integration3) { create(:confluence_integration) } + let!(:integration4) { create(:shimo_integration) } + + it 'returns the right group integration' do + expect(described_class.third_party_wikis).to contain_exactly(integration3, integration4) + end + end + describe '.with_default_settings' do it 'returns the correct integrations' do instance_integration = create(:integration, :instance) @@ -265,6 +276,20 @@ RSpec.describe Integration do end end + describe '#inheritable?' do + it 'is true for an instance integration' do + expect(create(:integration, :instance)).to be_inheritable + end + + it 'is true for a group integration' do + expect(create(:integration, :group)).to be_inheritable + end + + it 'is false for a project integration' do + expect(create(:integration)).not_to be_inheritable + end + end + describe '.build_from_integration' do context 'when integration is invalid' do let(:invalid_integration) do @@ -462,6 +487,18 @@ RSpec.describe Integration do expect(project.reload.integrations.first.inherit_from_id).to eq(group_integration.id) end + context 'there are multiple inheritable integrations, and a duplicate' do + let!(:group_integration_2) { create(:jenkins_integration, :group, group: group) } + let!(:group_integration_3) { create(:datadog_integration, :instance) } + let!(:duplicate) { create(:jenkins_integration, project: project) } + + it 'returns the number of successfully created integrations' do + expect(described_class.create_from_active_default_integrations(project, :project_id)).to eq 2 + + expect(project.reload.integrations.size).to eq(3) + end + end + context 'passing a group' do let!(:subgroup) { create(:group, parent: group) } @@ -621,6 +658,33 @@ RSpec.describe Integration do end end + describe '#properties=' do + let(:integration_type) do + Class.new(described_class) do + field :foo + field :bar + end + end + + it 'supports indifferent access' do + integration = integration_type.new + + integration.properties = { foo: 1, 'bar' => 2 } + + expect(integration).to have_attributes(foo: 1, bar: 2) + end + end + + describe '#properties' do + it 'is not mutable' do + integration = described_class.new + + integration.properties = { foo: 1, bar: 2 } + + expect { integration.properties[:foo] = 3 }.to raise_error + end + end + describe "{property}_touched?" do let(:integration) do Integrations::Bamboo.create!( @@ -720,7 +784,7 @@ RSpec.describe Integration do describe '#api_field_names' do shared_examples 'api field names' do - it 'filters out sensitive fields' do + it 'filters out secret fields' do safe_fields = %w[some_safe_field safe_field url trojan_gift] expect(fake_integration.new).to have_attributes( @@ -857,7 +921,7 @@ RSpec.describe Integration do end end - describe '#password_fields' do + describe '#secret_fields' do it 'returns all fields with type `password`' do allow(subject).to receive(:fields).and_return([ { name: 'password', type: 'password' }, @@ -865,53 +929,34 @@ RSpec.describe Integration do { name: 'public', type: 'text' } ]) - expect(subject.password_fields).to match_array(%w[password secret]) + expect(subject.secret_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([]) + it 'returns an empty array if no secret fields exist' do + expect(subject.secret_fields).to eq([]) end end - describe 'encrypted_properties' do + describe '#to_integration_hash' do let(:properties) { { foo: 1, bar: true } } let(:db_props) { properties.stringify_keys } let(:record) { create(:integration, :instance, properties: properties) } - it 'contains the same data as properties' do - expect(record).to have_attributes( - properties: db_props, - encrypted_properties_tmp: db_props - ) - end - - it 'is persisted' do - encrypted_properties = described_class.id_in(record.id) - - expect(encrypted_properties).to contain_exactly have_attributes(encrypted_properties_tmp: db_props) - end - - it 'is updated when using prop_accessors' do - some_integration = Class.new(described_class) do - prop_accessor :foo - end - - record = some_integration.new - - record.foo = 'the foo' + it 'does not include the properties key' do + hash = record.to_integration_hash - expect(record.encrypted_properties_tmp).to eq({ 'foo' => 'the foo' }) + expect(hash).not_to have_key('properties') end it 'saves correctly using insert_all' do hash = record.to_integration_hash - hash[:project_id] = project + hash[:project_id] = project.id expect do described_class.insert_all([hash]) end.to change(described_class, :count).by(1) - expect(described_class.last).to have_attributes(encrypted_properties_tmp: db_props) + expect(described_class.last).to have_attributes(properties: db_props) end it 'is part of the to_integration_hash' do @@ -921,7 +966,7 @@ RSpec.describe Integration do expect(hash['encrypted_properties']).not_to eq(record.encrypted_properties) expect(hash['encrypted_properties_iv']).not_to eq(record.encrypted_properties_iv) - decrypted = described_class.decrypt(:encrypted_properties_tmp, + decrypted = described_class.decrypt(:properties, hash['encrypted_properties'], { iv: hash['encrypted_properties_iv'] }) @@ -946,7 +991,7 @@ RSpec.describe Integration do end.to change(described_class, :count).by(1) expect(described_class.last).not_to eq record - expect(described_class.last).to have_attributes(encrypted_properties_tmp: db_props) + expect(described_class.last).to have_attributes(properties: db_props) end end end @@ -1021,4 +1066,97 @@ RSpec.describe Integration do ) end end + + describe 'boolean_accessor' do + let(:klass) do + Class.new(Integration) do + boolean_accessor :test_value + end + end + + let(:integration) { klass.new(properties: { test_value: input }) } + + where(:input, :method_result, :predicate_method_result) do + true | true | true + false | false | false + 1 | true | true + 0 | false | false + '1' | true | true + '0' | false | false + 'true' | true | true + 'false' | false | false + 'foobar' | nil | false + '' | nil | false + nil | nil | false + 'on' | true | true + 'off' | false | false + 'yes' | true | true + 'no' | false | false + 'n' | false | false + 'y' | true | true + 't' | true | true + 'f' | false | false + end + + with_them do + it 'has the correct value' do + expect(integration).to have_attributes( + test_value: be(method_result), + test_value?: be(predicate_method_result) + ) + end + end + + it 'returns values when initialized without input' do + integration = klass.new + + expect(integration).to have_attributes( + test_value: be(nil), + test_value?: be(false) + ) + end + end + + describe '#attributes' do + it 'does not include properties' do + expect(create(:integration).attributes).not_to have_key('properties') + end + + it 'can be used in assign_attributes without nullifying properties' do + record = create(:integration, :instance, properties: { url: generate(:url) }) + + attrs = record.attributes + + expect { record.assign_attributes(attrs) }.not_to change(record, :properties) + end + end + + describe '#dup' do + let(:original) { create(:integration, properties: { one: 1, two: 2, three: 3 }) } + + it 'results in distinct ciphertexts, but identical properties' do + copy = original.dup + + expect(copy).to have_attributes(properties: eq(original.properties)) + + expect(copy).not_to have_attributes( + encrypted_properties: eq(original.encrypted_properties) + ) + end + + context 'when the model supports data-fields' do + let(:original) { create(:jira_integration, username: generate(:username), url: generate(:url)) } + + it 'creates distinct but identical data-fields' do + copy = original.dup + + expect(copy).to have_attributes( + username: original.username, + url: original.url + ) + + expect(copy.data_fields).not_to eq(original.data_fields) + end + end + end end diff --git a/spec/models/integrations/base_third_party_wiki_spec.rb b/spec/models/integrations/base_third_party_wiki_spec.rb new file mode 100644 index 00000000000..11e044c2a18 --- /dev/null +++ b/spec/models/integrations/base_third_party_wiki_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::BaseThirdPartyWiki do + describe 'Validations' do + let_it_be_with_reload(:project) { create(:project) } + + describe 'only one third party wiki per project' do + subject(:integration) { create(:shimo_integration, project: project, active: true) } + + before_all do + create(:confluence_integration, project: project, active: true) + end + + context 'when integration is changed manually by user' do + it 'executes the validation' do + valid = integration.valid?(:manual_change) + + expect(valid).to be_falsey + error_message = 'Another third-party wiki is already in use. '\ + 'Only one third-party wiki integration can be active at a time' + expect(integration.errors[:base]).to include _(error_message) + end + end + + context 'when integration is changed internally' do + it 'does not execute the validation' do + expect(integration.valid?).to be_truthy + end + end + + context 'when integration is not on the project level' do + subject(:integration) { create(:shimo_integration, :instance, active: true) } + + it 'executes the validation' do + expect(integration.valid?(:manual_change)).to be_truthy + end + end + end + end +end diff --git a/spec/models/integrations/emails_on_push_spec.rb b/spec/models/integrations/emails_on_push_spec.rb index bdca267f6cb..15aa105e379 100644 --- a/spec/models/integrations/emails_on_push_spec.rb +++ b/spec/models/integrations/emails_on_push_spec.rb @@ -78,9 +78,10 @@ RSpec.describe Integrations::EmailsOnPush do end describe '.valid_recipients' do - let(:recipients) { '<invalid> foobar Valid@recipient.com Dup@lica.te dup@lica.te Dup@Lica.te' } + let(:recipients) { '<invalid> foobar valid@dup@asd Valid@recipient.com Dup@lica.te dup@lica.te Dup@Lica.te' } it 'removes invalid email addresses and removes duplicates by keeping the original capitalization' do + expect(described_class.valid_recipients(recipients)).not_to contain_exactly('valid@dup@asd') expect(described_class.valid_recipients(recipients)).to contain_exactly('Valid@recipient.com', 'Dup@lica.te') end end diff --git a/spec/models/integrations/external_wiki_spec.rb b/spec/models/integrations/external_wiki_spec.rb index e4d6a1c7c84..1621605d39f 100644 --- a/spec/models/integrations/external_wiki_spec.rb +++ b/spec/models/integrations/external_wiki_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Integrations::ExternalWiki do describe 'test' do before do - subject.properties['external_wiki_url'] = url + subject.external_wiki_url = url end let(:url) { 'http://foo' } diff --git a/spec/models/integrations/field_spec.rb b/spec/models/integrations/field_spec.rb index 0d660c4a3ab..c8caf831191 100644 --- a/spec/models/integrations/field_spec.rb +++ b/spec/models/integrations/field_spec.rb @@ -84,17 +84,17 @@ RSpec.describe ::Integrations::Field do end end - describe '#sensitive' do + describe '#secret?' do context 'when empty' do - it { is_expected.not_to be_sensitive } + it { is_expected.not_to be_secret } end - context 'when a password field' do + context 'when a secret field' do before do attrs[:type] = 'password' end - it { is_expected.to be_sensitive } + it { is_expected.to be_secret } end %w[token api_token api_key secret_key secret_sauce password passphrase].each do |name| @@ -103,7 +103,7 @@ RSpec.describe ::Integrations::Field do attrs[:name] = name end - it { is_expected.to be_sensitive } + it { is_expected.to be_secret } end end @@ -112,7 +112,7 @@ RSpec.describe ::Integrations::Field do attrs[:name] = :url end - it { is_expected.not_to be_sensitive } + it { is_expected.not_to be_secret } end end end diff --git a/spec/models/integrations/jira_spec.rb b/spec/models/integrations/jira_spec.rb index 08656bfe543..d244b1d33d5 100644 --- a/spec/models/integrations/jira_spec.rb +++ b/spec/models/integrations/jira_spec.rb @@ -187,7 +187,7 @@ RSpec.describe Integrations::Jira do subject(:integration) { described_class.create!(params) } it 'does not store data into properties' do - expect(integration.properties).to be_nil + expect(integration.properties).to be_empty end it 'stores data in data_fields correctly' do diff --git a/spec/models/integrations/slack_spec.rb b/spec/models/integrations/slack_spec.rb index 9f69f4f51f8..3997d69f947 100644 --- a/spec/models/integrations/slack_spec.rb +++ b/spec/models/integrations/slack_spec.rb @@ -6,12 +6,12 @@ RSpec.describe Integrations::Slack do it_behaves_like Integrations::SlackMattermostNotifier, "Slack" describe '#execute' do + let_it_be(:slack_integration) { create(:integrations_slack, branches_to_be_notified: 'all') } + before do stub_request(:post, slack_integration.webhook) end - let_it_be(:slack_integration) { create(:integrations_slack, branches_to_be_notified: 'all') } - it 'uses only known events', :aggregate_failures do described_class::SUPPORTED_EVENTS_FOR_USAGE_LOG.each do |action| expect(Gitlab::UsageDataCounters::HLLRedisCounter.known_event?("i_ecosystem_slack_service_#{action}_notification")).to be true diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 29305ba435c..fe09dadd0db 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -238,6 +238,24 @@ RSpec.describe Issue do end end + context 'order by escalation status' do + let_it_be(:triggered_incident) { create(:incident_management_issuable_escalation_status, :triggered).issue } + let_it_be(:resolved_incident) { create(:incident_management_issuable_escalation_status, :resolved).issue } + let_it_be(:issue_no_status) { create(:issue) } + + describe '.order_escalation_status_asc' do + subject { described_class.order_escalation_status_asc } + + it { is_expected.to eq([triggered_incident, resolved_incident, issue_no_status]) } + end + + describe '.order_escalation_status_desc' do + subject { described_class.order_escalation_status_desc } + + it { is_expected.to eq([resolved_incident, triggered_incident, issue_no_status]) } + end + end + # TODO: Remove when NOT NULL constraint is added to the relationship describe '#work_item_type' do let(:issue) { create(:issue, :incident, project: reusable_project, work_item_type: nil) } @@ -1154,18 +1172,6 @@ RSpec.describe Issue do end end - describe '#hook_attrs' do - it 'delegates to Gitlab::HookData::IssueBuilder#build' do - builder = double - - expect(Gitlab::HookData::IssueBuilder) - .to receive(:new).with(subject).and_return(builder) - expect(builder).to receive(:build) - - subject.hook_attrs - end - end - describe '#check_for_spam?' do let_it_be(:support_bot) { ::User.support_bot } @@ -1314,15 +1320,6 @@ RSpec.describe Issue do subject { create(:issue, updated_at: 1.hour.ago) } end - describe "#labels_hook_attrs" do - let(:label) { create(:label) } - let(:issue) { create(:labeled_issue, project: reusable_project, labels: [label]) } - - it "returns a list of label hook attributes" do - expect(issue.labels_hook_attrs).to eq([label.hook_attrs]) - end - end - context "relative positioning" do let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 6cf73de6cef..e1135aa440b 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -123,25 +123,55 @@ RSpec.describe Key, :mailer do end end - context "validation of uniqueness (based on fingerprint uniqueness)" do + context 'validation of uniqueness (based on fingerprint uniqueness)' do let(:user) { create(:user) } - it "accepts the key once" do - expect(build(:key, user: user)).to be_valid + shared_examples 'fingerprint uniqueness' do + it 'accepts the key once' do + expect(build(:rsa_key_4096, user: user)).to be_valid + end + + it 'does not accept the exact same key twice' do + first_key = create(:rsa_key_4096, user: user) + + expect(build(:key, user: user, key: first_key.key)).not_to be_valid + end + + it 'does not accept a duplicate key with a different comment' do + first_key = create(:rsa_key_4096, user: user) + duplicate = build(:key, user: user, key: first_key.key) + duplicate.key << ' extra comment' + + expect(duplicate).not_to be_valid + end end - it "does not accept the exact same key twice" do - first_key = create(:key, user: user) + context 'with FIPS mode off' do + it_behaves_like 'fingerprint uniqueness' + end - expect(build(:key, user: user, key: first_key.key)).not_to be_valid + context 'with FIPS mode', :fips_mode do + it_behaves_like 'fingerprint uniqueness' end + end - it "does not accept a duplicate key with a different comment" do - first_key = create(:key, user: user) - duplicate = build(:key, user: user, key: first_key.key) - duplicate.key << ' extra comment' + context 'fingerprint generation' do + it 'generates both md5 and sha256 fingerprints' do + key = build(:rsa_key_4096) + + expect(key).to be_valid + expect(key.fingerprint).to be_kind_of(String) + expect(key.fingerprint_sha256).to be_kind_of(String) + end - expect(duplicate).not_to be_valid + context 'with FIPS mode', :fips_mode do + it 'generates only sha256 fingerprint' do + key = build(:rsa_key_4096) + + expect(key).to be_valid + expect(key.fingerprint).to be_nil + expect(key.fingerprint_sha256).to be_kind_of(String) + end end end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 79491edba94..4ab17ee1e6d 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -682,14 +682,46 @@ RSpec.describe Member do member.accept_invite!(user) end - it "refreshes user's authorized projects", :delete do - project = member.source + context 'authorized projects' do + let(:project) { member.source } - expect(user.authorized_projects).not_to include(project) + before do + expect(user.authorized_projects).not_to include(project) + end - member.accept_invite!(user) + it 'successfully completes a blocking refresh', :delete do + expect(member).to receive(:refresh_member_authorized_projects).with(blocking: true).and_call_original + + member.accept_invite!(user) + + expect(user.authorized_projects.reload).to include(project) + end + + it 'successfully completes a non-blocking refresh', :delete, :sidekiq_inline do + member.blocking_refresh = false + + expect(member).to receive(:refresh_member_authorized_projects).with(blocking: false).and_call_original + + member.accept_invite!(user) + + expect(user.authorized_projects.reload).to include(project) + end - expect(user.authorized_projects.reload).to include(project) + context 'when the feature flag is disabled' do + before do + stub_feature_flags(allow_non_blocking_member_refresh: false) + end + + it 'successfully completes a blocking refresh', :delete, :sidekiq_inline do + member.blocking_refresh = false + + expect(member).to receive(:refresh_member_authorized_projects).with(blocking: true).and_call_original + + member.accept_invite!(user) + + expect(user.authorized_projects.reload).to include(project) + end + end end it 'does not accept the invite if saving a new user fails' do @@ -926,4 +958,64 @@ RSpec.describe Member do end end end + + describe '.sort_by_attribute' do + let_it_be(:user1) { create(:user, created_at: Date.today, last_sign_in_at: Date.today, last_activity_on: Date.today, name: 'Alpha') } + let_it_be(:user2) { create(:user, created_at: Date.today - 1, last_sign_in_at: Date.today - 1, last_activity_on: Date.today - 1, name: 'Omega') } + let_it_be(:user3) { create(:user, created_at: Date.today - 2, name: 'Beta') } + let_it_be(:group) { create(:group) } + let_it_be(:member1) { create(:group_member, :reporter, group: group, user: user1) } + let_it_be(:member2) { create(:group_member, :developer, group: group, user: user2) } + let_it_be(:member3) { create(:group_member, :maintainer, group: group, user: user3) } + + it 'sort users in ascending order by access-level' do + expect(described_class.sort_by_attribute('access_level_asc')).to eq([member1, member2, member3]) + end + + it 'sort users in descending order by access-level' do + expect(described_class.sort_by_attribute('access_level_desc')).to eq([member3, member2, member1]) + end + + context 'when sort by recent_sign_in' do + subject { described_class.sort_by_attribute('recent_sign_in') } + + it 'sorts users by recent sign-in time' do + expect(subject.first).to eq(member1) + expect(subject.second).to eq(member2) + end + + it 'pushes users who never signed in to the end' do + expect(subject.third).to eq(member3) + end + end + + context 'when sort by oldest_sign_in' do + subject { described_class.sort_by_attribute('oldest_sign_in') } + + it 'sorts users by the oldest sign-in time' do + expect(subject.first).to eq(member2) + expect(subject.second).to eq(member1) + end + + it 'pushes users who never signed in to the end' do + expect(subject.third).to eq(member3) + end + end + + it 'sorts users in descending order by their creation time' do + expect(described_class.sort_by_attribute('recent_created_user')).to eq([member1, member2, member3]) + end + + it 'sorts users in ascending order by their creation time' do + expect(described_class.sort_by_attribute('oldest_created_user')).to eq([member3, member2, member1]) + end + + it 'sort users by recent last activity' do + expect(described_class.sort_by_attribute('recent_last_activity')).to eq([member1, member2, member3]) + end + + it 'sort users by oldest last activity' do + expect(described_class.sort_by_attribute('oldest_last_activity')).to eq([member3, member2, member1]) + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 0d15851e583..8545c7bc6c7 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1757,18 +1757,6 @@ RSpec.describe MergeRequest, factory_default: :keep do end end - describe '#hook_attrs' do - it 'delegates to Gitlab::HookData::MergeRequestBuilder#build' do - builder = double - - expect(Gitlab::HookData::MergeRequestBuilder) - .to receive(:new).with(subject).and_return(builder) - expect(builder).to receive(:build) - - subject.hook_attrs - end - end - describe '#diverged_commits_count' do let(:project) { create(:project, :repository) } let(:forked_project) { fork_project(project, nil, repository: true) } @@ -3550,8 +3538,8 @@ RSpec.describe MergeRequest, factory_default: :keep do end end - describe "#environments" do - subject { merge_request.environments } + describe "#legacy_environments" do + subject { merge_request.legacy_environments } let(:merge_request) { create(:merge_request, source_branch: 'feature', target_branch: 'master') } let(:project) { merge_request.project } diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index ebd153f6f10..09ac15429a5 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -2230,4 +2230,10 @@ RSpec.describe Namespace do expect(namespace.storage_enforcement_date).to be(nil) end end + + describe 'serialization' do + let(:object) { build(:namespace) } + + it_behaves_like 'blocks unsafe serialization' + end end diff --git a/spec/models/namespaces/project_namespace_spec.rb b/spec/models/namespaces/project_namespace_spec.rb index 47cf866c143..c995571c3c9 100644 --- a/spec/models/namespaces/project_namespace_spec.rb +++ b/spec/models/namespaces/project_namespace_spec.rb @@ -17,11 +17,11 @@ RSpec.describe Namespaces::ProjectNamespace, type: :model do let_it_be(:project) { create(:project) } let_it_be(:project_namespace) { project.project_namespace } - it 'keeps the associated project' do + it 'also deletes associated project' do project_namespace.delete expect { project_namespace.reload }.to raise_error(ActiveRecord::RecordNotFound) - expect(project.reload.project_namespace).to be_nil + 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 cbfedf54ffa..4b262c1f3a9 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -105,6 +105,104 @@ RSpec.describe Note do it { is_expected.to be_valid } end end + + describe 'confidentiality' do + context 'for existing public note' do + let_it_be(:existing_note) { create(:note) } + + it 'is not possible to change the note to confidential' do + existing_note.confidential = true + + expect(existing_note).not_to be_valid + expect(existing_note.errors[:confidential]).to include('can not be changed for existing notes') + end + + it 'is possible to change confidentiality from nil to false' do + existing_note.confidential = false + + expect(existing_note).to be_valid + end + end + + context 'for existing confidential note' do + let_it_be(:existing_note) { create(:note, confidential: true) } + + it 'is not possible to change the note to public' do + existing_note.confidential = false + + expect(existing_note).not_to be_valid + expect(existing_note.errors[:confidential]).to include('can not be changed for existing notes') + end + end + + context 'for a new note' do + let_it_be(:noteable) { create(:issue) } + + let(:note_params) { { confidential: true, noteable: noteable, project: noteable.project } } + + subject { build(:note, **note_params) } + + it 'allows to create a confidential note for an issue' do + expect(subject).to be_valid + end + + context 'when noteable is not allowed to have confidential notes' do + let_it_be(:noteable) { create(:merge_request) } + + it 'can not be set confidential' do + expect(subject).not_to be_valid + expect(subject.errors[:confidential]).to include('can not be set for this resource') + end + end + + context 'when note type is not allowed to be confidential' do + let(:note_params) { { type: 'DiffNote', confidential: true, noteable: noteable, project: noteable.project } } + + it 'can not be set confidential' do + expect(subject).not_to be_valid + expect(subject.errors[:confidential]).to include('can not be set for this type of note') + end + end + + context 'when the note is a discussion note' do + let(:note_params) { { type: 'DiscussionNote', confidential: true, noteable: noteable, project: noteable.project } } + + it { is_expected.to be_valid } + end + + context 'when replying to a note' do + let(:note_params) { { confidential: true, noteable: noteable, project: noteable.project } } + + subject { build(:discussion_note, discussion_id: original_note.discussion_id, **note_params) } + + context 'when the note is reply to a confidential note' do + let_it_be(:original_note) { create(:note, confidential: true, noteable: noteable, project: noteable.project) } + + it { is_expected.to be_valid } + end + + context 'when the note is reply to a public note' do + let_it_be(:original_note) { create(:note, noteable: noteable, project: noteable.project) } + + it 'can not be set confidential' do + expect(subject).not_to be_valid + expect(subject.errors[:confidential]).to include('reply should have same confidentiality as top-level note') + end + end + + context 'when reply note is public but discussion is confidential' do + let_it_be(:original_note) { create(:note, confidential: true, noteable: noteable, project: noteable.project) } + + let(:note_params) { { noteable: noteable, project: noteable.project } } + + it 'can not be set confidential' do + expect(subject).not_to be_valid + expect(subject.errors[:confidential]).to include('reply should have same confidentiality as top-level note') + end + end + end + end + end end describe 'callbacks' do @@ -1169,8 +1267,8 @@ RSpec.describe Note do end describe "#discussion" do - let!(:note1) { create(:discussion_note_on_merge_request) } - let!(:note2) { create(:diff_note_on_merge_request, project: note1.project, noteable: note1.noteable) } + let_it_be(:note1) { create(:discussion_note_on_merge_request) } + let_it_be(:note2) { create(:diff_note_on_merge_request, project: note1.project, noteable: note1.noteable) } context 'when the note is part of a discussion' do subject { create(:discussion_note_on_merge_request, project: note1.project, noteable: note1.noteable, in_reply_to: note1) } @@ -1655,4 +1753,27 @@ RSpec.describe Note do expect(note.commands_changes.keys).to contain_exactly(:emoji_award, :time_estimate, :spend_time) end end + + describe '#bump_updated_at', :freeze_time do + it 'sets updated_at to the current timestamp' do + note = create(:note, updated_at: 1.day.ago) + + note.bump_updated_at + note.reload + + expect(note.updated_at).to be_like_time(Time.current) + end + + context 'with legacy edited note' do + it 'copies updated_at to last_edited_at before bumping the timestamp' do + note = create(:note, updated_at: 1.day.ago, updated_by: create(:user), last_edited_at: nil) + + note.bump_updated_at + note.reload + + expect(note.last_edited_at).to be_like_time(1.day.ago) + expect(note.updated_at).to be_like_time(Time.current) + end + end + end end diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb index fd453d8e5a9..f6af8f6a951 100644 --- a/spec/models/packages/package_file_spec.rb +++ b/spec/models/packages/package_file_spec.rb @@ -23,6 +23,28 @@ RSpec.describe Packages::PackageFile, type: :model do describe 'validations' do it { is_expected.to validate_presence_of(:package) } + + context 'with pypi package' do + let_it_be(:package) { create(:pypi_package) } + + let(:package_file) { package.package_files.first } + let(:status) { :default } + let(:file) { fixture_file_upload('spec/fixtures/dk.png') } + + subject { package.package_files.create!(file: file, file_name: package_file.file_name, status: status) } + + it 'can not save a duplicated file' do + expect { subject }.to raise_error(ActiveRecord::RecordInvalid, "Validation failed: File name has already been taken") + end + + context 'with a pending destruction package duplicated file' do + let(:status) { :pending_destruction } + + it 'can save it' do + expect { subject }.to change { package.package_files.count }.from(1).to(2) + end + end + end end context 'with package filenames' do diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb index 52ed52de193..6c86db1197f 100644 --- a/spec/models/packages/package_spec.rb +++ b/spec/models/packages/package_spec.rb @@ -1021,13 +1021,13 @@ RSpec.describe Packages::Package, type: :model do context 'ascending direction' do let(:direction) { :asc } - it { is_expected.to eq('projects.name asc NULLS LAST, "packages_packages"."id" ASC') } + it { is_expected.to eq('"projects"."name" ASC NULLS LAST, "packages_packages"."id" ASC') } end context 'descending direction' do let(:direction) { :desc } - it { is_expected.to eq('projects.name desc NULLS FIRST, "packages_packages"."id" DESC') } + it { is_expected.to eq('"projects"."name" DESC NULLS FIRST, "packages_packages"."id" DESC') } end end end diff --git a/spec/models/plan_limits_spec.rb b/spec/models/plan_limits_spec.rb index 72fda2280e5..381e42978f4 100644 --- a/spec/models/plan_limits_spec.rb +++ b/spec/models/plan_limits_spec.rb @@ -214,6 +214,7 @@ RSpec.describe PlanLimits do daily_invites web_hook_calls ci_daily_pipeline_schedule_triggers + repository_size ] + disabled_max_artifact_size_columns end diff --git a/spec/models/preloaders/environments/deployment_preloader_spec.rb b/spec/models/preloaders/environments/deployment_preloader_spec.rb index 3f2f28a069e..4c05d9632de 100644 --- a/spec/models/preloaders/environments/deployment_preloader_spec.rb +++ b/spec/models/preloaders/environments/deployment_preloader_spec.rb @@ -6,14 +6,14 @@ RSpec.describe Preloaders::Environments::DeploymentPreloader do let_it_be(:user) { create(:user) } let_it_be(:project, reload: true) { create(:project, :repository) } - let_it_be(:pipeline) { create(:ci_pipeline, user: user, project: project, sha: project.commit.sha) } - let_it_be(:ci_build_a) { create(:ci_build, user: user, project: project, pipeline: pipeline) } - let_it_be(:ci_build_b) { create(:ci_build, user: user, project: project, pipeline: pipeline) } - let_it_be(:ci_build_c) { create(:ci_build, user: user, project: project, pipeline: pipeline) } - let_it_be(:environment_a) { create(:environment, project: project, state: :available) } let_it_be(:environment_b) { create(:environment, project: project, state: :available) } + let_it_be(:pipeline) { create(:ci_pipeline, user: user, project: project, sha: project.commit.sha) } + let_it_be(:ci_build_a) { create(:ci_build, user: user, project: project, pipeline: pipeline, environment: environment_a.name) } + let_it_be(:ci_build_b) { create(:ci_build, user: user, project: project, pipeline: pipeline, environment: environment_a.name) } + let_it_be(:ci_build_c) { create(:ci_build, user: user, project: project, pipeline: pipeline, environment: environment_b.name) } + before do create(:deployment, :success, project: project, environment: environment_a, deployable: ci_build_a) create(:deployment, :success, project: project, environment: environment_a, deployable: ci_build_b) diff --git a/spec/models/preloaders/group_root_ancestor_preloader_spec.rb b/spec/models/preloaders/group_root_ancestor_preloader_spec.rb new file mode 100644 index 00000000000..0d622e84ef1 --- /dev/null +++ b/spec/models/preloaders/group_root_ancestor_preloader_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Preloaders::GroupRootAncestorPreloader do + let_it_be(:user) { create(:user) } + let_it_be(:root_parent1) { create(:group, :private, name: 'root-1', path: 'root-1') } + let_it_be(:root_parent2) { create(:group, :private, name: 'root-2', path: 'root-2') } + 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', parent: root_parent1) } + let_it_be(:private_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', parent: root_parent2) } + + let(:root_query_regex) { /\ASELECT.+FROM "namespaces" WHERE "namespaces"."id" = \d+/ } + let(:additional_preloads) { [] } + let(:groups) { [guest_group, private_maintainer_group, private_developer_group, public_maintainer_group] } + let(:pristine_groups) { Group.where(id: groups) } + + shared_examples 'executes N matching DB queries' do |expected_query_count, query_method = nil| + it 'executes the specified root_ancestor queries' do + expect do + pristine_groups.each do |group| + root_ancestor = group.root_ancestor + + root_ancestor.public_send(query_method) if query_method.present? + end + end.to make_queries_matching(root_query_regex, expected_query_count) + end + + it 'strong_memoizes the correct root_ancestor' do + pristine_groups.each do |group| + expected_parent_id = group.root_ancestor.id == group.id ? nil : group.root_ancestor.id + + expect(group.parent_id).to eq(expected_parent_id) + end + end + end + + context 'when the preloader is used' do + before do + preload_ancestors + end + + context 'when no additional preloads are provided' do + it_behaves_like 'executes N matching DB queries', 0 + end + + context 'when additional preloads are provided' do + let(:additional_preloads) { [:route] } + let(:root_query_regex) { /\ASELECT.+FROM "routes" WHERE "routes"."source_id" = \d+/ } + + it_behaves_like 'executes N matching DB queries', 0, :full_path + end + end + + context 'when the preloader is not used' do + it_behaves_like 'executes N matching DB queries', 2 + end + + def preload_ancestors + described_class.new(pristine_groups, additional_preloads).execute + end +end diff --git a/spec/models/programming_language_spec.rb b/spec/models/programming_language_spec.rb index f2201eabd1c..b202c10e30b 100644 --- a/spec/models/programming_language_spec.rb +++ b/spec/models/programming_language_spec.rb @@ -10,4 +10,22 @@ RSpec.describe ProgrammingLanguage do it { is_expected.to allow_value("#000000").for(:color) } it { is_expected.not_to allow_value("000000").for(:color) } it { is_expected.not_to allow_value("#0z0000").for(:color) } + + describe '.with_name_case_insensitive scope' do + let_it_be(:ruby) { create(:programming_language, name: 'Ruby') } + let_it_be(:python) { create(:programming_language, name: 'Python') } + let_it_be(:swift) { create(:programming_language, name: 'Swift') } + + it 'accepts a single name parameter' do + expect(described_class.with_name_case_insensitive('swift')).to( + contain_exactly(swift) + ) + end + + it 'accepts multiple names' do + expect(described_class.with_name_case_insensitive('ruby', 'python')).to( + contain_exactly(ruby, python) + ) + end + end end diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index 75e43ed9a67..941f6c0a49d 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe ProjectFeature do using RSpec::Parameterized::TableSyntax - let(:project) { create(:project) } - let(:user) { create(:user) } + let_it_be_with_reload(:project) { create(:project) } + let_it_be(:user) { create(:user) } it { is_expected.to belong_to(:project) } @@ -242,4 +242,95 @@ RSpec.describe ProjectFeature do end end end + + # rubocop:disable Gitlab/FeatureAvailableUsage + describe '#feature_available?' do + let(:features) { ProjectFeature::FEATURES } + + context 'when features are disabled' do + it 'returns false' do + update_all_project_features(project, features, ProjectFeature::DISABLED) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" + end + end + end + + context 'when features are enabled only for team members' do + it 'returns false when user is not a team member' do + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" + end + end + + it 'returns true when user is a team member' do + project.add_developer(user) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(true) + end + end + + it 'returns true when user is a member of project group' do + group = create(:group) + project = create(:project, namespace: group) + group.add_developer(user) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(true) + end + end + + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns true if user is an admin' do + user.update_attribute(:admin, true) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(true) + end + end + end + + context 'when admin mode is disabled' do + it 'returns false when user is an admin' do + user.update_attribute(:admin, true) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" + end + end + end + end + + context 'when feature is enabled for everyone' do + it 'returns true' do + expect(project.feature_available?(:issues, user)).to eq(true) + end + end + + context 'when feature has any other value' do + it 'returns true' do + project.project_feature.update_attribute(:issues_access_level, 200) + + expect(project.feature_available?(:issues)).to eq(true) + end + end + + def update_all_project_features(project, features, value) + project_feature_attributes = features.to_h { |f| ["#{f}_access_level", value] } + project.project_feature.update!(project_feature_attributes) + end + end + # rubocop:enable Gitlab/FeatureAvailableUsage end diff --git a/spec/models/project_import_state_spec.rb b/spec/models/project_import_state_spec.rb index 4ad2446f8d0..42ca8130734 100644 --- a/spec/models/project_import_state_spec.rb +++ b/spec/models/project_import_state_spec.rb @@ -22,7 +22,7 @@ RSpec.describe ProjectImportState, type: :model do before do allow_any_instance_of(Gitlab::GitalyClient::RepositoryService).to receive(:import_repository) - .with(project.import_url).and_return(true) + .with(project.import_url, http_authorization_header: '', mirror: false).and_return(true) # Works around https://github.com/rspec/rspec-mocks/issues/910 allow(Project).to receive(:find).with(project.id).and_return(project) @@ -89,19 +89,6 @@ RSpec.describe ProjectImportState, type: :model do import_state.mark_as_failed(error_message) end.to change { project.reload.import_data }.from(import_data).to(nil) end - - context 'when remove_import_data_on_failure feature flag is disabled' do - it 'removes project import data' do - stub_feature_flags(remove_import_data_on_failure: false) - - project = create(:project, import_data: ProjectImportData.new(data: { 'test' => 'some data' })) - import_state = create(:import_state, :started, project: project) - - expect do - import_state.mark_as_failed(error_message) - end.not_to change { project.reload.import_data } - end - end end describe '#human_status_name' do @@ -114,6 +101,34 @@ RSpec.describe ProjectImportState, type: :model do end end + describe '#expire_etag_cache' do + context 'when project import type has realtime changes endpoint' do + before do + import_state.project.import_type = 'github' + end + + it 'expires revelant etag cache' do + expect_next_instance_of(Gitlab::EtagCaching::Store) do |instance| + expect(instance).to receive(:touch).with(Gitlab::Routing.url_helpers.realtime_changes_import_github_path(format: :json)) + end + + subject.expire_etag_cache + end + end + + context 'when project import type does not have realtime changes endpoint' do + before do + import_state.project.import_type = 'jira' + end + + it 'does not touch etag caches' do + expect(Gitlab::EtagCaching::Store).not_to receive(:new) + + subject.expire_etag_cache + end + end + end + describe 'import state transitions' do context 'state transition: [:started] => [:finished]' do let(:after_import_service) { spy(:after_import_service) } @@ -191,4 +206,20 @@ RSpec.describe ProjectImportState, type: :model do end end end + + describe 'callbacks' do + context 'after_commit :expire_etag_cache' do + before do + import_state.project.import_type = 'github' + end + + it 'expires etag cache' do + expect_next_instance_of(Gitlab::EtagCaching::Store) do |instance| + expect(instance).to receive(:touch).with(Gitlab::Routing.url_helpers.realtime_changes_import_github_path(format: :json)) + end + + subject.save! + end + end + end end diff --git a/spec/models/project_setting_spec.rb b/spec/models/project_setting_spec.rb index 5572304d666..d03eb3c8bfe 100644 --- a/spec/models/project_setting_spec.rb +++ b/spec/models/project_setting_spec.rb @@ -4,4 +4,34 @@ require 'spec_helper' RSpec.describe ProjectSetting, type: :model do it { is_expected.to belong_to(:project) } + + describe 'validations' do + it { is_expected.not_to allow_value(nil).for(:target_platforms) } + it { is_expected.to allow_value([]).for(:target_platforms) } + + it 'allows any combination of the allowed target platforms' do + valid_target_platform_combinations.each do |target_platforms| + expect(subject).to allow_value(target_platforms).for(:target_platforms) + end + end + + [nil, 'not_allowed', :invalid].each do |invalid_value| + it { is_expected.not_to allow_value([invalid_value]).for(:target_platforms) } + end + end + + describe 'target_platforms=' do + it 'stringifies and sorts' do + project_setting = build(:project_setting, target_platforms: [:watchos, :ios]) + expect(project_setting.target_platforms).to eq %w(ios watchos) + end + end + + def valid_target_platform_combinations + target_platforms = described_class::ALLOWED_TARGET_PLATFORMS + + 0.upto(target_platforms.size).flat_map do |n| + target_platforms.permutation(n).to_a + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index fc7ac35ed41..0bb584845c2 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -536,7 +536,7 @@ RSpec.describe Project, factory_default: :keep do project = build(:project) aggregate_failures do - urls_with_CRLF.each do |url| + urls_with_crlf.each do |url| project.import_url = url expect(project).not_to be_valid @@ -549,7 +549,7 @@ RSpec.describe Project, factory_default: :keep do project = build(:project) aggregate_failures do - valid_urls_with_CRLF.each do |url| + valid_urls_with_crlf.each do |url| project.import_url = url expect(project).to be_valid @@ -635,6 +635,8 @@ RSpec.describe Project, factory_default: :keep do end end + it_behaves_like 'a BulkUsersByEmailLoad model' + describe '#all_pipelines' do let_it_be(:project) { create(:project) } @@ -724,6 +726,33 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#personal_namespace_holder?' do + let_it_be(:group) { create(:group) } + let_it_be(:namespace_user) { create(:user) } + let_it_be(:admin_user) { create(:user, :admin) } + let_it_be(:personal_project) { create(:project, namespace: namespace_user.namespace) } + let_it_be(:group_project) { create(:project, group: group) } + let_it_be(:another_user) { create(:user) } + let_it_be(:group_owner_user) { create(:user).tap { |user| group.add_owner(user) } } + + where(:project, :user, :result) do + ref(:personal_project) | ref(:namespace_user) | true + ref(:personal_project) | ref(:admin_user) | false + ref(:personal_project) | ref(:another_user) | false + ref(:personal_project) | nil | false + ref(:group_project) | ref(:namespace_user) | false + ref(:group_project) | ref(:group_owner_user) | false + ref(:group_project) | ref(:another_user) | false + ref(:group_project) | nil | false + ref(:group_project) | nil | false + ref(:group_project) | ref(:admin_user) | false + end + + with_them do + it { expect(project.personal_namespace_holder?(user)).to eq(result) } + end + end + describe '#default_pipeline_lock' do let(:project) { build_stubbed(:project) } @@ -1189,29 +1218,8 @@ RSpec.describe Project, factory_default: :keep do end describe 'last_activity_date' do - it 'returns the creation date of the project\'s last event if present' do - new_event = create(:event, :closed, project: project, created_at: Time.current) - - project.reload - expect(project.last_activity_at.to_i).to eq(new_event.created_at.to_i) - end - - it 'returns the project\'s last update date if it has no events' do - expect(project.last_activity_date).to eq(project.updated_at) - end - - it 'returns the most recent timestamp' do - project.update!(updated_at: nil, - last_activity_at: timestamp, - last_repository_updated_at: timestamp - 1.hour) - - expect(project.last_activity_date).to be_like_time(timestamp) - - project.update!(updated_at: timestamp, - last_activity_at: timestamp - 1.hour, - last_repository_updated_at: nil) - - expect(project.last_activity_date).to be_like_time(timestamp) + it 'returns the project\'s last update date' do + expect(project.last_activity_date).to be_like_time(project.updated_at) end end end @@ -1688,15 +1696,27 @@ RSpec.describe Project, factory_default: :keep do end describe '.sort_by_attribute' do - it 'reorders the input relation by start count desc' do - project1 = create(:project, star_count: 2) - project2 = create(:project, star_count: 1) - project3 = create(:project) + let_it_be(:project1) { create(:project, star_count: 2, updated_at: 1.minute.ago) } + let_it_be(:project2) { create(:project, star_count: 1) } + let_it_be(:project3) { create(:project, updated_at: 2.minutes.ago) } + it 'reorders the input relation by start count desc' do projects = described_class.sort_by_attribute(:stars_desc) expect(projects).to eq([project1, project2, project3]) end + + it 'reorders the input relation by last activity desc' do + projects = described_class.sort_by_attribute(:latest_activity_desc) + + expect(projects).to eq([project2, project1, project3]) + end + + it 'reorders the input relation by last activity asc' do + projects = described_class.sort_by_attribute(:latest_activity_asc) + + expect(projects).to eq([project3, project1, project2]) + end end describe '.with_shared_runners' do @@ -2273,6 +2293,44 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#pages_show_onboarding?' do + let(:project) { create(:project) } + + subject { project.pages_show_onboarding? } + + context "if there is no metadata" do + it { is_expected.to be_truthy } + end + + context 'if onboarding is complete' do + before do + project.pages_metadatum.update_column(:onboarding_complete, true) + end + + it { is_expected.to be_falsey } + end + + context 'if there is metadata, but onboarding is not complete' do + before do + project.pages_metadatum.update_column(:onboarding_complete, false) + end + + it { is_expected.to be_truthy } + end + + # During migration, the onboarding_complete property can still be false, + # but will be updated later. To account for that case, pages_show_onboarding? + # should return false if `deployed` is true. + context "will return false if pages is deployed even if onboarding_complete is false" do + before do + project.pages_metadatum.update_column(:onboarding_complete, false) + project.pages_metadatum.update_column(:deployed, true) + end + + it { is_expected.to be_falsey } + end + end + describe '#pages_deployed?' do let(:project) { create(:project) } @@ -2695,6 +2753,39 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#container_repositories_size' do + let(:project) { build(:project) } + + subject { project.container_repositories_size } + + context 'on gitlab.com' do + where(:no_container_repositories, :all_migrated, :gitlab_api_supported, :returned_size, :expected_result) do + true | nil | nil | nil | 0 + false | false | nil | nil | nil + false | true | false | nil | nil + false | true | true | 555 | 555 + false | true | true | nil | nil + end + + with_them do + before do + stub_container_registry_config(enabled: true, api_url: 'http://container-registry', key: 'spec/fixtures/x509_certificate_pk.key') + allow(Gitlab).to receive(:com?).and_return(true) + allow(project.container_repositories).to receive(:empty?).and_return(no_container_repositories) + allow(project.container_repositories).to receive(:all_migrated?).and_return(all_migrated) + allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(gitlab_api_supported) + allow(ContainerRegistry::GitlabApiClient).to receive(:deduplicated_size).with(project.full_path).and_return(returned_size) + end + + it { is_expected.to eq(expected_result) } + end + end + + context 'not on gitlab.com' do + it { is_expected.to eq(nil) } + end + end + describe '#container_registry_enabled=' do let_it_be_with_reload(:project) { create(:project) } @@ -5602,6 +5693,18 @@ RSpec.describe Project, factory_default: :keep do expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MAINTAINER]) end end + + describe 'project target platforms detection' do + before do + create(:import_state, :started, project: project) + end + + it 'calls enqueue_record_project_target_platforms' do + expect(project).to receive(:enqueue_record_project_target_platforms) + + project.after_import + end + end end describe '#update_project_counter_caches' do @@ -6256,6 +6359,10 @@ RSpec.describe Project, factory_default: :keep do expect(subject.find_or_initialize_integration('prometheus')).to be_nil end + it 'returns nil if integration does not exist' do + expect(subject.find_or_initialize_integration('non-existing')).to be_nil + end + context 'with an existing integration' do subject { create(:project) } @@ -6557,6 +6664,25 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#mark_pages_onboarding_complete' do + let(:project) { create(:project) } + + it "creates new record and sets onboarding_complete to true if none exists yet" do + project.mark_pages_onboarding_complete + + expect(project.pages_metadatum.reload.onboarding_complete).to eq(true) + end + + it "overrides an existing setting" do + pages_metadatum = project.pages_metadatum + pages_metadatum.update!(onboarding_complete: false) + + expect do + project.mark_pages_onboarding_complete + end.to change { pages_metadatum.reload.onboarding_complete }.from(false).to(true) + end + end + describe '#mark_pages_as_deployed' do let(:project) { create(:project) } @@ -8009,12 +8135,112 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#work_items_feature_flag_enabled?' do + shared_examples 'project checking work_items feature flag' do + context 'when work_items FF is disabled globally' do + before do + stub_feature_flags(work_items: false) + end + + it { is_expected.to be_falsey } + end + + context 'when work_items FF is enabled for the project' do + before do + stub_feature_flags(work_items: project) + end + + it { is_expected.to be_truthy } + end + + context 'when work_items FF is enabled globally' do + it { is_expected.to be_truthy } + end + end + + subject { project.work_items_feature_flag_enabled? } + + context 'when a project does not belong to a group' do + let_it_be(:project) { create(:project, namespace: namespace) } + + it_behaves_like 'project checking work_items feature flag' + end + + context 'when project belongs to a group' do + let_it_be(:root_group) { create(:group) } + let_it_be(:group) { create(:group, parent: root_group) } + let_it_be(:project) { create(:project, group: group) } + + it_behaves_like 'project checking work_items feature flag' + + context 'when work_items FF is enabled for the root group' do + before do + stub_feature_flags(work_items: root_group) + end + + it { is_expected.to be_truthy } + end + + context 'when work_items FF is enabled for the group' do + before do + stub_feature_flags(work_items: group) + end + + it { is_expected.to be_truthy } + end + end + end + describe 'serialization' do let(:object) { build(:project) } it_behaves_like 'blocks unsafe serialization' end + describe '#enqueue_record_project_target_platforms' do + let_it_be(:project) { create(:project) } + + let(:com) { true } + + before do + allow(Gitlab).to receive(:com?).and_return(com) + end + + it 'enqueues a Projects::RecordTargetPlatformsWorker' do + expect(Projects::RecordTargetPlatformsWorker).to receive(:perform_async).with(project.id) + + project.enqueue_record_project_target_platforms + end + + shared_examples 'does not enqueue a Projects::RecordTargetPlatformsWorker' do + it 'does not enqueue a Projects::RecordTargetPlatformsWorker' do + expect(Projects::RecordTargetPlatformsWorker).not_to receive(:perform_async) + + project.enqueue_record_project_target_platforms + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(record_projects_target_platforms: false) + end + + it_behaves_like 'does not enqueue a Projects::RecordTargetPlatformsWorker' + end + + context 'when not in gitlab.com' do + let(:com) { false } + + it_behaves_like 'does not enqueue a Projects::RecordTargetPlatformsWorker' + end + end + + describe '#inactive?' do + let_it_be_with_reload(:project) { create(:project, name: 'test-project') } + + it_behaves_like 'returns true if project is inactive' + end + private def finish_job(export_job) diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb index 5fbf1a9c502..20fc14113ef 100644 --- a/spec/models/project_statistics_spec.rb +++ b/spec/models/project_statistics_spec.rb @@ -35,7 +35,8 @@ RSpec.describe ProjectStatistics do build_artifacts_size: 1.exabyte, snippets_size: 1.exabyte, pipeline_artifacts_size: 512.petabytes - 1, - uploads_size: 512.petabytes + uploads_size: 512.petabytes, + container_registry_size: 8.exabytes - 1 ) statistics.reload @@ -49,6 +50,7 @@ RSpec.describe ProjectStatistics do expect(statistics.snippets_size).to eq(1.exabyte) expect(statistics.pipeline_artifacts_size).to eq(512.petabytes - 1) expect(statistics.uploads_size).to eq(512.petabytes) + expect(statistics.container_registry_size).to eq(8.exabytes - 1) end end diff --git a/spec/models/projects/build_artifacts_size_refresh_spec.rb b/spec/models/projects/build_artifacts_size_refresh_spec.rb index 22c27c986f8..a55e4b31d21 100644 --- a/spec/models/projects/build_artifacts_size_refresh_spec.rb +++ b/spec/models/projects/build_artifacts_size_refresh_spec.rb @@ -14,13 +14,13 @@ RSpec.describe Projects::BuildArtifactsSizeRefresh, type: :model do end describe 'scopes' do - let_it_be(:refresh_1) { create(:project_build_artifacts_size_refresh, :running, updated_at: 4.days.ago) } - let_it_be(:refresh_2) { create(:project_build_artifacts_size_refresh, :running, updated_at: 2.days.ago) } + let_it_be(:refresh_1) { create(:project_build_artifacts_size_refresh, :running, updated_at: (described_class::STALE_WINDOW + 1.second).ago) } + let_it_be(:refresh_2) { create(:project_build_artifacts_size_refresh, :running, updated_at: 1.hour.ago) } let_it_be(:refresh_3) { create(:project_build_artifacts_size_refresh, :pending) } let_it_be(:refresh_4) { create(:project_build_artifacts_size_refresh, :created) } describe 'stale' do - it 'returns records in running state and has not been updated for more than 3 days' do + it 'returns records in running state and has not been updated for more than 2 hours' do expect(described_class.stale).to eq([refresh_1]) end end diff --git a/spec/models/projects/topic_spec.rb b/spec/models/projects/topic_spec.rb index aa3230da1e6..8fc4d11f0d9 100644 --- a/spec/models/projects/topic_spec.rb +++ b/spec/models/projects/topic_spec.rb @@ -56,6 +56,14 @@ RSpec.describe Projects::Topic do end end + describe '#find_by_name_case_insensitive' do + it 'returns topic with case insensitive name' do + %w(topic TOPIC Topic).each do |name| + expect(described_class.find_by_name_case_insensitive(name)).to eq(topic) + end + end + end + describe '#search' do it 'returns topics with a matching name' do expect(described_class.search(topic.name)).to eq([topic]) diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb index d4491aacd9f..2492521c634 100644 --- a/spec/models/user_preference_spec.rb +++ b/spec/models/user_preference_spec.rb @@ -5,6 +5,48 @@ require 'spec_helper' RSpec.describe UserPreference do let(:user_preference) { create(:user_preference) } + describe 'validations' do + describe 'diffs_deletion_color and diffs_addition_color' do + using RSpec::Parameterized::TableSyntax + + where(color: [ + '#000000', + '#123456', + '#abcdef', + '#AbCdEf', + '#ffffff', + '#fFfFfF', + '#000', + '#123', + '#abc', + '#AbC', + '#fff', + '#fFf', + '' + ]) + + with_them do + it { is_expected.to allow_value(color).for(:diffs_deletion_color) } + it { is_expected.to allow_value(color).for(:diffs_addition_color) } + end + + where(color: [ + '#1', + '#12', + '#1234', + '#12345', + '#1234567', + '123456', + '#12345x' + ]) + + with_them do + it { is_expected.not_to allow_value(color).for(:diffs_deletion_color) } + it { is_expected.not_to allow_value(color).for(:diffs_addition_color) } + end + end + end + describe 'notes filters global keys' do it 'contains expected values' do expect(UserPreference::NOTES_FILTERS.keys).to match_array([:all_notes, :only_comments, :only_activity]) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 6ee38048025..bc425b15c6e 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -69,6 +69,12 @@ RSpec.describe User do it { is_expected.to delegate_method(:markdown_surround_selection).to(:user_preference) } it { is_expected.to delegate_method(:markdown_surround_selection=).to(:user_preference).with_arguments(:args) } + it { is_expected.to delegate_method(:diffs_deletion_color).to(:user_preference) } + it { is_expected.to delegate_method(:diffs_deletion_color=).to(:user_preference).with_arguments(:args) } + + it { is_expected.to delegate_method(:diffs_addition_color).to(:user_preference) } + it { is_expected.to delegate_method(:diffs_addition_color=).to(:user_preference).with_arguments(:args) } + it { is_expected.to delegate_method(:job_title).to(:user_detail).allow_nil } it { is_expected.to delegate_method(:job_title=).to(:user_detail).with_arguments(:args).allow_nil } @@ -1554,7 +1560,7 @@ RSpec.describe User do end it 'adds the confirmed primary email to emails' do - expect(user.emails.confirmed.map(&:email)).not_to include(user.email) + expect(user.emails.confirmed.map(&:email)).not_to include(user.unconfirmed_email) user.confirm @@ -1613,14 +1619,23 @@ RSpec.describe User do context 'when the email is changed but not confirmed' do let(:user) { create(:user, email: 'primary@example.com') } - it 'does not add the new email to emails yet' do + before do user.update!(email: 'new_primary@example.com') + end + it 'does not add the new email to emails yet' do expect(user.unconfirmed_email).to eq('new_primary@example.com') expect(user.email).to eq('primary@example.com') expect(user).to be_confirmed expect(user.emails.pluck(:email)).not_to include('new_primary@example.com') end + + it 'adds the new email to emails upon confirmation' do + user.confirm + expect(user.email).to eq('new_primary@example.com') + expect(user).to be_confirmed + expect(user.emails.pluck(:email)).to include('new_primary@example.com') + end end context 'when the user is created as not confirmed' do @@ -1630,6 +1645,11 @@ RSpec.describe User do expect(user).not_to be_confirmed expect(user.emails.pluck(:email)).not_to include('primary@example.com') end + + it 'adds the email to emails upon confirmation' do + user.confirm + expect(user.emails.pluck(:email)).to include('primary@example.com') + end end context 'when the user is created as confirmed' do @@ -2083,6 +2103,74 @@ RSpec.describe User do end end + describe 'needs_new_otp_secret?', :freeze_time do + let(:user) { create(:user) } + + context 'when two-factor is not enabled' do + it 'returns true if otp_secret_expires_at is nil' do + expect(user.needs_new_otp_secret?).to eq(true) + end + + it 'returns true if the otp_secret_expires_at has passed' do + user.update!(otp_secret_expires_at: 10.minutes.ago) + + expect(user.reload.needs_new_otp_secret?).to eq(true) + end + + it 'returns false if the otp_secret_expires_at has not passed' do + user.update!(otp_secret_expires_at: 10.minutes.from_now) + + expect(user.reload.needs_new_otp_secret?).to eq(false) + end + end + + context 'when two-factor is enabled' do + let(:user) { create(:user, :two_factor) } + + it 'returns false even if ttl is expired' do + user.otp_secret_expires_at = 10.minutes.ago + + expect(user.needs_new_otp_secret?).to eq(false) + end + end + end + + describe 'otp_secret_expired?', :freeze_time do + let(:user) { create(:user) } + + it 'returns true if otp_secret_expires_at is nil' do + expect(user.otp_secret_expired?).to eq(true) + end + + it 'returns true if the otp_secret_expires_at has passed' do + user.otp_secret_expires_at = 10.minutes.ago + + expect(user.otp_secret_expired?).to eq(true) + end + + it 'returns false if the otp_secret_expires_at has not passed' do + user.otp_secret_expires_at = 20.minutes.from_now + + expect(user.otp_secret_expired?).to eq(false) + end + end + + describe 'update_otp_secret!', :freeze_time do + let(:user) { create(:user) } + + before do + user.update_otp_secret! + end + + it 'sets the otp_secret' do + expect(user.otp_secret).to have_attributes(length: described_class::OTP_SECRET_LENGTH) + end + + it 'updates the otp_secret_expires_at' do + expect(user.otp_secret_expires_at).to eq(Time.current + described_class::OTP_SECRET_TTL) + end + end + describe 'projects' do before do @user = create(:user) @@ -2653,6 +2741,19 @@ RSpec.describe User do let_it_be(:user3) { create(:user, name: 'us', username: 'se', email: 'foo@example.com') } let_it_be(:email) { create(:email, user: user, email: 'alias@example.com') } + describe 'name user and email relative ordering' do + let_it_be(:named_alexander) { create(:user, name: 'Alexander Person', username: 'abcd', email: 'abcd@example.com') } + let_it_be(:username_alexand) { create(:user, name: 'Joao Alexander', username: 'Alexand', email: 'joao@example.com') } + + it 'prioritizes exact matches' do + expect(described_class.search('Alexand')).to eq([username_alexand, named_alexander]) + end + + it 'falls back to ordering by name' do + expect(described_class.search('Alexander')).to eq([named_alexander, username_alexand]) + end + end + describe 'name matching' do it 'returns users with a matching name with exact match first' do expect(described_class.search(user.name)).to eq([user, user2]) @@ -4251,16 +4352,34 @@ RSpec.describe User do end end - it_behaves_like '#ci_owned_runners' + describe '#ci_owned_runners' do + it_behaves_like '#ci_owned_runners' - context 'when FF ci_owned_runners_cross_joins_fix is disabled' do - before do - skip_if_multiple_databases_are_setup + context 'when FF use_traversal_ids is disabled fallbacks to inefficient implementation' do + before do + stub_feature_flags(use_traversal_ids: false) + end - stub_feature_flags(ci_owned_runners_cross_joins_fix: false) + it_behaves_like '#ci_owned_runners' end - it_behaves_like '#ci_owned_runners' + context 'when FF ci_owned_runners_cross_joins_fix is disabled' do + before do + skip_if_multiple_databases_are_setup + + stub_feature_flags(ci_owned_runners_cross_joins_fix: false) + end + + it_behaves_like '#ci_owned_runners' + end + + context 'when FF ci_owned_runners_unnest_index is disabled uses GIN index' do + before do + stub_feature_flags(ci_owned_runners_unnest_index: false) + end + + it_behaves_like '#ci_owned_runners' + end end describe '#projects_with_reporter_access_limited_to' do @@ -4882,17 +5001,36 @@ RSpec.describe User do end describe '#attention_requested_open_merge_requests_count' do - it 'returns number of open merge requests from non-archived projects' do - user = create(:user) - project = create(:project, :public) - archived_project = create(:project, :public, :archived) + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:archived_project) { create(:project, :public, :archived) } + before do create(:merge_request, source_project: project, author: user, reviewers: [user]) create(:merge_request, :closed, source_project: project, author: user, reviewers: [user]) create(:merge_request, source_project: archived_project, author: user, reviewers: [user]) + end + it 'returns number of open merge requests from non-archived projects' do + expect(Rails.cache).not_to receive(:fetch) expect(user.attention_requested_open_merge_requests_count(force: true)).to eq 1 end + + context 'when uncached_mr_attention_requests_count is disabled' do + before do + stub_feature_flags(uncached_mr_attention_requests_count: false) + end + + it 'fetches from cache' do + expect(Rails.cache).to receive(:fetch).with( + user.attention_request_cache_key, + force: false, + expires_in: described_class::COUNT_CACHE_VALIDITY_PERIOD + ).and_call_original + + expect(user.attention_requested_open_merge_requests_count).to eq 1 + end + end end describe '#assigned_open_issues_count' do @@ -6632,6 +6770,23 @@ RSpec.describe User do end end + describe '.without_forbidden_states' do + let_it_be(:normal_user) { create(:user, username: 'johndoe') } + let_it_be(:admin_user) { create(:user, :admin, username: 'iamadmin') } + let_it_be(:blocked_user) { create(:user, :blocked, username: 'notsorandom') } + let_it_be(:banned_user) { create(:user, :banned, username: 'iambanned') } + let_it_be(:external_user) { create(:user, :external) } + let_it_be(:unconfirmed_user) { create(:user, confirmed_at: nil) } + let_it_be(:omniauth_user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') } + let_it_be(:internal_user) { User.alert_bot.tap { |u| u.confirm } } + + it 'does not return blocked or banned users' do + expect(described_class.without_forbidden_states).to match_array([ + normal_user, admin_user, external_user, unconfirmed_user, omniauth_user, internal_user + ]) + end + end + describe 'user_project' do it 'returns users project matched by username and public visibility' do user = create(:user) diff --git a/spec/models/users/in_product_marketing_email_spec.rb b/spec/models/users/in_product_marketing_email_spec.rb index cf08cf7ceed..ca03c3e645d 100644 --- a/spec/models/users/in_product_marketing_email_spec.rb +++ b/spec/models/users/in_product_marketing_email_spec.rb @@ -19,13 +19,6 @@ RSpec.describe Users::InProductMarketingEmail, type: :model do it { is_expected.to validate_uniqueness_of(:user_id).scoped_to([:track, :series]).with_message('has already been sent') } end - describe '.tracks' do - it 'has an entry for every track' do - tracks = [Namespaces::InviteTeamEmailService::TRACK, Namespaces::InProductMarketingEmailsService::TRACKS.keys].flatten - expect(tracks).to match_array(described_class.tracks.keys.map(&:to_sym)) - end - end - describe '.without_track_and_series' do let_it_be(:user) { create(:user) } @@ -135,4 +128,15 @@ RSpec.describe Users::InProductMarketingEmail, type: :model do end end end + + describe '.ACTIVE_TRACKS' do + it 'has an entry for every track' do + tracks = Namespaces::InProductMarketingEmailsService::TRACKS.keys + expect(tracks).to match_array(described_class::ACTIVE_TRACKS.keys.map(&:to_sym)) + end + + it 'does not include INACTIVE_TRACK_NAMES' do + expect(described_class::ACTIVE_TRACKS.keys).not_to include(*described_class::INACTIVE_TRACK_NAMES) + end + end end diff --git a/spec/models/web_ide_terminal_spec.rb b/spec/models/web_ide_terminal_spec.rb index 149fce33f43..fc30bc18f68 100644 --- a/spec/models/web_ide_terminal_spec.rb +++ b/spec/models/web_ide_terminal_spec.rb @@ -41,7 +41,7 @@ RSpec.describe WebIdeTerminal do context 'when image does not have an alias' do let(:config) do - { image: 'ruby:2.7' }.merge(services_with_aliases) + { image: 'image:1.0' }.merge(services_with_aliases) end it 'returns services aliases' do @@ -51,7 +51,7 @@ RSpec.describe WebIdeTerminal do context 'when both image and services have aliases' do let(:config) do - { image: { name: 'ruby:2.7', alias: 'ruby' } }.merge(services_with_aliases) + { image: { name: 'image:1.0', alias: 'ruby' } }.merge(services_with_aliases) end it 'returns all aliases' do @@ -61,7 +61,7 @@ RSpec.describe WebIdeTerminal do context 'when image and services does not have any alias' do let(:config) do - { image: 'ruby:2.7', services: ['postgres'] } + { image: 'image:1.0', services: ['postgres'] } end it 'returns an empty array' do diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index 0016d2f517b..51970064c54 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -473,6 +473,21 @@ RSpec.describe WikiPage do end end + describe 'in subdir' do + it 'keeps the page in the same dir when the content is updated' do + title = 'foo/Existing Page' + page = create_wiki_page(title: title) + + expect(page.slug).to eq 'foo/Existing-Page' + expect(page.update(title: title, content: 'new_content')).to be_truthy + + page = wiki.find_page(title) + + expect(page.slug).to eq 'foo/Existing-Page' + expect(page.content).to eq 'new_content' + end + end + context 'when renaming a page' do it 'raises an error if the page already exists' do existing_page = create_wiki_page diff --git a/spec/policies/alert_management/alert_policy_spec.rb b/spec/policies/alert_management/alert_policy_spec.rb index 3e08d8b4ccc..2027c205c7b 100644 --- a/spec/policies/alert_management/alert_policy_spec.rb +++ b/spec/policies/alert_management/alert_policy_spec.rb @@ -3,9 +3,10 @@ require 'spec_helper' RSpec.describe AlertManagement::AlertPolicy, :models do - let(:alert) { create(:alert_management_alert) } - let(:project) { alert.project } - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:alert) { create(:alert_management_alert, project: project, issue: incident) } + let_it_be(:incident) { nil } subject(:policy) { described_class.new(user, alert) } @@ -21,5 +22,50 @@ RSpec.describe AlertManagement::AlertPolicy, :models do it { is_expected.to be_allowed :read_alert_management_alert } it { is_expected.to be_allowed :update_alert_management_alert } end + + shared_examples 'does not allow metric image reads' do + it { expect(policy).to be_disallowed(:read_alert_management_metric_image) } + end + + shared_examples 'does not allow metric image updates' do + specify do + expect(policy).to be_disallowed(:upload_alert_management_metric_image) + expect(policy).to be_disallowed(:destroy_alert_management_metric_image) + end + end + + shared_examples 'allows metric image reads' do + it { expect(policy).to be_allowed(:read_alert_management_metric_image) } + end + + shared_examples 'allows metric image updates' do + specify do + expect(policy).to be_allowed(:upload_alert_management_metric_image) + expect(policy).to be_allowed(:destroy_alert_management_metric_image) + end + end + + context 'when user is not a member' do + include_examples 'does not allow metric image reads' + include_examples 'does not allow metric image updates' + end + + context 'when user is a guest' do + before do + project.add_guest(user) + end + + include_examples 'does not allow metric image reads' + include_examples 'does not allow metric image updates' + end + + context 'when user is a developer' do + before do + project.add_developer(user) + end + + include_examples 'allows metric image reads' + include_examples 'allows metric image updates' + end end end diff --git a/spec/policies/note_policy_spec.rb b/spec/policies/note_policy_spec.rb index f6cd84f29ae..eeaa77a4589 100644 --- a/spec/policies/note_policy_spec.rb +++ b/spec/policies/note_policy_spec.rb @@ -359,39 +359,6 @@ RSpec.describe NotePolicy do expect(permissions(assignee, confidential_note)).to be_disallowed(:admin_note, :reposition_note, :resolve_note) end end - - context 'for merge requests' do - let(:merge_request) { create(:merge_request, source_project: project, author: author, assignees: [assignee]) } - let(:confidential_note) { create(:note, :confidential, project: project, noteable: merge_request) } - - it_behaves_like 'confidential notes permissions' - - it 'allows noteable assignees to read all notes' do - expect(permissions(assignee, confidential_note)).to be_allowed(:read_note, :award_emoji) - expect(permissions(assignee, confidential_note)).to be_disallowed(:admin_note, :reposition_note, :resolve_note) - end - end - - context 'for project snippets' do - let(:project_snippet) { create(:project_snippet, project: project, author: author) } - let(:confidential_note) { create(:note, :confidential, project: project, noteable: project_snippet) } - - it_behaves_like 'confidential notes permissions' - end - - context 'for personal snippets' do - let(:personal_snippet) { create(:personal_snippet, author: author) } - let(:confidential_note) { create(:note, :confidential, project: nil, noteable: personal_snippet) } - - it 'allows snippet author to read and resolve all notes' do - expect(permissions(author, confidential_note)).to be_allowed(:read_note, :resolve_note, :award_emoji) - expect(permissions(author, confidential_note)).to be_disallowed(:admin_note, :reposition_note) - end - - it 'does not allow maintainers to read confidential notes and replies' do - expect(permissions(maintainer, confidential_note)).to be_disallowed(:read_note, :admin_note, :reposition_note, :resolve_note, :award_emoji) - end - end end end end diff --git a/spec/policies/project_member_policy_spec.rb b/spec/policies/project_member_policy_spec.rb index 12b3e60fdb2..b19ab71fcb5 100644 --- a/spec/policies/project_member_policy_spec.rb +++ b/spec/policies/project_member_policy_spec.rb @@ -23,9 +23,9 @@ RSpec.describe ProjectMemberPolicy do it { is_expected.not_to be_allowed(:destroy_project_bot_member) } end - context 'when user is project owner' do - let(:member_user) { project.first_owner } - let(:member) { project.members.find_by!(user: member_user) } + context 'when user is the holder of personal namespace in which the project resides' do + let(:namespace_holder) { project.namespace.owner } + let(:member) { project.members.find_by!(user: namespace_holder) } it { is_expected.to be_allowed(:read_project) } it { is_expected.to be_disallowed(:update_project_member) } diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index fb1c5874335..bde83d647db 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -346,6 +346,36 @@ RSpec.describe ProjectPolicy do end end + context 'reading usage quotas' do + %w(maintainer owner).each do |role| + context "with #{role}" do + let(:current_user) { send(role) } + + it { is_expected.to be_allowed(:read_usage_quotas) } + end + end + + %w(guest reporter developer anonymous).each do |role| + context "with #{role}" do + let(:current_user) { send(role) } + + it { is_expected.to be_disallowed(:read_usage_quotas) } + end + end + + context 'with an admin' do + let(:current_user) { admin } + + context 'when admin mode is enabled', :enable_admin_mode do + it { expect_allowed(:read_usage_quotas) } + end + + context 'when admin mode is disabled' do + it { expect_disallowed(:read_usage_quotas) } + end + end + end + it_behaves_like 'clusterable policies' do let_it_be(:clusterable) { create(:project, :repository) } let_it_be(:cluster) do diff --git a/spec/presenters/ci/bridge_presenter_spec.rb b/spec/presenters/ci/bridge_presenter_spec.rb index 6291c3426e2..bd6c4777d0c 100644 --- a/spec/presenters/ci/bridge_presenter_spec.rb +++ b/spec/presenters/ci/bridge_presenter_spec.rb @@ -3,9 +3,10 @@ require 'spec_helper' RSpec.describe Ci::BridgePresenter do + let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) } - let_it_be(:bridge) { create(:ci_bridge, pipeline: pipeline, status: :failed) } + let_it_be(:bridge) { create(:ci_bridge, pipeline: pipeline, status: :failed, user: user) } subject(:presenter) do described_class.new(bridge) @@ -14,4 +15,10 @@ RSpec.describe Ci::BridgePresenter do it 'presents information about recoverable state' do expect(presenter).to be_recoverable end + + it 'presents the detailed status for the user' do + expect(bridge).to receive(:detailed_status).with(user) + + presenter.detailed_status + end end diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb index d25102532a7..ace65307321 100644 --- a/spec/presenters/ci/build_runner_presenter_spec.rb +++ b/spec/presenters/ci/build_runner_presenter_spec.rb @@ -78,16 +78,72 @@ RSpec.describe Ci::BuildRunnerPresenter do artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(file_type), paths: [filename], when: 'always' - } + }.compact end it 'presents correct hash' do - expect(presenter.artifacts.first).to include(report_expectation) + expect(presenter.artifacts).to contain_exactly(report_expectation) end end end end + context 'when a specific coverage_report type is given' do + let(:coverage_format) { :cobertura } + let(:filename) { 'cobertura-coverage.xml' } + let(:coverage_report) { { path: filename, coverage_format: coverage_format } } + let(:report) { { coverage_report: coverage_report } } + let(:build) { create(:ci_build, options: { artifacts: { reports: report } }) } + + let(:expected_coverage_report) do + { + name: filename, + artifact_type: coverage_format, + artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(coverage_format), + paths: [filename], + when: 'always' + } + end + + it 'presents the coverage report hash with the coverage format' do + expect(presenter.artifacts).to contain_exactly(expected_coverage_report) + end + end + + context 'when a specific coverage_report type is given with another report type' do + let(:coverage_format) { :cobertura } + let(:coverage_filename) { 'cobertura-coverage.xml' } + let(:coverage_report) { { path: coverage_filename, coverage_format: coverage_format } } + let(:ds_filename) { 'gl-dependency-scanning-report.json' } + + let(:report) { { coverage_report: coverage_report, dependency_scanning: [ds_filename] } } + let(:build) { create(:ci_build, options: { artifacts: { reports: report } }) } + + let(:expected_coverage_report) do + { + name: coverage_filename, + artifact_type: coverage_format, + artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(coverage_format), + paths: [coverage_filename], + when: 'always' + } + end + + let(:expected_ds_report) do + { + name: ds_filename, + artifact_type: :dependency_scanning, + artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(:dependency_scanning), + paths: [ds_filename], + when: 'always' + } + end + + it 'presents both reports' do + expect(presenter.artifacts).to contain_exactly(expected_coverage_report, expected_ds_report) + end + end + context "when option has both archive and reports specification" do let(:report) { { junit: ['junit.xml'] } } let(:build) { create(:ci_build, options: { script: 'echo', artifacts: { **archive, reports: report } }) } diff --git a/spec/presenters/gitlab/blame_presenter_spec.rb b/spec/presenters/gitlab/blame_presenter_spec.rb index b163926154b..ff128416692 100644 --- a/spec/presenters/gitlab/blame_presenter_spec.rb +++ b/spec/presenters/gitlab/blame_presenter_spec.rb @@ -27,6 +27,14 @@ RSpec.describe Gitlab::BlamePresenter do end end + describe '#first_line' do + it 'delegates #first_line call to the blame' do + expect(blame).to receive(:first_line).at_least(:once).and_call_original + + subject.first_line + end + end + describe '#commit_data' do it 'has the data necessary to render the view' do commit = blame.groups.first[:commit] @@ -37,9 +45,28 @@ RSpec.describe Gitlab::BlamePresenter do expect(data.age_map_class).to include('blame-commit-age-') expect(data.commit_link.to_s).to include '913c66a37b4a45b9769037c55c2d238bd0942d2e">Files, encoding and much more</a>' expect(data.commit_author_link.to_s).to include('<a class="commit-author-link" href=') - expect(data.project_blame_link.to_s).to include('<a title="View blame prior to this change"') expect(data.time_ago_tooltip.to_s).to include('data-container="body">Feb 27, 2014</time>') end end + + context 'renamed file' do + let(:path) { 'files/plain_text/renamed' } + let(:commit) { project.commit('blame-on-renamed') } + + it 'does not generate link to previous blame on initial commit' do + commit = blame.groups[0][:commit] + data = subject.commit_data(commit) + + expect(data.project_blame_link.to_s).to eq('') + end + + it 'generates link link to previous blame' do + commit = blame.groups[1][:commit] + data = subject.commit_data(commit) + + expect(data.project_blame_link.to_s).to include('<a title="View blame prior to this change"') + expect(data.project_blame_link.to_s).to include('/blame/405a45736a75e439bb059e638afaa9a3c2eeda79/files/plain_text/initial-commit') + end + end end end diff --git a/spec/presenters/issue_presenter_spec.rb b/spec/presenters/issue_presenter_spec.rb index 55a6b50ffa7..e17ae218cd3 100644 --- a/spec/presenters/issue_presenter_spec.rb +++ b/spec/presenters/issue_presenter_spec.rb @@ -5,19 +5,42 @@ require 'spec_helper' RSpec.describe IssuePresenter do include Gitlab::Routing.url_helpers - let(:user) { create(:user) } - let(:group) { create(:group) } - let(:project) { create(:project, group: group) } - let(:issue) { create(:issue, project: project) } - let(:presenter) { described_class.new(issue, current_user: user) } + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:task) { create(:issue, :task, project: project) } - before do + let(:presented_issue) { issue } + let(:presenter) { described_class.new(presented_issue, current_user: user) } + + before_all do group.add_developer(user) end describe '#web_url' do it 'returns correct path' do - expect(presenter.web_url).to eq("http://localhost/#{group.name}/#{project.name}/-/issues/#{issue.iid}") + expect(presenter.web_url).to eq("http://localhost/#{group.name}/#{project.name}/-/issues/#{presented_issue.iid}") + end + + context 'when issue type is task' do + let(:presented_issue) { task } + + context 'when work_items feature flag is enabled' do + it 'returns a work item url for the task' do + expect(presenter.web_url).to eq(project_work_items_url(project, work_items_path: presented_issue.id)) + end + end + + context 'when work_items feature flag is disabled' do + before do + stub_feature_flags(work_items: false) + end + + it 'returns an issue url for the task' do + expect(presenter.web_url).to eq("http://localhost/#{group.name}/#{project.name}/-/issues/#{presented_issue.iid}") + end + end end end @@ -29,7 +52,7 @@ RSpec.describe IssuePresenter do end it 'returns subscribed' do - create(:subscription, user: user, project: project, subscribable: issue, subscribed: true) + create(:subscription, user: user, project: project, subscribable: presented_issue, subscribed: true) is_expected.to be(true) end @@ -37,7 +60,27 @@ RSpec.describe IssuePresenter do describe '#issue_path' do it 'returns correct path' do - expect(presenter.issue_path).to eq("/#{group.name}/#{project.name}/-/issues/#{issue.iid}") + expect(presenter.issue_path).to eq("/#{group.name}/#{project.name}/-/issues/#{presented_issue.iid}") + end + + context 'when issue type is task' do + let(:presented_issue) { task } + + context 'when work_items feature flag is enabled' do + it 'returns a work item path for the task' do + expect(presenter.issue_path).to eq(project_work_items_path(project, work_items_path: presented_issue.id)) + end + end + + context 'when work_items feature flag is disabled' do + before do + stub_feature_flags(work_items: false) + end + + it 'returns an issue path for the task' do + expect(presenter.issue_path).to eq("/#{group.name}/#{project.name}/-/issues/#{presented_issue.iid}") + end + end end end diff --git a/spec/presenters/project_clusterable_presenter_spec.rb b/spec/presenters/project_clusterable_presenter_spec.rb index 900630bb6e2..bd4319c9411 100644 --- a/spec/presenters/project_clusterable_presenter_spec.rb +++ b/spec/presenters/project_clusterable_presenter_spec.rb @@ -49,6 +49,12 @@ RSpec.describe ProjectClusterablePresenter do it { is_expected.to eq(connect_project_clusters_path(project)) } end + describe '#new_cluster_docs_path' do + subject { presenter.new_cluster_docs_path } + + it { is_expected.to eq(new_cluster_docs_project_clusters_path(project)) } + end + describe '#authorize_aws_role_path' do subject { presenter.authorize_aws_role_path } diff --git a/spec/presenters/projects/security/configuration_presenter_spec.rb b/spec/presenters/projects/security/configuration_presenter_spec.rb index 47ef0cf1192..779d6b88fd5 100644 --- a/spec/presenters/projects/security/configuration_presenter_spec.rb +++ b/spec/presenters/projects/security/configuration_presenter_spec.rb @@ -87,6 +87,7 @@ RSpec.describe Projects::Security::ConfigurationPresenter do expect(feature['configuration_path']).to be_nil expect(feature['available']).to eq(true) expect(feature['can_enable_by_merge_request']).to eq(true) + expect(feature['meta_info_path']).to be_nil end context 'when checking features configured status' do diff --git a/spec/requests/admin/background_migrations_controller_spec.rb b/spec/requests/admin/background_migrations_controller_spec.rb index 55971a00e55..9933008502f 100644 --- a/spec/requests/admin/background_migrations_controller_spec.rb +++ b/spec/requests/admin/background_migrations_controller_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Admin::BackgroundMigrationsController, :enable_admin_mode do end describe 'POST #retry' do - let(:migration) { create(:batched_background_migration, status: 'failed') } + let(:migration) { create(:batched_background_migration, :failed) } before do create(:batched_background_migration_job, :failed, batched_migration: migration, batch_size: 10, min_value: 6, max_value: 15, attempts: 3) @@ -37,11 +37,11 @@ RSpec.describe Admin::BackgroundMigrationsController, :enable_admin_mode do it 'retries the migration' do retry_migration - expect(migration.reload.status).to eql 'active' + expect(migration.reload.status_name).to be :active end context 'when the migration is not failed' do - let(:migration) { create(:batched_background_migration, status: 'paused') } + let(:migration) { create(:batched_background_migration, :paused) } it 'keeps the same migration status' do expect { retry_migration }.not_to change { migration.reload.status } diff --git a/spec/requests/api/alert_management_alerts_spec.rb b/spec/requests/api/alert_management_alerts_spec.rb new file mode 100644 index 00000000000..99293e5ae95 --- /dev/null +++ b/spec/requests/api/alert_management_alerts_spec.rb @@ -0,0 +1,411 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::AlertManagementAlerts do + let_it_be(:creator) { create(:user) } + let_it_be(:project) do + create(:project, :public, creator_id: creator.id, namespace: creator.namespace) + end + + let_it_be(:user) { create(:user) } + let_it_be(:alert) { create(:alert_management_alert, project: project) } + + describe 'PUT /projects/:id/alert_management_alerts/:alert_iid/metric_images/authorize' do + include_context 'workhorse headers' + + before do + project.add_developer(user) + end + + subject do + post api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images/authorize", user), + headers: workhorse_headers + end + + it 'authorizes uploading with workhorse header' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + + it 'rejects requests that bypassed gitlab-workhorse' do + workhorse_headers.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER) + + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + + context 'when using remote storage' do + context 'when direct upload is enabled' do + before do + stub_uploads_object_storage(MetricImageUploader, enabled: true, direct_upload: true) + end + + it 'responds with status 200, location of file remote store and object details' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response).not_to have_key('TempPath') + expect(json_response['RemoteObject']).to have_key('ID') + expect(json_response['RemoteObject']).to have_key('GetURL') + expect(json_response['RemoteObject']).to have_key('StoreURL') + expect(json_response['RemoteObject']).to have_key('DeleteURL') + expect(json_response['RemoteObject']).to have_key('MultipartUpload') + end + end + + context 'when direct upload is disabled' do + before do + stub_uploads_object_storage(MetricImageUploader, enabled: true, direct_upload: false) + end + + it 'handles as a local file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).to eq(MetricImageUploader.workhorse_local_upload_path) + expect(json_response['RemoteObject']).to be_nil + end + end + end + end + + describe 'POST /projects/:id/alert_management_alerts/:alert_iid/metric_images' do + include WorkhorseHelpers + using RSpec::Parameterized::TableSyntax + + include_context 'workhorse headers' + + let(:file) { fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') } + let(:file_name) { 'rails_sample.jpg' } + let(:url) { 'http://gitlab.com' } + let(:url_text) { 'GitLab' } + + let(:params) { { url: url, url_text: url_text } } + + subject do + workhorse_finalize( + api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images", user), + method: :post, + file_key: :file, + params: params.merge(file: file), + headers: workhorse_headers, + send_rewritten_field: true + ) + end + + shared_examples 'can_upload_metric_image' do + it 'creates a new metric image' do + subject + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['filename']).to eq(file_name) + expect(json_response['url']).to eq(url) + expect(json_response['url_text']).to eq(url_text) + expect(json_response['created_at']).not_to be_nil + expect(json_response['id']).not_to be_nil + file_path_regex = %r{/uploads/-/system/alert_management_metric_image/file/\d+/#{file_name}} + expect(json_response['file_path']).to match(file_path_regex) + end + end + + shared_examples 'unauthorized_upload' do + it 'disallows the upload' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('403 Forbidden') + end + end + + where(:user_role, :expected_status) do + :guest | :unauthorized_upload + :reporter | :unauthorized_upload + :developer | :can_upload_metric_image + end + + with_them do + before do + # Local storage + stub_uploads_object_storage(MetricImageUploader, enabled: false) + allow_next_instance_of(MetricImageUploader) do |uploader| + allow(uploader).to receive(:file_storage?).and_return(true) + end + + project.send("add_#{user_role}", user) + end + + it_behaves_like "#{params[:expected_status]}" + end + + context 'file size too large' do + before do + allow_next_instance_of(UploadedFile) do |upload_file| + allow(upload_file).to receive(:size).and_return(AlertManagement::MetricImage::MAX_FILE_SIZE + 1) + end + end + + it 'returns an error' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + expect(response.body).to match(/File is too large/) + end + end + + context 'error when saving' do + before do + project.add_developer(user) + + allow_next_instance_of(::AlertManagement::MetricImages::UploadService) do |service| + error = instance_double(ServiceResponse, success?: false, message: 'some error', http_status: :bad_request) + allow(service).to receive(:execute).and_return(error) + end + end + + it 'returns an error' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + expect(response.body).to match(/some error/) + end + end + + context 'object storage enabled' do + before do + # Object storage + stub_uploads_object_storage(MetricImageUploader) + + allow_next_instance_of(MetricImageUploader) do |uploader| + allow(uploader).to receive(:file_storage?).and_return(true) + end + project.add_developer(user) + end + + it_behaves_like 'can_upload_metric_image' + + it 'uploads to remote storage' do + subject + + last_upload = AlertManagement::MetricImage.last.uploads.last + expect(last_upload.store).to eq(::ObjectStorage::Store::REMOTE) + end + end + end + + describe 'GET /projects/:id/alert_management_alerts/:alert_iid/metric_images' do + using RSpec::Parameterized::TableSyntax + + let!(:image) { create(:alert_metric_image, alert: alert) } + + subject { get api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images", user) } + + shared_examples 'can_read_metric_image' do + it 'can read the metric images' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.first).to match( + { + id: image.id, + created_at: image.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + filename: image.filename, + file_path: image.file_path, + url: image.url, + url_text: nil + }.with_indifferent_access + ) + end + end + + shared_examples 'unauthorized_read' do + it 'cannot read the metric images' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + where(:user_role, :public_project, :expected_status) do + :not_member | false | :unauthorized_read + :not_member | true | :unauthorized_read + :guest | false | :unauthorized_read + :reporter | false | :unauthorized_read + :developer | false | :can_read_metric_image + end + + with_them do + before do + project.send("add_#{user_role}", user) unless user_role == :not_member + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) unless public_project + end + + it_behaves_like "#{params[:expected_status]}" + end + end + + describe 'PUT /projects/:id/alert_management_alerts/:alert_iid/metric_images/:metric_image_id' do + using RSpec::Parameterized::TableSyntax + + let!(:image) { create(:alert_metric_image, alert: alert) } + let(:params) { { url: 'http://test.example.com', url_text: 'Example website 123' } } + + subject do + put api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images/#{image.id}", user), + params: params + end + + shared_examples 'can_update_metric_image' do + it 'can update the metric images' do + subject + + expect(response).to have_gitlab_http_status(:success) + expect(json_response['url']).to eq(params[:url]) + expect(json_response['url_text']).to eq(params[:url_text]) + end + end + + shared_examples 'unauthorized_update' do + it 'cannot update the metric image' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + expect(image.reload).to eq(image) + end + end + + where(:user_role, :public_project, :expected_status) do + :not_member | false | :unauthorized_update + :not_member | true | :unauthorized_update + :guest | false | :unauthorized_update + :reporter | false | :unauthorized_update + :developer | false | :can_update_metric_image + end + + with_them do + before do + project.send("add_#{user_role}", user) unless user_role == :not_member + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) unless public_project + end + + it_behaves_like "#{params[:expected_status]}" + end + + context 'when user has access' do + before do + project.add_developer(user) + end + + context 'and metric image not found' do + subject do + put api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images/#{non_existing_record_id}", user) # rubocop: disable Layout/LineLength + end + + it 'returns an error' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('Metric image not found') + end + end + + context 'metric image cannot be updated' do + let(:params) { { url_text: 'something_long' * 100 } } + + it 'returns an error' do + subject + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response['message']).to eq('Metric image could not be updated') + end + end + end + end + + describe 'DELETE /projects/:id/alert_management_alerts/:alert_iid/metric_images/:metric_image_id' do + using RSpec::Parameterized::TableSyntax + + let!(:image) { create(:alert_metric_image, alert: alert) } + + subject do + delete api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images/#{image.id}", user) + end + + shared_examples 'can delete metric image successfully' do + it 'can delete the metric images' do + subject + + expect(response).to have_gitlab_http_status(:no_content) + expect { image.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + shared_examples 'unauthorized delete' do + it 'cannot delete the metric image' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + expect(image.reload).to eq(image) + end + end + + where(:user_role, :public_project, :expected_status) do + :not_member | false | 'unauthorized delete' + :not_member | true | 'unauthorized delete' + :guest | false | 'unauthorized delete' + :reporter | false | 'unauthorized delete' + :developer | false | 'can delete metric image successfully' + end + + with_them do + before do + project.send("add_#{user_role}", user) unless user_role == :not_member + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) unless public_project + end + + it_behaves_like "#{params[:expected_status]}" + end + + context 'when user has access' do + before do + project.add_developer(user) + end + + context 'when metric image not found' do + subject do + delete api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images/#{non_existing_record_id}", user) # rubocop: disable Layout/LineLength + end + + it 'returns an error' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('Metric image not found') + end + end + + context 'when error when deleting' do + before do + allow_next_instance_of(AlertManagement::AlertsFinder) do |finder| + allow(finder).to receive(:execute).and_return([alert]) + end + + allow(alert).to receive_message_chain('metric_images.find_by_id') { image } + allow(image).to receive(:destroy).and_return(false) + end + + it 'returns an error' do + subject + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response['message']).to eq('Metric image could not be deleted') + end + end + end + end +end diff --git a/spec/requests/api/award_emoji_spec.rb b/spec/requests/api/award_emoji_spec.rb index 07a9f7dfd74..782e14593f7 100644 --- a/spec/requests/api/award_emoji_spec.rb +++ b/spec/requests/api/award_emoji_spec.rb @@ -26,6 +26,23 @@ RSpec.describe API::AwardEmoji do expect(json_response.first['name']).to eq(award_emoji.name) end + it "includes custom emoji attributes" do + group = create(:group) + group.add_maintainer(user) + + project = create(:project, namespace: group) + custom_emoji = create(:custom_emoji, name: 'partyparrot', namespace: group) + issue = create(:issue, project: project) + create(:award_emoji, awardable: issue, user: user, name: custom_emoji.name) + + get api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(json_response.first['name']).to eq(custom_emoji.name) + expect(json_response.first['url']).to eq(custom_emoji.file) + end + it "returns a 404 error when issue id not found" do get api("/projects/#{project.id}/issues/#{non_existing_record_iid}/award_emoji", user) diff --git a/spec/requests/api/bulk_imports_spec.rb b/spec/requests/api/bulk_imports_spec.rb index 1602819a02e..3b8136f265b 100644 --- a/spec/requests/api/bulk_imports_spec.rb +++ b/spec/requests/api/bulk_imports_spec.rb @@ -18,6 +18,29 @@ RSpec.describe API::BulkImports do expect(response).to have_gitlab_http_status(:ok) expect(json_response.pluck('id')).to contain_exactly(import_1.id, import_2.id) end + + context 'sort parameter' do + it 'sorts by created_at descending by default' do + get api('/bulk_imports', user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq([import_2.id, import_1.id]) + end + + it 'sorts by created_at descending when explicitly specified' do + get api('/bulk_imports', user), params: { sort: 'desc' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq([import_2.id, import_1.id]) + end + + it 'sorts by created_at ascending when explicitly specified' do + get api('/bulk_imports', user), params: { sort: 'asc' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('id')).to eq([import_1.id, import_2.id]) + end + end end describe 'POST /bulk_imports' do diff --git a/spec/requests/api/ci/job_artifacts_spec.rb b/spec/requests/api/ci/job_artifacts_spec.rb index 0db6acbc7b8..5abff85af9c 100644 --- a/spec/requests/api/ci/job_artifacts_spec.rb +++ b/spec/requests/api/ci/job_artifacts_spec.rb @@ -82,18 +82,6 @@ RSpec.describe API::Ci::JobArtifacts do end describe 'DELETE /projects/:id/artifacts' do - context 'when feature flag is disabled' do - before do - stub_feature_flags(bulk_expire_project_artifacts: false) - end - - it 'returns 404' do - delete api("/projects/#{project.id}/artifacts", api_user) - - expect(response).to have_gitlab_http_status(:not_found) - end - end - context 'when user is anonymous' do let(:api_user) { nil } @@ -236,6 +224,8 @@ RSpec.describe API::Ci::JobArtifacts do expect(response.headers.to_h) .to include('Content-Type' => 'application/json', 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/) + expect(response.headers.to_h) + .not_to include('Gitlab-Workhorse-Detect-Content-Type' => 'true') expect(response.parsed_body).to be_empty end @@ -568,7 +558,8 @@ RSpec.describe API::Ci::JobArtifacts do expect(response).to have_gitlab_http_status(:ok) expect(response.headers.to_h) .to include('Content-Type' => 'application/json', - 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/) + 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/, + 'Gitlab-Workhorse-Detect-Content-Type' => 'true') end end @@ -638,7 +629,8 @@ RSpec.describe API::Ci::JobArtifacts do expect(response).to have_gitlab_http_status(:ok) expect(response.headers.to_h) .to include('Content-Type' => 'application/json', - 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/) + 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/, + 'Gitlab-Workhorse-Detect-Content-Type' => 'true') expect(response.parsed_body).to be_empty end end @@ -656,7 +648,8 @@ RSpec.describe API::Ci::JobArtifacts do expect(response).to have_gitlab_http_status(:ok) expect(response.headers.to_h) .to include('Content-Type' => 'application/json', - 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/) + 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/, + 'Gitlab-Workhorse-Detect-Content-Type' => 'true') end end diff --git a/spec/requests/api/ci/jobs_spec.rb b/spec/requests/api/ci/jobs_spec.rb index f6dae7e8e23..d3820e4948e 100644 --- a/spec/requests/api/ci/jobs_spec.rb +++ b/spec/requests/api/ci/jobs_spec.rb @@ -623,6 +623,15 @@ RSpec.describe API::Ci::Jobs do end end + context 'when a build is not retryable' do + let(:job) { create(:ci_build, :created, pipeline: pipeline) } + + it 'responds with unprocessable entity' do + expect(json_response['message']).to eq('403 Forbidden - Job is not retryable') + expect(response).to have_gitlab_http_status(:forbidden) + end + end + context 'user without :update_build permission' do let(:api_user) { reporter } diff --git a/spec/requests/api/ci/runner/jobs_request_post_spec.rb b/spec/requests/api/ci/runner/jobs_request_post_spec.rb index d317386dc73..9e6bac41d59 100644 --- a/spec/requests/api/ci/runner/jobs_request_post_spec.rb +++ b/spec/requests/api/ci/runner/jobs_request_post_spec.rb @@ -216,7 +216,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do expect(json_response['token']).to eq(job.token) expect(json_response['job_info']).to eq(expected_job_info) expect(json_response['git_info']).to eq(expected_git_info) - expect(json_response['image']).to eq({ 'name' => 'ruby:2.7', 'entrypoint' => '/bin/sh', 'ports' => [] }) + expect(json_response['image']).to eq({ 'name' => 'image:1.0', 'entrypoint' => '/bin/sh', 'ports' => [] }) expect(json_response['services']).to eq([{ 'name' => 'postgres', 'entrypoint' => nil, 'alias' => nil, 'command' => nil, 'ports' => [], 'variables' => nil }, { 'name' => 'docker:stable-dind', 'entrypoint' => '/bin/sh', @@ -611,6 +611,40 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do end end + context 'when job has code coverage report' do + let(:job) do + create(:ci_build, :pending, :queued, :coverage_report_cobertura, + pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) + end + + let(:expected_artifacts) do + [ + { + 'name' => 'cobertura-coverage.xml', + 'paths' => ['cobertura.xml'], + 'when' => 'always', + 'expire_in' => '7d', + "artifact_type" => "cobertura", + "artifact_format" => "gzip" + } + ] + end + + it 'returns job with the correct artifact specification', :aggregate_failures do + request_job info: { platform: :darwin, features: { upload_multiple_artifacts: true } } + + expect(response).to have_gitlab_http_status(:created) + expect(response.headers['Content-Type']).to eq('application/json') + expect(response.headers).not_to have_key('X-GitLab-Last-Update') + expect(runner.reload.platform).to eq('darwin') + expect(json_response['id']).to eq(job.id) + expect(json_response['token']).to eq(job.token) + expect(json_response['job_info']).to eq(expected_job_info) + expect(json_response['git_info']).to eq(expected_git_info) + expect(json_response['artifacts']).to eq(expected_artifacts) + end + end + context 'when triggered job is available' do let(:expected_variables) do [{ 'key' => 'CI_JOB_NAME', 'value' => 'spinach', 'public' => true, 'masked' => false }, @@ -819,11 +853,11 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do subject { request_job(id: job.id) } it_behaves_like 'storing arguments in the application context for the API' do - let(:expected_params) { { user: user.username, project: project.full_path, client_id: "user/#{user.id}" } } + let(:expected_params) { { user: user.username, project: project.full_path, client_id: "runner/#{runner.id}", job_id: job.id, pipeline_id: job.pipeline_id } } end - it_behaves_like 'not executing any extra queries for the application context', 3 do - # Extra queries: User, Project, Route + it_behaves_like 'not executing any extra queries for the application context', 4 do + # Extra queries: User, Project, Route, Runner let(:subject_proc) { proc { request_job(id: job.id) } } end end diff --git a/spec/requests/api/ci/secure_files_spec.rb b/spec/requests/api/ci/secure_files_spec.rb index aa479cb8713..6de6d1ef222 100644 --- a/spec/requests/api/ci/secure_files_spec.rb +++ b/spec/requests/api/ci/secure_files_spec.rb @@ -6,15 +6,24 @@ RSpec.describe API::Ci::SecureFiles do before do stub_ci_secure_file_object_storage stub_feature_flags(ci_secure_files: true) + stub_feature_flags(ci_secure_files_read_only: false) end let_it_be(:maintainer) { create(:user) } let_it_be(:developer) { create(:user) } let_it_be(:guest) { create(:user) } let_it_be(:anonymous) { create(:user) } + let_it_be(:unconfirmed) { create(:user, :unconfirmed) } let_it_be(:project) { create(:project, creator_id: maintainer.id) } let_it_be(:secure_file) { create(:ci_secure_file, project: project) } + let(:file_params) do + { + file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'), + name: 'upload-keystore.jks' + } + end + before_all do project.add_maintainer(maintainer) project.add_developer(developer) @@ -39,6 +48,43 @@ RSpec.describe API::Ci::SecureFiles do end end + context 'ci_secure_files_read_only feature flag' do + context 'when the flag is enabled' do + before do + stub_feature_flags(ci_secure_files_read_only: true) + end + + it 'returns a 503 when attempting to upload a file' do + stub_feature_flags(ci_secure_files_read_only: true) + + expect do + post api("/projects/#{project.id}/secure_files", maintainer), params: file_params + end.not_to change {project.secure_files.count} + + expect(response).to have_gitlab_http_status(:service_unavailable) + end + + it 'returns a 200 when downloading a file' do + stub_feature_flags(ci_secure_files_read_only: true) + + get api("/projects/#{project.id}/secure_files", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_a(Array) + end + end + + context 'when the flag is disabled' do + it 'returns a 201 when uploading a file when the ci_secure_files_read_only feature flag is disabled' do + expect do + post api("/projects/#{project.id}/secure_files", maintainer), params: file_params + end.to change {project.secure_files.count}.by(1) + + expect(response).to have_gitlab_http_status(:created) + end + end + end + context 'authenticated user with admin permissions' do it 'returns project secure files' do get api("/projects/#{project.id}/secure_files", maintainer) @@ -73,6 +119,14 @@ RSpec.describe API::Ci::SecureFiles do end end + context 'unconfirmed user' do + it 'does not return project secure files' do + get api("/projects/#{project.id}/secure_files", unconfirmed) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + context 'unauthenticated user' do it 'does not return project secure files' do get api("/projects/#{project.id}/secure_files") @@ -117,6 +171,14 @@ RSpec.describe API::Ci::SecureFiles do end end + context 'unconfirmed user' do + it 'does not return project secure file details' do + get api("/projects/#{project.id}/secure_files/#{secure_file.id}", unconfirmed) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + context 'unauthenticated user' do it 'does not return project secure file details' do get api("/projects/#{project.id}/secure_files/#{secure_file.id}") @@ -167,6 +229,14 @@ RSpec.describe API::Ci::SecureFiles do end end + context 'unconfirmed user' do + it 'does not return project secure file details' do + get api("/projects/#{project.id}/secure_files/#{secure_file.id}/download", unconfirmed) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + context 'unauthenticated user' do it 'does not return project secure file details' do get api("/projects/#{project.id}/secure_files/#{secure_file.id}/download") @@ -179,14 +249,8 @@ RSpec.describe API::Ci::SecureFiles do describe 'POST /projects/:id/secure_files' do context 'authenticated user with admin permissions' do it 'creates a secure file' do - params = { - file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'), - name: 'upload-keystore.jks', - permissions: 'execute' - } - expect do - post api("/projects/#{project.id}/secure_files", maintainer), params: params + post api("/projects/#{project.id}/secure_files", maintainer), params: file_params.merge(permissions: 'execute') end.to change {project.secure_files.count}.by(1) expect(response).to have_gitlab_http_status(:created) @@ -204,26 +268,15 @@ RSpec.describe API::Ci::SecureFiles do end it 'creates a secure file with read_only permissions by default' do - params = { - file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'), - name: 'upload-keystore.jks' - } - expect do - post api("/projects/#{project.id}/secure_files", maintainer), params: params + post api("/projects/#{project.id}/secure_files", maintainer), params: file_params end.to change {project.secure_files.count}.by(1) expect(json_response['permissions']).to eq('read_only') end it 'uploads and downloads a secure file' do - post_params = { - file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'), - name: 'upload-keystore.jks', - permissions: 'read_write' - } - - post api("/projects/#{project.id}/secure_files", maintainer), params: post_params + post api("/projects/#{project.id}/secure_files", maintainer), params: file_params secure_file_id = json_response['id'] @@ -243,12 +296,8 @@ RSpec.describe API::Ci::SecureFiles do end it 'returns an error when no file is uploaded' do - post_params = { - name: 'upload-keystore.jks' - } - expect do - post api("/projects/#{project.id}/secure_files", maintainer), params: post_params + post api("/projects/#{project.id}/secure_files", maintainer), params: { name: 'upload-keystore.jks' } end.not_to change { project.secure_files.count } expect(response).to have_gitlab_http_status(:bad_request) @@ -256,7 +305,17 @@ RSpec.describe API::Ci::SecureFiles do end it 'returns an error when the file name is missing' do + expect do + post api("/projects/#{project.id}/secure_files", maintainer), params: { file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks') } + end.not_to change { project.secure_files.count } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('name is missing') + end + + it 'returns an error when the file name has already been used' do post_params = { + name: secure_file.name, file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks') } @@ -265,18 +324,12 @@ RSpec.describe API::Ci::SecureFiles do end.not_to change { project.secure_files.count } expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['error']).to eq('name is missing') + expect(json_response['message']['name']).to include('has already been taken') end it 'returns an error when an unexpected permission is supplied' do - post_params = { - file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'), - name: 'upload-keystore.jks', - permissions: 'foo' - } - expect do - post api("/projects/#{project.id}/secure_files", maintainer), params: post_params + post api("/projects/#{project.id}/secure_files", maintainer), params: file_params.merge(permissions: 'foo') end.not_to change { project.secure_files.count } expect(response).to have_gitlab_http_status(:bad_request) @@ -290,13 +343,8 @@ RSpec.describe API::Ci::SecureFiles do allow(instance).to receive_message_chain(:errors, :messages).and_return(['Error 1', 'Error 2']) end - post_params = { - file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'), - name: 'upload-keystore.jks' - } - expect do - post api("/projects/#{project.id}/secure_files", maintainer), params: post_params + post api("/projects/#{project.id}/secure_files", maintainer), params: file_params end.not_to change { project.secure_files.count } expect(response).to have_gitlab_http_status(:bad_request) @@ -307,13 +355,8 @@ RSpec.describe API::Ci::SecureFiles do allow(instance).to receive_message_chain(:file, :size).and_return(6.megabytes.to_i) end - post_params = { - file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'), - name: 'upload-keystore.jks' - } - expect do - post api("/projects/#{project.id}/secure_files", maintainer), params: post_params + post api("/projects/#{project.id}/secure_files", maintainer), params: file_params end.not_to change { project.secure_files.count } expect(response).to have_gitlab_http_status(:payload_too_large) @@ -340,6 +383,16 @@ RSpec.describe API::Ci::SecureFiles do end end + context 'unconfirmed user' do + it 'does not create a secure file' do + expect do + post api("/projects/#{project.id}/secure_files", unconfirmed) + end.not_to change { project.secure_files.count } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + context 'unauthenticated user' do it 'does not create a secure file' do expect do @@ -390,6 +443,16 @@ RSpec.describe API::Ci::SecureFiles do end end + context 'unconfirmed user' do + it 'does not delete the secure_file' do + expect do + delete api("/projects/#{project.id}/secure_files#{secure_file.id}", unconfirmed) + end.not_to change { project.secure_files.count } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + context 'unauthenticated user' do it 'does not delete the secure_file' do expect do diff --git a/spec/requests/api/clusters/agents_spec.rb b/spec/requests/api/clusters/agents_spec.rb new file mode 100644 index 00000000000..e29be255289 --- /dev/null +++ b/spec/requests/api/clusters/agents_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Clusters::Agents do + let_it_be(:agent) { create(:cluster_agent) } + + let(:user) { agent.created_by_user } + let(:unauthorized_user) { create(:user) } + let!(:project) { agent.project } + + before do + project.add_maintainer(user) + end + + describe 'GET /projects/:id/cluster_agents' do + context 'authorized user' do + it 'returns project agents' do + get api("/projects/#{project.id}/cluster_agents", user) + + aggregate_failures "testing response" do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(response).to match_response_schema('public_api/v4/agents') + expect(json_response.count).to eq(1) + expect(json_response.first['name']).to eq(agent.name) + end + end + end + + context 'unauthorized user' do + it 'unable to access agents' do + get api("/projects/#{project.id}/cluster_agents", unauthorized_user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + it 'avoids N+1 queries', :request_store do + # Establish baseline + get api("/projects/#{project.id}/cluster_agents", user) + + control = ActiveRecord::QueryRecorder.new do + get api("/projects/#{project.id}/cluster_agents", user) + end + + # Now create a second record and ensure that the API does not execute + # any more queries than before + create(:cluster_agent, project: project) + + expect do + get api("/projects/#{project.id}/cluster_agents", user) + end.not_to exceed_query_limit(control) + end + end + + describe 'GET /projects/:id/cluster_agents/:agent_id' do + context 'authorized user' do + it 'returns a project agent' do + get api("/projects/#{project.id}/cluster_agents/#{agent.id}", user) + + aggregate_failures "testing response" do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/agent') + expect(json_response['name']).to eq(agent.name) + end + end + + it 'returns a 404 error if agent id is not available' do + get api("/projects/#{project.id}/cluster_agents/#{non_existing_record_id}", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'unauthorized user' do + it 'unable to access an existing agent' do + get api("/projects/#{project.id}/cluster_agents/#{agent.id}", unauthorized_user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'POST /projects/:id/cluster_agents' do + it 'adds agent to project' do + expect do + post(api("/projects/#{project.id}/cluster_agents", user), + params: { name: 'some-agent' }) + end.to change {project.cluster_agents.count}.by(1) + + aggregate_failures "testing response" do + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/agent') + expect(json_response['name']).to eq('some-agent') + end + end + + it 'returns a 400 error if name not given' do + post api("/projects/#{project.id}/cluster_agents", user) + + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'returns a 400 error if name is invalid' do + post api("/projects/#{project.id}/cluster_agents", user), params: { name: '#4^x' } + + aggregate_failures "testing response" do + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']) + .to include("Name can contain only lowercase letters, digits, and '-', but cannot start or end with '-'") + end + end + + it 'returns 404 error if project does not exist' do + post api("/projects/#{non_existing_record_id}/cluster_agents", user), params: { name: 'some-agent' } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + describe 'DELETE /projects/:id/cluster_agents/:agent_id' do + it 'deletes agent from project' do + expect do + delete api("/projects/#{project.id}/cluster_agents/#{agent.id}", user) + + expect(response).to have_gitlab_http_status(:no_content) + end.to change {project.cluster_agents.count}.by(-1) + end + + it 'returns a 404 error when deleting non existent agent' do + delete api("/projects/#{project.id}/cluster_agents/#{non_existing_record_id}", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns a 404 error if agent id not given' do + delete api("/projects/#{project.id}/cluster_agents", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns a 404 if the user is unauthorized to delete' do + delete api("/projects/#{project.id}/cluster_agents/#{agent.id}", unauthorized_user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it_behaves_like '412 response' do + let(:request) { api("/projects/#{project.id}/cluster_agents/#{agent.id}", user) } + end + end +end diff --git a/spec/requests/api/composer_packages_spec.rb b/spec/requests/api/composer_packages_spec.rb index bc30fc3b230..53f3ef10743 100644 --- a/spec/requests/api/composer_packages_spec.rb +++ b/spec/requests/api/composer_packages_spec.rb @@ -430,11 +430,23 @@ RSpec.describe API::ComposerPackages do context 'with valid project' do let!(:package) { create(:composer_package, :with_metadatum, name: package_name, project: project) } + let(:headers) { basic_auth_header(user.username, personal_access_token.token) } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end context 'when the sha does not match the package name' do let(:sha) { '123' } + let(:headers) { basic_auth_header(user.username, personal_access_token.token) } - it_behaves_like 'process Composer api request', :anonymous, :not_found + context 'anonymous' do + let(:headers) { {} } + + it_behaves_like 'process Composer api request', :anonymous, :unauthorized + end + + it_behaves_like 'process Composer api request', :developer, :not_found end context 'when the package name does not match the sha' do @@ -442,7 +454,13 @@ RSpec.describe API::ComposerPackages do let(:sha) { branch.target } let(:url) { "/projects/#{project.id}/packages/composer/archives/unexisting-package-name.zip" } - it_behaves_like 'process Composer api request', :anonymous, :not_found + context 'anonymous' do + let(:headers) { {} } + + it_behaves_like 'process Composer api request', :anonymous, :unauthorized + end + + it_behaves_like 'process Composer api request', :developer, :not_found end context 'with a match package name and sha' do @@ -460,14 +478,14 @@ RSpec.describe API::ComposerPackages do 'PUBLIC' | :guest | false | false | :success 'PUBLIC' | :anonymous | false | true | :success 'PRIVATE' | :developer | true | true | :success - 'PRIVATE' | :developer | true | false | :success - 'PRIVATE' | :developer | false | true | :success - 'PRIVATE' | :developer | false | false | :success - 'PRIVATE' | :guest | true | true | :success - 'PRIVATE' | :guest | true | false | :success - 'PRIVATE' | :guest | false | true | :success - 'PRIVATE' | :guest | false | false | :success - 'PRIVATE' | :anonymous | false | true | :success + 'PRIVATE' | :developer | true | false | :unauthorized + 'PRIVATE' | :developer | false | true | :not_found + 'PRIVATE' | :developer | false | false | :unauthorized + 'PRIVATE' | :guest | true | true | :forbidden + 'PRIVATE' | :guest | true | false | :unauthorized + 'PRIVATE' | :guest | false | true | :not_found + 'PRIVATE' | :guest | false | false | :unauthorized + 'PRIVATE' | :anonymous | false | true | :unauthorized end with_them do @@ -480,8 +498,17 @@ RSpec.describe API::ComposerPackages do end it_behaves_like 'process Composer api request', params[:user_role], params[:expected_status], params[:member] - it_behaves_like 'a package tracking event', described_class.name, 'pull_package' + + include_context 'Composer user type', params[:user_role], params[:member] do + if params[:expected_status] == :success + it_behaves_like 'a package tracking event', described_class.name, 'pull_package' + else + it_behaves_like 'not a package tracking event' + end + end end + + it_behaves_like 'Composer publish with deploy tokens' end end diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index 6aa12b6ff48..cb0b5f6bfc3 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -9,6 +9,7 @@ RSpec.describe API::Files do let!(:project) { create(:project, :repository, namespace: user.namespace ) } let(:guest) { create(:user) { |u| project.add_guest(u) } } let(:file_path) { "files%2Fruby%2Fpopen%2Erb" } + let(:executable_file_path) { "files%2Fexecutables%2Fls" } let(:rouge_file_path) { "%2e%2e%2f" } let(:absolute_path) { "%2Fetc%2Fpasswd.rb" } let(:invalid_file_message) { 'file_path should be a valid file path' } @@ -18,6 +19,12 @@ RSpec.describe API::Files do } end + let(:executable_ref_params) do + { + ref: 'with-executables' + } + end + let(:author_email) { 'user@example.org' } let(:author_name) { 'John Doe' } @@ -219,9 +226,26 @@ RSpec.describe API::Files do expect(json_response['file_name']).to eq('popen.rb') expect(json_response['last_commit_id']).to eq('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') expect(json_response['content_sha256']).to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887') + expect(json_response['execute_filemode']).to eq(false) expect(Base64.decode64(json_response['content']).lines.first).to eq("require 'fileutils'\n") end + context 'for executable file' do + it 'returns file attributes as json' do + get api(route(executable_file_path), api_user, **options), params: executable_ref_params + + aggregate_failures 'testing response' do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['file_path']).to eq(CGI.unescape(executable_file_path)) + expect(json_response['file_name']).to eq('ls') + expect(json_response['last_commit_id']).to eq('6b8dc4a827797aa025ff6b8f425e583858a10d4f') + expect(json_response['content_sha256']).to eq('2c74b1181ef780dfb692c030d3a0df6e0b624135c38a9344e56b9f80007b6191') + expect(json_response['execute_filemode']).to eq(true) + expect(Base64.decode64(json_response['content']).lines.first).to eq("#!/bin/sh\n") + end + end + end + it 'returns json when file has txt extension' do file_path = "bar%2Fbranch-test.txt" @@ -386,6 +410,23 @@ RSpec.describe API::Files do expect(response.headers['X-Gitlab-Last-Commit-Id']).to eq('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') expect(response.headers['X-Gitlab-Content-Sha256']) .to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887') + expect(response.headers['X-Gitlab-Execute-Filemode']).to eq("false") + end + + context 'for executable file' do + it 'returns file attributes in headers' do + head api(route(executable_file_path) + '/blame', current_user), params: executable_ref_params + + aggregate_failures 'testing response' do + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['X-Gitlab-File-Path']).to eq(CGI.unescape(executable_file_path)) + expect(response.headers['X-Gitlab-File-Name']).to eq('ls') + expect(response.headers['X-Gitlab-Last-Commit-Id']).to eq('6b8dc4a827797aa025ff6b8f425e583858a10d4f') + expect(response.headers['X-Gitlab-Content-Sha256']) + .to eq('2c74b1181ef780dfb692c030d3a0df6e0b624135c38a9344e56b9f80007b6191') + expect(response.headers['X-Gitlab-Execute-Filemode']).to eq("true") + end + end end it 'returns 400 when file path is invalid' do @@ -642,6 +683,15 @@ RSpec.describe API::Files do } end + let(:executable_params) do + { + branch: "master", + content: "puts 8", + commit_message: "Added newfile", + execute_filemode: true + } + end + it 'returns 400 when file path is invalid' do post api(route(rouge_file_path), user), params: params @@ -661,6 +711,18 @@ RSpec.describe API::Files do last_commit = project.repository.commit.raw expect(last_commit.author_email).to eq(user.email) expect(last_commit.author_name).to eq(user.name) + expect(project.repository.blob_at_branch(params[:branch], CGI.unescape(file_path)).executable?).to eq(false) + end + + it "creates a new executable file in project repo" do + post api(route(file_path), user), params: executable_params + + expect(response).to have_gitlab_http_status(:created) + expect(json_response["file_path"]).to eq(CGI.unescape(file_path)) + last_commit = project.repository.commit.raw + expect(last_commit.author_email).to eq(user.email) + expect(last_commit.author_name).to eq(user.name) + expect(project.repository.blob_at_branch(params[:branch], CGI.unescape(file_path)).executable?).to eq(true) end it "returns a 400 bad request if no mandatory params given" do @@ -820,6 +882,44 @@ RSpec.describe API::Files do expect(last_commit.author_name).to eq(author_name) end end + + context 'when specifying the execute_filemode' do + let(:executable_params) do + { + branch: 'master', + content: 'puts 8', + commit_message: 'Changed file', + execute_filemode: true + } + end + + let(:non_executable_params) do + { + branch: 'with-executables', + content: 'puts 8', + commit_message: 'Changed file', + execute_filemode: false + } + end + + it 'updates to executable file mode' do + put api(route(file_path), user), params: executable_params + + aggregate_failures 'testing response' do + expect(response).to have_gitlab_http_status(:ok) + expect(project.repository.blob_at_branch(executable_params[:branch], CGI.unescape(file_path)).executable?).to eq(true) + end + end + + it 'updates to non-executable file mode' do + put api(route(executable_file_path), user), params: non_executable_params + + aggregate_failures 'testing response' do + expect(response).to have_gitlab_http_status(:ok) + expect(project.repository.blob_at_branch(non_executable_params[:branch], CGI.unescape(executable_file_path)).executable?).to eq(false) + end + end + end end describe "DELETE /projects/:id/repository/files" do diff --git a/spec/requests/api/graphql/ci/job_spec.rb b/spec/requests/api/graphql/ci/job_spec.rb index b0514a0a963..ddb2664d353 100644 --- a/spec/requests/api/graphql/ci/job_spec.rb +++ b/spec/requests/api/graphql/ci/job_spec.rb @@ -52,6 +52,7 @@ RSpec.describe 'Query.project(fullPath).pipelines.job(id)' do 'name' => job_2.name, 'allowFailure' => job_2.allow_failure, 'duration' => 25, + 'kind' => 'BUILD', 'queuedDuration' => 2.0, 'status' => job_2.status.upcase ) diff --git a/spec/requests/api/graphql/ci/jobs_spec.rb b/spec/requests/api/graphql/ci/jobs_spec.rb index b191b585d06..2d1bb45390b 100644 --- a/spec/requests/api/graphql/ci/jobs_spec.rb +++ b/spec/requests/api/graphql/ci/jobs_spec.rb @@ -155,6 +155,56 @@ RSpec.describe 'Query.project.pipeline' do end end + describe '.jobs.kind' do + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + pipeline(iid: "#{pipeline.iid}") { + stages { + nodes { + groups{ + nodes { + jobs { + nodes { + kind + } + } + } + } + } + } + } + } + } + ) + end + + context 'when the job is a build' do + it 'returns BUILD' do + create(:ci_build, pipeline: pipeline) + + post_graphql(query, current_user: user) + + job_data = graphql_data_at(:project, :pipeline, :stages, :nodes, :groups, :nodes, :jobs, :nodes).first + expect(job_data['kind']).to eq 'BUILD' + end + end + + context 'when the job is a bridge' do + it 'returns BRIDGE' do + create(:ci_bridge, pipeline: pipeline) + + post_graphql(query, current_user: user) + + job_data = graphql_data_at(:project, :pipeline, :stages, :nodes, :groups, :nodes, :jobs, :nodes).first + expect(job_data['kind']).to eq 'BRIDGE' + end + end + end + describe '.jobs.artifacts' do let_it_be(:pipeline) { create(:ci_pipeline, project: project) } diff --git a/spec/requests/api/graphql/ci/runner_spec.rb b/spec/requests/api/graphql/ci/runner_spec.rb index b99a3d14fb9..39f0f696b08 100644 --- a/spec/requests/api/graphql/ci/runner_spec.rb +++ b/spec/requests/api/graphql/ci/runner_spec.rb @@ -27,27 +27,18 @@ RSpec.describe 'Query.runner(id)' do let_it_be(:active_project_runner) { create(:ci_runner, :project) } - def get_runner(id) - case id - when :active_instance_runner - active_instance_runner - when :inactive_instance_runner - inactive_instance_runner - when :active_group_runner - active_group_runner - when :active_project_runner - active_project_runner - end + before do + allow(Gitlab::Ci::RunnerUpgradeCheck.instance).to receive(:check_runner_upgrade_status) end - shared_examples 'runner details fetch' do |runner_id| + shared_examples 'runner details fetch' do let(:query) do wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner'))) end let(:query_path) do [ - [:runner, { id: get_runner(runner_id).to_global_id.to_s }] + [:runner, { id: runner.to_global_id.to_s }] ] end @@ -57,7 +48,6 @@ RSpec.describe 'Query.runner(id)' do runner_data = graphql_data_at(:runner) expect(runner_data).not_to be_nil - runner = get_runner(runner_id) expect(runner_data).to match a_hash_including( 'id' => runner.to_global_id.to_s, 'description' => runner.description, @@ -90,14 +80,14 @@ RSpec.describe 'Query.runner(id)' do end end - shared_examples 'retrieval with no admin url' do |runner_id| + shared_examples 'retrieval with no admin url' do let(:query) do wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner'))) end let(:query_path) do [ - [:runner, { id: get_runner(runner_id).to_global_id.to_s }] + [:runner, { id: runner.to_global_id.to_s }] ] end @@ -107,7 +97,6 @@ RSpec.describe 'Query.runner(id)' do runner_data = graphql_data_at(:runner) expect(runner_data).not_to be_nil - runner = get_runner(runner_id) expect(runner_data).to match a_hash_including( 'id' => runner.to_global_id.to_s, 'adminUrl' => nil @@ -116,14 +105,14 @@ RSpec.describe 'Query.runner(id)' do end end - shared_examples 'retrieval by unauthorized user' do |runner_id| + shared_examples 'retrieval by unauthorized user' do let(:query) do wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner'))) end let(:query_path) do [ - [:runner, { id: get_runner(runner_id).to_global_id.to_s }] + [:runner, { id: runner.to_global_id.to_s }] ] end @@ -135,7 +124,9 @@ RSpec.describe 'Query.runner(id)' do end describe 'for active runner' do - it_behaves_like 'runner details fetch', :active_instance_runner + let(:runner) { active_instance_runner } + + it_behaves_like 'runner details fetch' context 'when tagList is not requested' do let(:query) do @@ -144,7 +135,7 @@ RSpec.describe 'Query.runner(id)' do let(:query_path) do [ - [:runner, { id: active_instance_runner.to_global_id.to_s }] + [:runner, { id: runner.to_global_id.to_s }] ] end @@ -193,7 +184,9 @@ RSpec.describe 'Query.runner(id)' do end describe 'for inactive runner' do - it_behaves_like 'runner details fetch', :inactive_instance_runner + let(:runner) { inactive_instance_runner } + + it_behaves_like 'runner details fetch' end describe 'for group runner request' do @@ -369,15 +362,21 @@ RSpec.describe 'Query.runner(id)' do let(:user) { create(:user) } context 'on instance runner' do - it_behaves_like 'retrieval by unauthorized user', :active_instance_runner + let(:runner) { active_instance_runner } + + it_behaves_like 'retrieval by unauthorized user' end context 'on group runner' do - it_behaves_like 'retrieval by unauthorized user', :active_group_runner + let(:runner) { active_group_runner } + + it_behaves_like 'retrieval by unauthorized user' end context 'on project runner' do - it_behaves_like 'retrieval by unauthorized user', :active_project_runner + let(:runner) { active_project_runner } + + it_behaves_like 'retrieval by unauthorized user' end end @@ -388,13 +387,17 @@ RSpec.describe 'Query.runner(id)' do group.add_user(user, Gitlab::Access::OWNER) end - it_behaves_like 'retrieval with no admin url', :active_group_runner + it_behaves_like 'retrieval with no admin url' do + let(:runner) { active_group_runner } + end end describe 'by unauthenticated user' do let(:user) { nil } - it_behaves_like 'retrieval by unauthorized user', :active_instance_runner + it_behaves_like 'retrieval by unauthorized user' do + let(:runner) { active_instance_runner } + end end describe 'Query limits' do diff --git a/spec/requests/api/graphql/ci/runners_spec.rb b/spec/requests/api/graphql/ci/runners_spec.rb index 267dd1b5e6f..6b88c82b025 100644 --- a/spec/requests/api/graphql/ci/runners_spec.rb +++ b/spec/requests/api/graphql/ci/runners_spec.rb @@ -34,6 +34,8 @@ RSpec.describe 'Query.runners' do end before do + allow(Gitlab::Ci::RunnerUpgradeCheck.instance).to receive(:check_runner_upgrade_status) + post_graphql(query, current_user: current_user) end diff --git a/spec/requests/api/graphql/mutations/boards/create_spec.rb b/spec/requests/api/graphql/mutations/boards/create_spec.rb index 22d05f36f0f..ca848c0c92f 100644 --- a/spec/requests/api/graphql/mutations/boards/create_spec.rb +++ b/spec/requests/api/graphql/mutations/boards/create_spec.rb @@ -4,6 +4,16 @@ require 'spec_helper' RSpec.describe Mutations::Boards::Create do let_it_be(:parent) { create(:project) } + let_it_be(:current_user, reload: true) { create(:user) } + + let(:name) { 'board name' } + let(:mutation) { graphql_mutation(:create_board, params) } + + subject { post_graphql_mutation(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:create_board) + end let(:project_path) { parent.full_path } let(:params) do diff --git a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb b/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb index a14935379dc..ef640183bd8 100644 --- a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb @@ -8,7 +8,8 @@ RSpec.describe 'JobRetry' do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project) } let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) } - let_it_be(:job) { create(:ci_build, :success, pipeline: pipeline, name: 'build') } + + let(:job) { create(:ci_build, :success, pipeline: pipeline, name: 'build') } let(:mutation) do variables = { @@ -37,10 +38,23 @@ RSpec.describe 'JobRetry' do end it 'retries a job' do - job_id = ::Gitlab::GlobalId.build(job, id: job.id).to_s post_graphql_mutation(mutation, current_user: user) expect(response).to have_gitlab_http_status(:success) - expect(mutation_response['job']['id']).to eq(job_id) + new_job_id = GitlabSchema.object_from_id(mutation_response['job']['id']).sync.id + + new_job = ::Ci::Build.find(new_job_id) + expect(new_job).not_to be_retried + end + + context 'when the job is not retryable' do + let(:job) { create(:ci_build, :retried, pipeline: pipeline) } + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: user) + + expect(mutation_response['job']).to be(nil) + expect(mutation_response['errors']).to match_array(['Job cannot be retried']) + end end end diff --git a/spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb b/spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb index a20ac823550..d9106aa42c4 100644 --- a/spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb @@ -47,5 +47,6 @@ RSpec.describe 'PipelineCancel' do expect(response).to have_gitlab_http_status(:success) expect(build.reload).to be_canceled + expect(pipeline.reload).to be_canceled 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 0f2eeb90894..f38deb426b1 100644 --- a/spec/requests/api/graphql/mutations/issues/update_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/update_spec.rb @@ -8,8 +8,8 @@ RSpec.describe 'Update of an existing issue' do let_it_be(:current_user) { create(:user) } let_it_be(:project) { create(:project, :public) } let_it_be(:issue) { create(:issue, project: project) } - let_it_be(:label1) { create(:label, project: project) } - let_it_be(:label2) { create(:label, project: project) } + let_it_be(:label1) { create(:label, title: "a", project: project) } + let_it_be(:label2) { create(:label, title: "b", project: project) } let(:input) do { @@ -124,7 +124,7 @@ RSpec.describe 'Update of an existing issue' do context 'add and remove labels' do let(:input_params) { input.merge(extra_params).merge({ addLabelIds: [label1.id], removeLabelIds: [label2.id] }) } - it 'returns error for mutually exclusive arguments' do + it 'returns correct labels' do post_graphql_mutation(mutation, current_user: current_user) expect(response).to have_gitlab_http_status(:success) @@ -132,6 +132,22 @@ RSpec.describe 'Update of an existing issue' do expect(mutation_response['issue']['labels']).to include({ "nodes" => [{ "id" => label1.to_global_id.to_s }] }) end end + + context 'add labels' do + let(:input_params) { input.merge(extra_params).merge({ addLabelIds: [label1.id] }) } + + before do + issue.update!({ labels: [label2] }) + end + + it 'adds labels and keeps the title ordering' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(json_response['errors']).to be_nil + expect(mutation_response['issue']['labels']['nodes']).to eq([{ "id" => label1.to_global_id.to_s }, { "id" => label2.to_global_id.to_s }]) + end + end end end end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb index 0d0cc66c52a..e40a3cf7ce9 100644 --- a/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb +++ b/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb @@ -8,8 +8,8 @@ RSpec.describe 'Setting labels of a merge request' do let(:current_user) { create(:user) } let(:merge_request) { create(:merge_request) } let(:project) { merge_request.project } - let(:label) { create(:label, project: project) } - let(:label2) { create(:label, project: project) } + let(:label) { create(:label, title: "a", project: project) } + let(:label2) { create(:label, title: "b", project: project) } let(:input) { { label_ids: [GitlabSchema.id_from_object(label).to_s] } } let(:mutation) do @@ -81,12 +81,12 @@ RSpec.describe 'Setting labels of a merge request' do merge_request.update!(labels: [label2]) end - it 'sets the labels, without removing others' do + it 'sets the labels and resets labels to keep the title ordering, without removing others' do post_graphql_mutation(mutation, current_user: current_user) expect(response).to have_gitlab_http_status(:success) expect(mutation_label_nodes.count).to eq(2) - expect(mutation_label_nodes).to contain_exactly({ 'id' => label.to_global_id.to_s }, { 'id' => label2.to_global_id.to_s }) + expect(mutation_label_nodes).to eq([{ 'id' => label.to_global_id.to_s }, { 'id' => label2.to_global_id.to_s }]) end end diff --git a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb index 2bc671e4ca5..63b94dccca0 100644 --- a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb +++ b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb @@ -17,8 +17,7 @@ RSpec.describe 'Adding a Note' do noteable_id: GitlabSchema.id_from_object(noteable).to_s, discussion_id: (GitlabSchema.id_from_object(discussion).to_s if discussion), merge_request_diff_head_sha: head_sha.presence, - body: body, - confidential: true + body: body } graphql_mutation(:create_note, variables) @@ -49,7 +48,6 @@ RSpec.describe 'Adding a Note' do post_graphql_mutation(mutation, current_user: current_user) expect(mutation_response['note']['body']).to eq('Body text') - expect(mutation_response['note']['confidential']).to eq(true) end describe 'creating Notes in reply to a discussion' do @@ -79,6 +77,25 @@ RSpec.describe 'Adding a Note' do end end + context 'for an issue' do + let(:noteable) { create(:issue, project: project) } + let(:mutation) do + variables = { + noteable_id: GitlabSchema.id_from_object(noteable).to_s, + body: body, + confidential: true + } + + graphql_mutation(:create_note, variables) + end + + before do + project.add_developer(current_user) + end + + it_behaves_like 'a Note mutation with confidential notes' + end + context 'when body only contains quick actions' do let(:head_sha) { noteable.diff_head_sha } let(:body) { '/merge' } diff --git a/spec/requests/api/graphql/mutations/notes/update/note_spec.rb b/spec/requests/api/graphql/mutations/notes/update/note_spec.rb index 5a92ffe61b8..bae5c58abff 100644 --- a/spec/requests/api/graphql/mutations/notes/update/note_spec.rb +++ b/spec/requests/api/graphql/mutations/notes/update/note_spec.rb @@ -8,7 +8,7 @@ RSpec.describe 'Updating a Note' do let!(:note) { create(:note, note: original_body) } let(:original_body) { 'Initial body text' } let(:updated_body) { 'Updated body text' } - let(:params) { { body: updated_body, confidential: true } } + let(:params) { { body: updated_body } } let(:mutation) do variables = params.merge(id: GitlabSchema.id_from_object(note).to_s) @@ -28,7 +28,6 @@ RSpec.describe 'Updating a Note' do post_graphql_mutation(mutation, current_user: current_user) expect(note.reload.note).to eq(original_body) - expect(note.confidential).to be_falsey end end @@ -41,46 +40,19 @@ RSpec.describe 'Updating a Note' do post_graphql_mutation(mutation, current_user: current_user) expect(note.reload.note).to eq(updated_body) - expect(note.confidential).to be_truthy end it 'returns the updated Note' do post_graphql_mutation(mutation, current_user: current_user) expect(mutation_response['note']['body']).to eq(updated_body) - expect(mutation_response['note']['confidential']).to be_truthy - end - - context 'when only confidential param is present' do - let(:params) { { confidential: true } } - - it 'updates only the note confidentiality' do - post_graphql_mutation(mutation, current_user: current_user) - - expect(note.reload.note).to eq(original_body) - expect(note.confidential).to be_truthy - end - end - - context 'when only body param is present' do - let(:params) { { body: updated_body } } - - before do - note.update_column(:confidential, true) - end - - it 'updates only the note body' do - post_graphql_mutation(mutation, current_user: current_user) - - expect(note.reload.note).to eq(updated_body) - expect(note.confidential).to be_truthy - end end context 'when there are ActiveRecord validation errors' do - let(:updated_body) { '' } + let(:params) { { body: '', confidential: true } } - it_behaves_like 'a mutation that returns errors in the response', errors: ["Note can't be blank"] + it_behaves_like 'a mutation that returns errors in the response', + errors: ["Note can't be blank", 'Confidential can not be changed for existing notes'] it 'does not update the Note' do post_graphql_mutation(mutation, current_user: current_user) diff --git a/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb b/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb index 9ac98db91e2..c5c34e16717 100644 --- a/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb +++ b/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb @@ -50,6 +50,37 @@ RSpec.describe 'Marking all todos done' do expect(updated_todo_ids).to contain_exactly(global_id_of(todo1), global_id_of(todo3)) end + context 'when target_id is given', :aggregate_failures do + let_it_be(:target) { create(:issue, project: project) } + let_it_be(:target_todo1) { create(:todo, user: current_user, author: author, state: :pending, target: target) } + let_it_be(:target_todo2) { create(:todo, user: current_user, author: author, state: :pending, target: target) } + + let(:input) { { 'targetId' => target.to_global_id.to_s } } + + it 'marks all pending todos for the target as done' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(target_todo1.reload.state).to eq('done') + expect(target_todo2.reload.state).to eq('done') + + expect(todo1.reload.state).to eq('pending') + expect(todo3.reload.state).to eq('pending') + + updated_todo_ids = mutation_response['todos'].map { |todo| todo['id'] } + expect(updated_todo_ids).to contain_exactly(global_id_of(target_todo1), global_id_of(target_todo2)) + end + + context 'when target does not exist' do + let(:input) { { 'targetId' => "gid://gitlab/Issue/#{non_existing_record_id}" } } + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_errors).to include(a_hash_including('message' => include('Resource not available'))) + end + end + end + it 'behaves as expected if there are no todos for the requesting user' do post_graphql_mutation(mutation, current_user: other_user2) diff --git a/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb b/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb index e1c7fd9d60d..85194e6eb20 100644 --- a/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb +++ b/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb @@ -28,6 +28,17 @@ RSpec.describe Mutations::UserPreferences::Update do expect(current_user.user_preference.persisted?).to eq(true) expect(current_user.user_preference.issues_sort).to eq(Types::IssueSortEnum.values[sort_value].value.to_s) end + + context 'when incident_escalations feature flag is disabled' do + let(:sort_value) { 'ESCALATION_STATUS_ASC' } + + before do + stub_feature_flags(incident_escalations: false) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: ['Feature flag `incident_escalations` must be enabled to use this sort order.'] + end end context 'when user has existing preference' do @@ -45,5 +56,16 @@ RSpec.describe Mutations::UserPreferences::Update do expect(current_user.user_preference.issues_sort).to eq(Types::IssueSortEnum.values[sort_value].value.to_s) end + + context 'when incident_escalations feature flag is disabled' do + let(:sort_value) { 'ESCALATION_STATUS_DESC' } + + before do + stub_feature_flags(incident_escalations: false) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: ['Feature flag `incident_escalations` must be enabled to use this sort order.'] + end end end diff --git a/spec/requests/api/graphql_spec.rb b/spec/requests/api/graphql_spec.rb index da0c87fcefe..3bd59450d49 100644 --- a/spec/requests/api/graphql_spec.rb +++ b/spec/requests/api/graphql_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'GraphQL' do let(:expected_execute_query_log) do { "correlation_id" => kind_of(String), - "meta.caller_id" => "graphql:anonymous", + "meta.caller_id" => "graphql:unknown", "meta.client_id" => kind_of(String), "meta.feature_category" => "not_owned", "meta.remote_ip" => kind_of(String), diff --git a/spec/requests/api/group_export_spec.rb b/spec/requests/api/group_export_spec.rb index 31eef21654a..ffa313d4464 100644 --- a/spec/requests/api/group_export_spec.rb +++ b/spec/requests/api/group_export_spec.rb @@ -30,76 +30,62 @@ RSpec.describe API::GroupExport do group.add_owner(user) end - context 'group_import_export feature flag enabled' do + context 'when export file exists' do before do - stub_feature_flags(group_import_export: true) - allow(Gitlab::ApplicationRateLimiter) .to receive(:increment) .and_return(0) - end - - context 'when export file exists' do - before do - upload.export_file = fixture_file_upload('spec/fixtures/group_export.tar.gz', "`/tar.gz") - upload.save! - end - it 'downloads exported group archive' do - get api(download_path, user) - - expect(response).to have_gitlab_http_status(:ok) - end + upload.export_file = fixture_file_upload('spec/fixtures/group_export.tar.gz', "`/tar.gz") + upload.save! + end - context 'when export_file.file does not exist' do - before do - expect_next_instance_of(ImportExportUploader) do |uploader| - expect(uploader).to receive(:file).and_return(nil) - end - end + it 'downloads exported group archive' do + get api(download_path, user) - it 'returns 404' do - get api(download_path, user) + expect(response).to have_gitlab_http_status(:ok) + end - expect(response).to have_gitlab_http_status(:not_found) + context 'when export_file.file does not exist' do + before do + expect_next_instance_of(ImportExportUploader) do |uploader| + expect(uploader).to receive(:file).and_return(nil) end end - context 'when object is not present' do - let(:other_group) { create(:group, :with_export) } - let(:other_download_path) { "/groups/#{other_group.id}/export/download" } + it 'returns 404' do + get api(download_path, user) - before do - other_group.add_owner(user) - other_group.export_file.file.delete - end + expect(response).to have_gitlab_http_status(:not_found) + end + end - it 'returns 404' do - get api(other_download_path, user) + context 'when object is not present' do + let(:other_group) { create(:group, :with_export) } + let(:other_download_path) { "/groups/#{other_group.id}/export/download" } - expect(response).to have_gitlab_http_status(:not_found) - expect(json_response['message']).to eq('The group export file is not available yet') - end + before do + other_group.add_owner(user) + other_group.export_file.file.delete end - end - context 'when export file does not exist' do it 'returns 404' do - get api(download_path, user) + get api(other_download_path, user) expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('The group export file is not available yet') end end end - context 'group_import_export feature flag disabled' do - before do - stub_feature_flags(group_import_export: false) - end - - it 'responds with 404 Not Found' do + context 'when export file does not exist' do + it 'returns 404' do get api(download_path, user) + allow(Gitlab::ApplicationRateLimiter) + .to receive(:increment) + .and_return(0) + expect(response).to have_gitlab_http_status(:not_found) end end @@ -122,58 +108,40 @@ RSpec.describe API::GroupExport do end describe 'POST /groups/:group_id/export' do - context 'group_import_export feature flag enabled' do + context 'when user is a group owner' do before do - stub_feature_flags(group_import_export: true) + group.add_owner(user) end - context 'when user is a group owner' do - before do - group.add_owner(user) - end - - it 'accepts download' do - post api(path, user) + it 'accepts download' do + post api(path, user) - expect(response).to have_gitlab_http_status(:accepted) - end + expect(response).to have_gitlab_http_status(:accepted) end + end - context 'when the export cannot be started' do - before do - group.add_owner(user) - allow(GroupExportWorker).to receive(:perform_async).and_return(nil) - end - - it 'returns an error' do - post api(path, user) - - expect(response).to have_gitlab_http_status(:error) - end + context 'when the export cannot be started' do + before do + group.add_owner(user) + allow(GroupExportWorker).to receive(:perform_async).and_return(nil) end - context 'when user is not a group owner' do - before do - group.add_developer(user) - end - - it 'forbids the request' do - post api(path, user) + it 'returns an error' do + post api(path, user) - expect(response).to have_gitlab_http_status(:forbidden) - end + expect(response).to have_gitlab_http_status(:error) end end - context 'group_import_export feature flag disabled' do + context 'when user is not a group owner' do before do - stub_feature_flags(group_import_export: false) + group.add_developer(user) end - it 'responds with 404 Not Found' do + it 'forbids the request' do post api(path, user) - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:forbidden) end end @@ -202,7 +170,6 @@ RSpec.describe API::GroupExport do let(:status_path) { "/groups/#{group.id}/export_relations/status" } before do - stub_feature_flags(group_import_export: true) group.add_owner(user) end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 7de3567dcdd..ffc5d353958 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -1164,17 +1164,47 @@ RSpec.describe API::Groups do end context 'when include_subgroups is true' do - it "returns projects including those in subgroups" do + before do subgroup = create(:group, parent: group1) + subgroup2 = create(:group, parent: subgroup) + create(:project, group: subgroup) create(:project, group: subgroup) + create(:project, group: subgroup2) + + group1.reload + end + + it "only looks up root ancestor once and returns projects including those in subgroups" do + expect(Namespace).to receive(:find_by).with(id: group1.id.to_s).once.and_call_original # For the group sent in the API call + expect(Namespace).to receive(:find_by).with(id: group1.traversal_ids.first).once.and_call_original # root_ancestor direct lookup + expect(Namespace).to receive(:joins).with(start_with('INNER JOIN (SELECT id, traversal_ids[1]')).once.and_call_original # All-in-one root_ancestor query get api("/groups/#{group1.id}/projects", user1), params: { include_subgroups: true } expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(json_response).to be_an(Array) - expect(json_response.length).to eq(5) + expect(json_response.length).to eq(6) + end + + context 'when group_projects_api_preload_groups feature is disabled' do + before do + stub_feature_flags(group_projects_api_preload_groups: false) + end + + it 'looks up the root ancestor multiple times' do + expect(Namespace).to receive(:find_by).with(id: group1.id.to_s).once.and_call_original + expect(Namespace).to receive(:find_by).with(id: group1.traversal_ids.first).at_least(:twice).and_call_original + expect(Namespace).not_to receive(:joins).with(start_with('INNER JOIN (SELECT id, traversal_ids[1]')) + + get api("/groups/#{group1.id}/projects", user1), params: { include_subgroups: true } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an(Array) + expect(json_response.length).to eq(6) + end end end diff --git a/spec/requests/api/integrations_spec.rb b/spec/requests/api/integrations_spec.rb index 220c58afbe9..96cc101e73a 100644 --- a/spec/requests/api/integrations_spec.rb +++ b/spec/requests/api/integrations_spec.rb @@ -55,7 +55,7 @@ RSpec.describe API::Integrations do describe "PUT /projects/:id/#{endpoint}/#{integration.dasherize}" do include_context integration - it "updates #{integration} settings" do + it "updates #{integration} settings and returns the correct fields" do put api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user), params: integration_attrs expect(response).to have_gitlab_http_status(:ok) @@ -80,6 +80,8 @@ RSpec.describe API::Integrations do expect(project.integrations.first[event]).not_to eq(current_integration[event]), "expected #{!current_integration[event]} for event #{event} for #{endpoint} #{current_integration.title}, got #{current_integration[event]}" end + + assert_correct_response_fields(json_response['properties'].keys, current_integration) end it "returns if required fields missing" do @@ -142,22 +144,24 @@ RSpec.describe API::Integrations do expect(response).to have_gitlab_http_status(:unauthorized) end - it "returns all properties of active integration #{integration}" do + it "returns all properties of active integration #{integration}, except password fields" do get api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user) expect(initialized_integration).to be_active expect(response).to have_gitlab_http_status(:ok) - expect(json_response['properties'].keys).to match_array(integration_instance.api_field_names) + + assert_correct_response_fields(json_response['properties'].keys, integration_instance) end - it "returns all properties of inactive integration #{integration}" do + it "returns all properties of inactive integration #{integration}, except password fields" do deactive_integration! get api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user) expect(initialized_integration).not_to be_active expect(response).to have_gitlab_http_status(:ok) - expect(json_response['properties'].keys).to match_array(integration_instance.api_field_names) + + assert_correct_response_fields(json_response['properties'].keys, integration_instance) end it "returns not found if integration does not exist" do @@ -369,5 +373,20 @@ RSpec.describe API::Integrations do end end end + + private + + def assert_correct_response_fields(response_keys, integration) + assert_fields_match_integration(response_keys, integration) + assert_secret_fields_filtered(response_keys, integration) + end + + def assert_fields_match_integration(response_keys, integration) + expect(response_keys).to match_array(integration.api_field_names) + end + + def assert_secret_fields_filtered(response_keys, integration) + expect(response_keys).not_to include(*integration.secret_fields) + end end end diff --git a/spec/requests/api/internal/container_registry/migration_spec.rb b/spec/requests/api/internal/container_registry/migration_spec.rb index 27e99a21c65..35113c66f11 100644 --- a/spec/requests/api/internal/container_registry/migration_spec.rb +++ b/spec/requests/api/internal/container_registry/migration_spec.rb @@ -67,12 +67,17 @@ RSpec.describe API::Internal::ContainerRegistry::Migration do it_behaves_like 'returning an error', with_message: "Couldn't transition from pre_importing to importing" end - end - context 'with repository in importing migration state' do - let(:repository) { create(:container_repository, :importing) } + context 'with repository in importing migration state' do + let(:repository) { create(:container_repository, :importing) } + + it 'returns ok and does not update the migration state' do + expect { subject } + .not_to change { repository.reload.migration_state } - it_behaves_like 'returning an error', with_message: "Couldn't transition from pre_importing to importing" + expect(response).to have_gitlab_http_status(:ok) + end + end end end @@ -101,7 +106,7 @@ RSpec.describe API::Internal::ContainerRegistry::Migration do context 'with repository in pre_importing migration state' do let(:repository) { create(:container_repository, :pre_importing) } - it_behaves_like 'returning an error', with_message: "Couldn't transition from importing to import_done" + it_behaves_like 'updating the repository migration status', from: 'pre_importing', to: 'import_done' end end diff --git a/spec/requests/api/invitations_spec.rb b/spec/requests/api/invitations_spec.rb index 741cf793a77..d093894720e 100644 --- a/spec/requests/api/invitations_spec.rb +++ b/spec/requests/api/invitations_spec.rb @@ -69,7 +69,7 @@ RSpec.describe API::Invitations do end end - it 'invites a new member' do + it 'adds a new member by email' do expect do post invitations_url(source, maintainer), params: { email: email, access_level: Member::DEVELOPER } @@ -78,6 +78,24 @@ RSpec.describe API::Invitations do end.to change { source.members.invite.count }.by(1) end + it 'adds a new member by user_id' do + expect do + post invitations_url(source, maintainer), + params: { user_id: stranger.id, access_level: Member::DEVELOPER } + + expect(response).to have_gitlab_http_status(:created) + end.to change { source.members.non_invite.count }.by(1) + end + + it 'adds new members with email and user_id' do + expect do + post invitations_url(source, maintainer), + params: { email: email, user_id: stranger.id, access_level: Member::DEVELOPER } + + expect(response).to have_gitlab_http_status(:created) + end.to change { source.members.invite.count }.by(1).and change { source.members.non_invite.count }.by(1) + end + it 'invites a list of new email addresses' do expect do email_list = [email, email2].join(',') @@ -88,6 +106,19 @@ RSpec.describe API::Invitations do expect(response).to have_gitlab_http_status(:created) end.to change { source.members.invite.count }.by(2) end + + it 'invites a list of new email addresses and user ids' do + expect do + stranger2 = create(:user) + email_list = [email, email2].join(',') + user_id_list = "#{stranger.id},#{stranger2.id}" + + post invitations_url(source, maintainer), + params: { email: email_list, user_id: user_id_list, access_level: Member::DEVELOPER } + + expect(response).to have_gitlab_http_status(:created) + end.to change { source.members.invite.count }.by(2).and change { source.members.non_invite.count }.by(2) + end end context 'access levels' do @@ -235,27 +266,36 @@ RSpec.describe API::Invitations do expect(json_response['message'][developer.email]).to eq("User already exists in source") end - it 'returns 404 when the email is not valid' do + it 'returns 400 when the invite params of email and user_id are not sent' do + post invitations_url(source, maintainer), + params: { access_level: Member::MAINTAINER } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq('400 Bad request - Must provide either email or user_id as a parameter') + end + + it 'returns 400 when the email is blank' do post invitations_url(source, maintainer), params: { email: '', access_level: Member::MAINTAINER } - expect(response).to have_gitlab_http_status(:created) - expect(json_response['message']).to eq('Emails cannot be blank') + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq('400 Bad request - Must provide either email or user_id as a parameter') end - it 'returns 404 when the email list is not a valid format' do + it 'returns 400 when the user_id is blank' do post invitations_url(source, maintainer), - params: { email: 'email1@example.com,not-an-email', access_level: Member::MAINTAINER } + params: { user_id: '', access_level: Member::MAINTAINER } expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['error']).to eq('email contains an invalid email address') + expect(json_response['message']).to eq('400 Bad request - Must provide either email or user_id as a parameter') end - it 'returns 400 when email is not given' do + it 'returns 400 when the email list is not a valid format' do post invitations_url(source, maintainer), - params: { access_level: Member::MAINTAINER } + params: { email: 'email1@example.com,not-an-email', access_level: Member::MAINTAINER } expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('email contains an invalid email address') end it 'returns 400 when access_level is not given' do @@ -278,12 +318,90 @@ RSpec.describe API::Invitations do it_behaves_like 'POST /:source_type/:id/invitations', 'project' do let(:source) { project } end + + it 'records queries', :request_store, :use_sql_query_cache do + post invitations_url(project, maintainer), params: { email: email, access_level: Member::DEVELOPER } + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post invitations_url(project, maintainer), params: { email: email2, access_level: Member::DEVELOPER } + end + + emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com' + + unresolved_n_plus_ones = 44 # old 48 with 12 per new email, currently there are 11 queries added per email + + expect do + post invitations_url(project, maintainer), params: { email: emails, access_level: Member::DEVELOPER } + end.not_to exceed_all_query_limit(control.count).with_threshold(unresolved_n_plus_ones) + end + + it 'records queries with secondary emails', :request_store, :use_sql_query_cache do + create(:email, email: email, user: create(:user)) + + post invitations_url(project, maintainer), params: { email: email, access_level: Member::DEVELOPER } + + create(:email, email: email2, user: create(:user)) + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post invitations_url(project, maintainer), params: { email: email2, access_level: Member::DEVELOPER } + end + + create(:email, email: 'email4@example.com', user: create(:user)) + create(:email, email: 'email6@example.com', user: create(:user)) + + emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com' + + unresolved_n_plus_ones = 67 # currently there are 11 queries added per email + + expect do + post invitations_url(project, maintainer), params: { email: emails, access_level: Member::DEVELOPER } + end.not_to exceed_all_query_limit(control.count).with_threshold(unresolved_n_plus_ones) + end end describe 'POST /groups/:id/invitations' do it_behaves_like 'POST /:source_type/:id/invitations', 'group' do let(:source) { group } end + + it 'records queries', :request_store, :use_sql_query_cache do + post invitations_url(group, maintainer), params: { email: email, access_level: Member::DEVELOPER } + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post invitations_url(group, maintainer), params: { email: email2, access_level: Member::DEVELOPER } + end + + emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com' + + unresolved_n_plus_ones = 36 # old 40 with 10 per new email, currently there are 9 queries added per email + + expect do + post invitations_url(group, maintainer), params: { email: emails, access_level: Member::DEVELOPER } + end.not_to exceed_all_query_limit(control.count).with_threshold(unresolved_n_plus_ones) + end + + it 'records queries with secondary emails', :request_store, :use_sql_query_cache do + create(:email, email: email, user: create(:user)) + + post invitations_url(group, maintainer), params: { email: email, access_level: Member::DEVELOPER } + + create(:email, email: email2, user: create(:user)) + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post invitations_url(group, maintainer), params: { email: email2, access_level: Member::DEVELOPER } + end + + create(:email, email: 'email4@example.com', user: create(:user)) + create(:email, email: 'email6@example.com', user: create(:user)) + + emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com' + + unresolved_n_plus_ones = 62 # currently there are 9 queries added per email + + expect do + post invitations_url(group, maintainer), params: { email: emails, access_level: Member::DEVELOPER } + end.not_to exceed_all_query_limit(control.count).with_threshold(unresolved_n_plus_ones) + end end shared_examples 'GET /:source_type/:id/invitations' do |source_type| @@ -315,23 +433,6 @@ RSpec.describe API::Invitations do end end - it 'avoids N+1 queries' do - invite_member_by_email(source, source_type, email, maintainer) - - # Establish baseline - get invitations_url(source, maintainer) - - control = ActiveRecord::QueryRecorder.new do - get invitations_url(source, maintainer) - end - - invite_member_by_email(source, source_type, email2, maintainer) - - expect do - get invitations_url(source, maintainer) - end.not_to exceed_query_limit(control) - end - it 'does not find confirmed members' do get invitations_url(source, maintainer) diff --git a/spec/requests/api/issue_links_spec.rb b/spec/requests/api/issue_links_spec.rb index 45583f5c7dc..81dd4c3dfa0 100644 --- a/spec/requests/api/issue_links_spec.rb +++ b/spec/requests/api/issue_links_spec.rb @@ -34,7 +34,7 @@ RSpec.describe API::IssueLinks do expect(response).to have_gitlab_http_status(:ok) expect(json_response).to be_an Array expect(json_response.length).to eq(2) - expect(response).to match_response_schema('public_api/v4/issue_links') + expect(response).to match_response_schema('public_api/v4/related_issues') end it 'returns multiple links without N + 1' do @@ -205,16 +205,30 @@ RSpec.describe API::IssueLinks do end context 'when user has ability to delete the issue link' do + let_it_be(:target_issue) { create(:issue, project: project) } + + before do + project.add_reporter(user) + end + it 'returns 200' do - target_issue = create(:issue, project: project) issue_link = create(:issue_link, source: issue, target: target_issue) - project.add_reporter(user) delete api("/projects/#{project.id}/issues/#{issue.iid}/links/#{issue_link.id}", user) expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('public_api/v4/issue_link') end + + it 'returns 404 when the issue link does not belong to the specified issue' do + other_issue = create(:issue, project: project) + issue_link = create(:issue_link, source: other_issue, target: target_issue) + + delete api("/projects/#{project.id}/issues/#{issue.iid}/links/#{issue_link.id}", user) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Not found') + end end end end diff --git a/spec/requests/api/issues/get_project_issues_spec.rb b/spec/requests/api/issues/get_project_issues_spec.rb index 9948e13e9ae..346f8975835 100644 --- a/spec/requests/api/issues/get_project_issues_spec.rb +++ b/spec/requests/api/issues/get_project_issues_spec.rb @@ -877,5 +877,35 @@ RSpec.describe API::Issues do expect(response).to have_gitlab_http_status(:not_found) end + + context 'with a confidential note' do + let!(:note) do + create( + :note, + :confidential, + project: project, + noteable: issue, + author: create(:user) + ) + end + + it 'returns a full list of participants' do + get api("/projects/#{project.id}/issues/#{issue.iid}/participants", user) + + expect(response).to have_gitlab_http_status(:ok) + participant_ids = json_response.map { |el| el['id'] } + expect(participant_ids).to match_array([issue.author_id, note.author_id]) + end + + context 'when user cannot see a confidential note' do + it 'returns a limited list of participants' do + get api("/projects/#{project.id}/issues/#{issue.iid}/participants", create(:user)) + + expect(response).to have_gitlab_http_status(:ok) + participant_ids = json_response.map { |el| el['id'] } + expect(participant_ids).to match_array([issue.author_id]) + end + end + end end end diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb index c5e57b5b18b..1419d39981a 100644 --- a/spec/requests/api/issues/issues_spec.rb +++ b/spec/requests/api/issues/issues_spec.rb @@ -554,6 +554,27 @@ RSpec.describe API::Issues do end end + context 'with incident issues' do + let_it_be(:incident) { create(:incident, project: project) } + + it 'avoids N+1 queries' do + get api('/issues', user) # warm up + + control = ActiveRecord::QueryRecorder.new do + get api('/issues', user) + end + + create(:incident, project: project) + create(:incident, project: project) + + expect do + get api('/issues', user) + end.not_to exceed_query_limit(control) + # 2 pre-existed issues + 3 incidents + expect(json_response.count).to eq(5) + end + end + context 'filter by labels or label_name param' do context 'N+1' do let(:label_b) { create(:label, title: 'foo', project: project) } diff --git a/spec/requests/api/keys_spec.rb b/spec/requests/api/keys_spec.rb index 49b8f4a8520..67c3de324dc 100644 --- a/spec/requests/api/keys_spec.rb +++ b/spec/requests/api/keys_spec.rb @@ -3,10 +3,11 @@ require 'spec_helper' RSpec.describe API::Keys do - let(:user) { create(:user) } - let(:admin) { create(:admin) } - let(:key) { create(:key, user: user, expires_at: 1.day.from_now) } - let(:email) { create(:email, user: user) } + let_it_be(:user) { create(:user) } + let_it_be(:admin) { create(:admin) } + let_it_be(:email) { create(:email, user: user) } + let_it_be(:key) { create(:rsa_key_4096, user: user, expires_at: 1.day.from_now) } + let_it_be(:fingerprint_md5) { 'df:73:db:29:3c:a5:32:cf:09:17:7e:8e:9d:de:d7:f7' } describe 'GET /keys/:uid' do context 'when unauthenticated' do @@ -24,7 +25,6 @@ RSpec.describe API::Keys do end it 'returns single ssh key with user information' do - user.keys << key get api("/keys/#{key.id}", admin) expect(response).to have_gitlab_http_status(:ok) expect(json_response['title']).to eq(key.title) @@ -43,23 +43,50 @@ RSpec.describe API::Keys do describe 'GET /keys?fingerprint=' do it 'returns authentication error' do - get api("/keys?fingerprint=#{key.fingerprint}") + get api("/keys?fingerprint=#{fingerprint_md5}") expect(response).to have_gitlab_http_status(:unauthorized) end it 'returns authentication error when authenticated as user' do - get api("/keys?fingerprint=#{key.fingerprint}", user) + get api("/keys?fingerprint=#{fingerprint_md5}", user) expect(response).to have_gitlab_http_status(:forbidden) end context 'when authenticated as admin' do - it 'returns 404 for non-existing SSH md5 fingerprint' do - get api("/keys?fingerprint=11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11", admin) + context 'MD5 fingerprint' do + it 'returns 404 for non-existing SSH md5 fingerprint' do + get api("/keys?fingerprint=11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11", admin) - expect(response).to have_gitlab_http_status(:not_found) - expect(json_response['message']).to eq('404 Key Not Found') + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Key Not Found') + end + + it 'returns user if SSH md5 fingerprint found' do + get api("/keys?fingerprint=#{fingerprint_md5}", admin) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['title']).to eq(key.title) + expect(json_response['user']['id']).to eq(user.id) + expect(json_response['user']['username']).to eq(user.username) + end + + context 'with FIPS mode', :fips_mode do + it 'returns 404 for non-existing SSH md5 fingerprint' do + get api("/keys?fingerprint=11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11", admin) + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq('Failed to return the key') + end + + it 'returns 404 for existing SSH md5 fingerprint' do + get api("/keys?fingerprint=#{fingerprint_md5}", admin) + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq('Failed to return the key') + end + end end it 'returns 404 for non-existing SSH sha256 fingerprint' do @@ -69,20 +96,7 @@ RSpec.describe API::Keys do expect(json_response['message']).to eq('404 Key Not Found') end - it 'returns user if SSH md5 fingerprint found' do - user.keys << key - - get api("/keys?fingerprint=#{key.fingerprint}", admin) - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['title']).to eq(key.title) - expect(json_response['user']['id']).to eq(user.id) - expect(json_response['user']['username']).to eq(user.username) - end - it 'returns user if SSH sha256 fingerprint found' do - user.keys << key - get api("/keys?fingerprint=#{URI.encode_www_form_component("SHA256:" + key.fingerprint_sha256)}", admin) expect(response).to have_gitlab_http_status(:ok) @@ -92,8 +106,6 @@ RSpec.describe API::Keys do end it 'returns user if SSH sha256 fingerprint found' do - user.keys << key - get api("/keys?fingerprint=#{URI.encode_www_form_component("sha256:" + key.fingerprint_sha256)}", admin) expect(response).to have_gitlab_http_status(:ok) @@ -103,7 +115,7 @@ RSpec.describe API::Keys do end it "does not include the user's `is_admin` flag" do - get api("/keys?fingerprint=#{key.fingerprint}", admin) + get api("/keys?fingerprint=#{URI.encode_www_form_component("sha256:" + key.fingerprint_sha256)}", admin) expect(json_response['user']['is_admin']).to be_nil end diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb index 73bc4a5d1f3..ef7f5ee87dc 100644 --- a/spec/requests/api/lint_spec.rb +++ b/spec/requests/api/lint_spec.rb @@ -195,7 +195,7 @@ RSpec.describe API::Lint do end context 'with invalid configuration' do - let(:yaml_content) { '{ image: "ruby:2.7", services: ["postgres"] }' } + let(:yaml_content) { '{ image: "image:1.0", services: ["postgres"] }' } it 'responds with errors about invalid configuration' do post api('/ci/lint', api_user), params: { content: yaml_content } @@ -465,7 +465,7 @@ RSpec.describe API::Lint do context 'with invalid .gitlab-ci.yml content' do let(:yaml_content) do - { image: 'ruby:2.7', services: ['postgres'] }.deep_stringify_keys.to_yaml + { image: 'image:1.0', services: ['postgres'] }.deep_stringify_keys.to_yaml end before do @@ -712,7 +712,7 @@ RSpec.describe API::Lint do context 'with invalid .gitlab-ci.yml content' do let(:yaml_content) do - { image: 'ruby:2.7', services: ['postgres'] }.deep_stringify_keys.to_yaml + { image: 'image:1.0', services: ['postgres'] }.deep_stringify_keys.to_yaml end context 'when running as dry run' do diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb index 561d81f9860..6bacb3a59b2 100644 --- a/spec/requests/api/members_spec.rb +++ b/spec/requests/api/members_spec.rb @@ -11,16 +11,16 @@ RSpec.describe API::Members do let(:project) do create(:project, :public, creator_id: maintainer.id, namespace: maintainer.namespace) do |project| - project.add_developer(developer) project.add_maintainer(maintainer) + project.add_developer(developer, current_user: maintainer) project.request_access(access_requester) end end let!(:group) do create(:group, :public) do |group| - group.add_developer(developer) group.add_owner(maintainer) + group.add_developer(developer, maintainer) create(:group_member, :minimal_access, source: group, user: user_with_minimal_access) group.request_access(access_requester) end @@ -50,6 +50,10 @@ RSpec.describe API::Members do expect(json_response).to be_an Array expect(json_response.size).to eq(2) expect(json_response.map { |u| u['id'] }).to match_array [maintainer.id, developer.id] + expect(json_response).to contain_exactly( + a_hash_including('created_by' => a_hash_including('id' => maintainer.id)), + hash_not_including('created_by') + ) end end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 9e6fea9e5b4..b1183bb10fa 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1605,11 +1605,7 @@ RSpec.describe API::MergeRequests do expect(json_response['overflow']).to be_falsy end - context 'when using DB-backed diffs via feature flag' do - before do - stub_feature_flags(mrc_api_use_raw_diffs_from_gitaly: false) - end - + context 'when using DB-backed diffs' do it_behaves_like 'find an existing merge request' it 'accesses diffs via DB-backed diffs.diffs' do diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb index 455400072bf..f6a65274ca2 100644 --- a/spec/requests/api/notes_spec.rb +++ b/spec/requests/api/notes_spec.rb @@ -22,7 +22,7 @@ RSpec.describe API::Notes do let!(:issue) { create(:issue, project: project, author: user) } let!(:issue_note) { create(:note, noteable: issue, project: project, author: user) } - it_behaves_like "noteable API", 'projects', 'issues', 'iid' do + it_behaves_like "noteable API with confidential notes", 'projects', 'issues', 'iid' do let(:parent) { project } let(:noteable) { issue } let(:note) { issue_note } diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml index 02d377efd95..fbcaa404edb 100644 --- a/spec/requests/api/project_attributes.yml +++ b/spec/requests/api/project_attributes.yml @@ -11,8 +11,6 @@ itself: # project - has_external_wiki - hidden - import_source - - import_type - - import_url - jobs_cache_index - last_repository_check_at - last_repository_check_failed @@ -63,6 +61,8 @@ itself: # project - empty_repo - forks_count - http_url_to_repo + - import_status + - import_url - name_with_namespace - open_issues_count - owner @@ -148,6 +148,7 @@ project_setting: - updated_at - cve_id_request_enabled - mr_default_target_self + - target_platforms build_service_desk_setting: # service_desk_setting unexposed_attributes: diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb index 2bc31153f2c..07efd56fef4 100644 --- a/spec/requests/api/project_export_spec.rb +++ b/spec/requests/api/project_export_spec.rb @@ -260,6 +260,29 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache do expect(json_response['message']['error']).to eq('This endpoint has been requested too many times. Try again later.') end end + + context 'applies correct scope when throttling' do + before do + stub_application_setting(project_download_export_limit: 1) + end + + it 'throttles downloads within same namespaces' do + # simulate prior request to the same namespace, which increments the rate limit counter for that scope + Gitlab::ApplicationRateLimiter.throttled?(:project_download_export, scope: [user, project_finished.namespace]) + + get api(download_path_finished, user) + expect(response).to have_gitlab_http_status(:too_many_requests) + end + + it 'allows downloads from different namespaces' do + # simulate prior request to a different namespace, which increments the rate limit counter for that scope + Gitlab::ApplicationRateLimiter.throttled?(:project_download_export, + scope: [user, create(:project, :with_export).namespace]) + + get api(download_path_finished, user) + expect(response).to have_gitlab_http_status(:ok) + end + end end context 'when user is a maintainer' do diff --git a/spec/requests/api/project_import_spec.rb b/spec/requests/api/project_import_spec.rb index a0f6d3d0081..7e6d80c047c 100644 --- a/spec/requests/api/project_import_spec.rb +++ b/spec/requests/api/project_import_spec.rb @@ -13,7 +13,7 @@ RSpec.describe API::ProjectImport, :aggregate_failures do let(:namespace) { create(:group) } before do - namespace.add_owner(user) + namespace.add_owner(user) if user end shared_examples 'requires authentication' do @@ -47,7 +47,7 @@ RSpec.describe API::ProjectImport, :aggregate_failures do it 'executes a limited number of queries' do control_count = ActiveRecord::QueryRecorder.new { subject }.count - expect(control_count).to be <= 105 + expect(control_count).to be <= 108 end it 'schedules an import using a namespace' do @@ -306,63 +306,49 @@ RSpec.describe API::ProjectImport, :aggregate_failures do it_behaves_like 'requires authentication' - it 'returns NOT FOUND when the feature is disabled' do - stub_feature_flags(import_project_from_remote_file: false) - - subject - - expect(response).to have_gitlab_http_status(:not_found) - end - - context 'when the feature flag is enabled' do - before do - stub_feature_flags(import_project_from_remote_file: true) - end - - context 'when the response is successful' do - it 'schedules the import successfully' do - project = create( - :project, - namespace: user.namespace, - name: 'test-import', - path: 'test-import' - ) + context 'when the response is successful' do + it 'schedules the import successfully' do + project = create( + :project, + namespace: user.namespace, + name: 'test-import', + path: 'test-import' + ) - service_response = ServiceResponse.success(payload: project) - expect_next(::Import::GitlabProjects::CreateProjectService) - .to receive(:execute) - .and_return(service_response) + service_response = ServiceResponse.success(payload: project) + expect_next(::Import::GitlabProjects::CreateProjectService) + .to receive(:execute) + .and_return(service_response) - subject + subject - expect(response).to have_gitlab_http_status(:created) - expect(json_response).to include({ - 'id' => project.id, - 'name' => 'test-import', - 'name_with_namespace' => "#{user.namespace.name} / test-import", - 'path' => 'test-import', - 'path_with_namespace' => "#{user.namespace.path}/test-import" - }) - end + expect(response).to have_gitlab_http_status(:created) + expect(json_response).to include({ + 'id' => project.id, + 'name' => 'test-import', + 'name_with_namespace' => "#{user.namespace.name} / test-import", + 'path' => 'test-import', + 'path_with_namespace' => "#{user.namespace.path}/test-import" + }) end + end - context 'when the service returns an error' do - it 'fails to schedule the import' do - service_response = ServiceResponse.error( - message: 'Failed to import', - http_status: :bad_request - ) - expect_next(::Import::GitlabProjects::CreateProjectService) - .to receive(:execute) - .and_return(service_response) + context 'when the service returns an error' do + it 'fails to schedule the import' do + service_response = ServiceResponse.error( + message: 'Failed to import', + http_status: :bad_request + ) + expect_next(::Import::GitlabProjects::CreateProjectService) + .to receive(:execute) + .and_return(service_response) - subject + subject - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response).to eq({ - 'message' => 'Failed to import' - }) - end + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq({ + 'message' => 'Failed to import' + }) end end end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index fc1d815a64e..011300a038f 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -683,6 +683,33 @@ RSpec.describe API::Projects do end end + context 'and imported=true' do + before do + other_user = create(:user) + # imported project by other user + create(:project, creator: other_user, import_type: 'github', import_url: 'http://foo.com') + # project created by current user directly instead of importing + create(:project) + project.update_attribute(:import_url, 'http://user:password@host/path') + project.update_attribute(:import_type, 'github') + end + + it 'returns only imported projects owned by current user' do + get api('/projects?imported=true', user) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |p| p['id'] }).to eq [project.id] + end + + it 'does not expose import credentials' do + get api('/projects?imported=true', user) + + expect(json_response.first['import_url']).to eq 'http://host/path' + end + end + context 'when authenticated as a different user' do it_behaves_like 'projects response' do let(:filter) { {} } @@ -777,7 +804,7 @@ RSpec.describe API::Projects do subject { get api('/projects', current_user), params: params } before do - group_with_projects.add_owner(current_user) + group_with_projects.add_owner(current_user) if current_user end it 'returns non-public items based ordered by similarity' do @@ -3116,6 +3143,29 @@ RSpec.describe API::Projects do project2.add_developer(project2_user) end + it 'records the query', :request_store, :use_sql_query_cache do + post api("/projects/#{project.id}/import_project_members/#{project2.id}", user) + + control_project = create(:project) + control_project.add_maintainer(user) + control_project.add_developer(create(:user)) + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post api("/projects/#{project.id}/import_project_members/#{control_project.id}", user) + end + + measure_project = create(:project) + measure_project.add_maintainer(user) + measure_project.add_developer(create(:user)) + measure_project.add_developer(create(:user)) # make this 2nd one to find any n+1 + + unresolved_n_plus_ones = 21 # 21 queries added per member + + expect do + post api("/projects/#{project.id}/import_project_members/#{measure_project.id}", user) + end.not_to exceed_all_query_limit(control.count).with_threshold(unresolved_n_plus_ones) + end + it 'returns 200 when it successfully imports members from another project' do expect do post api("/projects/#{project.id}/import_project_members/#{project2.id}", user) diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb index 6038682de1e..c6bf72176a8 100644 --- a/spec/requests/api/releases_spec.rb +++ b/spec/requests/api/releases_spec.rb @@ -1358,4 +1358,95 @@ RSpec.describe API::Releases do release_cli: release_cli ) end + + describe 'GET /groups/:id/releases' do + let_it_be(:user1) { create(:user, can_create_group: false) } + let_it_be(:admin) { create(:admin) } + let_it_be(:group1) { create(:group) } + let_it_be(:group2) { create(:group, :private) } + let_it_be(:project1) { create(:project, namespace: group1) } + let_it_be(:project2) { create(:project, namespace: group2) } + let_it_be(:project3) { create(:project, namespace: group1, path: 'test', visibility_level: Gitlab::VisibilityLevel::PRIVATE) } + let_it_be(:release1) { create(:release, project: project1) } + let_it_be(:release2) { create(:release, project: project2) } + let_it_be(:release3) { create(:release, project: project3) } + + context 'when authenticated as owner' do + it 'gets releases from all projects in the group' do + get api("/groups/#{group1.id}/releases", admin) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.length).to eq(2) + expect(json_response.pluck('name')).to match_array([release1.name, release3.name]) + end + + it 'respects order by parameters' do + create(:release, project: project1, released_at: DateTime.now + 1.day) + get api("/groups/#{group1.id}/releases", admin), params: { sort: 'desc' } + + expect(DateTime.parse(json_response[0]["released_at"])) + .to be > (DateTime.parse(json_response[1]["released_at"])) + end + + it 'respects the simple parameter' do + get api("/groups/#{group1.id}/releases", admin), params: { simple: true } + + expect(json_response[0].keys).not_to include("assets") + end + + it 'denies access to private groups' do + get api("/groups/#{group2.id}/releases", user1), params: { simple: true } + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns not found unless :group_releases_finder_inoperator feature flag enabled' do + stub_feature_flags(group_releases_finder_inoperator: false) + + get api("/groups/#{group1.id}/releases", admin) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when authenticated as guest' do + before do + group1.add_guest(guest) + end + + it "does not expose tag, commit, source code or helper paths" do + get api("/groups/#{group1.id}/releases", guest) + + expect(response).to match_response_schema('public_api/v4/release/releases_for_guest') + expect(json_response[0]['assets']['count']).to eq(release1.links.count) + expect(json_response[0]['commit_path']).to be_nil + expect(json_response[0]['tag_path']).to be_nil + end + end + + context 'performance testing' do + shared_examples 'avoids N+1 queries' do |query_params = {}| + context 'with subgroups' do + let(:group) { create(:group) } + + it 'include_subgroups avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get api("/groups/#{group.id}/releases", admin), params: query_params.merge({ include_subgroups: true }) + end.count + + subgroups = create_list(:group, 10, parent: group1) + projects = create_list(:project, 10, namespace: subgroups[0]) + create_list(:release, 10, project: projects[0], author: admin) + + expect do + get api("/groups/#{group.id}/releases", admin), params: query_params.merge({ include_subgroups: true }) + end.not_to exceed_all_query_limit(control_count) + end + end + end + + it_behaves_like 'avoids N+1 queries' + it_behaves_like 'avoids N+1 queries', { simple: true } + end + end end diff --git a/spec/requests/api/remote_mirrors_spec.rb b/spec/requests/api/remote_mirrors_spec.rb index 436efb708fd..338647224e0 100644 --- a/spec/requests/api/remote_mirrors_spec.rb +++ b/spec/requests/api/remote_mirrors_spec.rb @@ -26,6 +26,26 @@ RSpec.describe API::RemoteMirrors do end end + describe 'GET /projects/:id/remote_mirrors/:mirror_id' do + let(:route) { "/projects/#{project.id}/remote_mirrors/#{mirror.id}" } + let(:mirror) { project.remote_mirrors.first } + + it 'requires `admin_remote_mirror` permission' do + get api(route, developer) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'returns at remote mirror' do + project.add_maintainer(user) + + get api(route, user) + + expect(response).to have_gitlab_http_status(:success) + expect(response).to match_response_schema('remote_mirror') + end + end + describe 'POST /projects/:id/remote_mirrors' do let(:route) { "/projects/#{project.id}/remote_mirrors" } @@ -75,11 +95,11 @@ RSpec.describe API::RemoteMirrors do end describe 'PUT /projects/:id/remote_mirrors/:mirror_id' do - let(:route) { ->(id) { "/projects/#{project.id}/remote_mirrors/#{id}" } } + let(:route) { "/projects/#{project.id}/remote_mirrors/#{mirror.id}" } let(:mirror) { project.remote_mirrors.first } it 'requires `admin_remote_mirror` permission' do - put api(route[mirror.id], developer) + put api(route, developer) expect(response).to have_gitlab_http_status(:unauthorized) end @@ -87,7 +107,7 @@ RSpec.describe API::RemoteMirrors do it 'updates a remote mirror' do project.add_maintainer(user) - put api(route[mirror.id], user), params: { + put api(route, user), params: { enabled: '0', only_protected_branches: 'true', keep_divergent_refs: 'true' @@ -99,4 +119,44 @@ RSpec.describe API::RemoteMirrors do expect(json_response['keep_divergent_refs']).to eq(true) end end + + describe 'DELETE /projects/:id/remote_mirrors/:mirror_id' do + let(:route) { ->(id) { "/projects/#{project.id}/remote_mirrors/#{id}" } } + let(:mirror) { project.remote_mirrors.first } + + it 'requires `admin_remote_mirror` permission' do + expect { delete api(route[mirror.id], developer) }.not_to change { project.remote_mirrors.count } + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + context 'when the user is a maintainer' do + before do + project.add_maintainer(user) + end + + it 'returns 404 for non existing id' do + delete api(route[non_existing_record_id], user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns bad request if the update service fails' do + expect_next_instance_of(Projects::UpdateService) do |service| + expect(service).to receive(:execute).and_return(status: :error, message: 'message') + end + + expect { delete api(route[mirror.id], user) }.not_to change { project.remote_mirrors.count } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq({ 'message' => 'message' }) + end + + it 'deletes a remote mirror' do + expect { delete api(route[mirror.id], user) }.to change { project.remote_mirrors.count }.from(1).to(0) + + expect(response).to have_gitlab_http_status(:no_content) + end + end + end end diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index 1d199a72d1d..d6d2bd5baf2 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -452,7 +452,7 @@ RSpec.describe API::Repositories do it "compare commits between different projects" do group = create(:group) - group.add_owner(current_user) + group.add_owner(current_user) if current_user forked_project = fork_project(project, current_user, repository: true, namespace: group) forked_project.repository.create_ref('refs/heads/improve/awesome', 'refs/heads/improve/more-awesome') diff --git a/spec/requests/api/resource_access_tokens_spec.rb b/spec/requests/api/resource_access_tokens_spec.rb index 7e3e682767f..369a8c1b0ab 100644 --- a/spec/requests/api/resource_access_tokens_spec.rb +++ b/spec/requests/api/resource_access_tokens_spec.rb @@ -29,6 +29,8 @@ RSpec.describe API::ResourceAccessTokens do token_ids = json_response.map { |token| token['id'] } expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(response).to match_response_schema('public_api/v4/resource_access_tokens') expect(token_ids).to match_array(access_tokens.pluck(:id)) end @@ -131,6 +133,103 @@ RSpec.describe API::ResourceAccessTokens do end end + context "GET #{source_type}s/:id/access_tokens/:token_id" do + subject(:get_token) { get api("/#{source_type}s/#{resource_id}/access_tokens/#{token_id}", user) } + + let_it_be(:project_bot) { create(:user, :project_bot) } + let_it_be(:token) { create(:personal_access_token, user: project_bot) } + let_it_be(:resource_id) { resource.id } + let_it_be(:token_id) { token.id } + + before do + if source_type == 'project' + resource.add_maintainer(project_bot) + else + resource.add_owner(project_bot) + end + end + + context "when the user has valid permissions" do + it "gets the #{source_type} access token from the #{source_type}" do + get_token + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/resource_access_token') + + expect(json_response["name"]).to eq(token.name) + expect(json_response["scopes"]).to eq(token.scopes) + + if source_type == 'project' + expect(json_response["access_level"]).to eq(resource.team.max_member_access(token.user.id)) + else + expect(json_response["access_level"]).to eq(resource.max_member_access_for_user(token.user)) + end + + expect(json_response["expires_at"]).to eq(token.expires_at.to_date.iso8601) + end + + context "when using #{source_type} access token to GET other #{source_type} access token" do + let_it_be(:other_project_bot) { create(:user, :project_bot) } + let_it_be(:other_token) { create(:personal_access_token, user: other_project_bot) } + let_it_be(:token_id) { other_token.id } + + before do + resource.add_maintainer(other_project_bot) + end + + it "gets the #{source_type} access token from the #{source_type}" do + get_token + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/resource_access_token') + + expect(json_response["name"]).to eq(other_token.name) + expect(json_response["scopes"]).to eq(other_token.scopes) + + if source_type == 'project' + expect(json_response["access_level"]).to eq(resource.team.max_member_access(other_token.user.id)) + else + expect(json_response["access_level"]).to eq(resource.max_member_access_for_user(other_token.user)) + end + + expect(json_response["expires_at"]).to eq(other_token.expires_at.to_date.iso8601) + end + end + + context "when attempting to get a non-existent #{source_type} access token" do + let_it_be(:token_id) { non_existing_record_id } + + it "does not get the token, and returns 404" do + get_token + + expect(response).to have_gitlab_http_status(:not_found) + expect(response.body).to include("Could not find #{source_type} access token with token_id: #{token_id}") + end + end + + context "when attempting to get a token that does not belong to the specified #{source_type}" do + let_it_be(:resource_id) { other_resource.id } + + it "does not get the token, and returns 404" do + get_token + + expect(response).to have_gitlab_http_status(:not_found) + expect(response.body).to include("Could not find #{source_type} access token with token_id: #{token_id}") + end + end + end + + context "when the user does not have valid permissions" do + let_it_be(:user) { user_non_priviledged } + + it "returns 401" do + get_token + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + context "DELETE #{source_type}s/:id/access_tokens/:token_id", :sidekiq_inline do subject(:delete_token) { delete api("/#{source_type}s/#{resource_id}/access_tokens/#{token_id}", user) } diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index f7048a1ca6b..c724c69045e 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -91,7 +91,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do end end - it "updates application settings" do + it "updates application settings", fips_mode: false do put api("/application/settings", admin), params: { default_ci_config_path: 'debian/salsa-ci.yml', @@ -286,6 +286,55 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do expect(json_response['hashed_storage_enabled']).to eq(true) end + context 'SSH key restriction settings', :fips_mode do + let(:settings) do + { + dsa_key_restriction: -1, + ecdsa_key_restriction: 256, + ecdsa_sk_key_restriction: 256, + ed25519_key_restriction: 256, + ed25519_sk_key_restriction: 256, + rsa_key_restriction: 3072 + } + end + + it 'allows updating the settings' do + put api("/application/settings", admin), params: settings + + expect(response).to have_gitlab_http_status(:ok) + settings.each do |attribute, value| + expect(ApplicationSetting.current.public_send(attribute)).to eq(value) + end + end + + it 'does not allow DSA keys' do + put api("/application/settings", admin), params: { dsa_key_restriction: 1024 } + + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'does not allow short RSA key values' do + put api("/application/settings", admin), params: { rsa_key_restriction: 2048 } + + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'does not allow unrestricted key lengths' do + types = %w(dsa_key_restriction + ecdsa_key_restriction + ecdsa_sk_key_restriction + ed25519_key_restriction + ed25519_sk_key_restriction + rsa_key_restriction) + + types.each do |type| + put api("/application/settings", admin), params: { type => 0 } + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end + context 'external policy classification settings' do let(:settings) do { diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index eadceeba03b..c554463df76 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -83,19 +83,21 @@ RSpec.describe API::Users do describe 'GET /users/' do context 'when unauthenticated' do - it "does not contain the note of users" do + it "does not contain certain fields" do get api("/users"), params: { username: user.username } expect(json_response.first).not_to have_key('note') + expect(json_response.first).not_to have_key('namespace_id') end end context 'when authenticated' do context 'as a regular user' do - it 'does not contain the note of users' do + it 'does not contain certain fields' do get api("/users", user), params: { username: user.username } expect(json_response.first).not_to have_key('note') + expect(json_response.first).not_to have_key('namespace_id') end end @@ -154,6 +156,7 @@ RSpec.describe API::Users do get api("/user", user) expect(json_response).not_to have_key('note') + expect(json_response).not_to have_key('namespace_id') end end end @@ -335,12 +338,14 @@ RSpec.describe API::Users do expect(response).to match_response_schema('public_api/v4/user/basics') expect(json_response.first.keys).not_to include 'is_admin' end + end + context "when admin" do context 'exclude_internal param' do let_it_be(:internal_user) { User.alert_bot } it 'returns all users when it is not set' do - get api("/users?exclude_internal=false", user) + get api("/users?exclude_internal=false", admin) expect(response).to match_response_schema('public_api/v4/user/basics') expect(response).to include_pagination_headers @@ -356,6 +361,26 @@ RSpec.describe API::Users do end end + context 'without_project_bots param' do + let_it_be(:project_bot) { create(:user, :project_bot) } + + it 'returns all users when it is not set' do + get api("/users?without_project_bots=false", user) + + expect(response).to match_response_schema('public_api/v4/user/basics') + expect(response).to include_pagination_headers + expect(json_response.map { |u| u['id'] }).to include(project_bot.id) + end + + it 'returns all non project_bot users when it is set' do + get api("/users?without_project_bots=true", user) + + expect(response).to match_response_schema('public_api/v4/user/basics') + expect(response).to include_pagination_headers + expect(json_response.map { |u| u['id'] }).not_to include(project_bot.id) + end + end + context 'admins param' do it 'returns all users' do get api("/users?admins=true", user) @@ -384,6 +409,15 @@ RSpec.describe API::Users do expect(response).to include_pagination_headers end + it "users contain the `namespace_id` field" do + get api("/users", admin) + + expect(response).to have_gitlab_http_status(:success) + expect(response).to match_response_schema('public_api/v4/user/admins') + expect(json_response.size).to eq(2) + expect(json_response.map { |u| u['namespace_id'] }).to include(user.namespace_id, admin.namespace_id) + end + it "returns an array of external users" do create(:user, external: true) @@ -697,6 +731,14 @@ RSpec.describe API::Users do expect(json_response['highest_role']).to be(0) end + it 'includes the `namespace_id` field' do + get api("/users/#{user.id}", admin) + + expect(response).to have_gitlab_http_status(:success) + expect(response).to match_response_schema('public_api/v4/user/admin') + expect(json_response['namespace_id']).to eq(user.namespace_id) + end + if Gitlab.ee? it 'does not include values for plan or trial' do get api("/users/#{user.id}", admin) @@ -1934,7 +1976,7 @@ RSpec.describe API::Users do end end - describe "POST /users/:id/emails" do + describe "POST /users/:id/emails", :mailer do it "does not create invalid email" do post api("/users/#{user.id}/emails", admin), params: {} @@ -1944,11 +1986,15 @@ RSpec.describe API::Users do it "creates unverified email" do email_attrs = attributes_for :email - expect do - post api("/users/#{user.id}/emails", admin), params: email_attrs - end.to change { user.emails.count }.by(1) + + perform_enqueued_jobs do + expect do + post api("/users/#{user.id}/emails", admin), params: email_attrs + end.to change { user.emails.count }.by(1) + end expect(json_response['confirmed_at']).to be_nil + should_email(user) end it "returns a 400 for invalid ID" do diff --git a/spec/requests/api/v3/github_spec.rb b/spec/requests/api/v3/github_spec.rb index 838948132dd..5bfea15f0ca 100644 --- a/spec/requests/api/v3/github_spec.rb +++ b/spec/requests/api/v3/github_spec.rb @@ -9,7 +9,7 @@ RSpec.describe API::V3::Github do let_it_be_with_reload(:project) { create(:project, :repository, creator: user) } before do - project.add_maintainer(user) + project.add_maintainer(user) if user end describe 'GET /orgs/:namespace/repos' do diff --git a/spec/requests/groups/crm/contacts_controller_spec.rb b/spec/requests/groups/crm/contacts_controller_spec.rb index 4d8ca0fcd60..0ee72233418 100644 --- a/spec/requests/groups/crm/contacts_controller_spec.rb +++ b/spec/requests/groups/crm/contacts_controller_spec.rb @@ -85,28 +85,19 @@ RSpec.describe Groups::Crm::ContactsController do end describe 'GET #index' do - subject do - get group_crm_contacts_path(group) - response - end + subject { get group_crm_contacts_path(group) } it_behaves_like 'ok response with index template if authorized' end describe 'GET #new' do - subject do - get new_group_crm_contact_path(group) - response - end + subject { get new_group_crm_contact_path(group) } it_behaves_like 'ok response with index template if authorized' end describe 'GET #edit' do - subject do - get edit_group_crm_contact_path(group, id: 1) - response - end + subject { get edit_group_crm_contact_path(group, id: 1) } it_behaves_like 'ok response with index template if authorized' end diff --git a/spec/requests/groups/crm/organizations_controller_spec.rb b/spec/requests/groups/crm/organizations_controller_spec.rb index 37ffac71772..410fc979262 100644 --- a/spec/requests/groups/crm/organizations_controller_spec.rb +++ b/spec/requests/groups/crm/organizations_controller_spec.rb @@ -85,18 +85,19 @@ RSpec.describe Groups::Crm::OrganizationsController do end describe 'GET #index' do - subject do - get group_crm_organizations_path(group) - response - end + subject { get group_crm_organizations_path(group) } it_behaves_like 'ok response with index template if authorized' end describe 'GET #new' do - subject do - get new_group_crm_organization_path(group) - end + subject { get new_group_crm_organization_path(group) } + + it_behaves_like 'ok response with index template if authorized' + end + + describe 'GET #edit' do + subject { get edit_group_crm_organization_path(group, id: 1) } it_behaves_like 'ok response with index template if authorized' end diff --git a/spec/requests/groups/email_campaigns_controller_spec.rb b/spec/requests/groups/email_campaigns_controller_spec.rb index 9ed828d1a9a..4d630ef6710 100644 --- a/spec/requests/groups/email_campaigns_controller_spec.rb +++ b/spec/requests/groups/email_campaigns_controller_spec.rb @@ -94,7 +94,7 @@ RSpec.describe Groups::EmailCampaignsController do describe 'track parameter' do context 'when valid' do - where(track: [Namespaces::InProductMarketingEmailsService::TRACKS.keys.without(:experience), Namespaces::InviteTeamEmailService::TRACK].flatten) + where(track: Namespaces::InProductMarketingEmailsService::TRACKS.keys.without(:experience)) with_them do it_behaves_like 'track and redirect' @@ -117,10 +117,6 @@ RSpec.describe Groups::EmailCampaignsController do with_them do it_behaves_like 'track and redirect' end - - it_behaves_like 'track and redirect' do - let(:track) { Namespaces::InviteTeamEmailService::TRACK.to_s } - end end context 'when invalid' do @@ -128,10 +124,6 @@ RSpec.describe Groups::EmailCampaignsController do with_them do it_behaves_like 'no track and 404' - - it_behaves_like 'no track and 404' do - let(:track) { Namespaces::InviteTeamEmailService::TRACK.to_s } - end end end end diff --git a/spec/requests/import/gitlab_groups_controller_spec.rb b/spec/requests/import/gitlab_groups_controller_spec.rb index 4abf99cf994..8d5c1e3ebab 100644 --- a/spec/requests/import/gitlab_groups_controller_spec.rb +++ b/spec/requests/import/gitlab_groups_controller_spec.rb @@ -155,20 +155,6 @@ RSpec.describe Import::GitlabGroupsController do end end - context 'when group import FF is disabled' do - let(:request_params) { { path: 'test-group-import', name: 'test-group-import' } } - - before do - stub_feature_flags(group_import_export: false) - end - - it 'returns an error' do - expect { import_request }.not_to change { Group.count } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - context 'when the parent group is invalid' do let(:request_params) { { path: 'test-group-import', name: 'test-group-import', parent_id: -1 } } diff --git a/spec/requests/jira_authorizations_spec.rb b/spec/requests/jira_authorizations_spec.rb index 24c6001814c..b43d36e94f4 100644 --- a/spec/requests/jira_authorizations_spec.rb +++ b/spec/requests/jira_authorizations_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'Jira authorization requests' do let(:user) { create :user } let(:application) { create :oauth_application, scopes: 'api' } - let(:redirect_uri) { oauth_jira_callback_url(host: "http://www.example.com") } + let(:redirect_uri) { oauth_jira_dvcs_callback_url(host: "http://www.example.com") } def generate_access_grant create :oauth_access_grant, application: application, resource_owner_id: user.id, redirect_uri: redirect_uri diff --git a/spec/requests/projects/work_items_spec.rb b/spec/requests/projects/work_items_spec.rb new file mode 100644 index 00000000000..e6365a3824a --- /dev/null +++ b/spec/requests/projects/work_items_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Work Items' do + let_it_be(:work_item) { create(:work_item) } + let_it_be(:developer) { create(:user) } + + before_all do + work_item.project.add_developer(developer) + end + + describe 'GET /:namespace/:project/work_items/:id' do + before do + sign_in(developer) + end + + context 'when the work_items feature flag is enabled' do + it 'renders index' do + get project_work_items_url(work_item.project, work_items_path: work_item.id) + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when the work_items feature flag is disabled' do + before do + stub_feature_flags(work_items: false) + end + + it 'returns 404' do + get project_work_items_url(work_item.project, work_items_path: work_item.id) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end +end diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb index 8c36d7d4668..f48b4de23a2 100644 --- a/spec/routing/admin_routing_spec.rb +++ b/spec/routing/admin_routing_spec.rb @@ -134,10 +134,17 @@ RSpec.describe Admin::HealthCheckController, "routing" do end end -# admin_dev_ops_report GET /admin/dev_ops_report(.:format) admin/dev_ops_report#show +# admin_dev_ops_reports GET /admin/dev_ops_reports(.:format) admin/dev_ops_report#show RSpec.describe Admin::DevOpsReportController, "routing" do it "to #show" do - expect(get("/admin/dev_ops_report")).to route_to('admin/dev_ops_report#show') + expect(get("/admin/dev_ops_reports")).to route_to('admin/dev_ops_report#show') + end + + describe 'admin devops reports' do + include RSpec::Rails::RequestExampleGroup + it 'redirects from /admin/dev_ops_report to /admin/dev_ops_reports' do + expect(get("/admin/dev_ops_report")).to redirect_to(admin_dev_ops_reports_path) + end end end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index 65772895826..21012399edf 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -905,6 +905,13 @@ RSpec.describe 'project routing' do ) end + it 'routes to 404 without format for invalid page' do + expect(get: "/gitlab/gitlabhq/-/metrics/invalid_page.md").to route_to( + 'application#route_not_found', + unmatched_route: 'gitlab/gitlabhq/-/metrics/invalid_page.md' + ) + end + it 'routes to 404 with invalid dashboard_path' do expect(get: "/gitlab/gitlabhq/-/metrics/invalid_dashboard").to route_to( 'application#route_not_found', diff --git a/spec/routing/uploads_routing_spec.rb b/spec/routing/uploads_routing_spec.rb index d1ddf8a6d6a..41646d1b515 100644 --- a/spec/routing/uploads_routing_spec.rb +++ b/spec/routing/uploads_routing_spec.rb @@ -21,6 +21,17 @@ RSpec.describe 'Uploads', 'routing' do ) end + it 'allows fetching alert metric metric images' do + expect(get('/uploads/-/system/alert_management_metric_image/file/1/test.jpg')).to route_to( + controller: 'uploads', + action: 'show', + model: 'alert_management_metric_image', + id: '1', + filename: 'test.jpg', + mounted_as: 'file' + ) + end + it 'does not allow creating uploads for other models' do unroutable_models = UploadsController::MODEL_CLASSES.keys.compact - %w(personal_snippet user) diff --git a/spec/rubocop/cop/database/disable_referential_integrity_spec.rb b/spec/rubocop/cop/database/disable_referential_integrity_spec.rb new file mode 100644 index 00000000000..9ac67363cb6 --- /dev/null +++ b/spec/rubocop/cop/database/disable_referential_integrity_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require_relative '../../../../rubocop/cop/database/disable_referential_integrity' + +RSpec.describe RuboCop::Cop::Database::DisableReferentialIntegrity do + subject(:cop) { described_class.new } + + it 'does not flag the use of disable_referential_integrity with a send receiver' do + expect_offense(<<~SOURCE) + foo.disable_referential_integrity + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use `disable_referential_integrity`, [...] + SOURCE + end + + it 'flags the use of disable_referential_integrity with a full definition' do + expect_offense(<<~SOURCE) + ActiveRecord::Base.connection.disable_referential_integrity + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use `disable_referential_integrity`, [...] + SOURCE + end + + it 'flags the use of disable_referential_integrity with a nil receiver' do + expect_offense(<<~SOURCE) + class Foo ; disable_referential_integrity ; end + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use `disable_referential_integrity`, [...] + SOURCE + end + + it 'flags the use of disable_referential_integrity when passing a block' do + expect_offense(<<~SOURCE) + class Foo ; disable_referential_integrity { :foo } ; end + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use `disable_referential_integrity`, [...] + SOURCE + end +end diff --git a/spec/rubocop/cop/gitlab/avoid_feature_category_not_owned_spec.rb b/spec/rubocop/cop/gitlab/avoid_feature_category_not_owned_spec.rb new file mode 100644 index 00000000000..f6c6955f6bb --- /dev/null +++ b/spec/rubocop/cop/gitlab/avoid_feature_category_not_owned_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require_relative '../../../../rubocop/cop/gitlab/avoid_feature_category_not_owned' + +RSpec.describe RuboCop::Cop::Gitlab::AvoidFeatureCategoryNotOwned do + subject(:cop) { described_class.new } + + shared_examples 'defining feature category on a class' do + it 'flags a method call on a class' do + expect_offense(<<~SOURCE) + feature_category :not_owned + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid adding new endpoints with `feature_category :not_owned`. See https://docs.gitlab.com/ee/development/feature_categorization + SOURCE + end + + it 'flags a method call on a class with an array passed' do + expect_offense(<<~SOURCE) + feature_category :not_owned, [:index, :edit] + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid adding new endpoints with `feature_category :not_owned`. See https://docs.gitlab.com/ee/development/feature_categorization + SOURCE + end + + it 'flags a method call on a class with an array passed' do + expect_offense(<<~SOURCE) + worker.feature_category :not_owned, [:index, :edit] + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid adding new endpoints with `feature_category :not_owned`. See https://docs.gitlab.com/ee/development/feature_categorization + SOURCE + end + end + + context 'in controllers' do + before do + allow(subject).to receive(:in_controller?).and_return(true) + end + + it_behaves_like 'defining feature category on a class' + end + + context 'in workers' do + before do + allow(subject).to receive(:in_worker?).and_return(true) + end + + it_behaves_like 'defining feature category on a class' + end + + context 'for grape endpoints' do + before do + allow(subject).to receive(:in_api?).and_return(true) + end + + it_behaves_like 'defining feature category on a class' + + it 'flags when passed as a hash for a Grape endpoint as keyword args' do + expect_offense(<<~SOURCE) + get :hello, feature_category: :not_owned + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid adding new endpoints with `feature_category :not_owned`. See https://docs.gitlab.com/ee/development/feature_categorization + SOURCE + end + + it 'flags when passed as a hash for a Grape endpoint in a hash' do + expect_offense(<<~SOURCE) + get :hello, { feature_category: :not_owned, urgency: :low} + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid adding new endpoints with `feature_category :not_owned`. See https://docs.gitlab.com/ee/development/feature_categorization + SOURCE + end + end +end diff --git a/spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb b/spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb deleted file mode 100644 index fb424da90e8..00000000000 --- a/spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'fast_spec_helper' - -require_relative '../../../../rubocop/cop/qa/duplicate_testcase_link' - -RSpec.describe RuboCop::Cop::QA::DuplicateTestcaseLink do - let(:source_file) { 'qa/page.rb' } - - subject(:cop) { described_class.new } - - context 'in a QA file' do - before do - allow(cop).to receive(:in_qa_file?).and_return(true) - end - - it "registers an offense for a duplicate testcase link" do - expect_offense(<<-RUBY) - it 'some test', testcase: '/quality/test_cases/1892' do - end - it 'another test', testcase: '/quality/test_cases/1892' do - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't reuse the same testcase link in different tests. Replace one of `/quality/test_cases/1892`. - end - RUBY - end - - it "doesnt offend if testcase link is unique" do - expect_no_offenses(<<-RUBY) - it 'some test', testcase: '/quality/test_cases/1893' do - end - it 'another test', testcase: '/quality/test_cases/1894' do - end - RUBY - end - end -end diff --git a/spec/rubocop/cop/qa/testcase_link_format_spec.rb b/spec/rubocop/cop/qa/testcase_link_format_spec.rb deleted file mode 100644 index f9b43f2a293..00000000000 --- a/spec/rubocop/cop/qa/testcase_link_format_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -require 'fast_spec_helper' - -require_relative '../../../../rubocop/cop/qa/testcase_link_format' - -RSpec.describe RuboCop::Cop::QA::TestcaseLinkFormat do - let(:source_file) { 'qa/page.rb' } - let(:msg) { 'Testcase link format incorrect. Please link a test case from the GitLab project. See: https://docs.gitlab.com/ee/development/testing_guide/end_to_end/best_practices.html#link-a-test-to-its-test-case.' } - - subject(:cop) { described_class.new } - - context 'in a QA file' do - before do - allow(cop).to receive(:in_qa_file?).and_return(true) - end - - it "registers an offense for a testcase link for an issue" do - node = "it 'another test', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/issues/557' do" - - expect_offense(<<-RUBY, node: node, msg: msg) - %{node} - ^{node} %{msg} - end - RUBY - end - - it "registers an offense for a testcase link for the wrong project" do - node = "it 'another test', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/quality/test_cases/2455' do" - - expect_offense(<<-RUBY, node: node, msg: msg) - %{node} - ^{node} %{msg} - end - RUBY - end - - it "doesnt offend if testcase link is correct" do - expect_no_offenses(<<-RUBY) - it 'some test', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/348312' do - end - RUBY - end - end -end diff --git a/spec/serializers/commit_entity_spec.rb b/spec/serializers/commit_entity_spec.rb index 6b4346faf5b..ba2d7e17d1a 100644 --- a/spec/serializers/commit_entity_spec.rb +++ b/spec/serializers/commit_entity_spec.rb @@ -3,8 +3,6 @@ require 'spec_helper' RSpec.describe CommitEntity do - let(:signature_html) { 'TEST' } - let(:entity) do described_class.new(commit, request: request) end @@ -16,11 +14,7 @@ RSpec.describe CommitEntity do subject { entity.as_json } before do - render = double('render') - allow(render).to receive(:call).and_return(signature_html) - allow(request).to receive(:project).and_return(project) - allow(request).to receive(:render).and_return(render) end context 'when commit author is a user' do @@ -83,8 +77,7 @@ RSpec.describe CommitEntity do let(:commit) { project.commit(TestEnv::BRANCH_SHA['signed-commits']) } it 'exposes "signature_html"' do - expect(request.render).to receive(:call) - expect(subject.fetch(:signature_html)).to be signature_html + expect(subject.fetch(:signature_html)).not_to be_nil end end diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb index 1dacc9513ee..500d5718bf1 100644 --- a/spec/serializers/deployment_entity_spec.rb +++ b/spec/serializers/deployment_entity_spec.rb @@ -9,7 +9,7 @@ RSpec.describe DeploymentEntity do let(:project) { create(:project, :repository) } let(:request) { double('request') } let(:deployment) { create(:deployment, deployable: build, project: project) } - let(:build) { create(:ci_build, :manual, pipeline: pipeline) } + let(:build) { create(:ci_build, :manual, :environment_with_deployment_tier, pipeline: pipeline) } let(:pipeline) { create(:ci_pipeline, project: project, user: user) } let(:entity) { described_class.new(deployment, request: request) } @@ -46,6 +46,10 @@ RSpec.describe DeploymentEntity do expect(subject).to include(:is_last) end + it 'exposes deployment tier in yaml' do + expect(subject).to include(:tier_in_yaml) + end + context 'when deployable is nil' do let(:entity) { described_class.new(deployment, request: request, deployment_details: false) } let(:deployment) { create(:deployment, deployable: nil, project: project) } diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb index ec0dd735755..fe6278084f9 100644 --- a/spec/serializers/environment_serializer_spec.rb +++ b/spec/serializers/environment_serializer_spec.rb @@ -50,7 +50,7 @@ RSpec.describe EnvironmentSerializer do context 'when there is a single environment' do before do - create(:environment, name: 'staging') + create(:environment, project: project, name: 'staging') end it 'represents one standalone environment' do @@ -63,8 +63,8 @@ RSpec.describe EnvironmentSerializer do context 'when there are multiple environments in folder' do before do - create(:environment, name: 'staging/my-review-1') - create(:environment, name: 'staging/my-review-2') + create(:environment, project: project, name: 'staging/my-review-1') + create(:environment, project: project, name: 'staging/my-review-2') end it 'represents one item that is a folder' do @@ -78,10 +78,10 @@ RSpec.describe EnvironmentSerializer do context 'when there are multiple folders and standalone environments' do before do - create(:environment, name: 'staging/my-review-1') - create(:environment, name: 'staging/my-review-2') - create(:environment, name: 'production/my-review-3') - create(:environment, name: 'testing') + create(:environment, project: project, name: 'staging/my-review-1') + create(:environment, project: project, name: 'staging/my-review-2') + create(:environment, project: project, name: 'production/my-review-3') + create(:environment, project: project, name: 'testing') end it 'represents multiple items grouped within folders' do @@ -124,7 +124,7 @@ RSpec.describe EnvironmentSerializer do context 'when resource is paginatable relation' do context 'when there is a single environment object in relation' do before do - create(:environment) + create(:environment, project: project) end it 'serializes environments' do @@ -134,7 +134,7 @@ RSpec.describe EnvironmentSerializer do context 'when multiple environment objects are serialized' do before do - create_list(:environment, 3) + create_list(:environment, 3, project: project) end it 'serializes appropriate number of objects' do @@ -159,10 +159,10 @@ RSpec.describe EnvironmentSerializer do end before do - create(:environment, name: 'staging/review-1') - create(:environment, name: 'staging/review-2') - create(:environment, name: 'production/deploy-3') - create(:environment, name: 'testing') + create(:environment, project: project, name: 'staging/review-1') + create(:environment, project: project, name: 'staging/review-2') + create(:environment, project: project, name: 'production/deploy-3') + create(:environment, project: project, name: 'testing') end it 'paginates grouped items including ordering' do @@ -189,7 +189,7 @@ RSpec.describe EnvironmentSerializer do let(:resource) { Environment.all } before do - create(:environment, name: 'staging/review-1') + create(:environment, project: project, name: 'staging/review-1') create_environment_with_associations(project) end diff --git a/spec/serializers/group_link/group_group_link_entity_spec.rb b/spec/serializers/group_link/group_group_link_entity_spec.rb index 2821c433784..502cdc5c048 100644 --- a/spec/serializers/group_link/group_group_link_entity_spec.rb +++ b/spec/serializers/group_link/group_group_link_entity_spec.rb @@ -7,7 +7,7 @@ RSpec.describe GroupLink::GroupGroupLinkEntity do let_it_be(:current_user) { create(:user) } - let(:entity) { described_class.new(group_group_link) } + let(:entity) { described_class.new(group_group_link, { current_user: current_user, source: shared_group }) } before do allow(entity).to receive(:current_user).and_return(current_user) @@ -17,16 +17,56 @@ RSpec.describe GroupLink::GroupGroupLinkEntity do expect(entity.to_json).to match_schema('group_link/group_group_link') end + context 'source' do + it 'exposes `source`' do + expect(entity.as_json[:source]).to include( + id: shared_group.id, + full_name: shared_group.full_name, + web_url: shared_group.web_url + ) + end + end + + context 'is_direct_member' do + it 'exposes `is_direct_member` as true for corresponding group' do + expect(entity.as_json[:is_direct_member]).to be true + end + + it 'exposes `is_direct_member` as false for other source' do + entity = described_class.new(group_group_link, { current_user: current_user, source: shared_with_group }) + expect(entity.as_json[:is_direct_member]).to be false + end + end + context 'when current user has `:admin_group_member` permissions' do before do allow(entity).to receive(:can?).with(current_user, :admin_group_member, shared_group).and_return(true) end - it 'exposes `can_update` and `can_remove` as `true`' do - json = entity.as_json + context 'when direct_member? is true' do + before do + allow(entity).to receive(:direct_member?).and_return(true) + end + + it 'exposes `can_update` and `can_remove` as `true`' do + json = entity.as_json + + expect(json[:can_update]).to be true + expect(json[:can_remove]).to be true + end + end + + context 'when direct_member? is false' do + before do + allow(entity).to receive(:direct_member?).and_return(false) + end + + it 'exposes `can_update` and `can_remove` as `true`' do + json = entity.as_json - expect(json[:can_update]).to be true - expect(json[:can_remove]).to be true + expect(json[:can_update]).to be false + expect(json[:can_remove]).to be false + end end end end diff --git a/spec/serializers/group_link/project_group_link_entity_spec.rb b/spec/serializers/group_link/project_group_link_entity_spec.rb index e7e42d79b5e..f2a9f3a107a 100644 --- a/spec/serializers/group_link/project_group_link_entity_spec.rb +++ b/spec/serializers/group_link/project_group_link_entity_spec.rb @@ -6,7 +6,7 @@ RSpec.describe GroupLink::ProjectGroupLinkEntity do let_it_be(:current_user) { create(:user) } let_it_be(:project_group_link) { create(:project_group_link) } - let(:entity) { described_class.new(project_group_link) } + let(:entity) { described_class.new(project_group_link, { current_user: current_user, source: project_group_link.project }) } before do allow(entity).to receive(:current_user).and_return(current_user) diff --git a/spec/serializers/member_user_entity_spec.rb b/spec/serializers/member_user_entity_spec.rb index b505571cbf2..0e6d4bcc3fb 100644 --- a/spec/serializers/member_user_entity_spec.rb +++ b/spec/serializers/member_user_entity_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe MemberUserEntity do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, last_activity_on: Date.today) } let_it_be(:emoji) { 'slight_smile' } let_it_be(:user_status) { create(:user_status, user: user, emoji: emoji) } @@ -36,4 +36,12 @@ RSpec.describe MemberUserEntity do it 'correctly exposes `status.emoji`' do expect(entity_hash[:status][:emoji]).to match(emoji) end + + it 'correctly exposes `created_at`' do + expect(entity_hash[:created_at]).to be(user.created_at) + end + + it 'correctly exposes `last_activity_on`' do + expect(entity_hash[:last_activity_on]).to be(user.last_activity_on) + end end diff --git a/spec/services/alert_management/metric_images/upload_service_spec.rb b/spec/services/alert_management/metric_images/upload_service_spec.rb new file mode 100644 index 00000000000..527d9db0fd9 --- /dev/null +++ b/spec/services/alert_management/metric_images/upload_service_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AlertManagement::MetricImages::UploadService do + subject(:service) { described_class.new(alert, current_user, params) } + + let_it_be_with_refind(:project) { create(:project) } + let_it_be_with_refind(:alert) { create(:alert_management_alert, project: project) } + let_it_be_with_refind(:current_user) { create(:user) } + + let(:params) do + { + file: fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg'), + url: 'https://www.gitlab.com' + } + end + + describe '#execute' do + subject { service.execute } + + shared_examples 'uploads the metric' do + it 'uploads the metric and returns a success' do + expect { subject }.to change(AlertManagement::MetricImage, :count).by(1) + expect(subject.success?).to eq(true) + expect(subject.payload).to match({ metric: instance_of(AlertManagement::MetricImage), alert: alert }) + end + end + + shared_examples 'no metric saved, an error given' do |message| + it 'returns an error and does not upload', :aggregate_failures do + expect(subject.success?).to eq(false) + expect(subject.message).to match(a_string_matching(message)) + expect(AlertManagement::MetricImage.count).to eq(0) + end + end + + context 'user does not have permissions' do + it_behaves_like 'no metric saved, an error given', 'You are not authorized to upload metric images' + end + + context 'user has permissions' do + before_all do + project.add_developer(current_user) + end + + it_behaves_like 'uploads the metric' + + context 'no url given' do + let(:params) do + { + file: fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') + } + end + + it_behaves_like 'uploads the metric' + end + + context 'record invalid' do + let(:params) do + { + file: fixture_file_upload('spec/fixtures/doc_sample.txt', 'text/plain'), + url: 'https://www.gitlab.com' + } + end + + it_behaves_like 'no metric saved, an error given', /File does not have a supported extension. Only png, jpg, jpeg, gif, bmp, tiff, ico, and webp are supported/ # rubocop: disable Layout/LineLength + end + + context 'user is guest' do + before_all do + project.add_guest(current_user) + end + + it_behaves_like 'no metric saved, an error given', 'You are not authorized to upload metric images' + end + end + end +end diff --git a/spec/services/audit_event_service_spec.rb b/spec/services/audit_event_service_spec.rb index 0379fd3f05c..6963515ba5c 100644 --- a/spec/services/audit_event_service_spec.rb +++ b/spec/services/audit_event_service_spec.rb @@ -17,7 +17,8 @@ RSpec.describe AuditEventService do author_name: user.name, entity_id: project.id, entity_type: "Project", - action: :destroy) + action: :destroy, + created_at: anything) expect { service.security_event }.to change(AuditEvent, :count).by(1) end @@ -39,7 +40,8 @@ RSpec.describe AuditEventService do from: 'true', to: 'false', action: :create, - target_id: 1) + target_id: 1, + created_at: anything) expect { service.security_event }.to change(AuditEvent, :count).by(1) @@ -50,6 +52,25 @@ RSpec.describe AuditEventService do expect(details[:target_id]).to eq(1) end + context 'when defining created_at manually' do + let(:service) { described_class.new(user, project, { action: :destroy }, :database, 3.weeks.ago) } + + it 'is overridden successfully' do + freeze_time do + expect(service).to receive(:file_logger).and_return(logger) + expect(logger).to receive(:info).with(author_id: user.id, + author_name: user.name, + entity_id: project.id, + entity_type: "Project", + action: :destroy, + created_at: 3.weeks.ago) + + expect { service.security_event }.to change(AuditEvent, :count).by(1) + expect(AuditEvent.last.created_at).to eq(3.weeks.ago) + end + end + end + context 'authentication event' do let(:audit_service) { described_class.new(user, user, with: 'standard') } @@ -110,7 +131,8 @@ RSpec.describe AuditEventService do author_name: user.name, entity_type: 'Project', entity_id: project.id, - action: :destroy) + action: :destroy, + created_at: anything) service.log_security_event_to_file end diff --git a/spec/services/bulk_imports/relation_export_service_spec.rb b/spec/services/bulk_imports/relation_export_service_spec.rb index 27a6ca60515..f0f85217d2e 100644 --- a/spec/services/bulk_imports/relation_export_service_spec.rb +++ b/spec/services/bulk_imports/relation_export_service_spec.rb @@ -88,6 +88,18 @@ RSpec.describe BulkImports::RelationExportService do subject.execute end + + context 'when export is recently finished' do + it 'returns recently finished export instead of re-exporting' do + updated_at = 5.seconds.ago + export.update!(status: 1, updated_at: updated_at) + + expect { subject.execute }.not_to change { export.updated_at } + + expect(export.status).to eq(1) + expect(export.updated_at).to eq(updated_at) + end + end end context 'when exception occurs during export' do diff --git a/spec/services/bulk_update_integration_service_spec.rb b/spec/services/bulk_update_integration_service_spec.rb index 5e521b98482..dcc8d2df36d 100644 --- a/spec/services/bulk_update_integration_service_spec.rb +++ b/spec/services/bulk_update_integration_service_spec.rb @@ -9,7 +9,13 @@ RSpec.describe BulkUpdateIntegrationService do stub_jira_integration_test end - let(:excluded_attributes) { %w[id project_id group_id inherit_from_id instance template created_at updated_at] } + let(:excluded_attributes) do + %w[ + id project_id group_id inherit_from_id instance template + created_at updated_at encrypted_properties encrypted_properties_iv + ] + end + let(:batch) do Integration.inherited_descendants_from_self_or_ancestors_from(subgroup_integration).where(id: group_integration.id..integration.id) end @@ -50,7 +56,9 @@ RSpec.describe BulkUpdateIntegrationService do end context 'with integration with data fields' do - let(:excluded_attributes) { %w[id service_id created_at updated_at] } + let(:excluded_attributes) do + %w[id service_id created_at updated_at encrypted_properties encrypted_properties_iv] + end it 'updates the data fields from the integration', :aggregate_failures do described_class.new(subgroup_integration, batch).execute diff --git a/spec/services/ci/after_requeue_job_service_spec.rb b/spec/services/ci/after_requeue_job_service_spec.rb index 2f2baa15945..c9bd44f78e2 100644 --- a/spec/services/ci/after_requeue_job_service_spec.rb +++ b/spec/services/ci/after_requeue_job_service_spec.rb @@ -85,7 +85,7 @@ RSpec.describe Ci::AfterRequeueJobService, :sidekiq_inline do c2: 'skipped' ) - new_a1 = Ci::RetryBuildService.new(project, user).clone!(a1) + new_a1 = Ci::RetryJobService.new(project, user).clone!(a1) new_a1.enqueue! check_jobs_statuses( a1: 'pending', @@ -172,7 +172,7 @@ RSpec.describe Ci::AfterRequeueJobService, :sidekiq_inline do c2: 'skipped' ) - new_a1 = Ci::RetryBuildService.new(project, user).clone!(a1) + new_a1 = Ci::RetryJobService.new(project, user).clone!(a1) new_a1.enqueue! check_jobs_statuses( a1: 'pending', @@ -196,25 +196,6 @@ RSpec.describe Ci::AfterRequeueJobService, :sidekiq_inline do c2: 'created' ) end - - context 'when the FF ci_fix_order_of_subsequent_jobs is disabled' do - before do - stub_feature_flags(ci_fix_order_of_subsequent_jobs: false) - end - - it 'does not mark b1 as processable' do - execute_after_requeue_service(a1) - - check_jobs_statuses( - a1: 'pending', - a2: 'created', - b1: 'skipped', - b2: 'created', - c1: 'created', - c2: 'created' - ) - end - end end private diff --git a/spec/services/ci/create_pipeline_service/rate_limit_spec.rb b/spec/services/ci/create_pipeline_service/rate_limit_spec.rb new file mode 100644 index 00000000000..caea165cc6c --- /dev/null +++ b/spec/services/ci/create_pipeline_service/rate_limit_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Ci::CreatePipelineService, :freeze_time, :clean_gitlab_redis_rate_limiting do + describe 'rate limiting' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { project.first_owner } + + let(:ref) { 'refs/heads/master' } + + before do + stub_ci_pipeline_yaml_file(gitlab_ci_yaml) + stub_feature_flags(ci_throttle_pipelines_creation_dry_run: false) + + allow(Gitlab::ApplicationRateLimiter).to receive(:rate_limits) + .and_return(pipelines_create: { threshold: 1, interval: 1.minute }) + end + + context 'when user is under the limit' do + let(:pipeline) { create_pipelines(count: 1) } + + it 'allows pipeline creation' do + expect(pipeline).to be_created_successfully + expect(pipeline.statuses).not_to be_empty + end + end + + context 'when user is over the limit' do + let(:pipeline) { create_pipelines } + + it 'blocks pipeline creation' do + throttle_message = 'Too many pipelines created in the last minute. Try again later.' + + expect(pipeline).not_to be_persisted + expect(pipeline.statuses).to be_empty + expect(pipeline.errors.added?(:base, throttle_message)).to be_truthy + end + end + + context 'with different users' do + let(:other_user) { create(:user) } + + before do + project.add_maintainer(other_user) + end + + it 'allows other members to create pipelines' do + blocked_pipeline = create_pipelines(user: user) + allowed_pipeline = create_pipelines(count: 1, user: other_user) + + expect(blocked_pipeline).not_to be_persisted + expect(allowed_pipeline).to be_created_successfully + end + end + + context 'with different commits' do + it 'allows user to create pipeline' do + blocked_pipeline = create_pipelines(ref: ref) + allowed_pipeline = create_pipelines(count: 1, ref: 'refs/heads/feature') + + expect(blocked_pipeline).not_to be_persisted + expect(allowed_pipeline).to be_created_successfully + end + end + + context 'with different projects' do + let_it_be(:other_project) { create(:project, :repository) } + + before do + other_project.add_maintainer(user) + end + + it 'allows user to create pipeline' do + blocked_pipeline = create_pipelines(project: project) + allowed_pipeline = create_pipelines(count: 1, project: other_project) + + expect(blocked_pipeline).not_to be_persisted + expect(allowed_pipeline).to be_created_successfully + end + end + end + + def create_pipelines(attrs = {}) + attrs.reverse_merge!(user: user, ref: ref, project: project, count: 2) + + service = described_class.new(attrs[:project], attrs[:user], { ref: attrs[:ref] }) + + attrs[:count].pred.times { service.execute(:push) } + service.execute(:push).payload + end +end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index a7026f5062e..943d70ba142 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -12,6 +12,10 @@ RSpec.describe Ci::CreatePipelineService do before do stub_ci_pipeline_to_return_yaml_file + + # Disable rate limiting for pipeline creation + allow(Gitlab::ApplicationRateLimiter).to receive(:rate_limits) + .and_return(pipelines_create: { threshold: 0, interval: 1.minute }) end describe '#execute' do @@ -526,7 +530,7 @@ RSpec.describe Ci::CreatePipelineService do let(:ci_yaml) do <<-EOS image: - name: ruby:2.7 + name: image:1.0 ports: - 80 EOS @@ -538,12 +542,12 @@ RSpec.describe Ci::CreatePipelineService do context 'in the job image' do let(:ci_yaml) do <<-EOS - image: ruby:2.7 + image: image:1.0 test: script: rspec image: - name: ruby:2.7 + name: image:1.0 ports: - 80 EOS @@ -555,11 +559,11 @@ RSpec.describe Ci::CreatePipelineService do context 'in the service' do let(:ci_yaml) do <<-EOS - image: ruby:2.7 + image: image:1.0 test: script: rspec - image: ruby:2.7 + image: image:1.0 services: - name: test ports: diff --git a/spec/services/ci/create_web_ide_terminal_service_spec.rb b/spec/services/ci/create_web_ide_terminal_service_spec.rb index 0804773442d..3462b48cfe7 100644 --- a/spec/services/ci/create_web_ide_terminal_service_spec.rb +++ b/spec/services/ci/create_web_ide_terminal_service_spec.rb @@ -60,7 +60,7 @@ RSpec.describe Ci::CreateWebIdeTerminalService do <<-EOS terminal: image: - name: ruby:2.7 + name: image:1.0 ports: - 80 script: rspec 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 e95a449d615..1c6963e4a31 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 @@ -19,8 +19,23 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s let!(:artifact) { create(:ci_job_artifact, :expired, job: job, locked: job.pipeline.locked) } context 'with preloaded relationships' do + let(:second_artifact) { create(:ci_job_artifact, :expired, :junit, job: job) } + + let(:more_artifacts) do + [ + create(:ci_job_artifact, :expired, :sast, job: job), + create(:ci_job_artifact, :expired, :metadata, job: job), + create(:ci_job_artifact, :expired, :codequality, job: job), + create(:ci_job_artifact, :expired, :accessibility, job: job) + ] + end + before do - stub_const("#{described_class}::LARGE_LOOP_LIMIT", 1) + stub_const("#{described_class}::LOOP_LIMIT", 1) + + # This artifact-with-file is created before the control execution to ensure + # that the DeletedObject operations are accounted for in the query count. + second_artifact end context 'with ci_destroy_unlocked_job_artifacts feature flag disabled' do @@ -28,19 +43,12 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s stub_feature_flags(ci_destroy_unlocked_job_artifacts: false) end - it 'performs the smallest number of queries for job_artifacts' do - log = ActiveRecord::QueryRecorder.new { subject } + it 'performs a consistent number of queries' do + control = ActiveRecord::QueryRecorder.new { service.execute } - # SELECT expired ci_job_artifacts - 3 queries from each_batch - # PRELOAD projects, routes, project_statistics - # BEGIN - # INSERT into ci_deleted_objects - # DELETE loaded ci_job_artifacts - # DELETE security_findings -- for EE - # COMMIT - # SELECT next expired ci_job_artifacts + more_artifacts - expect(log.count).to be_within(1).of(10) + expect { subject }.not_to exceed_query_limit(control.count) end end @@ -49,9 +57,12 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s stub_feature_flags(ci_destroy_unlocked_job_artifacts: true) end - it 'performs the smallest number of queries for job_artifacts' do - log = ActiveRecord::QueryRecorder.new { subject } - expect(log.count).to be_within(1).of(8) + it 'performs a consistent number of queries' do + control = ActiveRecord::QueryRecorder.new { service.execute } + + more_artifacts + + expect { subject }.not_to exceed_query_limit(control.count) end end end @@ -119,7 +130,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s let!(:artifact) { create(:ci_job_artifact, :expired, job: job, locked: job.pipeline.locked) } before do - stub_const("#{described_class}::LARGE_LOOP_LIMIT", 10) + stub_const("#{described_class}::LOOP_LIMIT", 10) end context 'when the import fails' do @@ -189,8 +200,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s context 'when loop reached loop limit' do before do - stub_feature_flags(ci_artifact_fast_removal_large_loop_limit: false) - stub_const("#{described_class}::SMALL_LOOP_LIMIT", 1) + stub_const("#{described_class}::LOOP_LIMIT", 1) end it 'destroys one artifact' do diff --git a/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb b/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb index 67d664a617b..5e77041a632 100644 --- a/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb +++ b/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Ci::JobArtifacts::DestroyBatchService do - let(:artifacts) { Ci::JobArtifact.where(id: [artifact_with_file.id, artifact_without_file.id]) } + let(:artifacts) { Ci::JobArtifact.where(id: [artifact_with_file.id, artifact_without_file.id, trace_artifact.id]) } let(:service) { described_class.new(artifacts, pick_up_at: Time.current) } let_it_be(:artifact_with_file, refind: true) do @@ -18,6 +18,10 @@ RSpec.describe Ci::JobArtifacts::DestroyBatchService do create(:ci_job_artifact) end + let_it_be(:trace_artifact, refind: true) do + create(:ci_job_artifact, :trace, :expired) + end + describe '.execute' do subject(:execute) { service.execute } @@ -42,6 +46,12 @@ RSpec.describe Ci::JobArtifacts::DestroyBatchService do execute end + it 'preserves trace artifacts and removes any timestamp' do + expect { subject } + .to change { trace_artifact.reload.expire_at }.from(trace_artifact.expire_at).to(nil) + .and not_change { Ci::JobArtifact.exists?(trace_artifact.id) } + end + context 'ProjectStatistics' do it 'resets project statistics' do expect(ProjectStatistics).to receive(:increment_statistic).once diff --git a/spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb b/spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb new file mode 100644 index 00000000000..67412e41fb8 --- /dev/null +++ b/spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobArtifacts::UpdateUnknownLockedStatusService, :clean_gitlab_redis_shared_state do + include ExclusiveLeaseHelpers + + let(:service) { described_class.new } + + describe '.execute' do + subject { service.execute } + + 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) } + + let!(:unknown_unlocked_artifact) do + create(:ci_job_artifact, :junit, expire_at: 1.hour.ago, job: job, locked: Ci::JobArtifact.lockeds[:unknown]) + end + + let!(:unknown_locked_artifact) do + create(:ci_job_artifact, :lsif, + expire_at: 1.day.ago, + job: locked_job, + locked: Ci::JobArtifact.lockeds[:unknown] + ) + end + + let!(:unlocked_artifact) do + create(:ci_job_artifact, :archive, expire_at: 1.hour.ago, job: job, locked: Ci::JobArtifact.lockeds[:unlocked]) + end + + let!(:locked_artifact) do + create(:ci_job_artifact, :sast, :raw, + expire_at: 1.day.ago, + job: locked_job, + locked: Ci::JobArtifact.lockeds[:artifacts_locked] + ) + end + + context 'when artifacts are expired' do + it 'sets artifact_locked when the pipeline is locked' do + expect { service.execute } + .to change { unknown_locked_artifact.reload.locked }.from('unknown').to('artifacts_locked') + .and not_change { Ci::JobArtifact.exists?(locked_artifact.id) } + end + + it 'destroys the artifact when the pipeline is unlocked' do + expect { subject }.to change { Ci::JobArtifact.exists?(unknown_unlocked_artifact.id) }.from(true).to(false) + end + + it 'does not update ci_job_artifact rows with known locked values' do + expect { service.execute } + .to not_change(locked_artifact, :attributes) + .and not_change { Ci::JobArtifact.exists?(locked_artifact.id) } + .and not_change(unlocked_artifact, :attributes) + .and not_change { Ci::JobArtifact.exists?(unlocked_artifact.id) } + end + + it 'logs the counts of affected artifacts' do + expect(subject).to eq({ removed: 1, locked: 1 }) + end + end + + context 'in a single iteration' do + before do + stub_const("#{described_class}::BATCH_SIZE", 1) + end + + context 'due to the LOOP_TIMEOUT' do + before do + stub_const("#{described_class}::LOOP_TIMEOUT", 0.seconds) + end + + it 'affects the earliest expired artifact first' do + subject + + expect(unknown_locked_artifact.reload.locked).to eq('artifacts_locked') + expect(unknown_unlocked_artifact.reload.locked).to eq('unknown') + end + + it 'reports the number of destroyed artifacts' do + is_expected.to eq({ removed: 0, locked: 1 }) + end + end + + context 'due to @loop_limit' do + before do + stub_const("#{described_class}::LARGE_LOOP_LIMIT", 1) + end + + it 'affects the most recently expired artifact first' do + subject + + expect(unknown_locked_artifact.reload.locked).to eq('artifacts_locked') + expect(unknown_unlocked_artifact.reload.locked).to eq('unknown') + end + + it 'reports the number of destroyed artifacts' do + is_expected.to eq({ removed: 0, locked: 1 }) + end + end + end + + context 'when artifact is not expired' do + let!(:unknown_unlocked_artifact) do + create(:ci_job_artifact, :junit, + expire_at: 1.year.from_now, + job: job, + locked: Ci::JobArtifact.lockeds[:unknown] + ) + end + + it 'does not change the locked status' do + expect { service.execute }.not_to change { unknown_unlocked_artifact.locked } + expect(Ci::JobArtifact.exists?(unknown_unlocked_artifact.id)).to eq(true) + end + end + + context 'when exclusive lease has already been taken by the other instance' do + before do + stub_exclusive_lease_taken(described_class::EXCLUSIVE_LOCK_KEY, timeout: described_class::LOCK_TIMEOUT) + end + + it 'raises an error and' do + expect { subject }.to raise_error(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError) + end + end + + context 'when there are no unknown status artifacts' do + before do + Ci::JobArtifact.update_all(locked: :unlocked) + end + + it 'does not raise error' do + expect { subject }.not_to raise_error + end + + it 'reports the number of destroyed artifacts' do + is_expected.to eq({ removed: 0, locked: 0 }) + end + end + end +end diff --git a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb index 7365ad162d2..5bc508447c1 100644 --- a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb +++ b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb @@ -725,7 +725,7 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService do expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2'] - Ci::Build.retry(pipeline.builds.find_by(name: 'test:2'), user).reset.success! + Ci::RetryJobService.new(pipeline.project, user).execute(pipeline.builds.find_by(name: 'test:2'))[:job].reset.success! expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2', 'test:2', 'deploy:1', 'deploy:2'] @@ -1111,11 +1111,11 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService do end def enqueue_scheduled(name) - builds.scheduled.find_by(name: name).enqueue_scheduled + builds.scheduled.find_by(name: name).enqueue! end def retry_build(name) - Ci::Build.retry(builds.find_by(name: name), user) + Ci::RetryJobService.new(project, user).execute(builds.find_by(name: name)) end def manual_actions diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 245118e71fa..74adbc4efc8 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -103,6 +103,20 @@ module Ci pending_job.create_queuing_entry! end + context 'when build owner has been blocked' do + let(:user) { create(:user, :blocked) } + + before do + pending_job.update!(user: user) + end + + it 'does not pick the build and drops the build' do + expect(execute(shared_runner)).to be_falsey + + expect(pending_job.reload).to be_user_blocked + end + end + context 'for multiple builds' do let!(:project2) { create :project, shared_runners_enabled: true } let!(:pipeline2) { create :ci_pipeline, project: project2 } diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_job_service_spec.rb index 2421fd56c47..25aab73ab01 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_job_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Ci::RetryBuildService do +RSpec.describe Ci::RetryJobService do let_it_be(:reporter) { create(:user) } let_it_be(:developer) { create(:user) } let_it_be(:project) { create(:project, :repository) } @@ -17,7 +17,7 @@ RSpec.describe Ci::RetryBuildService do name: 'test') end - let_it_be_with_refind(:build) { create(:ci_build, pipeline: pipeline, stage_id: stage.id) } + let_it_be_with_refind(:build) { create(:ci_build, :success, pipeline: pipeline, stage_id: stage.id) } let(:user) { developer } @@ -30,7 +30,7 @@ RSpec.describe Ci::RetryBuildService do project.add_reporter(reporter) end - clone_accessors = described_class.clone_accessors.without(described_class.extra_accessors) + clone_accessors = ::Ci::Build.clone_accessors.without(::Ci::Build.extra_accessors) reject_accessors = %i[id status user token token_encrypted coverage trace runner @@ -94,6 +94,10 @@ RSpec.describe Ci::RetryBuildService do create(:terraform_state_version, build: build) end + before do + build.update!(retried: false, status: :success) + end + describe 'clone accessors' do let(:forbidden_associations) do Ci::Build.reflect_on_all_associations.each_with_object(Set.new) do |assoc, memo| @@ -156,8 +160,8 @@ RSpec.describe Ci::RetryBuildService do Ci::Build.attribute_aliases.keys.map(&:to_sym) + Ci::Build.reflect_on_all_associations.map(&:name) + [:tag_list, :needs_attributes, :job_variables_attributes] - - # ee-specific accessors should be tested in ee/spec/services/ci/retry_build_service_spec.rb instead - described_class.extra_accessors - + # ee-specific accessors should be tested in ee/spec/services/ci/retry_job_service_spec.rb instead + Ci::Build.extra_accessors - [:dast_site_profiles_build, :dast_scanner_profiles_build] # join tables current_accessors.uniq! @@ -170,7 +174,7 @@ RSpec.describe Ci::RetryBuildService do describe '#execute' do let(:new_build) do travel_to(1.second.from_now) do - service.execute(build) + service.execute(build)[:job] end end @@ -248,7 +252,7 @@ RSpec.describe Ci::RetryBuildService do context 'when build has scheduling_type' do it 'does not call populate_scheduling_type!' do - expect_any_instance_of(Ci::Pipeline).not_to receive(:ensure_scheduling_type!) + expect_any_instance_of(Ci::Pipeline).not_to receive(:ensure_scheduling_type!) # rubocop: disable RSpec/AnyInstanceOf expect(new_build.scheduling_type).to eq('stage') end @@ -286,6 +290,18 @@ RSpec.describe Ci::RetryBuildService do expect { service.execute(build) } .to raise_error Gitlab::Access::AccessDeniedError end + + context 'when the job is not retryable' do + let(:build) { create(:ci_build, :created, pipeline: pipeline) } + + it 'returns a ServiceResponse error' do + response = service.execute(build) + + expect(response).to be_a(ServiceResponse) + expect(response).to be_error + expect(response.message).to eq("Job cannot be retried") + end + end end end @@ -342,7 +358,7 @@ RSpec.describe Ci::RetryBuildService do end shared_examples_for 'when build with dynamic environment is retried' do - let_it_be(:other_developer) { create(:user).tap { |u| project.add_developer(other_developer) } } + let_it_be(:other_developer) { create(:user).tap { |u| project.add_developer(u) } } let(:environment_name) { 'review/$CI_COMMIT_REF_SLUG-$GITLAB_USER_ID' } diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb index df1e159b5c0..24272801480 100644 --- a/spec/services/ci/retry_pipeline_service_spec.rb +++ b/spec/services/ci/retry_pipeline_service_spec.rb @@ -340,7 +340,7 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do context 'when user is not allowed to retry build' do before do build = create(:ci_build, pipeline: pipeline, status: :failed) - allow_next_instance_of(Ci::RetryBuildService) do |service| + allow_next_instance_of(Ci::RetryJobService) do |service| allow(service).to receive(:can?).with(user, :update_build, build).and_return(false) end end diff --git a/spec/services/database/consistency_check_service_spec.rb b/spec/services/database/consistency_check_service_spec.rb new file mode 100644 index 00000000000..2e642451432 --- /dev/null +++ b/spec/services/database/consistency_check_service_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Database::ConsistencyCheckService do + let(:batch_size) { 5 } + let(:max_batches) { 2 } + + before do + stub_const("Gitlab::Database::ConsistencyChecker::BATCH_SIZE", batch_size) + stub_const("Gitlab::Database::ConsistencyChecker::MAX_BATCHES", max_batches) + end + + after do + redis_shared_state_cleanup! + end + + subject(:consistency_check_service) do + described_class.new( + source_model: Namespace, + target_model: Ci::NamespaceMirror, + source_columns: %w[id traversal_ids], + target_columns: %w[namespace_id traversal_ids] + ) + end + + describe '#random_start_id' do + let(:batch_size) { 5 } + + before do + create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects + end + + it 'generates a random start_id within the records ids' do + 10.times do + start_id = subject.send(:random_start_id) + expect(start_id).to be_between(Namespace.first.id, Namespace.last.id).inclusive + end + end + end + + describe '#execute' do + let(:empty_results) do + { batches: 0, matches: 0, mismatches: 0, mismatches_details: [] } + end + + context 'when empty tables' do + it 'returns results with zero counters' do + result = consistency_check_service.execute + + expect(result).to eq(empty_results) + end + + it 'does not call the ConsistencyCheckService' do + expect(Gitlab::Database::ConsistencyChecker).not_to receive(:new) + consistency_check_service.execute + end + end + + context 'no cursor has been saved before' do + let(:selected_start_id) { Namespace.order(:id).limit(5).pluck(:id).last } + let(:expected_next_start_id) { selected_start_id + batch_size * max_batches } + + before do + create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects + expect(consistency_check_service).to receive(:random_start_id).and_return(selected_start_id) + end + + it 'picks a random start_id' do + expected_result = { + batches: 2, + matches: 10, + mismatches: 0, + mismatches_details: [], + start_id: selected_start_id, + next_start_id: expected_next_start_id + } + expect(consistency_check_service.execute).to eq(expected_result) + end + + it 'calls the ConsistencyCheckService with the expected parameters' do + allow_next_instance_of(Gitlab::Database::ConsistencyChecker) do |instance| + expect(instance).to receive(:execute).with(start_id: selected_start_id).and_return({ + batches: 2, + next_start_id: expected_next_start_id, + matches: 10, + mismatches: 0, + mismatches_details: [] + }) + end + + expect(Gitlab::Database::ConsistencyChecker).to receive(:new).with( + source_model: Namespace, + target_model: Ci::NamespaceMirror, + source_columns: %w[id traversal_ids], + target_columns: %w[namespace_id traversal_ids] + ).and_call_original + + expected_result = { + batches: 2, + start_id: selected_start_id, + next_start_id: expected_next_start_id, + matches: 10, + mismatches: 0, + mismatches_details: [] + } + expect(consistency_check_service.execute).to eq(expected_result) + end + + it 'saves the next_start_id in Redis for he next iteration' do + expect(consistency_check_service).to receive(:save_next_start_id).with(expected_next_start_id).and_call_original + consistency_check_service.execute + end + end + + context 'cursor saved in Redis and moving' do + let(:first_namespace_id) { Namespace.order(:id).first.id } + let(:second_namespace_id) { Namespace.order(:id).second.id } + + before do + create_list(:namespace, 30) # This will also create Ci::NameSpaceMirror objects + end + + it "keeps moving the cursor with each call to the service" do + expect(consistency_check_service).to receive(:random_start_id).at_most(:once).and_return(first_namespace_id) + + allow_next_instance_of(Gitlab::Database::ConsistencyChecker) do |instance| + expect(instance).to receive(:execute).ordered.with(start_id: first_namespace_id).and_call_original + expect(instance).to receive(:execute).ordered.with(start_id: first_namespace_id + 10).and_call_original + expect(instance).to receive(:execute).ordered.with(start_id: first_namespace_id + 20).and_call_original + # Gets back to the start of the table + expect(instance).to receive(:execute).ordered.with(start_id: first_namespace_id).and_call_original + end + + 4.times do + consistency_check_service.execute + end + end + + it "keeps moving the cursor from any start point" do + expect(consistency_check_service).to receive(:random_start_id).at_most(:once).and_return(second_namespace_id) + + allow_next_instance_of(Gitlab::Database::ConsistencyChecker) do |instance| + expect(instance).to receive(:execute).ordered.with(start_id: second_namespace_id).and_call_original + expect(instance).to receive(:execute).ordered.with(start_id: second_namespace_id + 10).and_call_original + end + + 2.times do + consistency_check_service.execute + end + end + end + end +end diff --git a/spec/services/deployments/update_environment_service_spec.rb b/spec/services/deployments/update_environment_service_spec.rb index 6996563fdb8..0859aa2c9d1 100644 --- a/spec/services/deployments/update_environment_service_spec.rb +++ b/spec/services/deployments/update_environment_service_spec.rb @@ -286,6 +286,37 @@ RSpec.describe Deployments::UpdateEnvironmentService do end end + context 'when environment url uses a nested variable' do + let(:yaml_variables) do + [ + { key: 'MAIN_DOMAIN', value: '${STACK_NAME}.example.com' }, + { key: 'STACK_NAME', value: 'appname-${ENVIRONMENT_NAME}' }, + { key: 'ENVIRONMENT_NAME', value: '${CI_COMMIT_REF_SLUG}' } + ] + end + + let(:job) do + create(:ci_build, + :with_deployment, + pipeline: pipeline, + ref: 'master', + environment: 'production', + project: project, + yaml_variables: yaml_variables, + options: { environment: { name: 'production', url: 'http://$MAIN_DOMAIN' } }) + end + + it { is_expected.to eq('http://appname-master.example.com') } + + context 'when the FF ci_expand_environment_name_and_url is disabled' do + before do + stub_feature_flags(ci_expand_environment_name_and_url: false) + end + + it { is_expected.to eq('http://${STACK_NAME}.example.com') } + end + end + context 'when yaml environment does not have url' do let(:job) { create(:ci_build, :with_deployment, pipeline: pipeline, environment: 'staging', project: project) } diff --git a/spec/services/emails/create_service_spec.rb b/spec/services/emails/create_service_spec.rb index 2fabf4ae66a..b13197f21b8 100644 --- a/spec/services/emails/create_service_spec.rb +++ b/spec/services/emails/create_service_spec.rb @@ -25,5 +25,34 @@ RSpec.describe Emails::CreateService do expect(user.emails).to include(Email.find_by(opts)) end + + it 'sends a notification to the user' do + expect_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).to receive(:new_email_address_added) + end + + service.execute + end + + it 'does not send a notification when the email is not persisted' do + allow_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).not_to receive(:new_email_address_added) + end + + service.execute(email: 'invalid@@example.com') + end + + it 'does not send a notification email when the email is the primary, because we are creating the user' do + allow_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).not_to receive(:new_email_address_added) + end + + # This is here to ensure that the service is actually called. + allow_next_instance_of(described_class) do |create_service| + expect(create_service).to receive(:execute).and_call_original + end + + create(:user) + end end end diff --git a/spec/services/environments/stop_service_spec.rb b/spec/services/environments/stop_service_spec.rb index 362071c1c26..9e9ef127c67 100644 --- a/spec/services/environments/stop_service_spec.rb +++ b/spec/services/environments/stop_service_spec.rb @@ -198,6 +198,31 @@ RSpec.describe Environments::StopService do expect(pipeline.environments_in_self_and_descendants.first).to be_stopped end + + context 'with environment related jobs ' do + let!(:environment) { create(:environment, :available, name: 'staging', project: project) } + let!(:prepare_staging_job) { create(:ci_build, :prepare_staging, pipeline: pipeline, project: project) } + let!(:start_staging_job) { create(:ci_build, :start_staging, :with_deployment, :manual, pipeline: pipeline, project: project) } + let!(:stop_staging_job) { create(:ci_build, :stop_staging, :manual, pipeline: pipeline, project: project) } + + it 'does not stop environments that was not started by the merge request' do + subject + + expect(prepare_staging_job.persisted_environment.state).to eq('available') + end + + context 'when fix_related_environments_for_merge_requests feature flag is disabled' do + before do + stub_feature_flags(fix_related_environments_for_merge_requests: false) + end + + it 'stops unrelated environments too' do + subject + + expect(prepare_staging_job.persisted_environment.state).to eq('stopped') + end + end + end end context 'when user is a reporter' do diff --git a/spec/services/event_create_service_spec.rb b/spec/services/event_create_service_spec.rb index 611e821f3e5..c22099fe410 100644 --- a/spec/services/event_create_service_spec.rb +++ b/spec/services/event_create_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redis_shared_state do + include SnowplowHelpers + let(:service) { described_class.new } let_it_be(:user, reload: true) { create :user } @@ -18,6 +20,28 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi end end + shared_examples 'Snowplow event' do + it 'is not emitted if FF is disabled' do + stub_feature_flags(route_hll_to_snowplow: false) + + subject + + expect_no_snowplow_event + end + + it 'is emitted' do + subject + + expect_snowplow_event( + category: described_class.to_s, + action: 'action_active_users_project_repo', + namespace: project.namespace, + user: user, + project: project + ) + end + end + describe 'Issues' do describe '#open_issue' do let(:issue) { create(:issue) } @@ -247,7 +271,7 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi end end - describe '#push' do + describe '#push', :snowplow do let(:push_data) do { commits: [ @@ -270,9 +294,11 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi it_behaves_like "it records the event in the event counter" do let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::PUSH_ACTION } end + + it_behaves_like 'Snowplow event' end - describe '#bulk_push' do + describe '#bulk_push', :snowplow do let(:push_data) do { action: :created, @@ -288,6 +314,8 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi it_behaves_like "it records the event in the event counter" do let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::PUSH_ACTION } end + + it_behaves_like 'Snowplow event' end describe 'Project' do diff --git a/spec/services/git/branch_push_service_spec.rb b/spec/services/git/branch_push_service_spec.rb index 5a637b0956b..57c130f76a4 100644 --- a/spec/services/git/branch_push_service_spec.rb +++ b/spec/services/git/branch_push_service_spec.rb @@ -721,4 +721,14 @@ RSpec.describe Git::BranchPushService, services: true do it_behaves_like 'does not enqueue Jira sync worker' end end + + describe 'project target platforms detection' do + subject(:execute) { execute_service(project, user, oldrev: blankrev, newrev: newrev, ref: ref) } + + it 'calls enqueue_record_project_target_platforms on the project' do + expect(project).to receive(:enqueue_record_project_target_platforms) + + execute + end + end end diff --git a/spec/services/groups/create_service_spec.rb b/spec/services/groups/create_service_spec.rb index 819569d6e67..6e074f451c4 100644 --- a/spec/services/groups/create_service_spec.rb +++ b/spec/services/groups/create_service_spec.rb @@ -263,43 +263,6 @@ RSpec.describe Groups::CreateService, '#execute' do end end - describe 'invite team email' do - let(:service) { described_class.new(user, group_params) } - - before do - allow(Namespaces::InviteTeamEmailWorker).to receive(:perform_in) - end - - it 'is sent' do - group = service.execute - delay = Namespaces::InviteTeamEmailService::DELIVERY_DELAY_IN_MINUTES - expect(Namespaces::InviteTeamEmailWorker).to have_received(:perform_in).with(delay, group.id, user.id) - end - - context 'when group has not been persisted' do - let(:service) { described_class.new(user, group_params.merge(name: '<script>alert("Attack!")</script>')) } - - it 'not sent' do - expect(Namespaces::InviteTeamEmailWorker).not_to receive(:perform_in) - service.execute - end - end - - context 'when group is not root' do - let(:parent_group) { create :group } - let(:service) { described_class.new(user, group_params.merge(parent_id: parent_group.id)) } - - before do - parent_group.add_owner(user) - end - - it 'not sent' do - expect(Namespaces::InviteTeamEmailWorker).not_to receive(:perform_in) - service.execute - end - end - end - describe 'logged_out_marketing_header experiment', :experiment do let(:service) { described_class.new(user, group_params) } diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb index 3a696228382..1c4b7aac87e 100644 --- a/spec/services/groups/transfer_service_spec.rb +++ b/spec/services/groups/transfer_service_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do end let_it_be(:user) { create(:user) } - let_it_be(:new_parent_group) { create(:group, :public) } + let_it_be(:new_parent_group) { create(:group, :public, :crm_enabled) } let!(:group_member) { create(:group_member, :owner, group: group, user: user) } let(:transfer_service) { described_class.new(group, user) } @@ -252,23 +252,6 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do expect(transfer_service.execute(new_parent_group)).to be_falsy expect(transfer_service.error).to eq('Transfer failed: The parent group already has a subgroup or a project with the same path.') end - - # currently when a project is created it gets a corresponding project namespace - # so we test the case where a project without a project namespace is transferred - # for backward compatibility - context 'without project namespace' do - before do - project_namespace = project.project_namespace - project.update_column(:project_namespace_id, nil) - project_namespace.delete - end - - it 'adds an error on group' do - expect(project.reload.project_namespace).to be_nil - expect(transfer_service.execute(new_parent_group)).to be_falsy - expect(transfer_service.error).to eq('Transfer failed: Validation failed: Group URL has already been taken') - end - end end context 'when projects have project namespaces' do @@ -876,5 +859,108 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do end end end + + context 'crm' do + let(:root_group) { create(:group, :public) } + let(:subgroup) { create(:group, :public, parent: root_group) } + let(:another_subgroup) { create(:group, :public, parent: root_group) } + let(:subsubgroup) { create(:group, :public, parent: subgroup) } + + let(:root_project) { create(:project, group: root_group) } + let(:sub_project) { create(:project, group: subgroup) } + let(:another_project) { create(:project, group: another_subgroup) } + let(:subsub_project) { create(:project, group: subsubgroup) } + + let!(:contacts) { create_list(:contact, 4, group: root_group) } + let!(:organizations) { create_list(:organization, 2, group: root_group) } + + before do + create(:issue_customer_relations_contact, contact: contacts[0], issue: create(:issue, project: root_project)) + create(:issue_customer_relations_contact, contact: contacts[1], issue: create(:issue, project: sub_project)) + create(:issue_customer_relations_contact, contact: contacts[2], issue: create(:issue, project: another_project)) + create(:issue_customer_relations_contact, contact: contacts[3], issue: create(:issue, project: subsub_project)) + root_group.add_owner(user) + end + + context 'moving up' do + let(:group) { subsubgroup } + + it 'retains issue contacts' do + expect { transfer_service.execute(root_group) } + .not_to change { CustomerRelations::IssueContact.count } + end + end + + context 'moving down' do + let(:group) { subgroup } + + it 'retains issue contacts' do + expect { transfer_service.execute(another_subgroup) } + .not_to change { CustomerRelations::IssueContact.count } + end + end + + context 'moving sideways' do + let(:group) { subsubgroup } + + it 'retains issue contacts' do + expect { transfer_service.execute(another_subgroup) } + .not_to change { CustomerRelations::IssueContact.count } + end + end + + context 'moving to new root group' do + let(:group) { root_group } + + before do + new_parent_group.add_owner(user) + end + + it 'moves all crm objects' do + expect { transfer_service.execute(new_parent_group) } + .to change { root_group.contacts.count }.by(-4) + .and change { root_group.organizations.count }.by(-2) + end + + it 'retains issue contacts' do + expect { transfer_service.execute(new_parent_group) } + .not_to change { CustomerRelations::IssueContact.count } + end + end + + context 'moving to a subgroup within a new root group' do + let(:group) { root_group } + let(:subgroup_in_new_parent_group) { create(:group, parent: new_parent_group) } + + context 'with permission on the root group' do + before do + new_parent_group.add_owner(user) + end + + it 'moves all crm objects' do + expect { transfer_service.execute(subgroup_in_new_parent_group) } + .to change { root_group.contacts.count }.by(-4) + .and change { root_group.organizations.count }.by(-2) + end + + it 'retains issue contacts' do + expect { transfer_service.execute(subgroup_in_new_parent_group) } + .not_to change { CustomerRelations::IssueContact.count } + end + end + + context 'with permission on the subgroup' do + before do + subgroup_in_new_parent_group.add_owner(user) + end + + it 'raises error' do + transfer_service.execute(subgroup_in_new_parent_group) + + expect(transfer_service.error).to eq("Transfer failed: Group contains contacts/organizations and you don't have enough permissions to move them to the new root group.") + end + end + end + end end end diff --git a/spec/services/import/github_service_spec.rb b/spec/services/import/github_service_spec.rb index 04a94d96f67..58afae1e647 100644 --- a/spec/services/import/github_service_spec.rb +++ b/spec/services/import/github_service_spec.rb @@ -34,11 +34,11 @@ RSpec.describe Import::GithubService do subject.execute(access_params, :github) end - it 'returns an error' do + it 'returns an error with message and code' do result = subject.execute(access_params, :github) expect(result).to include( - message: 'Import failed due to a GitHub error: Not Found', + message: 'Import failed due to a GitHub error: Not Found (HTTP 404)', status: :error, http_status: :unprocessable_entity ) diff --git a/spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb new file mode 100644 index 00000000000..c20a0688ac2 --- /dev/null +++ b/spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe IncidentManagement::IssuableEscalationStatuses::BuildService do + let_it_be(:project) { create(:project) } + let_it_be(:incident, reload: true) { create(:incident, project: project) } + + let(:service) { described_class.new(incident) } + + subject(:execute) { service.execute } + + it_behaves_like 'initializes new escalation status with expected attributes' + + context 'with associated alert' do + let_it_be(:alert) { create(:alert_management_alert, :acknowledged, project: project, issue: incident) } + + it_behaves_like 'initializes new escalation status with expected attributes', { status_event: :acknowledge } + end +end diff --git a/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb index 8fbab361ec4..2c7d330766c 100644 --- a/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb +++ b/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb @@ -8,12 +8,12 @@ RSpec.describe IncidentManagement::IssuableEscalationStatuses::CreateService do let(:incident) { create(:incident, project: project) } let(:service) { described_class.new(incident) } - subject(:execute) { service.execute} + subject(:execute) { service.execute } it 'creates an escalation status for the incident with no policy set' do - expect { execute }.to change { incident.reload.incident_management_issuable_escalation_status }.from(nil) + expect { execute }.to change { incident.reload.escalation_status }.from(nil) - status = incident.incident_management_issuable_escalation_status + status = incident.escalation_status expect(status.policy_id).to eq(nil) expect(status.escalations_started_at).to eq(nil) @@ -24,7 +24,22 @@ RSpec.describe IncidentManagement::IssuableEscalationStatuses::CreateService do let!(:existing_status) { create(:incident_management_issuable_escalation_status, issue: incident) } it 'exits without changing anything' do - expect { execute }.not_to change { incident.reload.incident_management_issuable_escalation_status } + expect { execute }.not_to change { incident.reload.escalation_status } + end + end + + context 'with associated alert' do + before do + create(:alert_management_alert, :acknowledged, project: project, issue: incident) + end + + it 'creates an escalation status matching the alert attributes' do + expect { execute }.to change { incident.reload.escalation_status }.from(nil) + expect(incident.escalation_status).to have_attributes( + status_name: :acknowledged, + policy_id: nil, + escalations_started_at: nil + ) end end end diff --git a/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb index b30b3a69ae6..25164df40ca 100644 --- a/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb +++ b/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb @@ -71,7 +71,12 @@ RSpec.describe IncidentManagement::IssuableEscalationStatuses::PrepareUpdateServ context 'when an IssuableEscalationStatus record for the issue does not exist' do let(:issue) { create(:incident) } - it_behaves_like 'availability error response' + it_behaves_like 'successful response', { status_event: :acknowledge } + + it 'initializes an issuable escalation status record' do + expect { result }.not_to change(::IncidentManagement::IssuableEscalationStatus, :count) + expect(issue.escalation_status).to be_present + end end context 'when called without params' do diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 6d3c3dd4e39..d496857bb25 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -9,8 +9,8 @@ RSpec.describe Issues::UpdateService, :mailer do let_it_be(:guest) { create(:user) } let_it_be(:group) { create(:group, :public, :crm_enabled) } let_it_be(:project, reload: true) { create(:project, :repository, group: group) } - let_it_be(:label) { create(:label, project: project) } - let_it_be(:label2) { create(:label, project: project) } + let_it_be(:label) { create(:label, title: 'a', project: project) } + let_it_be(:label2) { create(:label, title: 'b', project: project) } let_it_be(:milestone) { create(:milestone, project: project) } let(:issue) do @@ -1224,6 +1224,18 @@ RSpec.describe Issues::UpdateService, :mailer do end context 'without an escalation status record' do + it 'creates a new record' do + expect { update_issue(opts) }.to change(::IncidentManagement::IssuableEscalationStatus, :count).by(1) + end + + it_behaves_like 'updates the escalation status record', :acknowledged + end + + context 'with :incident_escalations feature flag disabled' do + before do + stub_feature_flags(incident_escalations: false) + end + it_behaves_like 'does not change the status record' end end @@ -1349,6 +1361,19 @@ RSpec.describe Issues::UpdateService, :mailer do end end + context 'labels are updated' do + let(:label_a) { label } + let(:label_b) { label2 } + let(:issuable) { issue } + + it_behaves_like 'keeps issuable labels sorted after update' + it_behaves_like 'broadcasting issuable labels updates' + + def update_issuable(update_params) + update_issue(update_params) + end + end + it_behaves_like 'issuable record that supports quick actions' do let(:existing_issue) { create(:issue, project: project) } let(:issuable) { described_class.new(project: project, current_user: user, params: params).execute(existing_issue) } diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb index 4396a0d3ec3..25437be1e78 100644 --- a/spec/services/members/create_service_spec.rb +++ b/spec/services/members/create_service_spec.rb @@ -6,6 +6,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_ let_it_be(:source, reload: true) { create(:project) } let_it_be(:user) { create(:user) } let_it_be(:member) { create(:user) } + let_it_be(:user_invited_by_id) { create(:user) } let_it_be(:user_ids) { member.id.to_s } let_it_be(:access_level) { Gitlab::Access::GUEST } @@ -49,6 +50,36 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_ expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true) end + context 'when user_id is passed as an integer' do + let(:user_ids) { member.id } + + it 'successfully creates member' do + expect(execute_service[:status]).to eq(:success) + expect(source.users).to include member + expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true) + end + end + + context 'with user_ids as an array of integers' do + let(:user_ids) { [member.id, user_invited_by_id.id] } + + it 'successfully creates members' do + expect(execute_service[:status]).to eq(:success) + expect(source.users).to include(member, user_invited_by_id) + expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true) + end + end + + context 'with user_ids as an array of strings' do + let(:user_ids) { [member.id.to_s, user_invited_by_id.id.to_s] } + + it 'successfully creates members' do + expect(execute_service[:status]).to eq(:success) + expect(source.users).to include(member, user_invited_by_id) + expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true) + end + end + context 'when executing on a group' do let_it_be(:source) { create(:group) } @@ -112,6 +143,32 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_ end end + context 'when adding a project_bot' do + let_it_be(:project_bot) { create(:user, :project_bot) } + + let(:user_ids) { project_bot.id } + + context 'when project_bot is already a member' do + before do + source.add_developer(project_bot) + end + + it 'does not update the member' do + expect(execute_service[:status]).to eq(:error) + expect(execute_service[:message]).to eq("#{project_bot.username}: not authorized to update member") + expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(false) + end + end + + context 'when project_bot is not already a member' do + it 'adds the member' do + expect(execute_service[:status]).to eq(:success) + expect(source.users).to include project_bot + expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true) + end + end + end + context 'when tracking the invite source', :snowplow do context 'when invite_source is not passed' do let(:additional_params) { {} } @@ -191,6 +248,15 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_ ) end + context 'when it is an invite by email passed to user_ids' do + let(:user_ids) { 'email@example.org' } + + it 'does not create task issues' do + expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async) + execute_service + end + end + context 'when passing many user ids' do before do stub_licensed_features(multiple_issue_assignees: false) diff --git a/spec/services/members/creator_service_spec.rb b/spec/services/members/creator_service_spec.rb new file mode 100644 index 00000000000..ff5bf705b6c --- /dev/null +++ b/spec/services/members/creator_service_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::CreatorService do + let_it_be(:source, reload: true) { create(:group, :public) } + let_it_be(:member_type) { GroupMember } + let_it_be(:user) { create(:user) } + let_it_be(:current_user) { create(:user) } + + describe '#execute' do + it 'raises error for new member on authorization check implementation' do + expect do + described_class.new(source, user, :maintainer, current_user: current_user).execute + end.to raise_error(NotImplementedError) + end + + it 'raises error for an existing member on authorization check implementation' do + source.add_developer(user) + + expect do + described_class.new(source, user, :maintainer, current_user: current_user).execute + end.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb index 8ceb9979f33..ab740138a8b 100644 --- a/spec/services/members/invite_service_spec.rb +++ b/spec/services/members/invite_service_spec.rb @@ -6,6 +6,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_ let_it_be(:project, reload: true) { create(:project) } let_it_be(:user) { project.first_owner } let_it_be(:project_user) { create(:user) } + let_it_be(:user_invited_by_id) { create(:user) } let_it_be(:namespace) { project.namespace } let(:params) { {} } @@ -41,148 +42,422 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_ end end - context 'when email is not a valid email' do - let(:params) { { email: '_bogus_' } } + context 'when invites are passed as array' do + context 'with emails' do + let(:params) { { email: %w[email@example.org email2@example.org] } } - it 'returns an error' do - expect_not_to_create_members - expect(result[:message]['_bogus_']).to eq("Invite email is invalid") + it 'successfully creates members' do + expect_to_create_members(count: 2) + expect(result[:status]).to eq(:success) + end + end + + context 'with user_ids as integers' do + let(:params) { { user_ids: [project_user.id, user_invited_by_id.id] } } + + it 'successfully creates members' do + expect_to_create_members(count: 2) + expect(result[:status]).to eq(:success) + end end - it_behaves_like 'does not record an onboarding progress action' + context 'with user_ids as strings' do + let(:params) { { user_ids: [project_user.id.to_s, user_invited_by_id.id.to_s] } } + + it 'successfully creates members' do + expect_to_create_members(count: 2) + expect(result[:status]).to eq(:success) + end + end + + context 'with a mixture of emails and user_ids' do + let(:params) do + { user_ids: [project_user.id, user_invited_by_id.id], email: %w[email@example.org email2@example.org] } + end + + it 'successfully creates members' do + expect_to_create_members(count: 4) + expect(result[:status]).to eq(:success) + end + end end - context 'when emails are passed as an array' do - let(:params) { { email: %w[email@example.org email2@example.org] } } + context 'with multiple invites passed as strings' do + context 'with emails' do + let(:params) { { email: 'email@example.org,email2@example.org' } } - it 'successfully creates members' do - expect_to_create_members(count: 2) - expect(result[:status]).to eq(:success) + it 'successfully creates members' do + expect_to_create_members(count: 2) + expect(result[:status]).to eq(:success) + end + end + + context 'with user_ids' do + let(:params) { { user_ids: "#{project_user.id},#{user_invited_by_id.id}" } } + + it 'successfully creates members' do + expect_to_create_members(count: 2) + expect(result[:status]).to eq(:success) + end + end + + context 'with a mixture of emails and user_ids' do + let(:params) do + { user_ids: "#{project_user.id},#{user_invited_by_id.id}", email: 'email@example.org,email2@example.org' } + end + + it 'successfully creates members' do + expect_to_create_members(count: 4) + expect(result[:status]).to eq(:success) + end end end - context 'when emails are passed as an empty string' do - let(:params) { { email: '' } } + context 'when invites formats are mixed' do + context 'when user_ids is an array and emails is a string' do + let(:params) do + { user_ids: [project_user.id, user_invited_by_id.id], email: 'email@example.org,email2@example.org' } + end + + it 'successfully creates members' do + expect_to_create_members(count: 4) + expect(result[:status]).to eq(:success) + end + end + + context 'when user_ids is a string and emails is an array' do + let(:params) do + { user_ids: "#{project_user.id},#{user_invited_by_id.id}", email: %w[email@example.org email2@example.org] } + end - it 'returns an error' do - expect_not_to_create_members - expect(result[:message]).to eq('Emails cannot be blank') + it 'successfully creates members' do + expect_to_create_members(count: 4) + expect(result[:status]).to eq(:success) + end end end - context 'when email param is not included' do - it 'returns an error' do - expect_not_to_create_members - expect(result[:message]).to eq('Emails cannot be blank') + context 'when invites are passed in different formats' do + context 'when emails are passed as an empty string' do + let(:params) { { email: '' } } + + it 'returns an error' do + expect_not_to_create_members + expect(result[:message]).to eq('Invites cannot be blank') + end + end + + context 'when user_ids are passed as an empty string' do + let(:params) { { user_ids: '' } } + + it 'returns an error' do + expect_not_to_create_members + expect(result[:message]).to eq('Invites cannot be blank') + end + end + + context 'when user_ids and emails are both passed as empty strings' do + let(:params) { { user_ids: '', email: '' } } + + it 'returns an error' do + expect_not_to_create_members + expect(result[:message]).to eq('Invites cannot be blank') + end + end + + context 'when user_id is passed as an integer' do + let(:params) { { user_ids: project_user.id } } + + it 'successfully creates member' do + expect_to_create_members(count: 1) + expect(result[:status]).to eq(:success) + end + end + + context 'when invite params are not included' do + it 'returns an error' do + expect_not_to_create_members + expect(result[:message]).to eq('Invites cannot be blank') + end end end context 'when email is not a valid email format' do - let(:params) { { email: '_bogus_' } } + context 'with singular email' do + let(:params) { { email: '_bogus_' } } - it 'returns an error' do - expect { result }.not_to change(ProjectMember, :count) - expect(result[:status]).to eq(:error) - expect(result[:message][params[:email]]).to eq("Invite email is invalid") + it 'returns an error' do + expect_not_to_create_members + expect(result[:status]).to eq(:error) + expect(result[:message][params[:email]]).to eq("Invite email is invalid") + end + + it_behaves_like 'does not record an onboarding progress action' + end + + context 'with user_id and singular invalid email' do + let(:params) { { user_ids: project_user.id, email: '_bogus_' } } + + it 'has partial success' do + expect_to_create_members(count: 1) + expect(project.users).to include project_user + + expect(result[:status]).to eq(:error) + expect(result[:message][params[:email]]).to eq("Invite email is invalid") + end end end - context 'when duplicate email addresses are passed' do - let(:params) { { email: 'email@example.org,email@example.org' } } + context 'with duplicate invites' do + context 'with duplicate emails' do + let(:params) { { email: 'email@example.org,email@example.org' } } - it 'only creates one member per unique address' do - expect_to_create_members(count: 1) - expect(result[:status]).to eq(:success) + it 'only creates one member per unique invite' do + expect_to_create_members(count: 1) + expect(result[:status]).to eq(:success) + end + end + + context 'with duplicate user ids' do + let(:params) { { user_ids: "#{project_user.id},#{project_user.id}" } } + + it 'only creates one member per unique invite' do + expect_to_create_members(count: 1) + expect(result[:status]).to eq(:success) + end + end + + context 'with duplicate member by adding as user id and email' do + let(:params) { { user_ids: project_user.id, email: project_user.email } } + + it 'only creates one member per unique invite' do + expect_to_create_members(count: 1) + expect(result[:status]).to eq(:success) + end end end - context 'when observing email limits' do - let_it_be(:emails) { Array(1..101).map { |n| "email#{n}@example.com" } } + context 'when observing invite limits' do + context 'with emails and in general' do + let_it_be(:emails) { Array(1..101).map { |n| "email#{n}@example.com" } } - context 'when over the allowed default limit of emails' do - let(:params) { { email: emails } } + context 'when over the allowed default limit of emails' do + let(:params) { { email: emails } } - it 'limits the number of emails to 100' do - expect_not_to_create_members - expect(result[:message]).to eq('Too many users specified (limit is 100)') + it 'limits the number of emails to 100' do + expect_not_to_create_members + expect(result[:message]).to eq('Too many users specified (limit is 100)') + end + end + + context 'when over the allowed custom limit of emails' do + let(:params) { { email: 'email@example.org,email2@example.org', limit: 1 } } + + it 'limits the number of emails to the limit supplied' do + expect_not_to_create_members + expect(result[:message]).to eq('Too many users specified (limit is 1)') + end + end + + context 'when limit allowed is disabled via limit param' do + let(:params) { { email: emails, limit: -1 } } + + it 'does not limit number of emails' do + expect_to_create_members(count: 101) + expect(result[:status]).to eq(:success) + end end end - context 'when over the allowed custom limit of emails' do - let(:params) { { email: 'email@example.org,email2@example.org', limit: 1 } } + context 'with user_ids' do + let(:user_ids) { 1.upto(101).to_a.join(',') } + let(:params) { { user_ids: user_ids } } - it 'limits the number of emails to the limit supplied' do + it 'limits the number of users to 100' do expect_not_to_create_members - expect(result[:message]).to eq('Too many users specified (limit is 1)') + expect(result[:message]).to eq('Too many users specified (limit is 100)') end end + end - context 'when limit allowed is disabled via limit param' do - let(:params) { { email: emails, limit: -1 } } + context 'with an existing user' do + context 'with email' do + let(:params) { { email: project_user.email } } - it 'does not limit number of emails' do - expect_to_create_members(count: 101) + it 'adds an existing user to members' do + expect_to_create_members(count: 1) expect(result[:status]).to eq(:success) + expect(project.users).to include project_user end end - end - context 'when email belongs to an existing user' do - let(:params) { { email: project_user.email } } + context 'with user_id' do + let(:params) { { user_ids: project_user.id } } - it 'adds an existing user to members' do - expect_to_create_members(count: 1) - expect(result[:status]).to eq(:success) - expect(project.users).to include project_user + it_behaves_like 'records an onboarding progress action', :user_added + + it 'adds an existing user to members' do + expect_to_create_members(count: 1) + expect(result[:status]).to eq(:success) + expect(project.users).to include project_user + end + + context 'when assigning tasks to be done' do + let(:params) do + { user_ids: project_user.id, tasks_to_be_done: %w(ci code), tasks_project_id: project.id } + end + + it 'creates 2 task issues', :aggregate_failures do + expect(TasksToBeDone::CreateWorker) + .to receive(:perform_async) + .with(anything, user.id, [project_user.id]) + .once + .and_call_original + expect { result }.to change { project.issues.count }.by(2) + + expect(project.issues).to all have_attributes(project: project, author: user) + end + end end end context 'when access level is not valid' do - let(:params) { { email: project_user.email, access_level: -1 } } + context 'with email' do + let(:params) { { email: project_user.email, access_level: -1 } } - it 'returns an error' do - expect_not_to_create_members - expect(result[:message][project_user.email]) - .to eq("Access level is not included in the list") + it 'returns an error' do + expect_not_to_create_members + expect(result[:message][project_user.email]).to eq("Access level is not included in the list") + end end - end - context 'when invite already exists for an included email' do - let!(:invited_member) { create(:project_member, :invited, project: project) } - let(:params) { { email: "#{invited_member.invite_email},#{project_user.email}" } } + context 'with user_id' do + let(:params) { { user_ids: user_invited_by_id.id, access_level: -1 } } - it 'adds new email and returns an error for the already invited email' do - expect_to_create_members(count: 1) - expect(result[:status]).to eq(:error) - expect(result[:message][invited_member.invite_email]) - .to eq("The member's email address has already been taken") - expect(project.users).to include project_user + it 'returns an error' do + expect_not_to_create_members + expect(result[:message][user_invited_by_id.username]).to eq("Access level is not included in the list") + end end - end - context 'when access request already exists for an included email' do - let!(:requested_member) { create(:project_member, :access_request, project: project) } - let(:params) { { email: "#{requested_member.user.email},#{project_user.email}" } } + context 'with a mix of user_id and email' do + let(:params) { { user_ids: user_invited_by_id.id, email: project_user.email, access_level: -1 } } - it 'adds new email and returns an error for the already invited email' do - expect_to_create_members(count: 1) - expect(result[:status]).to eq(:error) - expect(result[:message][requested_member.user.email]) - .to eq("User already exists in source") - expect(project.users).to include project_user + it 'returns errors' do + expect_not_to_create_members + expect(result[:message][project_user.email]).to eq("Access level is not included in the list") + expect(result[:message][user_invited_by_id.username]).to eq("Access level is not included in the list") + end end end - context 'when email is already a member on the project' do - let!(:existing_member) { create(:project_member, :guest, project: project) } - let(:params) { { email: "#{existing_member.user.email},#{project_user.email}" } } + context 'when member already exists' do + context 'with email' do + let!(:invited_member) { create(:project_member, :invited, project: project) } + let(:params) { { email: "#{invited_member.invite_email},#{project_user.email}" } } + + it 'adds new email and returns an error for the already invited email' do + expect_to_create_members(count: 1) + expect(result[:status]).to eq(:error) + expect(result[:message][invited_member.invite_email]) + .to eq("The member's email address has already been taken") + expect(project.users).to include project_user + end + end - it 'adds new email and returns an error for the already invited email' do - expect_to_create_members(count: 1) - expect(result[:status]).to eq(:error) - expect(result[:message][existing_member.user.email]) - .to eq("User already exists in source") - expect(project.users).to include project_user + context 'when email is already a member with a user on the project' do + let!(:existing_member) { create(:project_member, :guest, project: project) } + let(:params) { { email: "#{existing_member.user.email}" } } + + it 'returns an error for the already invited email' do + expect_not_to_create_members + expect(result[:message][existing_member.user.email]).to eq("User already exists in source") + end + + context 'when email belongs to an existing user as a secondary email' do + let(:secondary_email) { create(:email, email: 'secondary@example.com', user: existing_member.user) } + let(:params) { { email: "#{secondary_email.email}" } } + + it 'returns an error for the already invited email' do + expect_not_to_create_members + expect(result[:message][secondary_email.email]).to eq("User already exists in source") + end + end + end + + context 'with user_id that already exists' do + let!(:existing_member) { create(:project_member, project: project, user: project_user) } + let(:params) { { user_ids: existing_member.user_id } } + + it 'does not add the member again and is successful' do + expect_to_create_members(count: 0) + expect(project.users).to include project_user + end + end + + context 'with user_id that already exists with a lower access_level' do + let!(:existing_member) { create(:project_member, :developer, project: project, user: project_user) } + let(:params) { { user_ids: existing_member.user_id, access_level: ProjectMember::MAINTAINER } } + + it 'does not add the member again and updates the access_level' do + expect_to_create_members(count: 0) + expect(project.users).to include project_user + expect(existing_member.reset.access_level).to eq ProjectMember::MAINTAINER + end + end + + context 'with user_id that already exists with a higher access_level' do + let!(:existing_member) { create(:project_member, :developer, project: project, user: project_user) } + let(:params) { { user_ids: existing_member.user_id, access_level: ProjectMember::GUEST } } + + it 'does not add the member again and updates the access_level' do + expect_to_create_members(count: 0) + expect(project.users).to include project_user + expect(existing_member.reset.access_level).to eq ProjectMember::GUEST + end + end + + context 'with user_id that already exists in a parent group' do + let_it_be(:group) { create(:group) } + let_it_be(:group_member) { create(:group_member, :developer, source: group, user: project_user) } + let_it_be(:project, reload: true) { create(:project, group: group) } + let_it_be(:user) { project.creator } + + before_all do + project.add_maintainer(user) + end + + context 'when access_level is lower than inheriting member' do + let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::GUEST }} + + it 'does not add the member and returns an error' do + msg = "Access level should be greater than or equal " \ + "to Developer inherited membership from group #{group.name}" + + expect_not_to_create_members + expect(result[:message][project_user.username]).to eq msg + end + end + + context 'when access_level is the same as the inheriting member' do + let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::DEVELOPER }} + + it 'adds the member with correct access_level' do + expect_to_create_members(count: 1) + expect(project.users).to include project_user + expect(project.project_members.last.access_level).to eq ProjectMember::DEVELOPER + end + end + + context 'when access_level is greater than the inheriting member' do + let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::MAINTAINER }} + + it 'adds the member with correct access_level' do + expect_to_create_members(count: 1) + expect(project.users).to include project_user + expect(project.project_members.last.access_level).to eq ProjectMember::MAINTAINER + end + end end end diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index eb587797201..30095ebeb50 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -10,7 +10,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do let(:user) { create(:user) } let(:user2) { create(:user) } let(:user3) { create(:user) } - let(:label) { create(:label, project: project) } + let(:label) { create(:label, title: 'a', project: project) } let(:label2) { create(:label) } let(:milestone) { create(:milestone, project: project) } @@ -1192,5 +1192,18 @@ RSpec.describe MergeRequests::UpdateService, :mailer do let(:existing_merge_request) { create(:merge_request, source_project: project) } let(:issuable) { described_class.new(project: project, current_user: user, params: params).execute(existing_merge_request) } end + + context 'labels are updated' do + let(:label_a) { label } + let(:label_b) { create(:label, title: 'b', project: project) } + let(:issuable) { merge_request } + + it_behaves_like 'keeps issuable labels sorted after update' + it_behaves_like 'broadcasting issuable labels updates' + + def update_issuable(update_params) + update_merge_request(update_params) + end + end end end diff --git a/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb b/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb index 5dc30c156ac..afeb1646005 100644 --- a/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb +++ b/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Metrics::Dashboard::CustomDashboardService, :use_clean_rails_memo subject { described_class.new(*service_params) } before do - project.add_maintainer(user) + project.add_maintainer(user) if user end describe '#raw_dashboard' do diff --git a/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb b/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb index 82321dbc822..127cec6275c 100644 --- a/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb +++ b/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Metrics::Dashboard::CustomMetricEmbedService do let_it_be(:environment) { create(:environment, project: project) } before do - project.add_maintainer(user) + project.add_maintainer(user) if user end let(:dashboard_path) { system_dashboard_path } diff --git a/spec/services/metrics/dashboard/default_embed_service_spec.rb b/spec/services/metrics/dashboard/default_embed_service_spec.rb index 2ce10eac026..647778eadc1 100644 --- a/spec/services/metrics/dashboard/default_embed_service_spec.rb +++ b/spec/services/metrics/dashboard/default_embed_service_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Metrics::Dashboard::DefaultEmbedService, :use_clean_rails_memory_ let_it_be(:environment) { create(:environment, project: project) } before do - project.add_maintainer(user) + project.add_maintainer(user) if user end describe '.valid_params?' do diff --git a/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb b/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb index 3c533b0c464..5eb8f24266c 100644 --- a/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb +++ b/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Metrics::Dashboard::DynamicEmbedService, :use_clean_rails_memory_ let_it_be(:environment) { create(:environment, project: project) } before do - project.add_maintainer(user) + project.add_maintainer(user) if user end let(:dashboard_path) { '.gitlab/dashboards/test.yml' } diff --git a/spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb b/spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb index 33b7e3c85cd..d0cefdbeb30 100644 --- a/spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb +++ b/spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb @@ -12,7 +12,7 @@ RSpec.describe Metrics::Dashboard::SelfMonitoringDashboardService, :use_clean_ra let(:service_params) { [project, user, { environment: environment }] } before do - project.add_maintainer(user) + project.add_maintainer(user) if user stub_application_setting(self_monitoring_project_id: project.id) end diff --git a/spec/services/metrics/dashboard/system_dashboard_service_spec.rb b/spec/services/metrics/dashboard/system_dashboard_service_spec.rb index ced7c29b507..e1c6aaeec66 100644 --- a/spec/services/metrics/dashboard/system_dashboard_service_spec.rb +++ b/spec/services/metrics/dashboard/system_dashboard_service_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Metrics::Dashboard::SystemDashboardService, :use_clean_rails_memo subject { described_class.new(*service_params) } before do - project.add_maintainer(user) + project.add_maintainer(user) if user end describe '#raw_dashboard' do diff --git a/spec/services/metrics/dashboard/transient_embed_service_spec.rb b/spec/services/metrics/dashboard/transient_embed_service_spec.rb index 3fd0c97d909..53ea83c33d6 100644 --- a/spec/services/metrics/dashboard/transient_embed_service_spec.rb +++ b/spec/services/metrics/dashboard/transient_embed_service_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Metrics::Dashboard::TransientEmbedService, :use_clean_rails_memor let_it_be(:environment) { create(:environment, project: project) } before do - project.add_maintainer(user) + project.add_maintainer(user) if user end describe '.valid_params?' do diff --git a/spec/services/namespaces/in_product_marketing_email_records_spec.rb b/spec/services/namespaces/in_product_marketing_email_records_spec.rb index e5f1b275f9c..d80e20135d5 100644 --- a/spec/services/namespaces/in_product_marketing_email_records_spec.rb +++ b/spec/services/namespaces/in_product_marketing_email_records_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Namespaces::InProductMarketingEmailRecords do before do allow(Users::InProductMarketingEmail).to receive(:bulk_insert!) - records.add(user, :invite_team, 0) + records.add(user, :team_short, 0) records.add(user, :create, 1) end @@ -33,13 +33,13 @@ RSpec.describe Namespaces::InProductMarketingEmailRecords do describe '#add' do it 'adds a Users::InProductMarketingEmail record to its records' do freeze_time do - records.add(user, :invite_team, 0) + records.add(user, :team_short, 0) records.add(user, :create, 1) first, second = records.records expect(first).to be_a Users::InProductMarketingEmail - expect(first.track.to_sym).to eq :invite_team + expect(first.track.to_sym).to eq :team_short expect(first.series).to eq 0 expect(first.created_at).to eq Time.zone.now expect(first.updated_at).to eq Time.zone.now diff --git a/spec/services/namespaces/in_product_marketing_emails_service_spec.rb b/spec/services/namespaces/in_product_marketing_emails_service_spec.rb index 58ba577b7e7..de84666ca1d 100644 --- a/spec/services/namespaces/in_product_marketing_emails_service_spec.rb +++ b/spec/services/namespaces/in_product_marketing_emails_service_spec.rb @@ -183,7 +183,7 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do expect( Users::InProductMarketingEmail.where( user: user, - track: Users::InProductMarketingEmail.tracks[:create], + track: Users::InProductMarketingEmail::ACTIVE_TRACKS[:create], series: 0 ) ).to exist diff --git a/spec/services/namespaces/invite_team_email_service_spec.rb b/spec/services/namespaces/invite_team_email_service_spec.rb deleted file mode 100644 index 60ba91f433d..00000000000 --- a/spec/services/namespaces/invite_team_email_service_spec.rb +++ /dev/null @@ -1,128 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Namespaces::InviteTeamEmailService do - let_it_be(:user) { create(:user, email_opted_in: true) } - - let(:track) { described_class::TRACK } - let(:series) { 0 } - - let(:setup_for_company) { true } - let(:parent_group) { nil } - let(:group) { create(:group, parent: parent_group) } - - subject(:action) { described_class.send_email(user, group) } - - before do - group.add_owner(user) - allow(group).to receive(:setup_for_company).and_return(setup_for_company) - allow(Notify).to receive(:in_product_marketing_email).and_return(double(deliver_later: nil)) - end - - RSpec::Matchers.define :send_invite_team_email do |*args| - match do - expect(Notify).to have_received(:in_product_marketing_email).with(*args).once - end - - match_when_negated do - expect(Notify).not_to have_received(:in_product_marketing_email) - end - end - - shared_examples 'unexperimented' do - it { is_expected.not_to send_invite_team_email } - - it 'does not record sent email' do - expect { subject }.not_to change { Users::InProductMarketingEmail.count } - end - end - - shared_examples 'candidate' do - it { is_expected.to send_invite_team_email(user.id, group.id, track, 0) } - - it 'records sent email' do - expect { subject }.to change { Users::InProductMarketingEmail.count }.by(1) - - expect( - Users::InProductMarketingEmail.where( - user: user, - track: track, - series: 0 - ) - ).to exist - end - - it_behaves_like 'tracks assignment and records the subject', :invite_team_email, :group do - subject { group } - end - end - - context 'when group is in control path' do - before do - stub_experiments(invite_team_email: :control) - end - - it { is_expected.not_to send_invite_team_email } - - it 'does not record sent email' do - expect { subject }.not_to change { Users::InProductMarketingEmail.count } - end - - it_behaves_like 'tracks assignment and records the subject', :invite_team_email, :group do - subject { group } - end - end - - context 'when group is in candidate path' do - before do - stub_experiments(invite_team_email: :candidate) - end - - it_behaves_like 'candidate' - - context 'when the user has not opted into marketing emails' do - let(:user) { create(:user, email_opted_in: false ) } - - it_behaves_like 'unexperimented' - end - - context 'when group is not top level' do - it_behaves_like 'unexperimented' do - let(:parent_group) do - create(:group).tap { |g| g.add_owner(user) } - end - end - end - - context 'when group is not set up for a company' do - it_behaves_like 'unexperimented' do - let(:setup_for_company) { nil } - end - end - - context 'when other users have already been added to the group' do - before do - group.add_developer(create(:user)) - end - - it_behaves_like 'unexperimented' - end - - context 'when other users have already been invited to the group' do - before do - group.add_developer('not_a_user_yet@example.com') - end - - it_behaves_like 'unexperimented' - end - - context 'when the user already got sent the email' do - before do - create(:in_product_marketing_email, user: user, track: track, series: 0) - end - - it_behaves_like 'unexperimented' - end - end -end diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb index b7b08390dcd..0e2bbcc8c66 100644 --- a/spec/services/notes/build_service_spec.rb +++ b/spec/services/notes/build_service_spec.rb @@ -5,12 +5,14 @@ require 'spec_helper' RSpec.describe Notes::BuildService do include AdminModeHelper - let(:note) { create(:discussion_note_on_issue) } - let(:project) { note.project } - let(:author) { note.author } - let(:user) { author } - let(:merge_request) { create(:merge_request, source_project: project) } - let(:mr_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, author: note.author) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:note) { create(:discussion_note_on_issue, project: project) } + let_it_be(:author) { note.author } + let_it_be(:user) { author } + let_it_be(:noteable_author) { create(:user) } + let_it_be(:other_user) { create(:user) } + let_it_be(:external) { create(:user, :external) } + let(:base_params) { { note: 'Test' } } let(:params) { {} } @@ -28,11 +30,10 @@ RSpec.describe Notes::BuildService do end context 'when discussion is resolved' do - let(:params) { { in_reply_to_discussion_id: mr_note.discussion_id } } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + let_it_be(:mr_note) { create(:discussion_note_on_merge_request, :resolved, noteable: merge_request, project: project, author: author) } - before do - mr_note.resolve!(author) - end + let(:params) { { in_reply_to_discussion_id: mr_note.discussion_id } } it 'resolves the note' do expect(new_note).to be_valid @@ -57,7 +58,7 @@ RSpec.describe Notes::BuildService do end context 'when user has no access to discussion' do - let(:user) { create(:user) } + let(:user) { other_user } it 'sets an error' do expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') @@ -65,16 +66,14 @@ RSpec.describe Notes::BuildService do end context 'personal snippet note' do - def reply(note, user = nil) - user ||= create(:user) - + def reply(note, user = other_user) described_class.new(nil, user, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute end - let(:snippet_author) { create(:user) } + let_it_be(:snippet_author) { noteable_author } context 'when a snippet is public' do it 'creates a reply note' do @@ -89,8 +88,8 @@ RSpec.describe Notes::BuildService do end context 'when a snippet is private' do - let(:snippet) { create(:personal_snippet, :private, author: snippet_author) } - let(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) } + let_it_be(:snippet) { create(:personal_snippet, :private, author: snippet_author) } + let_it_be(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) } it 'creates a reply note when the author replies' do new_note = reply(note, snippet_author) @@ -107,8 +106,8 @@ RSpec.describe Notes::BuildService do end context 'when a snippet is internal' do - let(:snippet) { create(:personal_snippet, :internal, author: snippet_author) } - let(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) } + let_it_be(:snippet) { create(:personal_snippet, :internal, author: snippet_author) } + let_it_be(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) } it 'creates a reply note when the author replies' do new_note = reply(note, snippet_author) @@ -125,7 +124,7 @@ RSpec.describe Notes::BuildService do end it 'sets an error when an external user replies' do - new_note = reply(note, create(:user, :external)) + new_note = reply(note, external) expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') end @@ -134,7 +133,8 @@ RSpec.describe Notes::BuildService do end context 'when replying to individual note' do - let(:note) { create(:note_on_issue) } + let_it_be(:note) { create(:note_on_issue, project: project) } + let(:params) { { in_reply_to_discussion_id: note.discussion_id } } it 'sets the note up to be in reply to that note' do @@ -144,7 +144,7 @@ RSpec.describe Notes::BuildService do end context 'when noteable does not support replies' do - let(:note) { create(:note_on_commit) } + let_it_be(:note) { create(:note_on_commit, project: project) } it 'builds another individual note' do expect(new_note).to be_valid @@ -155,87 +155,137 @@ RSpec.describe Notes::BuildService do end context 'confidential comments' do + let_it_be(:project) { create(:project, :public) } + let_it_be(:guest) { create(:user) } + let_it_be(:reporter) { create(:user) } + let_it_be(:admin) { create(:admin) } + let_it_be(:issuable_assignee) { other_user } + let_it_be(:issue) do + create(:issue, project: project, author: noteable_author, assignees: [issuable_assignee]) + end + before do - project.add_reporter(author) + project.add_guest(guest) + project.add_reporter(reporter) end - context 'when replying to a confidential comment' do - let(:note) { create(:note_on_issue, confidential: true) } - let(:params) { { in_reply_to_discussion_id: note.discussion_id, confidential: false } } + context 'when creating a new confidential comment' do + let(:params) { { confidential: true, noteable: issue } } - context 'when the user can read confidential comments' do - it '`confidential` param is ignored and set to `true`' do - expect(new_note.confidential).to be_truthy - end + shared_examples 'user allowed to set comment as confidential' do + it { expect(new_note.confidential).to be_truthy } end - context 'when the user cannot read confidential comments' do - let(:user) { create(:user) } + shared_examples 'user not allowed to set comment as confidential' do + it { expect(new_note.confidential).to be_falsey } + end - it 'returns `Discussion to reply to cannot be found` error' do - expect(new_note.errors.added?(:base, "Discussion to reply to cannot be found")).to be true + context 'reporter' do + let(:user) { reporter } + + it_behaves_like 'user allowed to set comment as confidential' + end + + context 'issuable author' do + let(:user) { noteable_author } + + it_behaves_like 'user allowed to set comment as confidential' + end + + context 'issuable assignee' do + let(:user) { issuable_assignee } + + it_behaves_like 'user allowed to set comment as confidential' + end + + context 'admin' do + before do + enable_admin_mode!(admin) end + + let(:user) { admin } + + it_behaves_like 'user allowed to set comment as confidential' end - end - context 'when replying to a public comment' do - let(:note) { create(:note_on_issue, confidential: false) } - let(:params) { { in_reply_to_discussion_id: note.discussion_id, confidential: true } } + context 'external' do + let(:user) { external } - it '`confidential` param is ignored and set to `false`' do - expect(new_note.confidential).to be_falsey + it_behaves_like 'user not allowed to set comment as confidential' + end + + context 'guest' do + let(:user) { guest } + + it_behaves_like 'user not allowed to set comment as confidential' end end - context 'when creating a new comment' do - context 'when the `confidential` note flag is set to `true`' do - context 'when the user is allowed (reporter)' do - let(:params) { { confidential: true, noteable: merge_request } } + context 'when replying to a confidential comment' do + let_it_be(:note) { create(:note_on_issue, confidential: true, noteable: issue, project: project) } - it 'note `confidential` flag is set to `true`' do - expect(new_note.confidential).to be_truthy - end - end + let(:params) { { in_reply_to_discussion_id: note.discussion_id, confidential: false } } - context 'when the user is allowed (issuable author)' do - let(:user) { create(:user) } - let(:issue) { create(:issue, author: user) } - let(:params) { { confidential: true, noteable: issue } } + shared_examples 'returns `Discussion to reply to cannot be found` error' do + it do + expect(new_note.errors.added?(:base, "Discussion to reply to cannot be found")).to be true + end + end - it 'note `confidential` flag is set to `true`' do - expect(new_note.confidential).to be_truthy - end + shared_examples 'confidential set to `true`' do + it '`confidential` param is ignored to match the parent note confidentiality' do + expect(new_note.confidential).to be_truthy end + end - context 'when the user is allowed (admin)' do - before do - enable_admin_mode!(admin) - end + context 'with reporter access' do + let(:user) { reporter } + + it_behaves_like 'confidential set to `true`' + end - let(:admin) { create(:admin) } - let(:params) { { confidential: true, noteable: merge_request } } + context 'with admin access' do + let(:user) { admin } - it 'note `confidential` flag is set to `true`' do - expect(new_note.confidential).to be_truthy - end + before do + enable_admin_mode!(admin) end - context 'when the user is not allowed' do - let(:user) { create(:user) } - let(:params) { { confidential: true, noteable: merge_request } } + it_behaves_like 'confidential set to `true`' + end + + context 'with noteable author' do + let(:user) { note.noteable.author } - it 'note `confidential` flag is set to `false`' do - expect(new_note.confidential).to be_falsey - end - end + it_behaves_like 'confidential set to `true`' end - context 'when the `confidential` note flag is set to `false`' do - let(:params) { { confidential: false, noteable: merge_request } } + context 'with noteable assignee' do + let(:user) { issuable_assignee } - it 'note `confidential` flag is set to `false`' do - expect(new_note.confidential).to be_falsey - end + it_behaves_like 'confidential set to `true`' + end + + context 'with guest access' do + let(:user) { guest } + + it_behaves_like 'returns `Discussion to reply to cannot be found` error' + end + + context 'with external user' do + let(:user) { external } + + it_behaves_like 'returns `Discussion to reply to cannot be found` error' + end + end + + context 'when replying to a public comment' do + let_it_be(:note) { create(:note_on_issue, confidential: false, noteable: issue, project: project) } + + let(:params) { { in_reply_to_discussion_id: note.discussion_id, confidential: true } } + + it '`confidential` param is ignored and set to `false`' do + expect(new_note.confidential).to be_falsey end end end diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index babbd44a9f1..b0410123630 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -106,7 +106,8 @@ RSpec.describe Notes::CreateService do type: 'DiffNote', noteable_type: 'MergeRequest', noteable_id: merge_request.id, - position: position.to_h) + position: position.to_h, + confidential: false) end before do @@ -141,7 +142,8 @@ RSpec.describe Notes::CreateService do type: 'DiffNote', noteable_type: 'MergeRequest', noteable_id: merge_request.id, - position: position.to_h) + position: position.to_h, + confidential: false) expect(merge_request).not_to receive(:diffs) @@ -173,7 +175,8 @@ RSpec.describe Notes::CreateService do type: 'DiffNote', noteable_type: 'MergeRequest', noteable_id: merge_request.id, - position: position.to_h) + position: position.to_h, + confidential: false) end it 'note is associated with a note diff file' do @@ -201,7 +204,8 @@ RSpec.describe Notes::CreateService do type: 'DiffNote', noteable_type: 'MergeRequest', noteable_id: merge_request.id, - position: position.to_h) + position: position.to_h, + confidential: false) end it 'note is not associated with a note diff file' do @@ -230,7 +234,8 @@ RSpec.describe Notes::CreateService do type: 'DiffNote', noteable_type: 'MergeRequest', noteable_id: merge_request.id, - position: image_position.to_h) + position: image_position.to_h, + confidential: false) end it 'note is not associated with a note diff file' do @@ -306,7 +311,7 @@ RSpec.describe Notes::CreateService do let_it_be(:merge_request) { create(:merge_request, source_project: project, labels: [bug_label]) } let(:issuable) { merge_request } - let(:note_params) { opts.merge(noteable_type: 'MergeRequest', noteable_id: merge_request.id) } + let(:note_params) { opts.merge(noteable_type: 'MergeRequest', noteable_id: merge_request.id, confidential: false) } let(:merge_request_quick_actions) do [ QuickAction.new( diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb index 71ac1641ca5..ae7bea30944 100644 --- a/spec/services/notes/update_service_spec.rb +++ b/spec/services/notes/update_service_spec.rb @@ -138,45 +138,6 @@ RSpec.describe Notes::UpdateService do end end - context 'setting confidentiality' do - let(:opts) { { confidential: true } } - - context 'simple note' do - it 'updates the confidentiality' do - expect { update_note(opts) }.to change { note.reload.confidential }.from(nil).to(true) - end - end - - context 'discussion notes' do - let(:note) { create(:discussion_note, project: project, noteable: issue, author: user, note: "Old note #{user2.to_reference}") } - let!(:response_note_1) { create(:discussion_note, project: project, noteable: issue, in_reply_to: note) } - let!(:response_note_2) { create(:discussion_note, project: project, noteable: issue, in_reply_to: note, confidential: false) } - let!(:other_note) { create(:note, project: project, noteable: issue) } - - context 'when updating the root note' do - it 'updates the confidentiality of the root note and all the responses' do - update_note(opts) - - expect(note.reload.confidential).to be_truthy - expect(response_note_1.reload.confidential).to be_truthy - expect(response_note_2.reload.confidential).to be_truthy - expect(other_note.reload.confidential).to be_falsey - end - end - - context 'when updating one of the response notes' do - it 'updates only the confidentiality of the note that is being updated' do - Notes::UpdateService.new(project, user, opts).execute(response_note_1) - - expect(note.reload.confidential).to be_falsey - expect(response_note_1.reload.confidential).to be_truthy - expect(response_note_2.reload.confidential).to be_falsey - expect(other_note.reload.confidential).to be_falsey - end - end - end - end - context 'todos' do shared_examples 'does not update todos' do it 'keep todos' do diff --git a/spec/services/notification_recipients/builder/default_spec.rb b/spec/services/notification_recipients/builder/default_spec.rb index c142cc11384..4d0ddc7c4f7 100644 --- a/spec/services/notification_recipients/builder/default_spec.rb +++ b/spec/services/notification_recipients/builder/default_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe NotificationRecipients::Builder::Default do describe '#build!' do let_it_be(:group) { create(:group, :public) } - let_it_be(:project) { create(:project, :public, group: group).tap { |p| p.add_developer(project_watcher) } } + let_it_be(:project) { create(:project, :public, group: group).tap { |p| p.add_developer(project_watcher) if project_watcher } } let_it_be(:target) { create(:issue, project: project) } let_it_be(:current_user) { create(:user) } diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 399b2b4be2d..d2d55c5ab79 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -376,6 +376,17 @@ RSpec.describe NotificationService, :mailer do end end + describe '#new_email_address_added' do + let_it_be(:user) { create(:user) } + let_it_be(:email) { create(:email, user: user) } + + subject { notification.new_email_address_added(user, email) } + + it 'sends email to the user' do + expect { subject }.to have_enqueued_email(user, email, mail: 'new_email_address_added_email') + end + end + describe 'Notes' do context 'issue note' do let_it_be(:project) { create(:project, :private) } @@ -2090,6 +2101,70 @@ RSpec.describe NotificationService, :mailer do should_not_email(@u_lazy_participant) end + describe 'triggers push_to_merge_request_email with corresponding email' do + let_it_be(:merge_request) { create(:merge_request, author: author, source_project: project) } + + def mock_commits(length) + Array.new(length) { |i| double(:commit, short_id: SecureRandom.hex(4), title: "This is commit #{i}") } + end + + def commit_to_hash(commit) + { short_id: commit.short_id, title: commit.title } + end + + let(:existing_commits) { mock_commits(50) } + let(:expected_existing_commits) { [commit_to_hash(existing_commits.first), commit_to_hash(existing_commits.last)] } + + before do + allow(::Notify).to receive(:push_to_merge_request_email).and_call_original + end + + where(:number_of_new_commits, :number_of_new_commits_displayed) do + limit = described_class::NEW_COMMIT_EMAIL_DISPLAY_LIMIT + [ + [0, 0], + [limit - 2, limit - 2], + [limit - 1, limit - 1], + [limit, limit], + [limit + 1, limit], + [limit + 2, limit] + ] + end + + with_them do + let(:new_commits) { mock_commits(number_of_new_commits) } + let(:expected_new_commits) { new_commits.first(number_of_new_commits_displayed).map(&method(:commit_to_hash)) } + + it 'triggers the corresponding mailer method with list of stripped commits' do + notification.push_to_merge_request( + merge_request, merge_request.author, + new_commits: new_commits, existing_commits: existing_commits + ) + + expect(Notify).to have_received(:push_to_merge_request_email).at_least(:once).with( + @subscriber.id, merge_request.id, merge_request.author.id, "subscribed", + new_commits: expected_new_commits, total_new_commits_count: number_of_new_commits, + existing_commits: expected_existing_commits, total_existing_commits_count: 50 + ) + end + end + + context 'there is only one existing commit' do + let(:new_commits) { mock_commits(10) } + let(:expected_new_commits) { new_commits.map(&method(:commit_to_hash)) } + + it 'triggers corresponding mailer method with only one existing commit' do + notification.push_to_merge_request(merge_request, merge_request.author, new_commits: new_commits, existing_commits: existing_commits.first(1)) + + expect(Notify).to have_received(:push_to_merge_request_email).at_least(:once).with( + @subscriber.id, merge_request.id, merge_request.author.id, "subscribed", + new_commits: expected_new_commits, total_new_commits_count: 10, + existing_commits: expected_existing_commits.first(1), total_existing_commits_count: 1 + ) + end + end + end + it_behaves_like 'participating notifications' do let(:participant) { create(:user, username: 'user-participant') } let(:issuable) { merge_request } diff --git a/spec/services/packages/rubygems/metadata_extraction_service_spec.rb b/spec/services/packages/rubygems/metadata_extraction_service_spec.rb index b308daad8f5..bbd5b6f3d59 100644 --- a/spec/services/packages/rubygems/metadata_extraction_service_spec.rb +++ b/spec/services/packages/rubygems/metadata_extraction_service_spec.rb @@ -46,5 +46,13 @@ RSpec.describe Packages::Rubygems::MetadataExtractionService do expect(metadata.requirements).to eq(gemspec.requirements.to_json) expect(metadata.rubygems_version).to eq(gemspec.rubygems_version) end + + context 'with an existing metadatum' do + let_it_be(:metadatum) { create(:rubygems_metadatum, package: package) } + + it 'updates it' do + expect { subject }.not_to change { Packages::Rubygems::Metadatum.count } + end + end end end diff --git a/spec/services/projects/apple_target_platform_detector_service_spec.rb b/spec/services/projects/apple_target_platform_detector_service_spec.rb new file mode 100644 index 00000000000..6391161824c --- /dev/null +++ b/spec/services/projects/apple_target_platform_detector_service_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::AppleTargetPlatformDetectorService do + let_it_be(:project) { build(:project) } + + subject { described_class.new(project).execute } + + context 'when project is not an xcode project' do + before do + allow(Gitlab::FileFinder).to receive(:new) { instance_double(Gitlab::FileFinder, find: []) } + end + + it 'returns an empty array' do + is_expected.to match_array [] + end + end + + context 'when project is an xcode project' do + using RSpec::Parameterized::TableSyntax + + let(:finder) { instance_double(Gitlab::FileFinder) } + + before do + allow(Gitlab::FileFinder).to receive(:new) { finder } + end + + def search_query(sdk, filename) + "SDKROOT = #{sdk} filename:#{filename}" + end + + context 'when setting string is found' do + where(:sdk, :filename, :result) do + 'iphoneos' | 'project.pbxproj' | [:ios] + 'iphoneos' | '*.xcconfig' | [:ios] + end + + with_them do + before do + allow(finder).to receive(:find).with(anything) { [] } + allow(finder).to receive(:find).with(search_query(sdk, filename)) { [instance_double(Gitlab::Search::FoundBlob)] } + end + + it 'returns an array of unique detected targets' do + is_expected.to match_array result + end + end + end + + context 'when setting string is not found' do + before do + allow(finder).to receive(:find).with(anything) { [] } + end + + it 'returns an empty array' do + is_expected.to match_array [] + end + end + end +end diff --git a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb index 38a3e00c8e7..86c0ba4222c 100644 --- a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb +++ b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb @@ -13,7 +13,7 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService, :clean_gitlab_ let(:tags) { %w[latest A Ba Bb C D E] } before do - project.add_maintainer(user) + project.add_maintainer(user) if user stub_container_registry_config(enabled: true) diff --git a/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb b/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb index 7fc963949eb..22cada7816b 100644 --- a/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb +++ b/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb @@ -58,7 +58,19 @@ RSpec.describe Projects::ContainerRepository::ThirdParty::DeleteTagsService do stub_put_manifest_request('Ba', 500, {}) end - it { is_expected.to eq(status: :error, message: 'could not delete tags') } + it { is_expected.to eq(status: :error, message: "could not delete tags: #{tags.join(', ')}")} + + context 'when a large list of tag updates fails' do + let(:tags) { Array.new(1000) { |i| "tag_#{i}" } } + + before do + expect(service).to receive(:replace_tag_manifests).and_return({}) + end + + it 'truncates the log message' do + expect(subject).to eq(status: :error, message: "could not delete tags: #{tags.join(', ')}".truncate(1000)) + end + end end context 'a single tag update fails' do diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 96a50b26871..c5c5af3cb01 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -135,10 +135,22 @@ RSpec.describe Projects::CreateService, '#execute' do create_project(user, opts) end - it 'builds associated project settings' do + it 'creates associated project settings' do project = create_project(user, opts) - expect(project.project_setting).to be_new_record + expect(project.project_setting).to be_persisted + end + + context 'create_project_settings feature flag is disabled' do + before do + stub_feature_flags(create_project_settings: false) + end + + it 'builds associated project settings' do + project = create_project(user, opts) + + expect(project.project_setting).to be_new_record + end end it_behaves_like 'storing arguments in the application context' do @@ -376,6 +388,18 @@ RSpec.describe Projects::CreateService, '#execute' do imported_project end + + describe 'import scheduling' do + context 'when project import type is gitlab project migration' do + it 'does not schedule project import' do + opts[:import_type] = 'gitlab_project_migration' + + project = create_project(user, opts) + + expect(project.import_state.status).to eq('none') + end + end + end end context 'builds_enabled global setting' do diff --git a/spec/services/projects/operations/update_service_spec.rb b/spec/services/projects/operations/update_service_spec.rb index b64f2d1e7d6..3ee867ba6f2 100644 --- a/spec/services/projects/operations/update_service_spec.rb +++ b/spec/services/projects/operations/update_service_spec.rb @@ -407,10 +407,11 @@ RSpec.describe Projects::Operations::UpdateService do context 'prometheus integration' do context 'prometheus params were passed into service' do - let(:prometheus_integration) do - build_stubbed(:prometheus_integration, project: project, properties: { + let!(:prometheus_integration) do + create(:prometheus_integration, :instance, properties: { api_url: "http://example.prometheus.com", - manual_configuration: "0" + manual_configuration: "0", + google_iap_audience_client_id: 123 }) end @@ -424,21 +425,23 @@ RSpec.describe Projects::Operations::UpdateService do end it 'uses Project#find_or_initialize_integration to include instance defined defaults and pass them to Projects::UpdateService', :aggregate_failures do - project_update_service = double(Projects::UpdateService) - - expect(project) - .to receive(:find_or_initialize_integration) - .with('prometheus') - .and_return(prometheus_integration) expect(Projects::UpdateService).to receive(:new) do |project_arg, user_arg, update_params_hash| + prometheus_attrs = update_params_hash[:prometheus_integration_attributes] + expect(project_arg).to eq project expect(user_arg).to eq user - expect(update_params_hash[:prometheus_integration_attributes]).to include('properties' => { 'api_url' => 'http://new.prometheus.com', 'manual_configuration' => '1' }) - expect(update_params_hash[:prometheus_integration_attributes]).not_to include(*%w(id project_id created_at updated_at)) - end.and_return(project_update_service) - expect(project_update_service).to receive(:execute) + expect(prometheus_attrs).to have_key('encrypted_properties') + expect(prometheus_attrs.keys).not_to include(*%w(id project_id created_at updated_at properties)) + expect(prometheus_attrs['encrypted_properties']).not_to eq(prometheus_integration.encrypted_properties) + end.and_call_original - subject.execute + expect { subject.execute }.to change(Integrations::Prometheus, :count).by(1) + + expect(Integrations::Prometheus.last).to have_attributes( + api_url: 'http://new.prometheus.com', + manual_configuration: true, + google_iap_audience_client_id: 123 + ) end end diff --git a/spec/services/projects/record_target_platforms_service_spec.rb b/spec/services/projects/record_target_platforms_service_spec.rb new file mode 100644 index 00000000000..85311f36428 --- /dev/null +++ b/spec/services/projects/record_target_platforms_service_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::RecordTargetPlatformsService, '#execute' do + let_it_be(:project) { create(:project) } + + subject(:execute) { described_class.new(project).execute } + + context 'when project is an XCode project' do + before do + double = instance_double(Projects::AppleTargetPlatformDetectorService, execute: [:ios, :osx]) + allow(Projects::AppleTargetPlatformDetectorService).to receive(:new) { double } + end + + it 'creates a new setting record for the project', :aggregate_failures do + expect { execute }.to change { ProjectSetting.count }.from(0).to(1) + expect(ProjectSetting.last.target_platforms).to match_array(%w(ios osx)) + end + + it 'returns array of detected target platforms' do + expect(execute).to match_array %w(ios osx) + end + + context 'when a project has an existing setting record' do + before do + create(:project_setting, project: project, target_platforms: saved_target_platforms) + end + + def project_setting + ProjectSetting.find_by_project_id(project.id) + end + + context 'when target platforms changed' do + let(:saved_target_platforms) { %w(tvos) } + + it 'updates' do + expect { execute }.to change { project_setting.target_platforms }.from(%w(tvos)).to(%w(ios osx)) + end + + it { is_expected.to match_array %w(ios osx) } + end + + context 'when target platforms are the same' do + let(:saved_target_platforms) { %w(osx ios) } + + it 'does not update' do + expect { execute }.not_to change { project_setting.updated_at } + end + end + end + end + + context 'when project is not an XCode project' do + before do + double = instance_double(Projects::AppleTargetPlatformDetectorService, execute: []) + allow(Projects::AppleTargetPlatformDetectorService).to receive(:new).with(project) { double } + end + + it 'does nothing' do + expect { execute }.not_to change { ProjectSetting.count } + end + + it { is_expected.to be_nil } + end +end diff --git a/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb b/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb index 41de8c6bdbb..41487e9ea48 100644 --- a/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb +++ b/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb @@ -10,7 +10,8 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl let_it_be(:artifact_1) { create(:ci_job_artifact, project: project, size: 1, created_at: 14.days.ago) } let_it_be(:artifact_2) { create(:ci_job_artifact, project: project, size: 2, created_at: 13.days.ago) } - let_it_be(:artifact_3) { create(:ci_job_artifact, project: project, size: 5, created_at: 12.days.ago) } + let_it_be(:artifact_3) { create(:ci_job_artifact, project: project, size: nil, created_at: 13.days.ago) } + let_it_be(:artifact_4) { create(:ci_job_artifact, project: project, size: 5, created_at: 12.days.ago) } # This should not be included in the recalculation as it is created later than the refresh start time let_it_be(:future_artifact) { create(:ci_job_artifact, project: project, size: 8, created_at: 2.days.from_now) } @@ -33,7 +34,7 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl end before do - stub_const("#{described_class}::BATCH_SIZE", 2) + stub_const("#{described_class}::BATCH_SIZE", 3) stats = create(:project_statistics, project: project, build_artifacts_size: 120) stats.increment_counter(:build_artifacts_size, 30) @@ -48,7 +49,7 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl end it 'updates the last_job_artifact_id to the ID of the last artifact from the batch' do - expect { service.execute }.to change { refresh.reload.last_job_artifact_id.to_i }.to(artifact_2.id) + expect { service.execute }.to change { refresh.reload.last_job_artifact_id.to_i }.to(artifact_3.id) end it 'requeues the refresh job' do @@ -62,7 +63,7 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl :project_build_artifacts_size_refresh, :pending, project: project, - last_job_artifact_id: artifact_2.id + last_job_artifact_id: artifact_3.id ) end @@ -73,7 +74,7 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl end it 'keeps the last_job_artifact_id unchanged' do - expect(refresh.reload.last_job_artifact_id).to eq(artifact_2.id) + expect(refresh.reload.last_job_artifact_id).to eq(artifact_3.id) end it 'keeps the state of the refresh record at running' do @@ -89,7 +90,7 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl project: project, updated_at: 2.days.ago, refresh_started_at: now, - last_job_artifact_id: artifact_3.id + last_job_artifact_id: artifact_4.id ) end diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index fb94e94fd18..e547ace1d9f 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -11,8 +11,9 @@ RSpec.describe Projects::TransferService do let(:project) { create(:project, :repository, :legacy_storage, namespace: user.namespace) } let(:target) { group } + let(:executor) { user } - subject(:execute_transfer) { described_class.new(project, user).execute(target).tap { project.reload } } + subject(:execute_transfer) { described_class.new(project, executor).execute(target).tap { project.reload } } context 'with npm packages' do before do @@ -92,6 +93,55 @@ RSpec.describe Projects::TransferService do end end + context 'project in a group -> a personal namespace', :enable_admin_mode do + let(:project) { create(:project, :repository, :legacy_storage, group: group) } + let(:target) { user.namespace } + # We need to use an admin user as the executor because + # only an admin user has required permissions to transfer projects + # under _all_ the different circumstances specified below. + let(:executor) { create(:user, :admin) } + + it 'executes the transfer to personal namespace successfully' do + execute_transfer + + expect(project.namespace).to eq(user.namespace) + end + + context 'the owner of the namespace does not have a direct membership in the project residing in the group' do + it 'creates a project membership record for the owner of the namespace, with OWNER access level, after the transfer' do + execute_transfer + + expect(project.members.owners.find_by(user_id: user.id)).to be_present + end + end + + context 'the owner of the namespace has a direct membership in the project residing in the group' do + context 'that membership has an access level of OWNER' do + before do + project.add_owner(user) + end + + it 'retains the project membership record for the owner of the namespace, with OWNER access level, after the transfer' do + execute_transfer + + expect(project.members.owners.find_by(user_id: user.id)).to be_present + end + end + + context 'that membership has an access level that is not OWNER' do + before do + project.add_developer(user) + end + + it 'updates the project membership record for the owner of the namespace, to OWNER access level, after the transfer' do + execute_transfer + + expect(project.members.owners.find_by(user_id: user.id)).to be_present + end + end + end + end + context 'when transfer succeeds' do before do group.add_owner(user) @@ -148,23 +198,23 @@ RSpec.describe Projects::TransferService do context 'with a project integration' do let_it_be_with_reload(:project) { create(:project, namespace: user.namespace) } - let_it_be(:instance_integration) { create(:integrations_slack, :instance, webhook: 'http://project.slack.com') } + let_it_be(:instance_integration) { create(:integrations_slack, :instance) } + let_it_be(:project_integration) { create(:integrations_slack, project: project) } - context 'with an inherited integration' do - let_it_be(:project_integration) { create(:integrations_slack, project: project, webhook: 'http://project.slack.com', inherit_from_id: instance_integration.id) } + context 'when it inherits from instance_integration' do + before do + project_integration.update!(inherit_from_id: instance_integration.id, webhook: instance_integration.webhook) + end it 'replaces inherited integrations', :aggregate_failures do - execute_transfer - - expect(project.slack_integration.webhook).to eq(group_integration.webhook) - expect(Integration.count).to eq(3) + expect { execute_transfer } + .to change(Integration, :count).by(0) + .and change { project.slack_integration.webhook }.to eq(group_integration.webhook) end end context 'with a custom integration' do - let_it_be(:project_integration) { create(:integrations_slack, project: project, webhook: 'http://project.slack.com') } - - it 'does not updates the integrations' do + it 'does not update the integrations' do expect { execute_transfer }.not_to change { project.slack_integration.webhook } end end diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index 94e0e8a9ea1..85dbc39edcf 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -671,6 +671,19 @@ RSpec.describe QuickActions::InterpretService do end shared_examples 'assign command' do + it 'assigns to users with escaped underscores' do + user = create(:user) + base = user.username + user.update!(username: "#{base}_") + issuable.project.add_developer(user) + + cmd = "/assign @#{base}\\_" + + _, updates, _ = service.execute(cmd, issuable) + + expect(updates).to eq(assignee_ids: [user.id]) + end + it 'assigns to a single user' do _, updates, _ = service.execute(content, issuable) @@ -726,6 +739,17 @@ RSpec.describe QuickActions::InterpretService do expect(reviewer).to be_attention_requested end + + it 'supports attn alias' do + attn_cmd = content.gsub(/attention/, 'attn') + _, _, message = service.execute(attn_cmd, issuable) + + expect(message).to eq("Requested attention from #{developer.to_reference}.") + + reviewer.reload + + expect(reviewer).to be_attention_requested + end end shared_examples 'remove attention command' do @@ -800,7 +824,7 @@ RSpec.describe QuickActions::InterpretService do let(:project) { repository_project } let(:service) { described_class.new(project, developer, {}) } - it_behaves_like 'failed command', 'Merge request diff sha parameter is required for the merge quick action.' do + it_behaves_like 'failed command', 'The `/merge` quick action requires the SHA of the head of the branch.' do let(:content) { "/merge" } let(:issuable) { merge_request } end diff --git a/spec/services/service_ping/build_payload_service_spec.rb b/spec/services/service_ping/build_payload_service_spec.rb index b90e5e66518..cd2685069c9 100644 --- a/spec/services/service_ping/build_payload_service_spec.rb +++ b/spec/services/service_ping/build_payload_service_spec.rb @@ -4,10 +4,6 @@ require 'spec_helper' RSpec.describe ServicePing::BuildPayloadService do describe '#execute', :without_license do - before do - stub_feature_flags(merge_service_ping_instrumented_metrics: false) - end - subject(:service_ping_payload) { described_class.new.execute } include_context 'stubbed service ping metrics definitions' do diff --git a/spec/services/task_list_toggle_service_spec.rb b/spec/services/task_list_toggle_service_spec.rb index 81f80ee926a..f889f298213 100644 --- a/spec/services/task_list_toggle_service_spec.rb +++ b/spec/services/task_list_toggle_service_spec.rb @@ -16,6 +16,8 @@ RSpec.describe TaskListToggleService do - [ ] loose list with an embedded paragraph + + + [ ] No-break space (U+00A0) EOT end @@ -40,12 +42,17 @@ RSpec.describe TaskListToggleService do </ul> </li> </ol> - <ul data-sourcepos="9:1-11:28" class="task-list" dir="auto"> - <li data-sourcepos="9:1-11:28" class="task-list-item"> + <ul data-sourcepos="9:1-12:0" class="task-list" dir="auto"> + <li data-sourcepos="9:1-12:0" class="task-list-item"> <p data-sourcepos="9:3-9:16"><input type="checkbox" class="task-list-item-checkbox" disabled=""> loose list</p> <p data-sourcepos="11:3-11:28">with an embedded paragraph</p> </li> </ul> + <ul data-sourcepos="13:1-13:21" class="task-list" dir="auto"> + <li data-sourcepos="13:1-13:21" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled=""> No-break space (U+00A0) + </li> + </ul> EOT end @@ -79,6 +86,16 @@ RSpec.describe TaskListToggleService do expect(toggler.updated_markdown_html).to include('disabled checked> loose list') end + it 'checks task with no-break space' do + toggler = described_class.new(markdown, markdown_html, + toggle_as_checked: true, + line_source: '+ [ ] No-break space (U+00A0)', line_number: 13) + + expect(toggler.execute).to be_truthy + expect(toggler.updated_markdown.lines[12]).to eq "+ [x] No-break space (U+00A0)" + expect(toggler.updated_markdown_html).to include('disabled checked> No-break space (U+00A0)') + end + it 'returns false if line_source does not match the text' do toggler = described_class.new(markdown, markdown_html, toggle_as_checked: false, diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb index 602db66dba1..80a506bb1d6 100644 --- a/spec/services/users/destroy_service_spec.rb +++ b/spec/services/users/destroy_service_spec.rb @@ -332,4 +332,39 @@ RSpec.describe Users::DestroyService do expect(User.exists?(other_user.id)).to be(false) end end + + context 'batched nullify' do + let(:other_user) { create(:user) } + + context 'when :nullify_in_batches_on_user_deletion feature flag is enabled' do + it 'nullifies related associations in batches' do + expect(other_user).to receive(:nullify_dependent_associations_in_batches).and_call_original + + described_class.new(user).execute(other_user, skip_authorization: true) + end + + it 'nullifies last_updated_issues and closed_issues' do + issue = create(:issue, closed_by: other_user, updated_by: other_user) + + described_class.new(user).execute(other_user, skip_authorization: true) + + issue.reload + + expect(issue.closed_by).to be_nil + expect(issue.updated_by).to be_nil + end + end + + context 'when :nullify_in_batches_on_user_deletion feature flag is disabled' do + before do + stub_feature_flags(nullify_in_batches_on_user_deletion: false) + end + + it 'does not use batching' do + expect(other_user).not_to receive(:nullify_dependent_associations_in_batches) + + described_class.new(user).execute(other_user, skip_authorization: true) + end + end + end end diff --git a/spec/services/users/saved_replies/destroy_service_spec.rb b/spec/services/users/saved_replies/destroy_service_spec.rb new file mode 100644 index 00000000000..cb97fac7b7c --- /dev/null +++ b/spec/services/users/saved_replies/destroy_service_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::SavedReplies::DestroyService do + describe '#execute' do + let!(:saved_reply) { create(:saved_reply) } + + subject { described_class.new(saved_reply: saved_reply).execute } + + context 'when destroy fails' do + before do + allow(saved_reply).to receive(:destroy).and_return(false) + end + + it 'does not remove Saved Reply from database' do + expect { subject }.not_to change(::Users::SavedReply, :count) + end + + it { is_expected.not_to be_success } + end + + context 'when destroy succeeds' do + it { is_expected.to be_success } + + it 'removes Saved Reply from database' do + expect { subject }.to change(::Users::SavedReply, :count).by(-1) + end + + it 'returns saved reply' do + expect(subject[:saved_reply]).to eq(saved_reply) + end + end + end +end diff --git a/spec/services/users/saved_replies/update_service_spec.rb b/spec/services/users/saved_replies/update_service_spec.rb index b67d09977c6..bdb54d7c8f7 100644 --- a/spec/services/users/saved_replies/update_service_spec.rb +++ b/spec/services/users/saved_replies/update_service_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Users::SavedReplies::UpdateService do let_it_be(:other_saved_reply) { create(:saved_reply, user: current_user) } let_it_be(:saved_reply_from_other_user) { create(:saved_reply) } - subject { described_class.new(current_user: current_user, saved_reply: saved_reply, name: name, content: content).execute } + subject { described_class.new(saved_reply: saved_reply, name: name, content: content).execute } context 'when update fails' do let(:name) { other_saved_reply.name } diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index c938ad9ee39..b99bc860523 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -107,6 +107,21 @@ RSpec.describe WebHookService, :request_store, :clean_gitlab_redis_shared_state ).once end + context 'when the data is a Gitlab::DataBuilder::Pipeline' do + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:data) { ::Gitlab::DataBuilder::Pipeline.new(pipeline) } + + it 'can log the request payload' do + stub_full_request(project_hook.url, method: :post) + + # we call this with force to ensure that the logs are written inline, + # which tests that we can serialize the data to the DB correctly. + service = described_class.new(project_hook, data, :push_hooks, force: true) + + expect { service.execute }.to change(::WebHookLog, :count).by(1) + end + end + context 'when auth credentials are present' do let_it_be(:url) {'https://example.org'} let_it_be(:project_hook) { create(:project_hook, url: 'https://demo:demo@example.org/') } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a72c8d2c4e8..88f10cc2a01 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true -# $" is $LOADED_FEATURES, but RuboCop didn't like it if $".include?(File.expand_path('fast_spec_helper.rb', __dir__)) warn 'Detected fast_spec_helper is loaded first than spec_helper.' warn 'If running test files using both spec_helper and fast_spec_helper,' - warn 'make sure test file with spec_helper is loaded first.' + warn 'make sure spec_helper is loaded first, or run rspec with `-r spec_helper`.' abort 'Aborting...' end @@ -192,6 +191,7 @@ RSpec.configure do |config| config.include MigrationsHelpers, :migration config.include RedisHelpers config.include Rails.application.routes.url_helpers, type: :routing + config.include Rails.application.routes.url_helpers, type: :component config.include PolicyHelpers, type: :policy config.include ExpectRequestWithStatus, type: :request config.include IdempotentWorkerHelper, type: :worker @@ -238,6 +238,7 @@ RSpec.configure do |config| # Enable all features by default for testing # Reset any changes in after hook. stub_all_feature_flags + stub_feature_flags(main_branch_over_master: false) TestEnv.seed_db end @@ -329,10 +330,9 @@ RSpec.configure do |config| 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) + # Specs should not get a CAPTCHA challenge by default, this makes the sign-in flow simpler in + # most cases. We do test the CAPTCHA flow in the appropriate specs. + stub_feature_flags(arkose_labs_login_challenge: false) allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged) else diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb index 8f706fdebc9..f8ddf3e66a5 100644 --- a/spec/support/database_cleaner.rb +++ b/spec/support/database_cleaner.rb @@ -20,6 +20,9 @@ RSpec.configure do |config| # We drop and recreate the database if any table has more than 1200 columns, just to be safe. if any_connection_class_with_more_than_allowed_columns? recreate_all_databases! + + # Seed required data as recreating DBs will delete it + TestEnv.seed_db end end diff --git a/spec/support/fips.rb b/spec/support/fips.rb new file mode 100644 index 00000000000..1d278dcdf60 --- /dev/null +++ b/spec/support/fips.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +# rubocop: disable RSpec/EnvAssignment + +RSpec.configure do |config| + config.around(:each, :fips_mode) do |example| + set_fips_mode(true) do + example.run + end + end + + config.around(:each, fips_mode: false) do |example| + set_fips_mode(false) do + example.run + end + end + + def set_fips_mode(value) + prior_value = ENV["FIPS_MODE"] + ENV["FIPS_MODE"] = value.to_s + + yield + + ENV["FIPS_MODE"] = prior_value + end +end + +# rubocop: enable RSpec/EnvAssignment diff --git a/spec/support/gitlab_stubs/gitlab_ci.yml b/spec/support/gitlab_stubs/gitlab_ci.yml index 52ae36229a6..b1533879e32 100644 --- a/spec/support/gitlab_stubs/gitlab_ci.yml +++ b/spec/support/gitlab_stubs/gitlab_ci.yml @@ -1,4 +1,4 @@ -image: ruby:2.6 +image: image:1.0 services: - postgres diff --git a/spec/support/helpers/cycle_analytics_helpers.rb b/spec/support/helpers/cycle_analytics_helpers.rb index 70b794f7d82..044ec56b1cc 100644 --- a/spec/support/helpers/cycle_analytics_helpers.rb +++ b/spec/support/helpers/cycle_analytics_helpers.rb @@ -86,6 +86,25 @@ module CycleAnalyticsHelpers wait_for_stages_to_load(ready_selector) end + def select_value_stream(value_stream_name) + toggle_value_stream_dropdown + + page.find('[data-testid="dropdown-value-streams"]').all('li button').find { |item| item.text == value_stream_name.to_s }.click + wait_for_requests + end + + def create_value_stream_group_aggregation(group) + aggregation = Analytics::CycleAnalytics::Aggregation.safe_create_for_group(group) + Analytics::CycleAnalytics::AggregatorService.new(aggregation: aggregation).execute + end + + def select_group_and_custom_value_stream(group, custom_value_stream_name) + create_value_stream_group_aggregation(group) + + select_group(group) + select_value_stream(custom_value_stream_name) + end + def toggle_dropdown(field) page.within("[data-testid*='#{field}']") do find('.dropdown-toggle').click diff --git a/spec/support/helpers/features/invite_members_modal_helper.rb b/spec/support/helpers/features/invite_members_modal_helper.rb index 2a4f78ca57f..7ed64615020 100644 --- a/spec/support/helpers/features/invite_members_modal_helper.rb +++ b/spec/support/helpers/features/invite_members_modal_helper.rb @@ -5,19 +5,22 @@ module Spec module Helpers module Features module InviteMembersModalHelper - def invite_member(name, role: 'Guest', expires_at: nil) + def invite_member(names, role: 'Guest', expires_at: nil, refresh: true) click_on 'Invite members' - page.within '[data-testid="invite-modal"]' do - find('[data-testid="members-token-select-input"]').set(name) + page.within invite_modal_selector do + Array.wrap(names).each do |name| + find(member_dropdown_selector).set(name) + + wait_for_requests + click_button name + end - wait_for_requests - click_button name choose_options(role, expires_at) click_button 'Invite' - page.refresh + page.refresh if refresh end end @@ -43,6 +46,31 @@ module Spec fill_in 'YYYY-MM-DD', with: expires_at.strftime('%Y-%m-%d') if expires_at end + + def click_groups_tab + expect(page).to have_link 'Groups' + click_link "Groups" + end + + def group_dropdown_selector + '[data-testid="group-select-dropdown"]' + end + + def member_dropdown_selector + '[data-testid="members-token-select-input"]' + end + + def invite_modal_selector + '[data-testid="invite-modal"]' + end + + def expect_to_have_group(group) + expect(page).to have_selector("[entity-id='#{group.id}']") + end + + def expect_not_to_have_group(group) + expect(page).not_to have_selector("[entity-id='#{group.id}']") + end end end end diff --git a/spec/support/helpers/features/runner_helpers.rb b/spec/support/helpers/features/runner_helpers.rb new file mode 100644 index 00000000000..63fc628358c --- /dev/null +++ b/spec/support/helpers/features/runner_helpers.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Spec + module Support + module Helpers + module Features + module RunnersHelpers + def within_runner_row(runner_id) + within "[data-testid='runner-row-#{runner_id}']" do + yield + end + end + + def search_bar_selector + '[data-testid="runners-filtered-search"]' + end + + # The filters must be clicked first to be able to receive events + # See: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1493 + def focus_filtered_search + page.within(search_bar_selector) do + page.find('.gl-filtered-search-term-token').click + end + end + + def input_filtered_search_keys(search_term) + focus_filtered_search + + page.within(search_bar_selector) do + page.find('input').send_keys(search_term) + click_on 'Search' + end + + wait_for_requests + end + + def open_filtered_search_suggestions(filter) + focus_filtered_search + + page.within(search_bar_selector) do + click_on filter + end + + wait_for_requests + end + + def input_filtered_search_filter_is_only(filter, value) + focus_filtered_search + + page.within(search_bar_selector) do + click_on filter + + # For OPERATOR_IS_ONLY, clicking the filter + # immediately preselects "=" operator + + page.find('input').send_keys(value) + page.find('input').send_keys(:enter) + + click_on 'Search' + end + + wait_for_requests + end + end + end + end + end +end diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb index a4ee618457d..0ad83bdeeb2 100644 --- a/spec/support/helpers/gitaly_setup.rb +++ b/spec/support/helpers/gitaly_setup.rb @@ -267,7 +267,7 @@ module GitalySetup { 'default' => repos_path }, force: true, options: { - internal_socket_dir: File.join(gitaly_dir, "internal_gitaly2"), + runtime_dir: File.join(gitaly_dir, "run2"), gitaly_socket: "gitaly2.socket", config_filename: "gitaly2.config.toml" } diff --git a/spec/support/helpers/login_helpers.rb b/spec/support/helpers/login_helpers.rb index 7666d71f13c..29b1bb260f2 100644 --- a/spec/support/helpers/login_helpers.rb +++ b/spec/support/helpers/login_helpers.rb @@ -99,7 +99,7 @@ module LoginHelpers fill_in "user_password", with: (password || "12345678") check 'user_remember_me' if remember - click_button "Sign in" + find('[data-testid="sign-in-button"]:enabled').click if two_factor_auth fill_in "user_otp_attempt", with: user.reload.current_otp diff --git a/spec/support/helpers/navbar_structure_helper.rb b/spec/support/helpers/navbar_structure_helper.rb index fb06ebfdae2..315303401cc 100644 --- a/spec/support/helpers/navbar_structure_helper.rb +++ b/spec/support/helpers/navbar_structure_helper.rb @@ -92,4 +92,16 @@ module NavbarStructureHelper new_sub_nav_item_name: _('Google Cloud') ) end + + def analytics_sub_nav_item + [ + _('Value stream'), + _('CI/CD'), + (_('Code review') if Gitlab.ee?), + (_('Merge request') if Gitlab.ee?), + _('Repository') + ] + end end + +NavbarStructureHelper.prepend_mod diff --git a/spec/support/helpers/search_helpers.rb b/spec/support/helpers/search_helpers.rb index f5a1a97a1d0..581ef07752e 100644 --- a/spec/support/helpers/search_helpers.rb +++ b/spec/support/helpers/search_helpers.rb @@ -2,9 +2,12 @@ module SearchHelpers def fill_in_search(text) - page.within('.search-input-wrap') do + # Once the `new_header_search` feature flag has been removed + # We can remove the `.search-input-wrap` selector + # https://gitlab.com/gitlab-org/gitlab/-/issues/339348 + page.within('.header-search-new') do find('#search').click - fill_in('search', with: text) + fill_in 'search', with: text end wait_for_all_requests diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index 587d4e22828..d81d0d436a1 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -41,7 +41,6 @@ module TestEnv 'pages-deploy-target' => '7975be0', 'audio' => 'c3c21fd', 'video' => '8879059', - 'add-balsamiq-file' => 'b89b56d', 'crlf-diff' => '5938907', 'conflict-start' => '824be60', 'conflict-resolvable' => '1450cd6', @@ -81,7 +80,9 @@ module TestEnv 'compare-with-merge-head-source' => 'f20a03d', 'compare-with-merge-head-target' => '2f1e176', 'trailers' => 'f0a5ed6', - 'add_commit_with_5mb_subject' => '8cf8e80' + 'add_commit_with_5mb_subject' => '8cf8e80', + 'blame-on-renamed' => '32c33da', + 'with-executables' => '6b8dc4a' }.freeze # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb index b9f90b11a69..50d1b14cf56 100644 --- a/spec/support/helpers/usage_data_helpers.rb +++ b/spec/support/helpers/usage_data_helpers.rb @@ -26,7 +26,6 @@ module UsageDataHelpers COUNTS_KEYS = %i( assignee_lists - boards ci_builds ci_internal_pipelines ci_external_pipelines diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb index dcaec176687..3ba88c3ae71 100644 --- a/spec/support/matchers/graphql_matchers.rb +++ b/spec/support/matchers/graphql_matchers.rb @@ -7,14 +7,14 @@ RSpec::Matchers.define :require_graphql_authorizations do |*expected| if klass.respond_to?(:required_permissions) klass.required_permissions else - [klass.to_graphql.metadata[:authorize]] + Array.wrap(klass.authorize) end end match do |klass| actual = permissions_for(klass) - expect(actual).to match_array(expected) + expect(actual).to match_array(expected.compact) end failure_message do |klass| @@ -213,16 +213,16 @@ RSpec::Matchers.define :have_graphql_resolver do |expected| match do |field| case expected when Method - expect(field.to_graphql.metadata[:type_class].resolve_proc).to eq(expected) + expect(field.type_class.resolve_proc).to eq(expected) else - expect(field.to_graphql.metadata[:type_class].resolver).to eq(expected) + expect(field.type_class.resolver).to eq(expected) end end end RSpec::Matchers.define :have_graphql_extension do |expected| match do |field| - expect(field.to_graphql.metadata[:type_class].extensions).to include(expected) + expect(field.type_class.extensions).to include(expected) end end diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb index f01c4075eeb..1932f78506f 100644 --- a/spec/support/matchers/markdown_matchers.rb +++ b/spec/support/matchers/markdown_matchers.rb @@ -270,7 +270,7 @@ module MarkdownMatchers set_default_markdown_messages match do |actual| - expect(actual).to have_link(href: 'http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==') + expect(actual).to have_link(href: 'http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjliuUCAE_tHdw=') end end end diff --git a/spec/support/matchers/project_namespace_matcher.rb b/spec/support/matchers/project_namespace_matcher.rb index 95aa5429679..8666a605276 100644 --- a/spec/support/matchers/project_namespace_matcher.rb +++ b/spec/support/matchers/project_namespace_matcher.rb @@ -10,7 +10,7 @@ RSpec::Matchers.define :be_in_sync_with_project do |project| project_namespace.present? && project.name == project_namespace.name && project.path == project_namespace.path && - project.namespace == project_namespace.parent && + project.namespace_id == project_namespace.parent_id && project.visibility_level == project_namespace.visibility_level && project.shared_runners_enabled == project_namespace.shared_runners_enabled end diff --git a/spec/support/services/deploy_token_shared_examples.rb b/spec/support/services/deploy_token_shared_examples.rb index adc5ea0fcdc..d322b3fc81d 100644 --- a/spec/support/services/deploy_token_shared_examples.rb +++ b/spec/support/services/deploy_token_shared_examples.rb @@ -19,6 +19,10 @@ RSpec.shared_examples 'a deploy token creation service' do it 'returns a DeployToken' do expect(subject[:deploy_token]).to be_an_instance_of DeployToken end + + it 'sets the creator_id as the id of the current_user' do + expect(subject[:deploy_token].read_attribute(:creator_id)).to eq(user.id) + end end context 'when expires at date is not passed' do diff --git a/spec/support/services/issuable_update_service_shared_examples.rb b/spec/support/services/issuable_update_service_shared_examples.rb index 4d2843af1c4..c168df7a7d2 100644 --- a/spec/support/services/issuable_update_service_shared_examples.rb +++ b/spec/support/services/issuable_update_service_shared_examples.rb @@ -23,3 +23,47 @@ RSpec.shared_examples 'issuable update service' do end end end + +RSpec.shared_examples 'keeps issuable labels sorted after update' do + before do + update_issuable(label_ids: [label_b.id]) + end + + context 'when label is changed' do + it 'keeps the labels sorted by title ASC' do + update_issuable({ add_label_ids: [label_a.id] }) + + expect(issuable.labels).to eq([label_a, label_b]) + end + end +end + +RSpec.shared_examples 'broadcasting issuable labels updates' do + before do + update_issuable(label_ids: [label_a.id]) + end + + context 'when label is added' do + it 'triggers the GraphQL subscription' do + expect(GraphqlTriggers).to receive(:issuable_labels_updated).with(issuable) + + update_issuable({ add_label_ids: [label_b.id] }) + end + end + + context 'when label is removed' do + it 'triggers the GraphQL subscription' do + expect(GraphqlTriggers).to receive(:issuable_labels_updated).with(issuable) + + update_issuable({ remove_label_ids: [label_a.id] }) + end + end + + context 'when label is unchanged' do + it 'does not trigger the GraphQL subscription' do + expect(GraphqlTriggers).not_to receive(:issuable_labels_updated).with(issuable) + + update_issuable({ label_ids: [label_a.id] }) + end + end +end diff --git a/spec/support/shared_contexts/container_repositories_shared_context.rb b/spec/support/shared_contexts/container_repositories_shared_context.rb index 9a9f80a3cbd..a74b09d38bd 100644 --- a/spec/support/shared_contexts/container_repositories_shared_context.rb +++ b/spec/support/shared_contexts/container_repositories_shared_context.rb @@ -1,13 +1,10 @@ # frozen_string_literal: true RSpec.shared_context 'importable repositories' do - let_it_be(:root_group) { create(:group) } - let_it_be(:group) { create(:group, parent_id: root_group.id) } - let_it_be(:project) { create(:project, namespace: group) } - let_it_be(:valid_container_repository) { create(:container_repository, project: project, created_at: 2.days.ago) } - let_it_be(:valid_container_repository2) { create(:container_repository, project: project, created_at: 1.year.ago) } - let_it_be(:importing_container_repository) { create(:container_repository, :importing, project: project, created_at: 2.days.ago) } - let_it_be(:new_container_repository) { create(:container_repository, project: project) } + let_it_be(:valid_container_repository) { create(:container_repository, created_at: 2.days.ago, migration_plan: 'free') } + let_it_be(:valid_container_repository2) { create(:container_repository, created_at: 1.year.ago, migration_plan: 'free') } + let_it_be(:importing_container_repository) { create(:container_repository, :importing, created_at: 2.days.ago, migration_plan: 'free') } + let_it_be(:new_container_repository) { create(:container_repository, migration_plan: 'free') } let_it_be(:denied_root_group) { create(:group) } let_it_be(:denied_group) { create(:group, parent_id: denied_root_group.id) } @@ -18,7 +15,8 @@ RSpec.shared_context 'importable repositories' do stub_application_setting(container_registry_import_created_before: 1.day.ago) stub_feature_flags( container_registry_phase_2_deny_list: false, - container_registry_migration_limit_gitlab_org: false + container_registry_migration_limit_gitlab_org: false, + container_registry_migration_phase2_all_plans: false ) Feature::FlipperGate.create!( diff --git a/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb index 6a09497a497..ef1c01f72f9 100644 --- a/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb +++ b/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb @@ -3,8 +3,10 @@ RSpec.shared_context 'UsersFinder#execute filter by project context' do let_it_be(:normal_user) { create(:user, username: 'johndoe') } let_it_be(:admin_user) { create(:user, :admin, username: 'iamadmin') } + let_it_be(:banned_user) { create(:user, :banned, username: 'iambanned') } let_it_be(:blocked_user) { create(:user, :blocked, username: 'notsorandom') } let_it_be(:external_user) { create(:user, :external) } + let_it_be(:unconfirmed_user) { create(:user, confirmed_at: nil) } let_it_be(:omniauth_user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') } - let_it_be(:internal_user) { User.alert_bot } + let_it_be(:internal_user) { User.alert_bot.tap { |u| u.confirm } } end diff --git a/spec/support/shared_contexts/lib/container_registry/client_stubs_shared_context.rb b/spec/support/shared_contexts/lib/container_registry/client_stubs_shared_context.rb index d857e683aa2..196173d4a63 100644 --- a/spec/support/shared_contexts/lib/container_registry/client_stubs_shared_context.rb +++ b/spec/support/shared_contexts/lib/container_registry/client_stubs_shared_context.rb @@ -8,8 +8,8 @@ RSpec.shared_context 'container registry client stubs' do end end - def stub_container_registry_gitlab_api_repository_details(client, path:, size_bytes:) - allow(client).to receive(:repository_details).with(path, with_size: true).and_return('size_bytes' => size_bytes) + def stub_container_registry_gitlab_api_repository_details(client, path:, size_bytes:, sizing: :self) + allow(client).to receive(:repository_details).with(path, sizing: sizing).and_return('size_bytes' => size_bytes) end def stub_container_registry_gitlab_api_network_error(client_method: :supports_gitlab_api?) diff --git a/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb b/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb index d0915bbf158..dea03af2248 100644 --- a/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb +++ b/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb @@ -64,6 +64,9 @@ RSpec.shared_context 'API::Markdown Golden Master shared context' do |markdown_y let(:substitutions) { markdown_example.fetch(:substitutions, {}) } it "verifies conversion of GFM to HTML", :unlimited_max_formatted_output_length do + stub_application_setting(plantuml_enabled: true, plantuml_url: 'http://localhost:8080') + stub_application_setting(kroki_enabled: true, kroki_url: 'http://localhost:8000') + pending pending_reason if pending_reason normalized_example_html = normalize_html(example_html, substitutions) diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index b4a71f52092..65c7f63cf6e 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true RSpec.shared_context 'project navbar structure' do + include NavbarStructureHelper + let(:security_and_compliance_nav_item) do { nav_item: _('Security & Compliance'), @@ -93,13 +95,7 @@ RSpec.shared_context 'project navbar structure' do }, { nav_item: _('Analytics'), - nav_sub_items: [ - _('Value stream'), - _('CI/CD'), - (_('Code review') if Gitlab.ee?), - (_('Merge request') if Gitlab.ee?), - _('Repository') - ] + nav_sub_items: analytics_sub_nav_item }, { nav_item: _('Wiki'), diff --git a/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb b/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb index fce78957eba..efd5d344a28 100644 --- a/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb +++ b/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true RSpec.shared_context 'group_group_link' do - let(:shared_with_group) { create(:group) } - let(:shared_group) { create(:group) } + let_it_be(:shared_with_group) { create(:group) } + let_it_be(:shared_group) { create(:group) } - let!(:group_group_link) do + let_it_be(:group_group_link) do create( :group_group_link, { 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 e1d864213b5..37d410a35bf 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 @@ -21,7 +21,7 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do let(:optional_metrics) do [ - metric_attributes('counts.boards', 'optional', 'number'), + metric_attributes('counts.boards', 'optional', 'number', 'CountBoardsMetric'), 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') @@ -43,11 +43,14 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do Gitlab::Usage::MetricDefinition.instance_variable_set(:@all, nil) end - def metric_attributes(key_path, category, value_type = 'string') + def metric_attributes(key_path, category, value_type = 'string', instrumentation_class = '') { 'key_path' => key_path, 'data_category' => category, - 'value_type' => value_type + 'value_type' => value_type, + 'status' => 'active', + 'instrumentation_class' => instrumentation_class, + 'time_frame' => 'all' } end end diff --git a/spec/support/shared_contexts/url_shared_context.rb b/spec/support/shared_contexts/url_shared_context.rb index da1d6e0049c..0e1534bf6c7 100644 --- a/spec/support/shared_contexts/url_shared_context.rb +++ b/spec/support/shared_contexts/url_shared_context.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.shared_context 'valid urls with CRLF' do - let(:valid_urls_with_CRLF) do + let(:valid_urls_with_crlf) do [ "http://example.com/pa%0dth", "http://example.com/pa%0ath", @@ -16,7 +16,7 @@ RSpec.shared_context 'valid urls with CRLF' do end RSpec.shared_context 'invalid urls' do - let(:urls_with_CRLF) do + let(:urls_with_crlf) do [ "git://example.com/pa%0dth", "git://example.com/pa%0ath", 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 1e303197990..15590fd10dc 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 @@ -138,7 +138,7 @@ RSpec.shared_examples 'multiple issue boards' do wait_for_requests - dropdown_selector = '.js-boards-selector .dropdown-menu' + dropdown_selector = '[data-testid="boards-selector"] .dropdown-menu' page.within(dropdown_selector) do yield 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 46fc2cbdc9b..2ea98002de1 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 @@ -184,6 +184,41 @@ RSpec.shared_examples 'a GitHub-ish import controller: GET status' do expect(json_response.dig("provider_repos").count).to eq(1) end end + + context 'when namespace_id query param is provided' do + let_it_be(:current_user) { create(:user) } + + let(:namespace) { create(:namespace) } + + before do + allow(controller).to receive(:current_user).and_return(current_user) + end + + context 'when user is allowed to create projects in this namespace' do + before do + allow(current_user).to receive(:can?).and_return(true) + end + + it 'provides namespace to the template' do + get :status, params: { namespace_id: namespace.id }, format: :html + + expect(response).to have_gitlab_http_status :ok + expect(assigns(:namespace)).to eq(namespace) + end + end + + context 'when user is not allowed to create projects in this namespace' do + before do + allow(current_user).to receive(:can?).and_return(false) + end + + it 'renders 404' do + get :status, params: { namespace_id: namespace.id }, format: :html + + expect(response).to have_gitlab_http_status :not_found + end + end + end end end @@ -515,7 +550,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: GET realtime_changes' do get :realtime_changes - expect(json_response).to eq([{ "id" => project.id, "import_status" => project.import_status }]) + expect(json_response).to match([a_hash_including({ "id" => project.id, "import_status" => project.import_status })]) expect(Integer(response.headers['Poll-Interval'])).to be > -1 end end diff --git a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb index bf26922d9c5..885c0229038 100644 --- a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb +++ b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb @@ -150,6 +150,7 @@ RSpec.shared_examples 'wiki controller actions' do expect(response).to render_template('shared/wikis/diff') expect(assigns(:diffs)).to be_a(Gitlab::Diff::FileCollection::Base) expect(assigns(:diff_notes_disabled)).to be(true) + expect(assigns(:page).content).to be_empty end end @@ -475,9 +476,13 @@ RSpec.shared_examples 'wiki controller actions' do context 'when page exists' do shared_examples 'deletes the page' do specify do - expect do - request - end.to change { wiki.list_pages.size }.by(-1) + aggregate_failures do + expect do + request + end.to change { wiki.list_pages.size }.by(-1) + + expect(assigns(:page).content).to be_empty + end end end diff --git a/spec/support/shared_examples/features/access_tokens_shared_examples.rb b/spec/support/shared_examples/features/access_tokens_shared_examples.rb index ae246a87bb6..215d9d3e5a8 100644 --- a/spec/support/shared_examples/features/access_tokens_shared_examples.rb +++ b/spec/support/shared_examples/features/access_tokens_shared_examples.rb @@ -29,15 +29,15 @@ RSpec.shared_examples 'resource access tokens creation' do |resource_type| click_on '1' # Scopes - check 'api' check 'read_api' + check 'read_repository' click_on "Create #{resource_type} access token" expect(active_resource_access_tokens).to have_text(name) expect(active_resource_access_tokens).to have_text('in') - expect(active_resource_access_tokens).to have_text('api') expect(active_resource_access_tokens).to have_text('read_api') + expect(active_resource_access_tokens).to have_text('read_repository') expect(active_resource_access_tokens).to have_text('Maintainer') expect(created_resource_access_token).not_to be_empty end diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb index 2332285540a..5c44cb7f04b 100644 --- a/spec/support/shared_examples/features/content_editor_shared_examples.rb +++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb @@ -1,14 +1,48 @@ # 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]' + content_editor_testid = '[data-testid="content-editor"] [contenteditable].ProseMirror' - expect(page).to have_css(content_editor_testid) + describe 'formatting bubble menu' do + it 'shows a formatting bubble menu for a regular paragraph' do + 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] + 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"]') + expect(page).to have_css('[data-testid="formatting-bubble-menu"]') + end + + it 'does not show a formatting bubble menu for code' do + find(content_editor_testid).send_keys 'This is a `code`' + find(content_editor_testid).send_keys [:shift, :left] + + expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]') + end + end + + describe 'code block bubble menu' do + it 'shows a code block bubble menu for a code block' do + find(content_editor_testid).send_keys '```js ' # trigger input rule + find(content_editor_testid).send_keys 'var a = 0' + find(content_editor_testid).send_keys [:shift, :left] + + expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]') + expect(page).to have_css('[data-testid="code-block-bubble-menu"]') + end + + it 'sets code block type to "javascript" for `js`' do + find(content_editor_testid).send_keys '```js ' + find(content_editor_testid).send_keys 'var a = 0' + + expect(find('[data-testid="code-block-bubble-menu"]')).to have_text('Javascript') + end + + it 'sets code block type to "Custom (nomnoml)" for `nomnoml`' do + find(content_editor_testid).send_keys '```nomnoml ' + find(content_editor_testid).send_keys 'test' + + expect(find('[data-testid="code-block-bubble-menu"]')).to have_text('Custom (nomnoml)') + end end end diff --git a/spec/support/shared_examples/features/inviting_members_shared_examples.rb b/spec/support/shared_examples/features/inviting_members_shared_examples.rb new file mode 100644 index 00000000000..58357b262f5 --- /dev/null +++ b/spec/support/shared_examples/features/inviting_members_shared_examples.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'inviting members' do |snowplow_invite_label| + before_all do + group.add_owner(user1) + end + + it 'adds user as member', :js, :snowplow, :aggregate_failures do + visit members_page_path + + invite_member(user2.name, role: 'Reporter') + + page.within find_member_row(user2) do + expect(page).to have_button('Reporter') + end + + expect_snowplow_event( + category: 'Members::InviteService', + action: 'create_member', + label: snowplow_invite_label, + property: 'existing_user', + user: user1 + ) + end + + it 'invites user by email', :js, :snowplow, :aggregate_failures do + visit members_page_path + + invite_member('test@example.com', role: 'Reporter') + + click_link 'Invited' + + page.within find_invited_member_row('test@example.com') do + expect(page).to have_button('Reporter') + end + + expect_snowplow_event( + category: 'Members::InviteService', + action: 'create_member', + label: snowplow_invite_label, + property: 'net_new_user', + user: user1 + ) + end + + it 'invites user by username and invites user by email', :js, :aggregate_failures do + visit members_page_path + + invite_member([user2.name, 'test@example.com'], role: 'Reporter') + + page.within find_member_row(user2) do + expect(page).to have_button('Reporter') + end + + click_link 'Invited' + + page.within find_invited_member_row('test@example.com') do + expect(page).to have_button('Reporter') + end + end + + context 'when member is already a member by username' do + it 'updates the member for that user', :js do + visit members_page_path + + invite_member(user2.name, role: 'Developer') + + invite_member(user2.name, role: 'Reporter', refresh: false) + + expect(page).not_to have_selector(invite_modal_selector) + + page.refresh + + page.within find_invited_member_row(user2.name) do + expect(page).to have_button('Reporter') + end + end + end + + context 'when member is already a member by email' do + it 'fails with an error', :js do + visit members_page_path + + invite_member('test@example.com', role: 'Developer') + + invite_member('test@example.com', role: 'Reporter', refresh: false) + + expect(page).to have_selector(invite_modal_selector) + expect(page).to have_content("The member's email address has already been taken") + + page.refresh + + click_link 'Invited' + + page.within find_invited_member_row('test@example.com') do + expect(page).to have_button('Developer') + end + end + end + + context 'when inviting a parent group member to the sub-entity' do + before_all do + group.add_owner(user1) + group.add_developer(user2) + end + + context 'when role is higher than parent group membership' do + let(:role) { 'Maintainer' } + + it 'adds the user as a member on sub-entity with higher access level', :js do + visit subentity_members_page_path + + invite_member(user2.name, role: role, refresh: false) + + expect(page).not_to have_selector(invite_modal_selector) + + page.refresh + + page.within find_invited_member_row(user2.name) do + expect(page).to have_button(role) + end + end + end + + context 'when role is lower than parent group membership' do + let(:role) { 'Reporter' } + + it 'fails with an error', :js do + visit subentity_members_page_path + + invite_member(user2.name, role: role, refresh: false) + + expect(page).to have_selector(invite_modal_selector) + expect(page).to have_content "Access level should be greater than or equal to Developer inherited membership " \ + "from group #{group.name}" + + page.refresh + + page.within find_invited_member_row(user2.name) do + expect(page).to have_content('Developer') + expect(page).not_to have_button('Developer') + end + end + + context 'when there are multiple users invited with errors' do + let_it_be(:user3) { create(:user) } + + before do + group.add_maintainer(user3) + end + + it 'only shows the first user error', :js do + visit subentity_members_page_path + + invite_member([user2.name, user3.name], role: role, refresh: false) + + expect(page).to have_selector(invite_modal_selector) + expect(page).to have_text("Access level should be greater than or equal to", count: 1) + + page.refresh + + page.within find_invited_member_row(user2.name) do + expect(page).to have_content('Developer') + expect(page).not_to have_button('Developer') + end + + page.within find_invited_member_row(user3.name) do + expect(page).to have_content('Maintainer') + expect(page).not_to have_button('Maintainer') + end + end + end + end + end +end diff --git a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb index 066c3e17a09..0a5ad5a59c0 100644 --- a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb +++ b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb @@ -62,7 +62,7 @@ RSpec.shared_examples 'it uploads and commits a new image file' do |drop: false| visit(project_blob_path(project, 'upload_image/logo_sample.svg')) - expect(page).to have_css('.file-content img') + expect(page).to have_css('.file-holder img') end end diff --git a/spec/support/shared_examples/features/runners_shared_examples.rb b/spec/support/shared_examples/features/runners_shared_examples.rb new file mode 100644 index 00000000000..d9460c7b8f1 --- /dev/null +++ b/spec/support/shared_examples/features/runners_shared_examples.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'shows and resets runner registration token' do + include Spec::Support::Helpers::ModalHelpers + include Spec::Support::Helpers::Features::RunnersHelpers + + before do + click_on dropdown_text + end + + describe 'shows registration instructions' do + before do + click_on 'Show runner installation and registration instructions' + + wait_for_requests + end + + it 'opens runner installation modal', :aggregate_failures do + within_modal do + expect(page).to have_text "Install a runner" + expect(page).to have_text "Environment" + expect(page).to have_text "Architecture" + expect(page).to have_text "Download and install binary" + end + end + + it 'dismisses runner installation modal' do + within_modal do + click_button('Close', match: :first) + end + + expect(page).not_to have_text "Install a runner" + end + end + + it 'has a registration token' do + click_on 'Click to reveal' + expect(page.find('[data-testid="token-value"] input').value).to have_content(registration_token) + end + + describe 'reset registration token' do + let!(:old_registration_token) { find('[data-testid="token-value"] input').value } + + before do + click_on 'Reset registration token' + + within_modal do + click_button('Reset token', match: :first) + end + + wait_for_requests + end + + it 'changes registration token' do + expect(find('.gl-toast')).to have_content('New registration token generated!') + + click_on dropdown_text + click_on 'Click to reveal' + + expect(old_registration_token).not_to eq registration_token + end + end +end + +RSpec.shared_examples 'shows no runners' do + it 'shows counts with 0' do + expect(page).to have_text "Online runners 0" + expect(page).to have_text "Offline runners 0" + expect(page).to have_text "Stale runners 0" + end + + it 'shows "no runners" message' do + expect(page).to have_text 'No runners found' + end +end + +RSpec.shared_examples 'shows runner in list' do + it 'does not show empty state' do + expect(page).not_to have_content 'No runners found' + end + + it 'shows runner row' do + within_runner_row(runner.id) do + expect(page).to have_text "##{runner.id}" + expect(page).to have_text runner.short_sha + expect(page).to have_text runner.description + end + end +end + +RSpec.shared_examples 'pauses, resumes and deletes a runner' do + include Spec::Support::Helpers::ModalHelpers + + it 'pauses and resumes runner' do + within_runner_row(runner.id) do + click_button "Pause" + + expect(page).to have_text 'paused' + expect(page).to have_button 'Resume' + expect(page).not_to have_button 'Pause' + + click_button "Resume" + + expect(page).not_to have_text 'paused' + expect(page).not_to have_button 'Resume' + expect(page).to have_button 'Pause' + end + end + + describe 'deletes runner' do + before do + within_runner_row(runner.id) do + click_on 'Delete runner' + end + end + + it 'shows a confirmation modal' do + expect(page).to have_text "Delete runner ##{runner.id} (#{runner.short_sha})?" + expect(page).to have_text "Are you sure you want to continue?" + end + + it 'deletes a runner' do + within_modal do + click_on 'Delete runner' + end + + expect(page.find('.gl-toast')).to have_text(/Runner .+ deleted/) + expect(page).not_to have_content runner.description + end + + it 'cancels runner deletion' do + within_modal do + click_on 'Cancel' + end + + wait_for_requests + + expect(page).to have_content runner.description + end + end +end diff --git a/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb b/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb index bb5460e2a6f..095c48cade8 100644 --- a/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb +++ b/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb @@ -11,6 +11,7 @@ RSpec.shared_examples 'search timeouts' do |scope| end it 'renders timeout information' do + # expect(page).to have_content('This endpoint has been requested too many times.') expect(page).to have_content('Your search timed out') end diff --git a/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb index f676b6aa60d..41b1964cff0 100644 --- a/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb +++ b/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb @@ -20,6 +20,12 @@ RSpec.shared_examples 'User creates wiki page' do click_link "Create your first page" end + it 'shows all available formats in the dropdown' do + Wiki::VALID_USER_MARKUPS.each do |key, markup| + expect(page).to have_css("#wiki_format option[value=#{key}]", text: markup[:name]) + end + end + it "disables the submit button", :js do page.within(".wiki-form") do fill_in(:wiki_content, with: "") 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 85490bffc0e..12a4c6d7583 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 @@ -145,19 +145,6 @@ RSpec.shared_examples 'User updates wiki page' do it_behaves_like 'edits content using the content editor' end - - context 'with feature flag off' do - before do - stub_feature_flags(wiki_switch_between_content_editor_raw_markdown: false) - visit(wiki_path(wiki)) - - click_link('Edit') - - click_button 'Use the new editor' - end - - it_behaves_like 'edits content using the content editor' - end end end diff --git a/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb index 2e3a3ce6b41..04bb2fb69bb 100644 --- a/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb @@ -3,16 +3,6 @@ RSpec.shared_examples 'boards create mutation' do include GraphqlHelpers - let_it_be(:current_user, reload: true) { create(:user) } - let(:name) { 'board name' } - let(:mutation) { graphql_mutation(:create_board, params) } - - subject { post_graphql_mutation(mutation, current_user: current_user) } - - def mutation_response - graphql_mutation_response(:create_board) - end - context 'when the user does not have permission' do it_behaves_like 'a mutation that returns a top-level access error' diff --git a/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb b/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb index 56b6dc682eb..2c6118779e6 100644 --- a/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb +++ b/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb @@ -85,3 +85,14 @@ RSpec.shared_examples 'a Note mutation when there are rate limit validation erro end end end + +RSpec.shared_examples 'a Note mutation with confidential notes' do + it_behaves_like 'a Note mutation that creates a Note' + + it 'returns a Note with confidentiality enabled' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response).to have_key('note') + expect(mutation_response['note']['confidential']).to eq(true) + end +end diff --git a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb index efb2c466f70..3caf153c2fa 100644 --- a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb +++ b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb @@ -62,9 +62,9 @@ RSpec.shared_examples 'Gitlab-style deprecations' do expect(deprecable.deprecation_reason).to include 'This was renamed.' end - it 'supports named reasons: discouraged' do - deprecable = subject(deprecated: { milestone: '1.10', reason: :discouraged }) + it 'supports named reasons: alpha' do + deprecable = subject(deprecated: { milestone: '1.10', reason: :alpha }) - expect(deprecable.deprecation_reason).to include 'Use of this is not recommended.' + expect(deprecable.deprecation_reason).to include 'This feature is in Alpha' end end diff --git a/spec/support/shared_examples/helpers/wiki_helpers_shared_examples.rb b/spec/support/shared_examples/helpers/wiki_helpers_shared_examples.rb new file mode 100644 index 00000000000..c2c27fb65ca --- /dev/null +++ b/spec/support/shared_examples/helpers/wiki_helpers_shared_examples.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'wiki endpoint helpers' do + let(:resource_path) { page.wiki.container.class.to_s.pluralize.downcase } + let(:url) { "/api/v4/#{resource_path}/#{page.wiki.container.id}/wikis/#{page.slug}?version=#{page.version.id}"} + + it 'returns the full endpoint url' do + expect(helper.wiki_page_render_api_endpoint(page)).to end_with(url) + end + + context 'when relative url is set' do + let(:relative_url) { "/gitlab#{url}" } + + it 'returns the full endpoint url with the relative path' do + stub_config_setting(relative_url_root: '/gitlab') + + expect(helper.wiki_page_render_api_endpoint(page)).to end_with(relative_url) + end + end +end diff --git a/spec/support/shared_examples/incident_management/issuable_escalation_statuses/build_examples.rb b/spec/support/shared_examples/incident_management/issuable_escalation_statuses/build_examples.rb new file mode 100644 index 00000000000..050fdc3fff7 --- /dev/null +++ b/spec/support/shared_examples/incident_management/issuable_escalation_statuses/build_examples.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'initializes new escalation status with expected attributes' do |attributes = {}| + let(:expected_attributes) { attributes } + + specify do + expect { execute }.to change { incident.escalation_status } + .from(nil) + .to(instance_of(::IncidentManagement::IssuableEscalationStatus)) + + expect(incident.escalation_status).to have_attributes( + id: nil, + issue_id: incident.id, + policy_id: nil, + escalations_started_at: nil, + status_event: nil, + **expected_attributes + ) + end +end diff --git a/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb new file mode 100644 index 00000000000..4fc15cacab4 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'subscribes to event' do + include AfterNextHelpers + + it 'consumes the published event', :sidekiq_inline do + expect_next(described_class) + .to receive(:handle_event) + .with(instance_of(event.class)) + .and_call_original + + ::Gitlab::EventStore.publish(event) + end +end + +def consume_event(subscriber:, event:) + subscriber.new.perform(event.class.name, event.data) +end diff --git a/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb index 4b956c2b566..b5d93aec1bf 100644 --- a/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb @@ -5,7 +5,7 @@ RSpec.shared_examples 'a daily tracked issuable event' do stub_application_setting(usage_ping_enabled: true) end - def count_unique(date_from:, date_to:) + def count_unique(date_from: 1.minute.ago, date_to: 1.minute.from_now) Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: action, start_date: date_from, end_date: date_to) end @@ -14,6 +14,7 @@ RSpec.shared_examples 'a daily tracked issuable event' do expect(track_action(author: user1)).to be_truthy expect(track_action(author: user1)).to be_truthy expect(track_action(author: user2)).to be_truthy + expect(count_unique).to eq(2) end end diff --git a/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb b/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb index b4c438771ce..d816754f328 100644 --- a/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb +++ b/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb @@ -34,8 +34,16 @@ RSpec.shared_examples 'ZenTao menu with CE version' do expect(subject.link).to eq zentao_integration.url end - it 'contains only open ZenTao item' do - expect(subject.renderable_items.map(&:item_id)).to match_array [:open_zentao] + it 'renders external-link icon' do + expect(subject.sprite_icon).to eq 'external-link' + end + + it 'renders ZenTao menu' do + expect(subject.title).to eq s_('ZentaoIntegration|ZenTao') + end + + it 'does not contain items' do + expect(subject.renderable_items.count).to eq 0 end end end diff --git a/spec/support/shared_examples/lib/wikis_api_examples.rb b/spec/support/shared_examples/lib/wikis_api_examples.rb index f068a7676ad..c57ac328a60 100644 --- a/spec/support/shared_examples/lib/wikis_api_examples.rb +++ b/spec/support/shared_examples/lib/wikis_api_examples.rb @@ -80,6 +80,8 @@ RSpec.shared_examples_for 'wikis API returns wiki page' do context 'when wiki page has versions' do let(:new_content) { 'New content' } + let(:old_content) { page.content } + let(:old_version_id) { page.version.id } before do wiki.update_page(page.page, content: new_content, message: 'updated page') @@ -96,10 +98,10 @@ RSpec.shared_examples_for 'wikis API returns wiki page' do end context 'when version param is set' do - let(:params) { { version: page.version.id } } + let(:params) { { version: old_version_id } } it 'retrieves the specific page version' do - expect(json_response['content']).to eq(page.content) + expect(json_response['content']).to eq(old_content) end context 'when version param is not valid or inexistent' do diff --git a/spec/support/shared_examples/models/application_setting_shared_examples.rb b/spec/support/shared_examples/models/application_setting_shared_examples.rb index 38f5c7be393..74ec6474e80 100644 --- a/spec/support/shared_examples/models/application_setting_shared_examples.rb +++ b/spec/support/shared_examples/models/application_setting_shared_examples.rb @@ -239,7 +239,7 @@ RSpec.shared_examples 'application settings examples' do describe '#allowed_key_types' do it 'includes all key types by default' do - expect(setting.allowed_key_types).to contain_exactly(*described_class::SUPPORTED_KEY_TYPES) + expect(setting.allowed_key_types).to contain_exactly(*Gitlab::SSHPublicKey.supported_types) end it 'excludes disabled key types' do diff --git a/spec/support/shared_examples/models/concerns/bulk_users_by_email_load_shared_examples.rb b/spec/support/shared_examples/models/concerns/bulk_users_by_email_load_shared_examples.rb new file mode 100644 index 00000000000..c3e9ff5c91a --- /dev/null +++ b/spec/support/shared_examples/models/concerns/bulk_users_by_email_load_shared_examples.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a BulkUsersByEmailLoad model' do + describe '#users_by_emails' do + let_it_be(:user1) { create(:user, emails: [create(:email, email: 'user1@example.com')]) } + let_it_be(:user2) { create(:user, emails: [create(:email, email: 'user2@example.com')]) } + + subject(:model) { described_class.new(id: non_existing_record_id) } + + context 'when nothing is loaded' do + let(:passed_emails) { [user1.emails.first.email, user2.email] } + + it 'preforms the yielded query and supplies the data with only emails desired' do + expect(model.users_by_emails(passed_emails).keys).to contain_exactly(*passed_emails) + end + end + + context 'when store is preloaded', :request_store do + let(:passed_emails) { [user1.emails.first.email, user2.email, user1.email] } + let(:resource_data) do + { + user1.emails.first.email => instance_double('User'), + user2.email => instance_double('User') + } + end + + before do + Gitlab::SafeRequestStore["user_by_email_for_users:#{model.class.name}:#{model.id}"] = resource_data + end + + it 'passes back loaded data and does not update the items that already exist' do + users_by_emails = model.users_by_emails(passed_emails) + + expect(users_by_emails.keys).to contain_exactly(*passed_emails) + expect(users_by_emails).to include(resource_data.merge(user1.email => user1)) + end + end + end +end diff --git a/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb b/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb index 6b208c0024d..e625ba785d2 100644 --- a/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb @@ -20,6 +20,12 @@ RSpec.shared_examples 'from set operator' do |sql_klass| expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\n#{operator_keyword}\n\(SELECT.+\)\) users/m) end + it "returns empty set when passing empty array" do + query = model.public_send(operator_method, []) + + expect(query.to_sql).to match(/WHERE \(1=0\)/m) + end + it 'supports the use of a custom alias for the sub query' do query = model.public_send(operator_method, [model.where(id: 1), model.where(id: 2)], diff --git a/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb b/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb index d6415e98289..da5c35c970a 100644 --- a/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb @@ -227,9 +227,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name end context 'for confidential notes' do - before_all do - issue_note.update!(confidential: true) - end + let_it_be(:issue_note) { create(:note_on_issue, project: project, note: "issue note", confidential: true) } it 'falls back to note channel' do expect(::Slack::Messenger).to execute_with_options(channel: ['random']) diff --git a/spec/support/shared_examples/models/group_shared_examples.rb b/spec/support/shared_examples/models/group_shared_examples.rb new file mode 100644 index 00000000000..9f3359ba4ab --- /dev/null +++ b/spec/support/shared_examples/models/group_shared_examples.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'checks self and root ancestor feature flag' do + let_it_be(:root_group) { create(:group) } + let_it_be(:group) { create(:group, parent: root_group) } + let_it_be(:project) { create(:project, group: group) } + + subject { group.public_send(feature_flag_method) } + + context 'when FF is enabled for the root group' do + before do + stub_feature_flags(feature_flag => root_group) + end + + it { is_expected.to be_truthy } + end + + context 'when FF is enabled for the group' do + before do + stub_feature_flags(feature_flag => group) + end + + it { is_expected.to be_truthy } + + context 'when root_group is the actor' do + it 'is not enabled if the FF is enabled for a child' do + expect(root_group.public_send(feature_flag_method)).to be_falsey + end + end + end + + context 'when FF is disabled globally' do + before do + stub_feature_flags(feature_flag => false) + end + + it { is_expected.to be_falsey } + end + + context 'when FF is enabled globally' do + it { is_expected.to be_truthy } + end +end diff --git a/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb b/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb deleted file mode 100644 index 13ffc1b7f87..00000000000 --- a/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -# This shared example requires a `builder` and `user` variable -RSpec.shared_examples 'issuable hook data' do |kind| - let(:data) { builder.build(user: user) } - - include_examples 'project hook data' do - let(:project) { builder.issuable.project } - end - - include_examples 'deprecated repository hook data' - - context "with a #{kind}" do - it 'contains issuable data' do - expect(data[:object_kind]).to eq(kind) - expect(data[:user]).to eq(user.hook_attrs) - expect(data[:project]).to eq(builder.issuable.project.hook_attrs) - expect(data[:object_attributes]).to eq(builder.issuable.hook_attrs) - expect(data[:changes]).to eq({}) - expect(data[:repository]).to eq(builder.issuable.project.hook_attrs.slice(:name, :url, :description, :homepage)) - end - - it 'does not contain certain keys' do - expect(data).not_to have_key(:assignees) - expect(data).not_to have_key(:assignee) - end - - describe 'changes are given' do - let(:changes) do - { - cached_markdown_version: %w[foo bar], - description: ['A description', 'A cool description'], - description_html: %w[foo bar], - in_progress_merge_commit_sha: %w[foo bar], - lock_version: %w[foo bar], - merge_jid: %w[foo bar], - title: ['A title', 'Hello World'], - title_html: %w[foo bar] - } - end - - let(:data) { builder.build(user: user, changes: changes) } - - it 'populates the :changes hash' do - expect(data[:changes]).to match(hash_including({ - title: { previous: 'A title', current: 'Hello World' }, - description: { previous: 'A description', current: 'A cool description' } - })) - end - - it 'does not contain certain keys' do - expect(data[:changes]).not_to have_key('cached_markdown_version') - expect(data[:changes]).not_to have_key('description_html') - expect(data[:changes]).not_to have_key('lock_version') - expect(data[:changes]).not_to have_key('title_html') - expect(data[:changes]).not_to have_key('in_progress_merge_commit_sha') - expect(data[:changes]).not_to have_key('merge_jid') - end - end - end -end diff --git a/spec/support/shared_examples/models/issuable_link_shared_examples.rb b/spec/support/shared_examples/models/issuable_link_shared_examples.rb index ca98c2597a2..9892e66b582 100644 --- a/spec/support/shared_examples/models/issuable_link_shared_examples.rb +++ b/spec/support/shared_examples/models/issuable_link_shared_examples.rb @@ -55,6 +55,19 @@ RSpec.shared_examples 'issuable link' do end end + describe 'scopes' do + describe '.for_source_or_target' do + it 'returns only links where id is either source or target id' do + link1 = create(issuable_link_factory, source: issuable_link.source) + link2 = create(issuable_link_factory, target: issuable_link.source) + # unrelated link, should not be included in result list + create(issuable_link_factory) # rubocop: disable Rails/SaveBang + + expect(described_class.for_source_or_target(issuable_link.source_id)).to match_array([issuable_link, link1, link2]) + end + end + end + describe '.link_type' do it { is_expected.to define_enum_for(:link_type).with_values(relates_to: 0, blocks: 1) } diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb index 17026f085bb..a329a6dca91 100644 --- a/spec/support/shared_examples/models/member_shared_examples.rb +++ b/spec/support/shared_examples/models/member_shared_examples.rb @@ -88,19 +88,55 @@ RSpec.shared_examples_for "member creation" do expect(member).to be_persisted end - context 'when admin mode is enabled', :enable_admin_mode do + context 'when adding a project_bot' do + let_it_be(:project_bot) { create(:user, :project_bot) } + + before_all do + source.add_owner(user) + end + + context 'when project_bot is already a member' do + before do + source.add_developer(project_bot) + end + + it 'does not update the member' do + member = described_class.new(source, project_bot, :maintainer, current_user: user).execute + + expect(source.users.reload).to include(project_bot) + expect(member).to be_persisted + expect(member.access_level).to eq(Gitlab::Access::DEVELOPER) + expect(member.errors.full_messages).to include(/not authorized to update member/) + end + end + + context 'when project_bot is not already a member' do + it 'adds the member' do + member = described_class.new(source, project_bot, :maintainer, current_user: user).execute + + expect(source.users.reload).to include(project_bot) + expect(member).to be_persisted + end + end + end + + context 'when admin mode is enabled', :enable_admin_mode, :aggregate_failures do it 'sets members.created_by to the given admin current_user' do member = described_class.new(source, user, :maintainer, current_user: admin).execute + expect(member).to be_persisted + expect(source.users.reload).to include(user) expect(member.created_by).to eq(admin) end end context 'when admin mode is disabled' do - it 'rejects setting members.created_by to the given admin current_user' do + it 'rejects setting members.created_by to the given admin current_user', :aggregate_failures do member = described_class.new(source, user, :maintainer, current_user: admin).execute - expect(member.created_by).to be_nil + expect(member).not_to be_persisted + expect(source.users.reload).not_to include(user) + expect(member.errors.full_messages).to include(/not authorized to create member/) end end @@ -142,7 +178,7 @@ RSpec.shared_examples_for "member creation" do end context 'when called with an unknown user id' do - it 'adds the user as a member' do + it 'does not add the user as a member' do expect(source.users).not_to include(user) described_class.new(source, non_existing_record_id, :maintainer).execute @@ -410,6 +446,22 @@ RSpec.shared_examples_for "bulk member creation" do end end + it 'with the same user sent more than once by user and by email' do + members = described_class.add_users(source, [user1, user1.email], :maintainer) + + expect(members.map(&:user)).to contain_exactly(user1) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end + + it 'with the same user sent more than once by user id and by email' do + members = described_class.add_users(source, [user1.id, user1.email], :maintainer) + + expect(members.map(&:user)).to contain_exactly(user1) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end + context 'when a member already exists' do before do source.add_user(user1, :developer) diff --git a/spec/support/shared_examples/models/project_shared_examples.rb b/spec/support/shared_examples/models/project_shared_examples.rb new file mode 100644 index 00000000000..475ac1da04b --- /dev/null +++ b/spec/support/shared_examples/models/project_shared_examples.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'returns true if project is inactive' do + using RSpec::Parameterized::TableSyntax + + where(:storage_size, :last_activity_at, :expected_result) do + 1.megabyte | 1.month.ago | false + 1.megabyte | 3.years.ago | false + 8.megabytes | 1.month.ago | false + 8.megabytes | 3.years.ago | true + end + + with_them do + before do + stub_application_setting(inactive_projects_min_size_mb: 5) + stub_application_setting(inactive_projects_send_warning_email_after_months: 24) + + project.statistics.storage_size = storage_size + project.last_activity_at = last_activity_at + project.save! + end + + it 'returns expected result' do + expect(project.inactive?).to eq(expected_result) + end + end +end diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb index b3f79d9fe6e..03e9dd65e33 100644 --- a/spec/support/shared_examples/models/wiki_shared_examples.rb +++ b/spec/support/shared_examples/models/wiki_shared_examples.rb @@ -11,6 +11,10 @@ RSpec.shared_examples 'wiki model' do subject { wiki } + it 'VALID_USER_MARKUPS contains all valid markups' do + expect(described_class::VALID_USER_MARKUPS.keys).to match_array(%i(markdown rdoc asciidoc org)) + end + it 'container class includes HasWiki' do # NOTE: This is not enforced at runtime, since we also need to support Geo::DeletedProject expect(wiki_container).to be_kind_of(HasWiki) @@ -427,45 +431,131 @@ RSpec.shared_examples 'wiki model' do end describe '#update_page' do - let(:page) { create(:wiki_page, wiki: subject, title: 'update-page') } + shared_examples 'update_page tests' do + with_them do + let!(:page) { create(:wiki_page, wiki: subject, title: original_title, format: original_format, content: 'original content') } + + let(:message) { 'updated page' } + let(:updated_content) { 'updated content' } + + def update_page + subject.update_page( + page.page, + content: updated_content, + title: updated_title, + format: updated_format, + message: message + ) + end + + specify :aggregate_failures do + expect(subject).to receive(:after_wiki_activity) + expect(update_page).to eq true + + page = subject.find_page(updated_title.presence || original_title) - def update_page - subject.update_page( - page.page, - content: 'some other content', - format: :markdown, - message: 'updated page' - ) + expect(page.raw_content).to eq(updated_content) + expect(page.path).to eq(expected_path) + expect(page.version.message).to eq(message) + expect(user.commit_email).not_to eq(user.email) + expect(commit.author_email).to eq(user.commit_email) + expect(commit.committer_email).to eq(user.commit_email) + end + end end - it 'updates the content of the page' do - update_page - page = subject.find_page('update-page') + shared_context 'common examples' do + using RSpec::Parameterized::TableSyntax + + where(:original_title, :original_format, :updated_title, :updated_format, :expected_path) do + 'test page' | :markdown | 'new test page' | :markdown | 'new-test-page.md' + 'test page' | :markdown | 'test page' | :markdown | 'test-page.md' + 'test page' | :markdown | 'test page' | :asciidoc | 'test-page.asciidoc' + + 'test page' | :markdown | 'new dir/new test page' | :markdown | 'new-dir/new-test-page.md' + 'test page' | :markdown | 'new dir/test page' | :markdown | 'new-dir/test-page.md' - expect(page.raw_content).to eq('some other content') + 'test dir/test page' | :markdown | 'new dir/new test page' | :markdown | 'new-dir/new-test-page.md' + 'test dir/test page' | :markdown | 'test dir/test page' | :markdown | 'test-dir/test-page.md' + 'test dir/test page' | :markdown | 'test dir/test page' | :asciidoc | 'test-dir/test-page.asciidoc' + + 'test dir/test page' | :markdown | 'new test page' | :markdown | 'new-test-page.md' + 'test dir/test page' | :markdown | 'test page' | :markdown | 'test-page.md' + + 'test page' | :markdown | nil | :markdown | 'test-page.md' + 'test.page' | :markdown | nil | :markdown | 'test.page.md' + end end - it 'sets the correct commit message' do - update_page - page = subject.find_page('update-page') + # There are two bugs in Gollum. THe first one is when the title and the format are updated + # at the same time https://gitlab.com/gitlab-org/gitlab/-/issues/243519. + # The second one is when the wiki page is within a dir and the `title` argument + # we pass to the update method is `nil`. Gollum will remove the dir and move the page. + # + # We can include this context into the former once it is fixed + # or when Gollum is removed since the Gitaly approach already fixes it. + shared_context 'extended examples' do + using RSpec::Parameterized::TableSyntax + + where(:original_title, :original_format, :updated_title, :updated_format, :expected_path) do + 'test page' | :markdown | 'new test page' | :asciidoc | 'new-test-page.asciidoc' + 'test page' | :markdown | 'new dir/new test page' | :asciidoc | 'new-dir/new-test-page.asciidoc' + 'test dir/test page' | :markdown | 'new dir/new test page' | :asciidoc | 'new-dir/new-test-page.asciidoc' + 'test dir/test page' | :markdown | 'new test page' | :asciidoc | 'new-test-page.asciidoc' + 'test page' | :markdown | nil | :asciidoc | 'test-page.asciidoc' + 'test dir/test page' | :markdown | nil | :asciidoc | 'test-dir/test-page.asciidoc' + 'test dir/test page' | :markdown | nil | :markdown | 'test-dir/test-page.md' + 'test page' | :markdown | '' | :markdown | 'test-page.md' + 'test.page' | :markdown | '' | :markdown | 'test.page.md' + end + end - expect(page.version.message).to eq('updated page') + it_behaves_like 'update_page tests' do + include_context 'common examples' + include_context 'extended examples' end - it 'sets the correct commit email' do - update_page + context 'when format is invalid' do + let!(:page) { create(:wiki_page, wiki: subject, title: 'test page') } - expect(user.commit_email).not_to eq(user.email) - expect(commit.author_email).to eq(user.commit_email) - expect(commit.committer_email).to eq(user.commit_email) + it 'returns false and sets error message' do + expect(subject.update_page(page.page, content: 'new content', format: :foobar)).to eq false + expect(subject.error_message).to match(/Invalid format selected/) + end end - it 'runs after_wiki_activity callbacks' do - page + context 'when format is not allowed' do + let!(:page) { create(:wiki_page, wiki: subject, title: 'test page') } - expect(subject).to receive(:after_wiki_activity) + it 'returns false and sets error message' do + expect(subject.update_page(page.page, content: 'new content', format: :creole)).to eq false + expect(subject.error_message).to match(/Invalid format selected/) + end + end + + context 'when page path does not have a default extension' do + let!(:page) { create(:wiki_page, wiki: subject, title: 'test page') } + + context 'when format is not different' do + it 'does not change the default extension' do + path = 'test-page.markdown' + page.page.instance_variable_set(:@path, path) - update_page + expect(subject.repository).to receive(:update_file).with(user, path, anything, anything) + + subject.update_page(page.page, content: 'new content', format: :markdown) + end + end + end + + context 'when feature flag :gitaly_replace_wiki_update_page is disabled' do + before do + stub_feature_flags(gitaly_replace_wiki_update_page: false) + end + + it_behaves_like 'update_page tests' do + include_context 'common examples' + end end end diff --git a/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb b/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb index 58822f4309b..991d6289373 100644 --- a/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb +++ b/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb @@ -107,10 +107,4 @@ RSpec.shared_examples 'model with wiki policies' do expect_disallowed(*disallowed_permissions) end end - - # TODO: Remove this helper once we implement group features - # https://gitlab.com/gitlab-org/gitlab/-/issues/208412 - def set_access_level(access_level) - raise NotImplementedError - end end diff --git a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb index 9f4fdcf7ba1..dc2c4f890b1 100644 --- a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb @@ -163,11 +163,11 @@ RSpec.shared_examples 'rejects Composer access with unknown project id' do let(:project) { double(id: non_existing_record_id) } context 'as anonymous' do - it_behaves_like 'process Composer api request', :anonymous, :not_found + it_behaves_like 'process Composer api request', :anonymous, :unauthorized end context 'as authenticated user' do - subject { get api(url), headers: basic_auth_header(user.username, personal_access_token.token) } + subject { get api(url), params: params, headers: basic_auth_header(user.username, personal_access_token.token) } it_behaves_like 'process Composer api request', :anonymous, :not_found end diff --git a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb index e1e75be2494..c1eccafa987 100644 --- a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb @@ -116,3 +116,93 @@ RSpec.shared_examples 'not hitting graphql network errors with the container reg expect_graphql_errors_to_be_empty end end + +RSpec.shared_examples 'reconciling migration_state' do + shared_examples 'enforcing states coherence to' do |expected_migration_state| + it 'leaves the repository in the expected migration_state' do + expect(repository.gitlab_api_client).not_to receive(:pre_import_repository) + expect(repository.gitlab_api_client).not_to receive(:import_repository) + + subject + + expect(repository.reload.migration_state).to eq(expected_migration_state) + end + end + + shared_examples 'retrying the pre_import' do + it 'retries the pre_import' do + expect(repository).to receive(:migration_pre_import).and_return(:ok) + + expect { subject }.to change { repository.reload.migration_state }.to('pre_importing') + end + end + + shared_examples 'retrying the import' do + it 'retries the import' do + expect(repository).to receive(:migration_import).and_return(:ok) + + expect { subject }.to change { repository.reload.migration_state }.to('importing') + end + end + + context 'native response' do + let(:status) { 'native' } + + it 'finishes the import' do + expect { subject } + .to change { repository.reload.migration_state }.to('import_done') + .and change { repository.reload.migration_skipped_reason }.to('native_import') + end + end + + context 'import_in_progress response' do + let(:status) { 'import_in_progress' } + + it_behaves_like 'enforcing states coherence to', 'importing' + end + + context 'import_complete response' do + let(:status) { 'import_complete' } + + it 'finishes the import' do + expect { subject }.to change { repository.reload.migration_state }.to('import_done') + end + end + + context 'import_failed response' do + let(:status) { 'import_failed' } + + it_behaves_like 'retrying the import' + end + + context 'pre_import_in_progress response' do + let(:status) { 'pre_import_in_progress' } + + it_behaves_like 'enforcing states coherence to', 'pre_importing' + end + + context 'pre_import_complete response' do + let(:status) { 'pre_import_complete' } + + it 'finishes the pre_import and starts the import' do + expect(repository).to receive(:finish_pre_import).and_call_original + expect(repository).to receive(:migration_import).and_return(:ok) + + expect { subject }.to change { repository.reload.migration_state }.to('importing') + end + end + + context 'pre_import_failed response' do + let(:status) { 'pre_import_failed' } + + it_behaves_like 'retrying the pre_import' + end + + %w[pre_import_canceled import_canceled].each do |canceled_status| + context "#{canceled_status} response" do + let(:status) { canceled_status } + + it_behaves_like 'enforcing states coherence to', 'import_skipped' + end + end +end diff --git a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb index 01ed6c26576..da9d254039b 100644 --- a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb @@ -54,11 +54,13 @@ RSpec.shared_examples 'group and project boards query' do end context 'when using default sorting' do + # rubocop:disable RSpec/VariableName let!(:board_B) { create(:board, resource_parent: board_parent, name: 'B') } let!(:board_C) { create(:board, resource_parent: board_parent, name: 'C') } let!(:board_a) { create(:board, resource_parent: board_parent, name: 'a') } let!(:board_A) { create(:board, resource_parent: board_parent, name: 'A') } let(:boards) { [board_a, board_A, board_B, board_C] } + # rubocop:enable RSpec/VariableName context 'when ascending' do it_behaves_like 'sorted paginated query' do diff --git a/spec/support/shared_examples/requests/api/issuable_participants_examples.rb b/spec/support/shared_examples/requests/api/issuable_participants_examples.rb index c5e5803c0a7..673d7741017 100644 --- a/spec/support/shared_examples/requests/api/issuable_participants_examples.rb +++ b/spec/support/shared_examples/requests/api/issuable_participants_examples.rb @@ -28,34 +28,4 @@ RSpec.shared_examples 'issuable participants endpoint' do expect(response).to have_gitlab_http_status(:not_found) end - - context 'with a confidential note' do - let!(:note) do - create( - :note, - :confidential, - project: project, - noteable: entity, - author: create(:user) - ) - end - - it 'returns a full list of participants' do - get api("/projects/#{project.id}/#{area}/#{entity.iid}/participants", user) - - expect(response).to have_gitlab_http_status(:ok) - participant_ids = json_response.map { |el| el['id'] } - expect(participant_ids).to match_array([entity.author_id, note.author_id]) - end - - context 'when user cannot see a confidential note' do - it 'returns a limited list of participants' do - get api("/projects/#{project.id}/#{area}/#{entity.iid}/participants", create(:user)) - - expect(response).to have_gitlab_http_status(:ok) - participant_ids = json_response.map { |el| el['id'] } - expect(participant_ids).to match_array([entity.author_id]) - end - end - end end diff --git a/spec/support/shared_examples/requests/api/notes_shared_examples.rb b/spec/support/shared_examples/requests/api/notes_shared_examples.rb index 2a157f6e855..e7e30665b08 100644 --- a/spec/support/shared_examples/requests/api/notes_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/notes_shared_examples.rb @@ -142,15 +142,6 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name| expect(json_response['author']['username']).to eq(user.username) end - it "creates a confidential note if confidential is set to true" do - post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: { body: 'hi!', confidential: true } - - expect(response).to have_gitlab_http_status(:created) - expect(json_response['body']).to eq('hi!') - expect(json_response['confidential']).to be_truthy - expect(json_response['author']['username']).to eq(user.username) - end - it "returns a 400 bad request error if body not given" do post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user) @@ -306,52 +297,31 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name| end describe "PUT /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id" do - let(:params) { { body: 'Hello!', confidential: false } } + let(:params) { { body: 'Hello!' } } subject do put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user), params: params end - context 'when eveything is ok' do - before do - note.update!(confidential: true) - end + context 'when only body param is present' do + let(:params) { { body: 'Hello!' } } - context 'with multiple params present' do - before do - subject - end - - it 'returns modified note' do - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['body']).to eq('Hello!') - expect(json_response['confidential']).to be_falsey - end - - it 'updates the note' do - expect(note.reload.note).to eq('Hello!') - expect(note.confidential).to be_falsey - end - end - - context 'when only body param is present' do - let(:params) { { body: 'Hello!' } } - - it 'updates only the note text' do - expect { subject }.not_to change { note.reload.confidential } + it 'updates the note text' do + subject - expect(note.note).to eq('Hello!') - end + expect(note.reload.note).to eq('Hello!') + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['body']).to eq('Hello!') end + end - context 'when only confidential param is present' do - let(:params) { { confidential: false } } + context 'when confidential param is present' do + let(:params) { { confidential: true } } - it 'updates only the note text' do - expect { subject }.not_to change { note.reload.note } + it 'does not allow to change confidentiality' do + expect { subject }.not_to change { note.reload.note } - expect(note.confidential).to be_falsey - end + expect(response).to have_gitlab_http_status(:bad_request) end end @@ -393,3 +363,24 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name| end end end + +RSpec.shared_examples 'noteable API with confidential notes' do |parent_type, noteable_type, id_name| + it_behaves_like 'noteable API', parent_type, noteable_type, id_name + + describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes" do + let(:params) { { body: 'hi!' } } + + subject do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: params + end + + it "creates a confidential note if confidential is set to true" do + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: params.merge(confidential: true) + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['body']).to eq('hi!') + expect(json_response['confidential']).to be_truthy + expect(json_response['author']['username']).to eq(user.username) + end + end +end diff --git a/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb b/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb index 87a33060435..fcd52cdf7fa 100644 --- a/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb +++ b/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true RSpec.shared_examples 'avoid N+1 on environments serialization' do |ee: false| - # Investigating in https://gitlab.com/gitlab-org/gitlab/-/issues/353209 - let(:query_threshold) { 1 + (ee ? 4 : 0) } - it 'avoids N+1 database queries with grouping', :request_store do create_environment_with_associations(project) @@ -11,9 +8,11 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do |ee: false| create_environment_with_associations(project) create_environment_with_associations(project) - expect { serialize(grouping: true) } - .not_to exceed_query_limit(control.count) - .with_threshold(query_threshold) + # Fix N+1 queries introduced by multi stop_actions for environment. + # Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780 + relax_count = 14 + + expect { serialize(grouping: true) }.not_to exceed_query_limit(control.count + relax_count) end it 'avoids N+1 database queries without grouping', :request_store do @@ -24,9 +23,11 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do |ee: false| create_environment_with_associations(project) create_environment_with_associations(project) - expect { serialize(grouping: false) } - .not_to exceed_query_limit(control.count) - .with_threshold(query_threshold) + # Fix N+1 queries introduced by multi stop_actions for environment. + # Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780 + relax_count = 14 + + expect { serialize(grouping: false) }.not_to exceed_query_limit(control.count + relax_count) end it 'does not preload for environments that does not exist in the page', :request_store do diff --git a/spec/support/shared_examples/services/boards/boards_list_service_shared_examples.rb b/spec/support/shared_examples/services/boards/boards_list_service_shared_examples.rb index 0e2bddc19ab..fd832d4484d 100644 --- a/spec/support/shared_examples/services/boards/boards_list_service_shared_examples.rb +++ b/spec/support/shared_examples/services/boards/boards_list_service_shared_examples.rb @@ -13,10 +13,12 @@ RSpec.shared_examples 'boards list service' do end RSpec.shared_examples 'multiple boards list service' do + # rubocop:disable RSpec/VariableName let(:service) { described_class.new(parent, double) } let!(:board_B) { create(:board, resource_parent: parent, name: 'B-board') } let!(:board_c) { create(:board, resource_parent: parent, name: 'c-board') } let!(:board_a) { create(:board, resource_parent: parent, name: 'a-board') } + # rubocop:enable RSpec/VariableName describe '#execute' do it 'returns all issue boards' do diff --git a/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb index a780952d51b..7677e5d8cb2 100644 --- a/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb +++ b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb @@ -154,6 +154,30 @@ RSpec.shared_examples 'logs an auth warning' do |requested_actions| end end +RSpec.shared_examples 'allowed to delete container repository images' do + let(:authentication_abilities) do + [:admin_container_image] + end + + it_behaves_like 'a valid token' + + context 'allow to delete images' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:*"] } + end + + it_behaves_like 'a deletable' + end + + context 'allow to delete images since registry 2.7' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:delete"] } + end + + it_behaves_like 'a deletable since registry 2.7' + end +end + RSpec.shared_examples 'a container registry auth service' do include_context 'container registry auth service context' @@ -204,6 +228,46 @@ RSpec.shared_examples 'a container registry auth service' do it_behaves_like 'not a container repository factory' end + describe '.pull_nested_repositories_access_token' do + let_it_be(:project) { create(:project) } + + let(:token) { described_class.pull_nested_repositories_access_token(project.full_path) } + let(:access) do + [ + { + 'type' => 'repository', + 'name' => project.full_path, + 'actions' => ['pull'] + }, + { + 'type' => 'repository', + 'name' => "#{project.full_path}/*", + 'actions' => ['pull'] + } + ] + end + + subject { { token: token } } + + it 'has the correct scope' do + expect(payload).to include('access' => access) + end + + it_behaves_like 'a valid token' + it_behaves_like 'not a container repository factory' + + context 'with path ending with a slash' do + let(:token) { described_class.pull_nested_repositories_access_token("#{project.full_path}/") } + + it 'has the correct scope' do + expect(payload).to include('access' => access) + end + + it_behaves_like 'a valid token' + it_behaves_like 'not a container repository factory' + end + end + context 'user authorization' do let_it_be(:current_user) { create(:user) } @@ -504,38 +568,14 @@ RSpec.shared_examples 'a container registry auth service' do end context 'delete authorized as maintainer' do - let_it_be(:current_project) { create(:project) } + let_it_be(:project) { create(:project) } let_it_be(:current_user) { create(:user) } - let(:authentication_abilities) do - [:admin_container_image] - end - before_all do - current_project.add_maintainer(current_user) - end - - it_behaves_like 'a valid token' - - context 'allow to delete images' do - let(:current_params) do - { scopes: ["repository:#{current_project.full_path}:*"] } - end - - it_behaves_like 'a deletable' do - let(:project) { current_project } - end + project.add_maintainer(current_user) end - context 'allow to delete images since registry 2.7' do - let(:current_params) do - { scopes: ["repository:#{current_project.full_path}:delete"] } - end - - it_behaves_like 'a deletable since registry 2.7' do - let(:project) { current_project } - end - end + it_behaves_like 'allowed to delete container repository images' end context 'build authorized as user' do diff --git a/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb b/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb index 6146aae6b9b..9610cdd18a3 100644 --- a/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb +++ b/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb @@ -70,8 +70,10 @@ shared_examples 'issuable link creation' do expect(issuable_link_class.find_by!(target: issuable3)).to have_attributes(source: issuable, link_type: 'relates_to') end - it 'returns success status' do - is_expected.to eq(status: :success) + it 'returns success status and created links', :aggregate_failures do + expect(subject.keys).to match_array([:status, :created_references]) + expect(subject[:status]).to eq(:success) + expect(subject[:created_references].map(&:target_id)).to match_array([issuable2.id, issuable3.id]) end it 'creates notes' do diff --git a/spec/support/shared_examples/views/milestone_shared_examples.rb b/spec/support/shared_examples/views/milestone_shared_examples.rb new file mode 100644 index 00000000000..b6f4d0db0e9 --- /dev/null +++ b/spec/support/shared_examples/views/milestone_shared_examples.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'milestone empty states' do + include Devise::Test::ControllerHelpers + + let_it_be(:user) { build(:user) } + let(:empty_state) { 'Use milestones to track issues and merge requests over a fixed period of time' } + + before do + assign(:projects, []) + allow(view).to receive(:current_user).and_return(user) + end + + context 'with no milestones' do + before do + assign(:milestones, []) + assign(:milestone_states, { opened: 0, closed: 0, all: 0 }) + render + end + + it 'shows empty state' do + expect(rendered).to have_content(empty_state) + end + + it 'does not show tabs or searchbar' do + expect(rendered).not_to have_link('Open') + expect(rendered).not_to have_link('Closed') + expect(rendered).not_to have_link('All') + end + end + + context 'with no open milestones' do + before do + allow(view).to receive(:milestone_path).and_return("/milestones/1") + assign(:milestones, []) + assign(:milestone_states, { opened: 0, closed: 1, all: 1 }) + end + + it 'shows tabs and searchbar', :aggregate_failures do + render + + expect(rendered).not_to have_content(empty_state) + expect(rendered).to have_link('Open') + expect(rendered).to have_link('Closed') + expect(rendered).to have_link('All') + end + + it 'shows empty state' do + render + + expect(rendered).to have_content('There are no open milestones') + end + end + + context 'with no closed milestones' do + before do + allow(view).to receive(:milestone_path).and_return("/milestones/1") + allow(view).to receive(:params).and_return(state: 'closed') + assign(:milestones, []) + assign(:milestone_states, { opened: 1, closed: 0, all: 1 }) + end + + it 'shows tabs and searchbar', :aggregate_failures do + render + + expect(rendered).not_to have_content(empty_state) + expect(rendered).to have_link('Open') + expect(rendered).to have_link('Closed') + expect(rendered).to have_link('All') + end + + it 'shows empty state on closed milestones' do + render + + expect(rendered).to have_content('There are no closed milestones') + end + end +end diff --git a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb index d202c4e00f0..26731f34ed6 100644 --- a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb +++ b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_database| +RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_database, feature_flag:| include ExclusiveLeaseHelpers describe 'defining the job attributes' do @@ -39,6 +39,16 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d end end + describe '.enabled?' do + it 'does not raise an error' do + expect { described_class.enabled? }.not_to raise_error + end + + it 'returns true' do + expect(described_class.enabled?).to be_truthy + end + end + describe '#perform' do subject(:worker) { described_class.new } @@ -76,7 +86,7 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d context 'when the feature flag is disabled' do before do - stub_feature_flags(execute_batched_migrations_on_schedule: false) + stub_feature_flags(feature_flag => false) end it 'does nothing' do @@ -89,7 +99,7 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d context 'when the feature flag is enabled' do before do - stub_feature_flags(execute_batched_migrations_on_schedule: true) + stub_feature_flags(feature_flag => true) allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration).and_return(nil) end diff --git a/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb b/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb index 202606c6aa6..4751d91efde 100644 --- a/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb +++ b/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb @@ -230,76 +230,6 @@ RSpec.shared_examples 'can collect git garbage' do |update_statistics: true| stub_feature_flags(optimized_housekeeping: false) end - it 'incremental repack adds a new packfile' do - create_objects(resource) - before_packs = packs(resource) - - expect(before_packs.count).to be >= 1 - - subject.perform(resource.id, 'incremental_repack', lease_key, lease_uuid) - after_packs = packs(resource) - - # Exactly one new pack should have been created - expect(after_packs.count).to eq(before_packs.count + 1) - - # Previously existing packs are still around - expect(before_packs & after_packs).to eq(before_packs) - end - - it 'full repack consolidates into 1 packfile' do - create_objects(resource) - subject.perform(resource.id, 'incremental_repack', lease_key, lease_uuid) - before_packs = packs(resource) - - expect(before_packs.count).to be >= 2 - - subject.perform(resource.id, 'full_repack', lease_key, lease_uuid) - after_packs = packs(resource) - - expect(after_packs.count).to eq(1) - - # Previously existing packs should be gone now - expect(after_packs - before_packs).to eq(after_packs) - - expect(File.exist?(bitmap_path(after_packs.first))).to eq(bitmaps_enabled) - end - - it 'gc consolidates into 1 packfile and updates packed-refs' do - create_objects(resource) - before_packs = packs(resource) - before_packed_refs = packed_refs(resource) - - expect(before_packs.count).to be >= 1 - - # It's quite difficult to use `expect_next_instance_of` in this place - # because the RepositoryService is instantiated several times to do - # some repository calls like `exists?`, `create_repository`, ... . - # Therefore, since we're instantiating the object several times, - # RSpec has troubles figuring out which instance is the next and which - # one we want to mock. - # Besides, at this point, we actually want to perform the call to Gitaly, - # otherwise we would just use `instance_double` like in other parts of the - # spec file. - expect_any_instance_of(Gitlab::GitalyClient::RepositoryService) # rubocop:disable RSpec/AnyInstanceOf - .to receive(:garbage_collect) - .with(bitmaps_enabled, prune: false) - .and_call_original - - subject.perform(resource.id, 'gc', lease_key, lease_uuid) - after_packed_refs = packed_refs(resource) - after_packs = packs(resource) - - expect(after_packs.count).to eq(1) - - # Previously existing packs should be gone now - expect(after_packs - before_packs).to eq(after_packs) - - # The packed-refs file should have been updated during 'git gc' - expect(before_packed_refs).not_to eq(after_packed_refs) - - expect(File.exist?(bitmap_path(after_packs.first))).to eq(bitmaps_enabled) - end - it 'cleans up repository after finishing' do expect(resource).to receive(:cleanup).and_call_original diff --git a/spec/tasks/dev_rake_spec.rb b/spec/tasks/dev_rake_spec.rb index 7bc27d2732c..73b1604aa10 100644 --- a/spec/tasks/dev_rake_spec.rb +++ b/spec/tasks/dev_rake_spec.rb @@ -7,9 +7,20 @@ RSpec.describe 'dev rake tasks' do Rake.application.rake_require 'tasks/gitlab/setup' Rake.application.rake_require 'tasks/gitlab/shell' Rake.application.rake_require 'tasks/dev' + Rake.application.rake_require 'active_record/railties/databases' + Rake.application.rake_require 'tasks/gitlab/db' end describe 'setup' do + around do |example| + old_force_value = ENV['force'] + + # setup rake task sets the force env var, so reset it + example.run + + ENV['force'] = old_force_value # rubocop:disable RSpec/EnvAssignment + end + subject(:setup_task) { run_rake_task('dev:setup') } let(:connections) { Gitlab::Database.database_base_models.values.map(&:connection) } @@ -17,7 +28,9 @@ RSpec.describe 'dev rake tasks' do it 'sets up the development environment', :aggregate_failures do expect(Rake::Task['gitlab:setup']).to receive(:invoke) + expect(connections).to all(receive(:execute).with('SET statement_timeout TO 0')) expect(connections).to all(receive(:execute).with('ANALYZE')) + expect(connections).to all(receive(:execute).with('RESET statement_timeout')) expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke) @@ -35,4 +48,103 @@ RSpec.describe 'dev rake tasks' do load_task end end + + describe 'terminate_all_connections' do + let(:connections) do + Gitlab::Database.database_base_models.values.filter_map do |model| + model.connection if Gitlab::Database.db_config_share_with(model.connection_db_config).nil? + end + end + + def expect_connections_to_be_terminated + expect(Gitlab::Database::EachDatabase).to receive(:each_database_connection) + .with(include_shared: false) + .and_call_original + + expect(connections).to all(receive(:execute).with(/SELECT pg_terminate_backend/)) + end + + def expect_connections_not_to_be_terminated + connections.each do |connection| + expect(connection).not_to receive(:execute) + end + end + + subject(:terminate_task) { run_rake_task('dev:terminate_all_connections') } + + it 'terminates all connections' do + expect_connections_to_be_terminated + + terminate_task + end + + context 'when in the production environment' do + it 'does not terminate connections' do + expect(Rails.env).to receive(:production?).and_return(true) + expect_connections_not_to_be_terminated + + terminate_task + end + end + + context 'when a database is not found' do + before do + skip_if_multiple_databases_not_setup + end + + it 'continues to next connection' do + expect(connections.first).to receive(:execute).and_raise(ActiveRecord::NoDatabaseError) + expect(connections.second).to receive(:execute).with(/SELECT pg_terminate_backend/) + + terminate_task + end + end + end + + context 'multiple databases' do + before do + skip_if_multiple_databases_not_setup + end + + context 'with a valid database' do + describe 'copy_db:ci' do + before do + allow(Rake::Task['dev:terminate_all_connections']).to receive(:invoke) + + configurations = instance_double(ActiveRecord::DatabaseConfigurations) + allow(ActiveRecord::Base).to receive(:configurations).and_return(configurations) + allow(configurations).to receive(:configs_for).with(env_name: Rails.env, name: 'ci').and_return(ci_configuration) + end + + subject(:load_task) { run_rake_task('dev:setup_ci_db') } + + let(:ci_configuration) { instance_double(ActiveRecord::DatabaseConfigurations::HashConfig, name: 'ci', database: '__test_db_ci') } + + it 'creates the database from main' do + expect(ApplicationRecord.connection).to receive(:create_database).with( + ci_configuration.database, + template: ApplicationRecord.connection_db_config.database + ) + + expect(Rake::Task['dev:terminate_all_connections']).to receive(:invoke) + + run_rake_task('dev:copy_db:ci') + end + + context 'when the database already exists' do + it 'prints out a warning' do + expect(ApplicationRecord.connection).to receive(:create_database).and_raise(ActiveRecord::DatabaseAlreadyExists) + + expect { run_rake_task('dev:copy_db:ci') }.to output(/Database '#{ci_configuration.database}' already exists/).to_stderr + end + end + end + end + + context 'with an invalid database' do + it 'raises an error' do + expect { run_rake_task('dev:copy_db:foo') }.to raise_error(RuntimeError, /Don't know how to build task/) + end + end + end end diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index df9f2a0d3bb..6080948403d 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -199,18 +199,25 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do end it 'logs the progress to log file' do - expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping database ... ") - expect(Gitlab::BackupLogger).to receive(:info).with(message: "[SKIPPED]") + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping database ... [SKIPPED]") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping repositories ... ") + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping repositories ... done") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping uploads ... ") + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping uploads ... done") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping builds ... ") + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping builds ... done") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping artifacts ... ") + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping artifacts ... done") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping pages ... ") + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping pages ... done") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping lfs objects ... ") + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping lfs objects ... done") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping terraform states ... ") + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping terraform states ... done") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping container registry images ... ") + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping container registry images ... done") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping packages ... ") - expect(Gitlab::BackupLogger).to receive(:info).with(message: "done").exactly(9).times + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping packages ... done") backup_tasks.each do |task| run_rake_task("gitlab:backup:#{task}:create") @@ -228,19 +235,19 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do db_backup_error = Backup::DatabaseBackupError.new(config, db_file_name) where(:backup_class, :rake_task, :error) do - Backup::Database | 'gitlab:backup:db:create' | db_backup_error - Backup::Builds | 'gitlab:backup:builds:create' | file_backup_error - Backup::Uploads | 'gitlab:backup:uploads:create' | file_backup_error - Backup::Artifacts | 'gitlab:backup:artifacts:create' | file_backup_error - Backup::Pages | 'gitlab:backup:pages:create' | file_backup_error - Backup::Lfs | 'gitlab:backup:lfs:create' | file_backup_error - Backup::Registry | 'gitlab:backup:registry:create' | file_backup_error + Backup::Database | 'gitlab:backup:db:create' | db_backup_error + Backup::Files | 'gitlab:backup:builds:create' | file_backup_error + Backup::Files | 'gitlab:backup:uploads:create' | file_backup_error + Backup::Files | 'gitlab:backup:artifacts:create' | file_backup_error + Backup::Files | 'gitlab:backup:pages:create' | file_backup_error + Backup::Files | 'gitlab:backup:lfs:create' | file_backup_error + Backup::Files | 'gitlab:backup:registry:create' | file_backup_error end with_them do before do - expect_next_instance_of(backup_class) do |instance| - expect(instance).to receive(:dump).and_raise(error) + allow_next_instance_of(backup_class) do |instance| + allow(instance).to receive(:dump).and_raise(error) end end @@ -408,25 +415,12 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do create(:project, :repository) end - it 'has defaults' do - expect(::Backup::Repositories).to receive(:new) - .with(anything, strategy: anything, max_concurrency: 1, max_storage_concurrency: 1) - .and_call_original - - expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process - end - it 'passes through concurrency environment variables' do - # The way concurrency is handled will change with the `gitaly_backup` - # feature flag. For now we need to check that both ways continue to - # work. This will be cleaned up in the rollout issue. - # See https://gitlab.com/gitlab-org/gitlab/-/issues/333034 - stub_env('GITLAB_BACKUP_MAX_CONCURRENCY', 5) stub_env('GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY', 2) expect(::Backup::Repositories).to receive(:new) - .with(anything, strategy: anything, max_concurrency: 5, max_storage_concurrency: 2) + .with(anything, strategy: anything) .and_call_original expect(::Backup::GitalyBackup).to receive(:new).with(anything, max_parallelism: 5, storage_parallelism: 2, incremental: false).and_call_original diff --git a/spec/tasks/gitlab/db/validate_config_rake_spec.rb b/spec/tasks/gitlab/db/validate_config_rake_spec.rb new file mode 100644 index 00000000000..0b2c844a91f --- /dev/null +++ b/spec/tasks/gitlab/db/validate_config_rake_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'rake_helper' + +RSpec.describe 'gitlab:db:validate_config', :silence_stdout do + before :all do + Rake.application.rake_require 'active_record/railties/databases' + Rake.application.rake_require 'tasks/seed_fu' + Rake.application.rake_require 'tasks/gitlab/db/validate_config' + + # empty task as env is already loaded + Rake::Task.define_task :environment + end + + context "when validating config" do + let(:main_database_config) do + Rails.application.config.load_database_yaml + .dig('test', 'main') + .slice('adapter', 'encoding', 'database', 'username', 'password', 'host') + .symbolize_keys + end + + let(:additional_database_config) do + # Use built-in postgres database + main_database_config.merge(database: 'postgres') + end + + around do |example| + with_reestablished_active_record_base(reconnect: true) do + with_db_configs(test: test_config) do + example.run + end + end + end + + shared_examples 'validates successfully' do + it 'by default' do + expect { run_rake_task('gitlab:db:validate_config') }.not_to output(/Database config validation failure/).to_stderr + expect { run_rake_task('gitlab:db:validate_config') }.not_to raise_error + end + + it 'for production' do + allow(Gitlab).to receive(:dev_or_test_env?).and_return(false) + + expect { run_rake_task('gitlab:db:validate_config') }.not_to output(/Database config validation failure/).to_stderr + expect { run_rake_task('gitlab:db:validate_config') }.not_to raise_error + end + + it 'always re-establishes ActiveRecord::Base connection to main config' do + run_rake_task('gitlab:db:validate_config') + + expect(ActiveRecord::Base.connection_db_config.configuration_hash).to include(main_database_config) # rubocop: disable Database/MultipleDatabases + end + + it 'if GITLAB_VALIDATE_DATABASE_CONFIG is set' do + stub_env('GITLAB_VALIDATE_DATABASE_CONFIG', '1') + allow(Gitlab).to receive(:dev_or_test_env?).and_return(false) + + expect { run_rake_task('gitlab:db:validate_config') }.not_to output(/Database config validation failure/).to_stderr + expect { run_rake_task('gitlab:db:validate_config') }.not_to raise_error + end + + context 'when finding the initializer fails' do + where(:raised_error) { [ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished, PG::ConnectionBad] } + with_them do + it "does not raise an error for #{params[:raised_error]}" do + allow(ActiveRecord::Base.connection).to receive(:select_one).and_raise(raised_error) # rubocop: disable Database/MultipleDatabases + + expect { run_rake_task('gitlab:db:validate_config') }.not_to output(/Database config validation failure/).to_stderr + expect { run_rake_task('gitlab:db:validate_config') }.not_to raise_error + end + end + end + end + + shared_examples 'raises an error' do |match| + it 'by default' do + expect { run_rake_task('gitlab:db:validate_config') }.to raise_error(match) + end + + it 'for production' do + allow(Gitlab).to receive(:dev_or_test_env?).and_return(false) + + expect { run_rake_task('gitlab:db:validate_config') }.to raise_error(match) + end + + it 'always re-establishes ActiveRecord::Base connection to main config' do + expect { run_rake_task('gitlab:db:validate_config') }.to raise_error(match) + + expect(ActiveRecord::Base.connection_db_config.configuration_hash).to include(main_database_config) # rubocop: disable Database/MultipleDatabases + end + + it 'if GITLAB_VALIDATE_DATABASE_CONFIG=1' do + stub_env('GITLAB_VALIDATE_DATABASE_CONFIG', '1') + + expect { run_rake_task('gitlab:db:validate_config') }.to raise_error(match) + end + + it 'to stderr if GITLAB_VALIDATE_DATABASE_CONFIG=0' do + stub_env('GITLAB_VALIDATE_DATABASE_CONFIG', '0') + + expect { run_rake_task('gitlab:db:validate_config') }.to output(match).to_stderr + end + end + + context 'when only main: is specified' do + let(:test_config) do + { + main: main_database_config + } + end + + it_behaves_like 'validates successfully' + end + + context 'when main: uses database_tasks=false' do + let(:test_config) do + { + main: main_database_config.merge(database_tasks: false) + } + end + + it_behaves_like 'raises an error', /The 'main' is required to use 'database_tasks: true'/ + end + + context 'when many configurations share the same database' do + context 'when no database_tasks is specified, assumes true' do + let(:test_config) do + { + main: main_database_config, + ci: main_database_config + } + end + + it_behaves_like 'raises an error', /Many configurations \(main, ci\) share the same database/ + end + + context 'when database_tasks is specified' do + let(:test_config) do + { + main: main_database_config.merge(database_tasks: true), + ci: main_database_config.merge(database_tasks: true) + } + end + + it_behaves_like 'raises an error', /Many configurations \(main, ci\) share the same database/ + end + + context "when there's no main: but something different, as currently we only can share with main:" do + let(:test_config) do + { + archive: main_database_config, + ci: main_database_config.merge(database_tasks: false) + } + end + + it_behaves_like 'raises an error', /The 'ci' is expecting to share configuration with 'main', but no such is to be found/ + end + end + + context 'when ci: uses different database' do + context 'and does not specify database_tasks which indicates using dedicated database' do + let(:test_config) do + { + main: main_database_config, + ci: additional_database_config + } + end + + it_behaves_like 'validates successfully' + end + + context 'and does specify database_tasks=false which indicates sharing with main:' do + let(:test_config) do + { + main: main_database_config, + ci: additional_database_config.merge(database_tasks: false) + } + end + + it_behaves_like 'raises an error', /The 'ci' since it is using 'database_tasks: false' should share database with 'main:'/ + end + end + end + + %w[db:migrate db:schema:load db:schema:dump].each do |task| + context "when running #{task}" do + it "does run gitlab:db:validate_config before" do + expect(Rake::Task['gitlab:db:validate_config']).to receive(:execute).and_return(true) + expect(Rake::Task[task]).to receive(:execute).and_return(true) + + Rake::Task['gitlab:db:validate_config'].reenable + run_rake_task(task) + end + end + end + + def with_db_configs(test: test_config) + current_configurations = ActiveRecord::Base.configurations # rubocop:disable Database/MultipleDatabases + ActiveRecord::Base.configurations = { test: test_config } + yield + ensure + ActiveRecord::Base.configurations = current_configurations + end +end diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb index 8d3ec7b1ee2..73f3b55e12e 100644 --- a/spec/tasks/gitlab/db_rake_spec.rb +++ b/spec/tasks/gitlab/db_rake_spec.rb @@ -20,14 +20,6 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do allow(Rake::Task['db:seed_fu']).to receive(:invoke).and_return(true) end - describe 'clear_all_connections' do - it 'calls clear_all_connections!' do - expect(ActiveRecord::Base).to receive(:clear_all_connections!) - - run_rake_task('gitlab:db:clear_all_connections') - end - end - describe 'mark_migration_complete' do context 'with a single database' do let(:main_model) { ActiveRecord::Base } @@ -51,7 +43,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do let(:base_models) { { 'main' => main_model, 'ci' => ci_model } } before do - skip_if_multiple_databases_not_setup + skip_unless_ci_uses_database_tasks allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models) end @@ -80,6 +72,17 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do run_rake_task('gitlab:db:mark_migration_complete:main', '[123]') end end + + context 'with geo configured' do + before do + skip_unless_geo_configured + end + + it 'does not create a task for the geo database' do + expect { run_rake_task('gitlab:db:mark_migration_complete:geo') } + .to raise_error(/Don't know how to build task 'gitlab:db:mark_migration_complete:geo'/) + end + end end context 'when the migration is already marked complete' do @@ -122,79 +125,228 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do end describe 'configure' do - it 'invokes db:migrate when schema has already been loaded' do - allow(ActiveRecord::Base.connection).to receive(:tables).and_return(%w[table1 table2]) - expect(Rake::Task['db:migrate']).to receive(:invoke) - expect(Rake::Task['db:structure:load']).not_to receive(:invoke) - expect(Rake::Task['db:seed_fu']).not_to receive(:invoke) - expect { run_rake_task('gitlab:db:configure') }.not_to raise_error - end + context 'with a single database' do + let(:connection) { Gitlab::Database.database_base_models[:main].connection } + let(:main_config) { double(:config, name: 'main') } - it 'invokes db:shema:load and db:seed_fu when schema is not loaded' do - allow(ActiveRecord::Base.connection).to receive(:tables).and_return([]) - expect(Rake::Task['db:structure:load']).to receive(:invoke) - expect(Rake::Task['db:seed_fu']).to receive(:invoke) - expect(Rake::Task['db:migrate']).not_to receive(:invoke) - expect { run_rake_task('gitlab:db:configure') }.not_to raise_error - end + before do + skip_if_multiple_databases_are_setup + end - it 'invokes db:shema:load and db:seed_fu when there is only a single table present' do - allow(ActiveRecord::Base.connection).to receive(:tables).and_return(['default']) - expect(Rake::Task['db:structure:load']).to receive(:invoke) - expect(Rake::Task['db:seed_fu']).to receive(:invoke) - expect(Rake::Task['db:migrate']).not_to receive(:invoke) - expect { run_rake_task('gitlab:db:configure') }.not_to raise_error - end + context 'when geo is not configured' do + before do + allow(ActiveRecord::Base).to receive_message_chain('configurations.configs_for').and_return([main_config]) + end - it 'does not invoke any other rake tasks during an error' do - allow(ActiveRecord::Base).to receive(:connection).and_raise(RuntimeError, 'error') - expect(Rake::Task['db:migrate']).not_to receive(:invoke) - expect(Rake::Task['db:structure:load']).not_to receive(:invoke) - expect(Rake::Task['db:seed_fu']).not_to receive(:invoke) - expect { run_rake_task('gitlab:db:configure') }.to raise_error(RuntimeError, 'error') - # unstub connection so that the database cleaner still works - allow(ActiveRecord::Base).to receive(:connection).and_call_original - end + context 'when the schema is already loaded' do + it 'migrates the database' do + allow(connection).to receive(:tables).and_return(%w[table1 table2]) + + expect(Rake::Task['db:migrate']).to receive(:invoke) + expect(Rake::Task['db:schema:load']).not_to receive(:invoke) + expect(Rake::Task['db:seed_fu']).not_to receive(:invoke) + + run_rake_task('gitlab:db:configure') + end + end + + context 'when the schema is not loaded' do + it 'loads the schema and seeds the database' do + allow(connection).to receive(:tables).and_return([]) + + expect(Rake::Task['db:schema:load']).to receive(:invoke) + expect(Rake::Task['db:seed_fu']).to receive(:invoke) + expect(Rake::Task['db:migrate']).not_to receive(:invoke) + + run_rake_task('gitlab:db:configure') + end + end + + context 'when only a single table is present' do + it 'loads the schema and seeds the database' do + allow(connection).to receive(:tables).and_return(['default']) + + expect(Rake::Task['db:schema:load']).to receive(:invoke) + expect(Rake::Task['db:seed_fu']).to receive(:invoke) + expect(Rake::Task['db:migrate']).not_to receive(:invoke) + + run_rake_task('gitlab:db:configure') + end + end + + context 'when loading the schema fails' do + it 'does not seed the database' do + allow(connection).to receive(:tables).and_return([]) + + expect(Rake::Task['db:schema:load']).to receive(:invoke).and_raise('error') + expect(Rake::Task['db:seed_fu']).not_to receive(:invoke) + expect(Rake::Task['db:migrate']).not_to receive(:invoke) + + expect { run_rake_task('gitlab:db:configure') }.to raise_error(RuntimeError, 'error') + end + end + + context 'SKIP_POST_DEPLOYMENT_MIGRATIONS environment variable set' do + let(:rails_paths) { { 'db' => ['db'], 'db/migrate' => ['db/migrate'] } } + + before do + stub_env('SKIP_POST_DEPLOYMENT_MIGRATIONS', true) + + # Our environment has already been loaded, so we need to pretend like post_migrations were not + allow(Rails.application.config).to receive(:paths).and_return(rails_paths) + allow(ActiveRecord::Migrator).to receive(:migrations_paths).and_return(rails_paths['db/migrate'].dup) + end + + context 'when the schema is not loaded' do + it 'adds the post deployment migration path before schema load' do + allow(connection).to receive(:tables).and_return([]) + + expect(Gitlab::Database).to receive(:add_post_migrate_path_to_rails).and_call_original + expect(Rake::Task['db:schema:load']).to receive(:invoke) + expect(Rake::Task['db:seed_fu']).to receive(:invoke) + expect(Rake::Task['db:migrate']).not_to receive(:invoke) + + run_rake_task('gitlab:db:configure') + + expect(rails_paths['db/migrate'].include?(File.join(Rails.root, 'db', 'post_migrate'))).to be(true) + end + end + + context 'when the schema is loaded' do + it 'ignores post deployment migrations' do + allow(connection).to receive(:tables).and_return(%w[table1 table2]) + + expect(Rake::Task['db:migrate']).to receive(:invoke) + expect(Gitlab::Database).not_to receive(:add_post_migrate_path_to_rails) + expect(Rake::Task['db:schema:load']).not_to receive(:invoke) + expect(Rake::Task['db:seed_fu']).not_to receive(:invoke) - it 'does not invoke seed after a failed schema_load' do - allow(ActiveRecord::Base.connection).to receive(:tables).and_return([]) - allow(Rake::Task['db:structure:load']).to receive(:invoke).and_raise(RuntimeError, 'error') - expect(Rake::Task['db:structure:load']).to receive(:invoke) - expect(Rake::Task['db:seed_fu']).not_to receive(:invoke) - expect(Rake::Task['db:migrate']).not_to receive(:invoke) - expect { run_rake_task('gitlab:db:configure') }.to raise_error(RuntimeError, 'error') + run_rake_task('gitlab:db:configure') + + expect(rails_paths['db/migrate'].include?(File.join(Rails.root, 'db', 'post_migrate'))).to be(false) + end + end + end + end + + context 'when geo is configured' do + context 'when the main database is also configured' do + before do + skip_unless_geo_configured + end + + it 'only configures the main database' do + allow(connection).to receive(:tables).and_return(%w[table1 table2]) + + expect(Rake::Task['db:migrate:main']).to receive(:invoke) + + expect(Rake::Task['db:migrate:geo']).not_to receive(:invoke) + expect(Rake::Task['db:schema:load:geo']).not_to receive(:invoke) + + run_rake_task('gitlab:db:configure') + end + end + end end - context 'SKIP_POST_DEPLOYMENT_MIGRATIONS environment variable set' do - let(:rails_paths) { { 'db' => ['db'], 'db/migrate' => ['db/migrate'] } } + context 'with multiple databases' do + let(:main_model) { double(:model, connection: double(:connection)) } + let(:ci_model) { double(:model, connection: double(:connection)) } + let(:base_models) { { 'main' => main_model, 'ci' => ci_model }.with_indifferent_access } + + let(:main_config) { double(:config, name: 'main') } + let(:ci_config) { double(:config, name: 'ci') } before do - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with('SKIP_POST_DEPLOYMENT_MIGRATIONS').and_return true + skip_unless_ci_uses_database_tasks - # Our environment has already been loaded, so we need to pretend like post_migrations were not - allow(Rails.application.config).to receive(:paths).and_return(rails_paths) - allow(ActiveRecord::Migrator).to receive(:migrations_paths).and_return(rails_paths['db/migrate'].dup) + allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models) end - it 'adds post deployment migrations before schema load if the schema is not already loaded' do - allow(ActiveRecord::Base.connection).to receive(:tables).and_return([]) - expect(Gitlab::Database).to receive(:add_post_migrate_path_to_rails).and_call_original - expect(Rake::Task['db:structure:load']).to receive(:invoke) - expect(Rake::Task['db:seed_fu']).to receive(:invoke) - expect(Rake::Task['db:migrate']).not_to receive(:invoke) - expect { run_rake_task('gitlab:db:configure') }.not_to raise_error - expect(rails_paths['db/migrate'].include?(File.join(Rails.root, 'db', 'post_migrate'))).to be(true) + context 'when geo is not configured' do + before do + allow(ActiveRecord::Base).to receive_message_chain('configurations.configs_for') + .and_return([main_config, ci_config]) + end + + context 'when no database has the schema loaded' do + before do + allow(main_model.connection).to receive(:tables).and_return(%w[schema_migrations]) + allow(ci_model.connection).to receive(:tables).and_return([]) + end + + it 'loads the schema and seeds all the databases' do + expect(Rake::Task['db:schema:load:main']).to receive(:invoke) + expect(Rake::Task['db:schema:load:ci']).to receive(:invoke) + + expect(Rake::Task['db:migrate:main']).not_to receive(:invoke) + expect(Rake::Task['db:migrate:ci']).not_to receive(:invoke) + + expect(Rake::Task['db:seed_fu']).to receive(:invoke) + + run_rake_task('gitlab:db:configure') + end + end + + context 'when both databases have the schema loaded' do + before do + allow(main_model.connection).to receive(:tables).and_return(%w[table1 table2]) + allow(ci_model.connection).to receive(:tables).and_return(%w[table1 table2]) + end + + it 'migrates the databases without seeding them' do + expect(Rake::Task['db:migrate:main']).to receive(:invoke) + expect(Rake::Task['db:migrate:ci']).to receive(:invoke) + + expect(Rake::Task['db:schema:load:main']).not_to receive(:invoke) + expect(Rake::Task['db:schema:load:ci']).not_to receive(:invoke) + + expect(Rake::Task['db:seed_fu']).not_to receive(:invoke) + + run_rake_task('gitlab:db:configure') + end + end + + context 'when only one database has the schema loaded' do + before do + allow(main_model.connection).to receive(:tables).and_return(%w[table1 table2]) + allow(ci_model.connection).to receive(:tables).and_return([]) + end + + it 'migrates and loads the schema correctly, without seeding the databases' do + expect(Rake::Task['db:migrate:main']).to receive(:invoke) + expect(Rake::Task['db:schema:load:main']).not_to receive(:invoke) + + expect(Rake::Task['db:schema:load:ci']).to receive(:invoke) + expect(Rake::Task['db:migrate:ci']).not_to receive(:invoke) + + expect(Rake::Task['db:seed_fu']).not_to receive(:invoke) + + run_rake_task('gitlab:db:configure') + end + end end - it 'ignores post deployment migrations when schema has already been loaded' do - allow(ActiveRecord::Base.connection).to receive(:tables).and_return(%w[table1 table2]) - expect(Rake::Task['db:migrate']).to receive(:invoke) - expect(Gitlab::Database).not_to receive(:add_post_migrate_path_to_rails) - expect(Rake::Task['db:structure:load']).not_to receive(:invoke) - expect(Rake::Task['db:seed_fu']).not_to receive(:invoke) - expect { run_rake_task('gitlab:db:configure') }.not_to raise_error - expect(rails_paths['db/migrate'].include?(File.join(Rails.root, 'db', 'post_migrate'))).to be(false) + context 'when geo is configured' do + let(:geo_config) { double(:config, name: 'geo') } + + before do + skip_unless_geo_configured + + allow(main_model.connection).to receive(:tables).and_return(%w[schema_migrations]) + allow(ci_model.connection).to receive(:tables).and_return(%w[schema_migrations]) + end + + it 'does not run tasks against geo' do + expect(Rake::Task['db:schema:load:main']).to receive(:invoke) + expect(Rake::Task['db:schema:load:ci']).to receive(:invoke) + expect(Rake::Task['db:seed_fu']).to receive(:invoke) + + expect(Rake::Task['db:migrate:geo']).not_to receive(:invoke) + expect(Rake::Task['db:schema:load:geo']).not_to receive(:invoke) + + run_rake_task('gitlab:db:configure') + end end end end @@ -290,7 +442,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do let(:base_models) { { 'main' => main_model, 'ci' => ci_model } } before do - skip_if_multiple_databases_not_setup + skip_unless_ci_uses_database_tasks allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models) @@ -319,6 +471,17 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do run_rake_task('gitlab:db:drop_tables:main') end end + + context 'with geo configured' do + before do + skip_unless_geo_configured + end + + it 'does not create a task for the geo database' do + expect { run_rake_task('gitlab:db:drop_tables:geo') } + .to raise_error(/Don't know how to build task 'gitlab:db:drop_tables:geo'/) + end + end end def expect_objects_to_be_dropped(connection) @@ -336,38 +499,119 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do end end - describe 'reindex' do - it 'delegates to Gitlab::Database::Reindexing' do - expect(Gitlab::Database::Reindexing).to receive(:invoke) + describe 'create_dynamic_partitions' do + context 'with a single database' do + before do + skip_if_multiple_databases_are_setup + end + + it 'delegates syncing of partitions without limiting databases' do + expect(Gitlab::Database::Partitioning).to receive(:sync_partitions) + + run_rake_task('gitlab:db:create_dynamic_partitions') + end + end + + context 'with multiple databases' do + before do + skip_unless_ci_uses_database_tasks + end + + context 'when running the multi-database variant' do + it 'delegates syncing of partitions without limiting databases' do + expect(Gitlab::Database::Partitioning).to receive(:sync_partitions) - run_rake_task('gitlab:db:reindex') + run_rake_task('gitlab:db:create_dynamic_partitions') + end + end + + context 'when running a single-database variant' do + it 'delegates syncing of partitions for the chosen database' do + expect(Gitlab::Database::Partitioning).to receive(:sync_partitions).with(only_on: 'main') + + run_rake_task('gitlab:db:create_dynamic_partitions:main') + end + end end - context 'when reindexing is not enabled' do - it 'is a no-op' do - expect(Gitlab::Database::Reindexing).to receive(:enabled?).and_return(false) - expect(Gitlab::Database::Reindexing).not_to receive(:invoke) + context 'with geo configured' do + before do + skip_unless_geo_configured + end - expect { run_rake_task('gitlab:db:reindex') }.to raise_error(SystemExit) + it 'does not create a task for the geo database' do + expect { run_rake_task('gitlab:db:create_dynamic_partitions:geo') } + .to raise_error(/Don't know how to build task 'gitlab:db:create_dynamic_partitions:geo'/) end end end - databases = ActiveRecord::Tasks::DatabaseTasks.setup_initial_database_yaml - ActiveRecord::Tasks::DatabaseTasks.for_each(databases) do |database_name| - describe "reindex:#{database_name}" do + describe 'reindex' do + context 'with a single database' do + before do + skip_if_multiple_databases_are_setup + end + it 'delegates to Gitlab::Database::Reindexing' do - expect(Gitlab::Database::Reindexing).to receive(:invoke).with(database_name) + expect(Gitlab::Database::Reindexing).to receive(:invoke).with(no_args) - run_rake_task("gitlab:db:reindex:#{database_name}") + run_rake_task('gitlab:db:reindex') end context 'when reindexing is not enabled' do it 'is a no-op' do expect(Gitlab::Database::Reindexing).to receive(:enabled?).and_return(false) - expect(Gitlab::Database::Reindexing).not_to receive(:invoke).with(database_name) + expect(Gitlab::Database::Reindexing).not_to receive(:invoke) - expect { run_rake_task("gitlab:db:reindex:#{database_name}") }.to raise_error(SystemExit) + expect { run_rake_task('gitlab:db:reindex') }.to raise_error(SystemExit) + end + end + end + + context 'with multiple databases' do + let(:base_models) { { 'main' => double(:model), 'ci' => double(:model) } } + + before do + skip_if_multiple_databases_not_setup + + allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models) + end + + it 'delegates to Gitlab::Database::Reindexing without a specific database' do + expect(Gitlab::Database::Reindexing).to receive(:invoke).with(no_args) + + run_rake_task('gitlab:db:reindex') + end + + context 'when the single database task is used' do + before do + skip_unless_ci_uses_database_tasks + end + + it 'delegates to Gitlab::Database::Reindexing with a specific database' do + expect(Gitlab::Database::Reindexing).to receive(:invoke).with('ci') + + run_rake_task('gitlab:db:reindex:ci') + end + + context 'when reindexing is not enabled' do + it 'is a no-op' do + expect(Gitlab::Database::Reindexing).to receive(:enabled?).and_return(false) + expect(Gitlab::Database::Reindexing).not_to receive(:invoke) + + expect { run_rake_task('gitlab:db:reindex:ci') }.to raise_error(SystemExit) + end + end + end + + context 'with geo configured' do + before do + skip_unless_geo_configured + end + + it 'does not create a task for the geo database' do + expect { run_rake_task('gitlab:db:reindex:geo') } + .to raise_error(/Don't know how to build task 'gitlab:db:reindex:geo'/) end end end @@ -439,34 +683,77 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do subject end end + + describe '#sample_background_migrations' do + it 'delegates to the migration runner with a default sample duration' do + expect(::Gitlab::Database::Migrations::Runner).to receive_message_chain(:background_migrations, :run_jobs).with(for_duration: 30.minutes) + + run_rake_task('gitlab:db:migration_testing:sample_background_migrations') + end + + it 'delegates to the migration runner with a configured sample duration' do + expect(::Gitlab::Database::Migrations::Runner).to receive_message_chain(:background_migrations, :run_jobs).with(for_duration: 100.seconds) + + run_rake_task('gitlab:db:migration_testing:sample_background_migrations', '[100]') + end + end end describe '#execute_batched_migrations' do - subject { run_rake_task('gitlab:db:execute_batched_migrations') } + subject(:execute_batched_migrations) { run_rake_task('gitlab:db:execute_batched_migrations') } - let(:migrations) { create_list(:batched_background_migration, 2) } - let(:runner) { instance_double('Gitlab::Database::BackgroundMigration::BatchedMigrationRunner') } + let(:connections) do + { + main: instance_double(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter), + ci: instance_double(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) + } + end + + let(:runners) do + { + main: instance_double('Gitlab::Database::BackgroundMigration::BatchedMigrationRunner'), + ci: instance_double('Gitlab::Database::BackgroundMigration::BatchedMigrationRunner') + } + end + + let(:migrations) do + { + main: build_list(:batched_background_migration, 1), + ci: build_list(:batched_background_migration, 1) + } + end before do - allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive_message_chain(:active, :queue_order).and_return(migrations) - allow(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner).to receive(:new).and_return(runner) + each_database = class_double('Gitlab::Database::EachDatabase').as_stubbed_const + + allow(each_database).to receive(:each_database_connection) + .and_yield(connections[:main], 'main') + .and_yield(connections[:ci], 'ci') + + keys = migrations.keys + allow(Gitlab::Database::BackgroundMigration::BatchedMigration) + .to receive_message_chain(:with_status, :queue_order) { migrations[keys.shift] } end it 'executes all migrations' do - migrations.each do |migration| - expect(runner).to receive(:run_entire_migration).with(migration) + [:main, :ci].each do |name| + expect(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner).to receive(:new) + .with(connection: connections[name]) + .and_return(runners[name]) + + expect(runners[name]).to receive(:run_entire_migration).with(migrations[name].first) end - subject + execute_batched_migrations end end context 'with multiple databases', :reestablished_active_record_base do before do - skip_if_multiple_databases_not_setup + skip_unless_ci_uses_database_tasks end - describe 'db:structure:dump' do + describe 'db:structure:dump against a single database' do it 'invokes gitlab:db:clean_structure_sql' do expect(Rake::Task['gitlab:db:clean_structure_sql']).to receive(:invoke).twice.and_return(true) @@ -474,7 +761,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do end end - describe 'db:schema:dump' do + describe 'db:schema:dump against a single database' do it 'invokes gitlab:db:clean_structure_sql' do expect(Rake::Task['gitlab:db:clean_structure_sql']).to receive(:invoke).once.and_return(true) @@ -482,26 +769,24 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do end end - describe 'db:migrate' do - it 'invokes gitlab:db:create_dynamic_partitions' do - expect(Rake::Task['gitlab:db:create_dynamic_partitions']).to receive(:invoke).once.and_return(true) + describe 'db:migrate against a single database' do + it 'invokes gitlab:db:create_dynamic_partitions for the same database' do + expect(Rake::Task['gitlab:db:create_dynamic_partitions:main']).to receive(:invoke).once.and_return(true) expect { run_rake_task('db:migrate:main') }.not_to raise_error end end describe 'db:migrate:geo' do - it 'does not invoke gitlab:db:create_dynamic_partitions' do - skip 'Skipping because geo database is not setup' unless geo_configured? + before do + skip_unless_geo_configured + end + it 'does not invoke gitlab:db:create_dynamic_partitions' do expect(Rake::Task['gitlab:db:create_dynamic_partitions']).not_to receive(:invoke) expect { run_rake_task('db:migrate:geo') }.not_to raise_error end - - def geo_configured? - !!ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'geo') - end end end @@ -559,4 +844,20 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do run_rake_task(test_task_name) end + + def skip_unless_ci_uses_database_tasks + skip "Skipping because database tasks won't run against the ci database" unless ci_database_tasks? + end + + def ci_database_tasks? + !!ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'ci')&.database_tasks? + end + + def skip_unless_geo_configured + skip 'Skipping because the geo database is not configured' unless geo_configured? + end + + def geo_configured? + !!ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'geo') + end end diff --git a/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb b/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb index e57704d0ebe..3495b535cff 100644 --- a/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb +++ b/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb @@ -11,37 +11,53 @@ RSpec.describe 'gitlab:refresh_project_statistics_build_artifacts_size rake task let_it_be(:project_3) { create(:project) } let(:string_of_ids) { "#{project_1.id} #{project_2.id} #{project_3.id} 999999" } + let(:csv_url) { 'https://www.example.com/foo.csv' } + let(:csv_body) do + <<~BODY + PROJECT_ID + #{project_1.id} + #{project_2.id} + #{project_3.id} + BODY + end before do Rake.application.rake_require('tasks/gitlab/refresh_project_statistics_build_artifacts_size') stub_const("BUILD_ARTIFACTS_SIZE_REFRESH_ENQUEUE_BATCH_SIZE", 2) - end - context 'when given a list of space-separated IDs through STDIN' do - before do - allow($stdin).to receive(:tty?).and_return(false) - allow($stdin).to receive(:read).and_return(string_of_ids) - end + stub_request(:get, csv_url).to_return(status: 200, body: csv_body) + allow(Kernel).to receive(:sleep).with(1) + end + context 'when given a list of space-separated IDs through rake argument' do it 'enqueues the projects for refresh' do - expect { run_rake_task(rake_task) }.to output(/Done/).to_stdout + expect { run_rake_task(rake_task, csv_url) }.to output(/Done/).to_stdout expect(Projects::BuildArtifactsSizeRefresh.all.map(&:project)).to match_array([project_1, project_2, project_3]) end - end - context 'when given a list of space-separated IDs through rake argument' do - it 'enqueues the projects for refresh' do - expect { run_rake_task(rake_task, string_of_ids) }.to output(/Done/).to_stdout + it 'inserts refreshes in batches with a sleep' do + expect(Projects::BuildArtifactsSizeRefresh).to receive(:enqueue_refresh).with([project_1, project_2]).ordered + expect(Kernel).to receive(:sleep).with(1) + expect(Projects::BuildArtifactsSizeRefresh).to receive(:enqueue_refresh).with([project_3]).ordered - expect(Projects::BuildArtifactsSizeRefresh.all.map(&:project)).to match_array([project_1, project_2, project_3]) + run_rake_task(rake_task, csv_url) end end - context 'when not given any IDs' do + context 'when CSV has invalid header' do + let(:csv_body) do + <<~BODY + projectid + #{project_1.id} + #{project_2.id} + #{project_3.id} + BODY + end + it 'returns an error message' do - expect { run_rake_task(rake_task) }.to output(/Please provide a string of space-separated project IDs/).to_stdout + expect { run_rake_task(rake_task, csv_url) }.to output(/Project IDs must be listed in the CSV under the header PROJECT_ID/).to_stdout end end end diff --git a/spec/tasks/gitlab/setup_rake_spec.rb b/spec/tasks/gitlab/setup_rake_spec.rb index 6e4d5087517..c31546fc259 100644 --- a/spec/tasks/gitlab/setup_rake_spec.rb +++ b/spec/tasks/gitlab/setup_rake_spec.rb @@ -6,6 +6,7 @@ RSpec.describe 'gitlab:setup namespace rake tasks', :silence_stdout do before do Rake.application.rake_require 'active_record/railties/databases' Rake.application.rake_require 'tasks/seed_fu' + Rake.application.rake_require 'tasks/dev' Rake.application.rake_require 'tasks/gitlab/setup' end @@ -22,8 +23,6 @@ RSpec.describe 'gitlab:setup namespace rake tasks', :silence_stdout do let(:server_service1) { double(:server_service) } let(:server_service2) { double(:server_service) } - let(:connections) { Gitlab::Database.database_base_models.values.map(&:connection) } - before do allow(Gitlab).to receive_message_chain('config.repositories.storages').and_return(storages) @@ -98,18 +97,6 @@ RSpec.describe 'gitlab:setup namespace rake tasks', :silence_stdout do end end - context 'when the database is not found when terminating connections' do - it 'continues setting up the database', :aggregate_failures do - expect_gitaly_connections_to_be_checked - - expect(connections).to all(receive(:execute).and_raise(ActiveRecord::NoDatabaseError)) - - expect_database_to_be_setup - - setup_task - end - end - def expect_gitaly_connections_to_be_checked expect(Gitlab::GitalyClient::ServerService).to receive(:new).with('name1').and_return(server_service1) expect(server_service1).to receive(:info) @@ -119,13 +106,11 @@ RSpec.describe 'gitlab:setup namespace rake tasks', :silence_stdout do end def expect_connections_to_be_terminated - expect(connections).to all(receive(:execute).with(/SELECT pg_terminate_backend/)) + expect(Rake::Task['dev:terminate_all_connections']).to receive(:invoke) end def expect_connections_not_to_be_terminated - connections.each do |connection| - expect(connection).not_to receive(:execute) - end + expect(Rake::Task['dev:terminate_all_connections']).not_to receive(:invoke) end def expect_database_to_be_setup diff --git a/spec/tooling/danger/product_intelligence_spec.rb b/spec/tooling/danger/product_intelligence_spec.rb index d0d4b8d4df4..ea08e3bc6db 100644 --- a/spec/tooling/danger/product_intelligence_spec.rb +++ b/spec/tooling/danger/product_intelligence_spec.rb @@ -20,70 +20,105 @@ RSpec.describe Tooling::Danger::ProductIntelligence do allow(fake_helper).to receive(:changed_lines).and_return(changed_lines) end - describe '#missing_labels' do - subject { product_intelligence.missing_labels } + describe '#check!' do + subject { product_intelligence.check! } + let(:markdown_formatted_list) { 'markdown formatted list' } + let(:review_pending_label) { 'product intelligence::review pending' } + let(:approved_label) { 'product intelligence::approved' } let(:ci_env) { true } + let(:previous_label_to_add) { 'label_to_add' } + let(:labels_to_add) { [previous_label_to_add] } + let(:has_product_intelligence_label) { true } before do - allow(fake_helper).to receive(:mr_has_labels?).and_return(false) + allow(fake_helper).to receive(:changes_by_category).and_return(product_intelligence: changed_files, database: ['other_files.yml']) allow(fake_helper).to receive(:ci?).and_return(ci_env) + allow(fake_helper).to receive(:mr_has_labels?).with('product intelligence').and_return(has_product_intelligence_label) + allow(fake_helper).to receive(:markdown_list).with(changed_files).and_return(markdown_formatted_list) + allow(fake_helper).to receive(:labels_to_add).and_return(labels_to_add) end - context 'with ci? false' do - let(:ci_env) { false } + shared_examples "doesn't add new labels" do + it "doesn't add new labels" do + subject - it { is_expected.to be_empty } + expect(labels_to_add).to match_array [previous_label_to_add] + end end - context 'with ci? true' do - let(:expected_labels) { ['product intelligence', 'product intelligence::review pending'] } + shared_examples "doesn't add new warnings" do + it "doesn't add new warnings" do + expect(product_intelligence).not_to receive(:warn) - it { is_expected.to match_array(expected_labels) } + subject + end end - context 'with product intelligence label' do - let(:expected_labels) { ['product intelligence::review pending'] } - let(:mr_labels) { [] } + shared_examples 'adds new labels' do + it 'adds new labels' do + subject + + expect(labels_to_add).to match_array [previous_label_to_add, review_pending_label] + end + end + context 'with growth experiment label' do before do - allow(fake_helper).to receive(:mr_has_labels?).with('product intelligence').and_return(true) - allow(fake_helper).to receive(:mr_labels).and_return(mr_labels) + allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(true) end - it { is_expected.to match_array(expected_labels) } + include_examples "doesn't add new labels" + include_examples "doesn't add new warnings" + end + + context 'without growth experiment label' do + before do + allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(false) + end - context 'with product intelligence::review pending' do - let(:mr_labels) { ['product intelligence::review pending'] } + context 'with approved label' do + let(:mr_labels) { [approved_label] } - it { is_expected.to be_empty } + include_examples "doesn't add new labels" + include_examples "doesn't add new warnings" end - context 'with product intelligence::approved' do - let(:mr_labels) { ['product intelligence::approved'] } + context 'without approved label' do + include_examples 'adds new labels' + + it 'warns with proper message' do + expect(product_intelligence).to receive(:warn).with(%r{#{markdown_formatted_list}}) - it { is_expected.to be_empty } + subject + end end - end - end - describe '#skip_review' do - subject { product_intelligence.skip_review? } + context 'with product intelligence::review pending label' do + let(:mr_labels) { ['product intelligence::review pending'] } - context 'with growth experiment label' do - before do - allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(true) + include_examples "doesn't add new labels" end - it { is_expected.to be true } - end + context 'with product intelligence::approved label' do + let(:mr_labels) { ['product intelligence::approved'] } - context 'without growth experiment label' do - before do - allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(false) + include_examples "doesn't add new labels" end - it { is_expected.to be false } + context 'with the product intelligence label' do + let(:has_product_intelligence_label) { true } + + context 'with ci? false' do + let(:ci_env) { false } + + include_examples "doesn't add new labels" + end + + context 'with ci? true' do + include_examples 'adds new labels' + end + end end end end diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb index 902e01e2cbd..b3fb592c2e3 100644 --- a/spec/tooling/danger/project_helper_spec.rb +++ b/spec/tooling/danger/project_helper_spec.rb @@ -276,40 +276,6 @@ RSpec.describe Tooling::Danger::ProjectHelper do end end - describe '.local_warning_message' do - it 'returns an informational message with rules that can run' do - expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: ci_config, database, documentation, duplicate_yarn_dependencies, eslint, gitaly, pajamas, pipeline, prettier, product_intelligence, utility_css, vue_shared_documentation, datateam') - end - end - - describe '.success_message' do - it 'returns an informational success message' do - expect(described_class.success_message).to eq('==> No Danger rule violations!') - end - end - - describe '#rule_names' do - context 'when running locally' do - before do - expect(fake_helper).to receive(:ci?).and_return(false) - end - - it 'returns local only rules' do - expect(project_helper.rule_names).to match_array(described_class::LOCAL_RULES) - end - end - - context 'when running under CI' do - before do - expect(fake_helper).to receive(:ci?).and_return(true) - end - - it 'returns all rules' do - expect(project_helper.rule_names).to eq(described_class::LOCAL_RULES | described_class::CI_ONLY_RULES) - end - end - end - describe '#file_lines' do let(:filename) { 'spec/foo_spec.rb' } let(:file_spy) { spy } diff --git a/spec/uploaders/ci/secure_file_uploader_spec.rb b/spec/uploaders/ci/secure_file_uploader_spec.rb index 3be4f742a24..4bac591704b 100644 --- a/spec/uploaders/ci/secure_file_uploader_spec.rb +++ b/spec/uploaders/ci/secure_file_uploader_spec.rb @@ -15,9 +15,9 @@ RSpec.describe Ci::SecureFileUploader do describe '#key' do it 'creates a digest with a secret key and the project id' do - expect(OpenSSL::HMAC) + expect(Digest::SHA256) .to receive(:digest) - .with('SHA256', Gitlab::Application.secrets.db_key_base, ci_secure_file.project_id.to_s) + .with(ci_secure_file.key_data) .and_return('digest') expect(subject.key).to eq('digest') diff --git a/spec/validators/addressable_url_validator_spec.rb b/spec/validators/addressable_url_validator_spec.rb index 7e2cc2afa8a..b3a4459db30 100644 --- a/spec/validators/addressable_url_validator_spec.rb +++ b/spec/validators/addressable_url_validator_spec.rb @@ -30,7 +30,7 @@ RSpec.describe AddressableUrlValidator do it 'allows urls with encoded CR or LF characters' do aggregate_failures do - valid_urls_with_CRLF.each do |url| + valid_urls_with_crlf.each do |url| validator.validate_each(badge, :link_url, url) expect(badge.errors).to be_empty @@ -40,7 +40,7 @@ RSpec.describe AddressableUrlValidator do it 'does not allow urls with CR or LF characters' do aggregate_failures do - urls_with_CRLF.each do |url| + urls_with_crlf.each do |url| badge = build(:badge, link_url: 'http://www.example.com') validator.validate_each(badge, :link_url, url) diff --git a/spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb b/spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb new file mode 100644 index 00000000000..12593b88009 --- /dev/null +++ b/spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'admin/application_settings/_ci_cd' do + let_it_be(:admin) { create(:admin) } + let_it_be(:application_setting) { build(:application_setting) } + + let_it_be(:limits_attributes) do + { + ci_pipeline_size: 10, + ci_active_jobs: 20, + ci_project_subscriptions: 30, + ci_pipeline_schedules: 40, + ci_needs_size_limit: 50, + ci_registered_group_runners: 60, + ci_registered_project_runners: 70 + } + end + + let_it_be(:default_plan_limits) { create(:plan_limits, :default_plan, **limits_attributes) } + + let(:page) { Capybara::Node::Simple.new(rendered) } + + before do + assign(:application_setting, application_setting) + allow(view).to receive(:current_user) { admin } + allow(view).to receive(:expanded) { true } + end + + subject { render partial: 'admin/application_settings/ci_cd' } + + context 'limits' do + before do + assign(:plans, [default_plan_limits.plan]) + end + + it 'has fields for CI/CD limits', :aggregate_failures do + subject + + expect(rendered).to have_field('Maximum number of jobs in a single pipeline', type: 'number') + expect(page.find_field('Maximum number of jobs in a single pipeline').value).to eq('10') + + expect(rendered).to have_field('Total number of jobs in currently active pipelines', type: 'number') + expect(page.find_field('Total number of jobs in currently active pipelines').value).to eq('20') + + expect(rendered).to have_field('Maximum number of pipeline subscriptions to and from a project', type: 'number') + expect(page.find_field('Maximum number of pipeline subscriptions to and from a project').value).to eq('30') + + expect(rendered).to have_field('Maximum number of pipeline schedules', type: 'number') + expect(page.find_field('Maximum number of pipeline schedules').value).to eq('40') + + expect(rendered).to have_field('Maximum number of DAG dependencies that a job can have', type: 'number') + expect(page.find_field('Maximum number of DAG dependencies that a job can have').value).to eq('50') + + expect(rendered).to have_field('Maximum number of runners registered per group', type: 'number') + expect(page.find_field('Maximum number of runners registered per group').value).to eq('60') + + expect(rendered).to have_field('Maximum number of runners registered per project', type: 'number') + expect(page.find_field('Maximum number of runners registered per project').value).to eq('70') + end + + it 'does not display the plan name when there is only one plan' do + subject + + expect(page).not_to have_selector('a[data-action="plan0"]') + end + end + + context 'with multiple plans' do + let_it_be(:plan) { create(:plan, name: 'Ultimate') } + let_it_be(:ultimate_plan_limits) { create(:plan_limits, plan: plan, **limits_attributes) } + + before do + assign(:plans, [default_plan_limits.plan, ultimate_plan_limits.plan]) + end + + it 'displays the plan name when there is more than one plan' do + subject + + expect(page).to have_content('Default') + expect(page).to have_content('Ultimate') + expect(page).to have_selector('a[data-action="plan0"]') + expect(page).to have_selector('a[data-action="plan1"]') + end + end +end diff --git a/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb b/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb index dc8f259eb56..244157a3b14 100644 --- a/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb +++ b/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb @@ -10,42 +10,32 @@ RSpec.describe 'admin/application_settings/_repository_storage.html.haml' do assign(:application_setting, app_settings) end - context 'additional storage config' do + context 'with storage weights configured' do let(:repository_storages_weighted) do { 'default' => 100, - 'mepmep' => 50 + 'mepmep' => 50, + 'something_old' => 100 } end - it 'lists them all' do + it 'lists storages with weight', :aggregate_failures do render - Gitlab.config.repositories.storages.keys.each do |storage_name| - expect(rendered).to have_content(storage_name) - end - - expect(rendered).to have_content('foobar') + expect(rendered).to have_field('default', with: 100) + expect(rendered).to have_field('mepmep', with: 50) end - end - context 'fewer storage configs' do - let(:repository_storages_weighted) do - { - 'default' => 100, - 'mepmep' => 50, - 'something_old' => 100 - } + it 'lists storages without weight' do + render + + expect(rendered).to have_field('foobar', with: 0) end it 'lists only configured storages' do render - Gitlab.config.repositories.storages.keys.each do |storage_name| - expect(rendered).to have_content(storage_name) - end - - expect(rendered).not_to have_content('something_old') + expect(rendered).not_to have_field('something_old') end end end diff --git a/spec/views/dashboard/milestones/index.html.haml_spec.rb b/spec/views/dashboard/milestones/index.html.haml_spec.rb new file mode 100644 index 00000000000..738d31bfa3f --- /dev/null +++ b/spec/views/dashboard/milestones/index.html.haml_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'dashboard/milestones/index.html.haml' do + it_behaves_like 'milestone empty states' +end diff --git a/spec/views/groups/milestones/index.html.haml_spec.rb b/spec/views/groups/milestones/index.html.haml_spec.rb new file mode 100644 index 00000000000..40f175ebdc3 --- /dev/null +++ b/spec/views/groups/milestones/index.html.haml_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'groups/milestones/index.html.haml' do + it_behaves_like 'milestone empty states' +end diff --git a/spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb b/spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb index 4b5027a5a56..5438fea85ee 100644 --- a/spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb +++ b/spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb @@ -4,27 +4,22 @@ require 'spec_helper' RSpec.describe 'groups/runners/sort_dropdown.html.haml' do describe 'render' do - let_it_be(:sort_options_hash) { { by_title: 'Title' } } - let_it_be(:sort_title_created_date) { 'Created date' } - - before do - allow(view).to receive(:sort).and_return('by_title') - end - describe 'when a sort option is not selected' do it 'renders a default sort option' do - render 'groups/runners/sort_dropdown', sort_options_hash: sort_options_hash, sort_title_created_date: sort_title_created_date + render 'groups/runners/sort_dropdown' - expect(rendered).to have_content 'Created date' + expect(rendered).to have_content _('Created date') end end describe 'when a sort option is selected' do - it 'renders the selected sort option' do - @sort = :by_title - render 'groups/runners/sort_dropdown', sort_options_hash: sort_options_hash, sort_title_created_date: sort_title_created_date + before do + assign(:sort, 'contacted_asc') + render 'groups/runners/sort_dropdown' + end - expect(rendered).to have_content 'Title' + it 'renders the selected sort option' do + expect(rendered).to have_content _('Last Contact') end end end diff --git a/spec/views/groups/show.html.haml_spec.rb b/spec/views/groups/show.html.haml_spec.rb deleted file mode 100644 index 43e11d31611..00000000000 --- a/spec/views/groups/show.html.haml_spec.rb +++ /dev/null @@ -1,118 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'groups/edit.html.haml' do - include Devise::Test::ControllerHelpers - - describe '"Share with group lock" setting' do - let(:root_owner) { create(:user) } - let(:root_group) { create(:group) } - - before do - root_group.add_owner(root_owner) - end - - shared_examples_for '"Share with group lock" setting' do |checkbox_options| - it 'has the correct label, help text, and checkbox options' do - assign(:group, test_group) - allow(view).to receive(:can?).with(test_user, :admin_group, test_group).and_return(true) - allow(view).to receive(:can_change_group_visibility_level?).and_return(false) - allow(view).to receive(:current_user).and_return(test_user) - expect(view).to receive(:can_change_share_with_group_lock?).and_return(!checkbox_options[:disabled]) - expect(view).to receive(:share_with_group_lock_help_text).and_return('help text here') - - render - - expect(rendered).to have_content("Prevent sharing a project within #{test_group.name} with other groups") - expect(rendered).to have_content('help text here') - expect(rendered).to have_field('group_share_with_group_lock', **checkbox_options) - end - end - - context 'for a root group' do - let(:test_group) { root_group } - let(:test_user) { root_owner } - - it_behaves_like '"Share with group lock" setting', { disabled: false, checked: false } - end - - context 'for a subgroup' do - let!(:subgroup) { create(:group, parent: root_group) } - let(:sub_owner) { create(:user) } - let(:test_group) { subgroup } - - context 'when the root_group has "Share with group lock" disabled' do - context 'when the subgroup has "Share with group lock" disabled' do - context 'as the root_owner' do - let(:test_user) { root_owner } - - it_behaves_like '"Share with group lock" setting', { disabled: false, checked: false } - end - - context 'as the sub_owner' do - let(:test_user) { sub_owner } - - it_behaves_like '"Share with group lock" setting', { disabled: false, checked: false } - end - end - - context 'when the subgroup has "Share with group lock" enabled' do - before do - subgroup.update_column(:share_with_group_lock, true) - end - - context 'as the root_owner' do - let(:test_user) { root_owner } - - it_behaves_like '"Share with group lock" setting', { disabled: false, checked: true } - end - - context 'as the sub_owner' do - let(:test_user) { sub_owner } - - it_behaves_like '"Share with group lock" setting', { disabled: false, checked: true } - end - end - end - - context 'when the root_group has "Share with group lock" enabled' do - before do - root_group.update_column(:share_with_group_lock, true) - end - - context 'when the subgroup has "Share with group lock" disabled (parent overridden)' do - context 'as the root_owner' do - let(:test_user) { root_owner } - - it_behaves_like '"Share with group lock" setting', { disabled: false, checked: false } - end - - context 'as the sub_owner' do - let(:test_user) { sub_owner } - - it_behaves_like '"Share with group lock" setting', { disabled: false, checked: false } - end - end - - context 'when the subgroup has "Share with group lock" enabled (same as parent)' do - before do - subgroup.update_column(:share_with_group_lock, true) - end - - context 'as the root_owner' do - let(:test_user) { root_owner } - - it_behaves_like '"Share with group lock" setting', { disabled: false, checked: true } - end - - context 'as the sub_owner' do - let(:test_user) { sub_owner } - - it_behaves_like '"Share with group lock" setting', { disabled: true, checked: true } - end - end - end - end - end -end diff --git a/spec/views/profiles/keys/_form.html.haml_spec.rb b/spec/views/profiles/keys/_form.html.haml_spec.rb index 624d7492aea..ba8394178d9 100644 --- a/spec/views/profiles/keys/_form.html.haml_spec.rb +++ b/spec/views/profiles/keys/_form.html.haml_spec.rb @@ -30,8 +30,8 @@ RSpec.describe 'profiles/keys/_form.html.haml' do end it 'has the title field', :aggregate_failures do - expect(rendered).to have_field('Title', type: 'text', placeholder: 'e.g. My MacBook key') - expect(rendered).to have_text('Give your individual key a title. This will be publicly visible.') + expect(rendered).to have_field('Title', type: 'text', placeholder: 'Example: MacBook key') + expect(rendered).to have_text('Key titles are publicly visible.') end it 'has the expires at field', :aggregate_failures do diff --git a/spec/views/projects/commit/show.html.haml_spec.rb b/spec/views/projects/commit/show.html.haml_spec.rb index e23ffe300c5..59182f6e757 100644 --- a/spec/views/projects/commit/show.html.haml_spec.rb +++ b/spec/views/projects/commit/show.html.haml_spec.rb @@ -25,6 +25,7 @@ RSpec.describe 'projects/commit/show.html.haml' do allow(view).to receive(:can_collaborate_with_project?).and_return(false) allow(view).to receive(:current_ref).and_return(project.repository.root_ref) allow(view).to receive(:diff_btn).and_return('') + allow(view).to receive(:pagination_params).and_return({}) end context 'inline diff view' do diff --git a/spec/views/projects/milestones/index.html.haml_spec.rb b/spec/views/projects/milestones/index.html.haml_spec.rb new file mode 100644 index 00000000000..f8117a71310 --- /dev/null +++ b/spec/views/projects/milestones/index.html.haml_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'projects/milestones/index.html.haml' do + it_behaves_like 'milestone empty states' +end diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb index fcae587f8c8..7e300fb1e6e 100644 --- a/spec/views/projects/pipelines/show.html.haml_spec.rb +++ b/spec/views/projects/pipelines/show.html.haml_spec.rb @@ -13,6 +13,7 @@ RSpec.describe 'projects/pipelines/show' do before do assign(:project, project) assign(:pipeline, presented_pipeline) + stub_feature_flags(pipeline_tabs_vue: false) end context 'when pipeline has errors' do diff --git a/spec/views/shared/_global_alert.html.haml_spec.rb b/spec/views/shared/_global_alert.html.haml_spec.rb deleted file mode 100644 index a400d5b39b0..00000000000 --- a/spec/views/shared/_global_alert.html.haml_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -RSpec.describe 'shared/_global_alert.html.haml' do - before do - allow(view).to receive(:sprite_icon).and_return('<span class="icon"></span>'.html_safe) - end - - it 'renders the title' do - title = "The alert's title" - render partial: 'shared/global_alert', locals: { title: title } - - expect(rendered).to have_text(title) - end - - context 'variants' do - it 'renders an info alert by default' do - render - - expect(rendered).to have_selector(".gl-alert-info") - end - - %w[warning success danger tip].each do |variant| - it "renders a #{variant} variant" do - allow(view).to receive(:variant).and_return(variant) - render partial: 'shared/global_alert', locals: { variant: variant } - - expect(rendered).to have_selector(".gl-alert-#{variant}") - end - end - end - - context 'dismissible option' do - it 'shows the dismiss button by default' do - render - - expect(rendered).to have_selector('.gl-dismiss-btn') - end - - it 'does not show the dismiss button when dismissible is false' do - render partial: 'shared/global_alert', locals: { dismissible: false } - - expect(rendered).not_to have_selector('.gl-dismiss-btn') - end - end -end diff --git a/spec/views/shared/_milestones_sort_dropdown.html.haml_spec.rb b/spec/views/shared/_milestones_sort_dropdown.html.haml_spec.rb new file mode 100644 index 00000000000..2fc286d607a --- /dev/null +++ b/spec/views/shared/_milestones_sort_dropdown.html.haml_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'shared/_milestones_sort_dropdown.html.haml' do + describe 'render' do + describe 'when a sort option is not selected' do + it 'renders a default sort option' do + render 'shared/milestones_sort_dropdown' + + expect(rendered).to have_content 'Due soon' + end + end + + describe 'when a sort option is selected' do + before do + assign(:sort, 'due_date_desc') + + render 'shared/milestones_sort_dropdown' + end + + it 'renders the selected sort option' do + expect(rendered).to have_content 'Due later' + end + end + end +end diff --git a/spec/views/shared/groups/_dropdown.html.haml_spec.rb b/spec/views/shared/groups/_dropdown.html.haml_spec.rb new file mode 100644 index 00000000000..71fa3a30711 --- /dev/null +++ b/spec/views/shared/groups/_dropdown.html.haml_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'shared/groups/_dropdown.html.haml' do + describe 'render' do + describe 'when a sort option is not selected' do + it 'renders a default sort option' do + render 'shared/groups/dropdown' + + expect(rendered).to have_content 'Last created' + end + end + + describe 'when a sort option is selected' do + before do + assign(:sort, 'name_desc') + + render 'shared/groups/dropdown' + end + + it 'renders the selected sort option' do + expect(rendered).to have_content 'Name, descending' + end + end + end +end diff --git a/spec/views/shared/projects/_list.html.haml_spec.rb b/spec/views/shared/projects/_list.html.haml_spec.rb index 037f988257b..5c38bb79ea1 100644 --- a/spec/views/shared/projects/_list.html.haml_spec.rb +++ b/spec/views/shared/projects/_list.html.haml_spec.rb @@ -20,6 +20,18 @@ RSpec.describe 'shared/projects/_list' do expect(rendered).to have_content(project.name) end end + + it "will not show elements a user shouldn't be able to see" do + allow(view).to receive(:can_show_last_commit_in_list?).and_return(false) + allow(view).to receive(:able_to_see_merge_requests?).and_return(false) + allow(view).to receive(:able_to_see_issues?).and_return(false) + + render + + expect(rendered).not_to have_css('a.commit-row-message') + expect(rendered).not_to have_css('a.issues') + expect(rendered).not_to have_css('a.merge-requests') + end end context 'without projects' do diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb index 12e29573156..7e301efe708 100644 --- a/spec/workers/bulk_import_worker_spec.rb +++ b/spec/workers/bulk_import_worker_spec.rb @@ -56,17 +56,6 @@ RSpec.describe BulkImportWorker do end end - context 'when maximum allowed number of import entities in progress' do - it 'reenqueues itself' do - bulk_import = create(:bulk_import, :started) - (described_class::DEFAULT_BATCH_SIZE + 1).times { |_| create(:bulk_import_entity, :started, bulk_import: bulk_import) } - - expect(described_class).to receive(:perform_in).with(described_class::PERFORM_DELAY, bulk_import.id) - - subject.perform(bulk_import.id) - end - end - context 'when bulk import is created' do it 'marks bulk import as started' do bulk_import = create(:bulk_import, :created) @@ -84,7 +73,7 @@ RSpec.describe BulkImportWorker do expect { subject.perform(bulk_import.id) } .to change(BulkImports::Tracker, :count) - .by(BulkImports::Groups::Stage.new(bulk_import).pipelines.size * 2) + .by(BulkImports::Groups::Stage.new(entity_1).pipelines.size * 2) expect(entity_1.trackers).not_to be_empty expect(entity_2.trackers).not_to be_empty @@ -93,21 +82,17 @@ RSpec.describe BulkImportWorker do context 'when there are created entities to process' do let_it_be(:bulk_import) { create(:bulk_import, :created) } - before do - stub_const("#{described_class}::DEFAULT_BATCH_SIZE", 1) - end - - it 'marks a batch of entities as started, enqueues EntityWorker, ExportRequestWorker and reenqueues' do + it 'marks all 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) expect(described_class).to receive(:perform_in).with(described_class::PERFORM_DELAY, bulk_import.id) - expect(BulkImports::EntityWorker).to receive(:perform_async) - expect(BulkImports::ExportRequestWorker).to receive(:perform_async) + expect(BulkImports::EntityWorker).to receive(:perform_async).twice + expect(BulkImports::ExportRequestWorker).to receive(:perform_async).twice subject.perform(bulk_import.id) - expect(bulk_import.entities.map(&:status_name)).to contain_exactly(:created, :started) + expect(bulk_import.entities.map(&:status_name)).to contain_exactly(:started, :started) end context 'when there are project entities to process' do diff --git a/spec/workers/bulk_imports/entity_worker_spec.rb b/spec/workers/bulk_imports/entity_worker_spec.rb index ce45299c7f7..ab85b587975 100644 --- a/spec/workers/bulk_imports/entity_worker_spec.rb +++ b/spec/workers/bulk_imports/entity_worker_spec.rb @@ -36,9 +36,11 @@ RSpec.describe BulkImports::EntityWorker do expect(logger) .to receive(:info).twice .with( - worker: described_class.name, - entity_id: entity.id, - current_stage: nil + hash_including( + 'entity_id' => entity.id, + 'current_stage' => nil, + 'message' => 'Stage starting' + ) ) end @@ -58,24 +60,26 @@ RSpec.describe BulkImports::EntityWorker do expect(BulkImports::PipelineWorker) .to receive(:perform_async) - .and_raise(exception) + .and_raise(exception) expect_next_instance_of(Gitlab::Import::Logger) do |logger| expect(logger) .to receive(:info).twice .with( - worker: described_class.name, - entity_id: entity.id, - current_stage: nil + hash_including( + 'entity_id' => entity.id, + 'current_stage' => nil + ) ) expect(logger) .to receive(:error) .with( - worker: described_class.name, - entity_id: entity.id, - current_stage: nil, - error_message: 'Error!' + hash_including( + 'entity_id' => entity.id, + 'current_stage' => nil, + 'message' => 'Error!' + ) ) end @@ -90,6 +94,18 @@ RSpec.describe BulkImports::EntityWorker do let(:job_args) { [entity.id, 0] } it 'do not enqueue a new pipeline job if the current stage still running' do + expect_next_instance_of(Gitlab::Import::Logger) do |logger| + expect(logger) + .to receive(:info).twice + .with( + hash_including( + 'entity_id' => entity.id, + 'current_stage' => 0, + 'message' => 'Stage running' + ) + ) + end + expect(BulkImports::PipelineWorker) .not_to receive(:perform_async) @@ -110,9 +126,10 @@ RSpec.describe BulkImports::EntityWorker do expect(logger) .to receive(:info).twice .with( - worker: described_class.name, - entity_id: entity.id, - current_stage: 0 + hash_including( + 'entity_id' => entity.id, + 'current_stage' => 0 + ) ) end diff --git a/spec/workers/bulk_imports/export_request_worker_spec.rb b/spec/workers/bulk_imports/export_request_worker_spec.rb index 4f452e3dd60..846df63a4d7 100644 --- a/spec/workers/bulk_imports/export_request_worker_spec.rb +++ b/spec/workers/bulk_imports/export_request_worker_spec.rb @@ -35,14 +35,16 @@ RSpec.describe BulkImports::ExportRequestWorker do expect(client).to receive(:post).and_raise(BulkImports::NetworkError, 'Export error').twice end - expect(Gitlab::Import::Logger).to receive(:warn).with( - bulk_import_entity_id: entity.id, - pipeline_class: 'ExportRequestWorker', - exception_class: 'BulkImports::NetworkError', - exception_message: 'Export error', - correlation_id_value: anything, - bulk_import_id: bulk_import.id, - bulk_import_entity_type: entity.source_type + expect(Gitlab::Import::Logger).to receive(:error).with( + hash_including( + 'bulk_import_entity_id' => entity.id, + 'pipeline_class' => 'ExportRequestWorker', + 'exception_class' => 'BulkImports::NetworkError', + 'exception_message' => 'Export error', + 'correlation_id_value' => anything, + 'bulk_import_id' => bulk_import.id, + 'bulk_import_entity_type' => entity.source_type + ) ).twice perform_multiple(job_args) diff --git a/spec/workers/bulk_imports/pipeline_worker_spec.rb b/spec/workers/bulk_imports/pipeline_worker_spec.rb index cb7e70a6749..3578fec5bc0 100644 --- a/spec/workers/bulk_imports/pipeline_worker_spec.rb +++ b/spec/workers/bulk_imports/pipeline_worker_spec.rb @@ -34,9 +34,10 @@ RSpec.describe BulkImports::PipelineWorker do expect(logger) .to receive(:info) .with( - worker: described_class.name, - pipeline_name: 'FakePipeline', - entity_id: entity.id + hash_including( + 'pipeline_name' => 'FakePipeline', + 'entity_id' => entity.id + ) ) end @@ -44,7 +45,7 @@ RSpec.describe BulkImports::PipelineWorker do .to receive(:perform_async) .with(entity.id, pipeline_tracker.stage) - expect(subject).to receive(:jid).and_return('jid') + allow(subject).to receive(:jid).and_return('jid') subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id) @@ -79,10 +80,11 @@ RSpec.describe BulkImports::PipelineWorker do expect(logger) .to receive(:error) .with( - worker: described_class.name, - pipeline_tracker_id: pipeline_tracker.id, - entity_id: entity.id, - message: 'Unstarted pipeline not found' + hash_including( + 'pipeline_tracker_id' => pipeline_tracker.id, + 'entity_id' => entity.id, + 'message' => 'Unstarted pipeline not found' + ) ) end @@ -107,10 +109,11 @@ RSpec.describe BulkImports::PipelineWorker do expect(logger) .to receive(:error) .with( - worker: described_class.name, - pipeline_name: 'InexistentPipeline', - entity_id: entity.id, - message: "'InexistentPipeline' is not a valid BulkImport Pipeline" + hash_including( + 'pipeline_name' => 'InexistentPipeline', + 'entity_id' => entity.id, + 'message' => "'InexistentPipeline' is not a valid BulkImport Pipeline" + ) ) end @@ -126,7 +129,7 @@ RSpec.describe BulkImports::PipelineWorker do .to receive(:perform_async) .with(entity.id, pipeline_tracker.stage) - expect(subject).to receive(:jid).and_return('jid') + allow(subject).to receive(:jid).and_return('jid') subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id) @@ -151,10 +154,11 @@ RSpec.describe BulkImports::PipelineWorker do expect(logger) .to receive(:error) .with( - worker: described_class.name, - pipeline_name: 'Pipeline', - entity_id: entity.id, - message: 'Failed entity status' + hash_including( + 'pipeline_name' => 'Pipeline', + 'entity_id' => entity.id, + 'message' => 'Failed entity status' + ) ) end @@ -183,7 +187,7 @@ RSpec.describe BulkImports::PipelineWorker do .and_raise(exception) end - expect(subject).to receive(:jid).and_return('jid').twice + allow(subject).to receive(:jid).and_return('jid') expect_any_instance_of(BulkImports::Tracker) do |tracker| expect(tracker).to receive(:retry).and_call_original @@ -193,9 +197,10 @@ RSpec.describe BulkImports::PipelineWorker do expect(logger) .to receive(:info) .with( - worker: described_class.name, - pipeline_name: 'FakePipeline', - entity_id: entity.id + hash_including( + 'pipeline_name' => 'FakePipeline', + 'entity_id' => entity.id + ) ) end @@ -292,10 +297,11 @@ RSpec.describe BulkImports::PipelineWorker do expect(logger) .to receive(:error) .with( - worker: described_class.name, - pipeline_name: 'NdjsonPipeline', - entity_id: entity.id, - message: 'Pipeline timeout' + hash_including( + 'pipeline_name' => 'NdjsonPipeline', + 'entity_id' => entity.id, + 'message' => 'Pipeline timeout' + ) ) end @@ -318,10 +324,11 @@ RSpec.describe BulkImports::PipelineWorker do expect(logger) .to receive(:error) .with( - worker: described_class.name, - pipeline_name: 'NdjsonPipeline', - entity_id: entity.id, - message: 'Error!' + hash_including( + 'pipeline_name' => 'NdjsonPipeline', + 'entity_id' => entity.id, + 'message' => 'Error!' + ) ) end diff --git a/spec/workers/bulk_imports/stuck_import_worker_spec.rb b/spec/workers/bulk_imports/stuck_import_worker_spec.rb new file mode 100644 index 00000000000..7dfb6532c07 --- /dev/null +++ b/spec/workers/bulk_imports/stuck_import_worker_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::StuckImportWorker do + let_it_be(:created_bulk_import) { create(:bulk_import, :created) } + let_it_be(:started_bulk_import) { create(:bulk_import, :started) } + let_it_be(:stale_created_bulk_import) { create(:bulk_import, :created, created_at: 3.days.ago) } + let_it_be(:stale_started_bulk_import) { create(:bulk_import, :started, created_at: 3.days.ago) } + let_it_be(:stale_created_bulk_import_entity) { create(:bulk_import_entity, :created, created_at: 3.days.ago) } + let_it_be(:stale_started_bulk_import_entity) { create(:bulk_import_entity, :started, created_at: 3.days.ago) } + let_it_be(:started_bulk_import_tracker) { create(:bulk_import_tracker, :started, entity: stale_started_bulk_import_entity) } + + subject { described_class.new.perform } + + describe 'perform' do + it 'updates the status of bulk imports to timeout' do + expect { subject }.to change { stale_created_bulk_import.reload.status_name }.from(:created).to(:timeout) + .and change { stale_started_bulk_import.reload.status_name }.from(:started).to(:timeout) + end + + it 'updates the status of bulk import entities to timeout' do + expect { subject }.to change { stale_created_bulk_import_entity.reload.status_name }.from(:created).to(:timeout) + .and change { stale_started_bulk_import_entity.reload.status_name }.from(:started).to(:timeout) + end + + it 'updates the status of stale entities trackers to timeout' do + expect { subject }.to change { started_bulk_import_tracker.reload.status_name }.from(:started).to(:timeout) + end + + it 'does not update the status of non-stale records' do + expect { subject }.to not_change { created_bulk_import.reload.status } + .and not_change { started_bulk_import.reload.status } + end + end +end diff --git a/spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb b/spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb new file mode 100644 index 00000000000..b42d135b1b6 --- /dev/null +++ b/spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::UpdateLockedUnknownArtifactsWorker do + let(:worker) { described_class.new } + + describe '#perform' do + it 'executes an instance of Ci::JobArtifacts::UpdateUnknownLockedStatusService' do + expect_next_instance_of(Ci::JobArtifacts::UpdateUnknownLockedStatusService) do |instance| + expect(instance).to receive(:execute).and_call_original + end + + expect(worker).to receive(:log_extra_metadata_on_done).with(:removed_count, 0) + expect(worker).to receive(:log_extra_metadata_on_done).with(:locked_count, 0) + + worker.perform + end + + context 'with the ci_job_artifacts_backlog_work flag shut off' do + before do + stub_feature_flags(ci_job_artifacts_backlog_work: false) + end + + it 'does not instantiate a new Ci::JobArtifacts::UpdateUnknownLockedStatusService' do + expect(Ci::JobArtifacts::UpdateUnknownLockedStatusService).not_to receive(:new) + + worker.perform + end + + it 'does not log any artifact counts' do + expect(worker).not_to receive(:log_extra_metadata_on_done) + + worker.perform + end + + it 'does not query the database' do + query_count = ActiveRecord::QueryRecorder.new { worker.perform }.count + + expect(query_count).to eq(0) + end + end + end +end diff --git a/spec/workers/concerns/application_worker_spec.rb b/spec/workers/concerns/application_worker_spec.rb index 95d9b982fc4..707fa0c9c78 100644 --- a/spec/workers/concerns/application_worker_spec.rb +++ b/spec/workers/concerns/application_worker_spec.rb @@ -49,7 +49,7 @@ RSpec.describe ApplicationWorker do worker.feature_category :pages expect(worker.sidekiq_options['queue']).to eq('queue_2') - worker.feature_category_not_owned! + worker.feature_category :not_owned expect(worker.sidekiq_options['queue']).to eq('queue_3') worker.urgency :high diff --git a/spec/workers/container_registry/migration/enqueuer_worker_spec.rb b/spec/workers/container_registry/migration/enqueuer_worker_spec.rb index 12c14c35365..81fa28dc603 100644 --- a/spec/workers/container_registry/migration/enqueuer_worker_spec.rb +++ b/spec/workers/container_registry/migration/enqueuer_worker_spec.rb @@ -2,8 +2,13 @@ require 'spec_helper' -RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures do +RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures, :clean_gitlab_redis_shared_state do + using RSpec::Parameterized::TableSyntax + include ExclusiveLeaseHelpers + let_it_be_with_reload(:container_repository) { create(:container_repository, created_at: 2.days.ago) } + let_it_be(:importing_repository) { create(:container_repository, :importing) } + let_it_be(:pre_importing_repository) { create(:container_repository, :pre_importing) } let(:worker) { described_class.new } @@ -24,14 +29,14 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures end end - shared_examples 're-enqueuing based on capacity' do + shared_examples 're-enqueuing based on capacity' do |capacity_limit: 4| context 'below capacity' do before do - allow(ContainerRegistry::Migration).to receive(:capacity).and_return(9999) + allow(ContainerRegistry::Migration).to receive(:capacity).and_return(capacity_limit) end it 're-enqueues the worker' do - expect(ContainerRegistry::Migration::EnqueuerWorker).to receive(:perform_async) + expect(described_class).to receive(:perform_async) subject end @@ -43,7 +48,7 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures end it 'does not re-enqueue the worker' do - expect(ContainerRegistry::Migration::EnqueuerWorker).not_to receive(:perform_async) + expect(described_class).not_to receive(:perform_async) subject end @@ -51,24 +56,46 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures end context 'with qualified repository' do - it 'starts the pre-import for the next qualified repository' do + before do method = worker.method(:next_repository) allow(worker).to receive(:next_repository) do next_qualified_repository = method.call allow(next_qualified_repository).to receive(:migration_pre_import).and_return(:ok) next_qualified_repository end + end - expect(worker).to receive(:log_extra_metadata_on_done) - .with(:container_repository_id, container_repository.id) - expect(worker).to receive(:log_extra_metadata_on_done) - .with(:import_type, 'next') + it 'starts the pre-import for the next qualified repository' do + expect_log_extra_metadata( + import_type: 'next', + container_repository_id: container_repository.id, + container_repository_path: container_repository.path, + container_repository_migration_state: 'pre_importing' + ) subject expect(container_repository.reload).to be_pre_importing end + context 'when the new pre-import maxes out the capacity' do + before do + # set capacity to 10 + stub_feature_flags( + container_registry_migration_phase2_capacity_25: false + ) + + # Plus 2 created above gives 9 importing repositories + create_list(:container_repository, 7, :importing) + end + + it 'does not re-enqueue the worker' do + expect(described_class).not_to receive(:perform_async) + + subject + end + end + it_behaves_like 're-enqueuing based on capacity' end @@ -77,7 +104,11 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures allow(ContainerRegistry::Migration).to receive(:enabled?).and_return(false) end - it_behaves_like 'no action' + it_behaves_like 'no action' do + before do + expect_log_extra_metadata(migration_enabled: false) + end + end end context 'above capacity' do @@ -87,7 +118,11 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures allow(ContainerRegistry::Migration).to receive(:capacity).and_return(1) end - it_behaves_like 'no action' + it_behaves_like 'no action' do + before do + expect_log_extra_metadata(below_capacity: false, max_capacity_setting: 1) + end + end it 'does not re-enqueue the worker' do expect(ContainerRegistry::Migration::EnqueuerWorker).not_to receive(:perform_async) @@ -97,38 +132,91 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures end context 'too soon before previous completed import step' do - before do - create(:container_repository, :import_done, migration_import_done_at: 1.minute.ago) - allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(1.hour) + where(:state, :timestamp) do + :import_done | :migration_import_done_at + :pre_import_done | :migration_pre_import_done_at + :import_aborted | :migration_aborted_at + :import_skipped | :migration_skipped_at + end + + with_them do + before do + allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(45.minutes) + create(:container_repository, state, timestamp => 1.minute.ago) + end + + it_behaves_like 'no action' do + before do + expect_log_extra_metadata(waiting_time_passed: false, current_waiting_time_setting: 45.minutes) + end + end end - it_behaves_like 'no action' + context 'when last completed repository has nil timestamps' do + before do + allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(45.minutes) + create(:container_repository, migration_state: 'import_done') + end + + it 'continues to try the next import' do + expect { subject }.to change { container_repository.reload.migration_state } + end + end end context 'when an aborted import is available' do let_it_be(:aborted_repository) { create(:container_repository, :import_aborted) } - it 'retries the import for the aborted repository' do - method = worker.method(:next_aborted_repository) - allow(worker).to receive(:next_aborted_repository) do - next_aborted_repository = method.call - allow(next_aborted_repository).to receive(:migration_import).and_return(:ok) - allow(next_aborted_repository.gitlab_api_client).to receive(:import_status).and_return('import_failed') - next_aborted_repository + context 'with a successful registry request' do + before do + method = worker.method(:next_aborted_repository) + allow(worker).to receive(:next_aborted_repository) do + next_aborted_repository = method.call + allow(next_aborted_repository).to receive(:migration_import).and_return(:ok) + allow(next_aborted_repository.gitlab_api_client).to receive(:import_status).and_return('import_failed') + next_aborted_repository + end end - expect(worker).to receive(:log_extra_metadata_on_done) - .with(:container_repository_id, aborted_repository.id) - expect(worker).to receive(:log_extra_metadata_on_done) - .with(:import_type, 'retry') + it 'retries the import for the aborted repository' do + expect_log_extra_metadata( + import_type: 'retry', + container_repository_id: aborted_repository.id, + container_repository_path: aborted_repository.path, + container_repository_migration_state: 'importing' + ) - subject + subject - expect(aborted_repository.reload).to be_importing - expect(container_repository.reload).to be_default + expect(aborted_repository.reload).to be_importing + expect(container_repository.reload).to be_default + end + + it_behaves_like 're-enqueuing based on capacity' end - it_behaves_like 're-enqueuing based on capacity' + context 'when an error occurs' do + it 'does not abort that migration' do + method = worker.method(:next_aborted_repository) + allow(worker).to receive(:next_aborted_repository) do + next_aborted_repository = method.call + allow(next_aborted_repository).to receive(:retry_aborted_migration).and_raise(StandardError) + next_aborted_repository + end + + expect_log_extra_metadata( + import_type: 'retry', + container_repository_id: aborted_repository.id, + container_repository_path: aborted_repository.path, + container_repository_migration_state: 'import_aborted' + ) + + subject + + expect(aborted_repository.reload).to be_import_aborted + expect(container_repository.reload).to be_default + end + end end context 'when no repository qualifies' do @@ -147,6 +235,15 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures end it 'skips the repository' do + expect_log_extra_metadata( + import_type: 'next', + container_repository_id: container_repository.id, + container_repository_path: container_repository.path, + container_repository_migration_state: 'import_skipped', + tags_count_too_high: true, + max_tags_count_setting: 2 + ) + subject expect(container_repository.reload).to be_import_skipped @@ -154,7 +251,7 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures expect(container_repository.migration_skipped_at).not_to be_nil end - it_behaves_like 're-enqueuing based on capacity' + it_behaves_like 're-enqueuing based on capacity', capacity_limit: 3 end context 'when an error occurs' do @@ -163,10 +260,16 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures end it 'aborts the import' do + expect_log_extra_metadata( + import_type: 'next', + container_repository_id: container_repository.id, + container_repository_path: container_repository.path, + container_repository_migration_state: 'import_aborted' + ) + expect(Gitlab::ErrorTracking).to receive(:log_exception).with( instance_of(StandardError), - next_repository_id: container_repository.id, - next_aborted_repository_id: nil + next_repository_id: container_repository.id ) subject @@ -174,5 +277,26 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures expect(container_repository.reload).to be_import_aborted end end + + context 'with the exclusive lease taken' do + let(:lease_key) { worker.send(:lease_key) } + + before do + stub_exclusive_lease_taken(lease_key, timeout: 30.minutes) + end + + it 'does not perform' do + expect(worker).not_to receive(:runnable?) + expect(worker).not_to receive(:re_enqueue_if_capacity) + + subject + end + end + + def expect_log_extra_metadata(metadata) + metadata.each do |key, value| + expect(worker).to receive(:log_extra_metadata_on_done).with(key, value) + end + end end end diff --git a/spec/workers/container_registry/migration/guard_worker_spec.rb b/spec/workers/container_registry/migration/guard_worker_spec.rb index 7d1df320d4e..299d1204af3 100644 --- a/spec/workers/container_registry/migration/guard_worker_spec.rb +++ b/spec/workers/container_registry/migration/guard_worker_spec.rb @@ -3,8 +3,6 @@ require 'spec_helper' RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do - include_context 'container registry client' - let(:worker) { described_class.new } describe '#perform' do @@ -13,11 +11,12 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do let(:importing_migrations) { ::ContainerRepository.with_migration_states(:importing) } let(:import_aborted_migrations) { ::ContainerRepository.with_migration_states(:import_aborted) } let(:import_done_migrations) { ::ContainerRepository.with_migration_states(:import_done) } + let(:import_skipped_migrations) { ::ContainerRepository.with_migration_states(:import_skipped) } subject { worker.perform } before do - stub_container_registry_config(enabled: true, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key') + stub_container_registry_config(enabled: true, api_url: 'http://container-registry', key: 'spec/fixtures/x509_certificate_pk.key') allow(::ContainerRegistry::Migration).to receive(:max_step_duration).and_return(5.minutes) end @@ -26,20 +25,57 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do allow(::Gitlab).to receive(:com?).and_return(true) end - shared_examples 'not aborting any migration' do - it 'will not abort the migration' do - expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1) - expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 0) - expect(worker).to receive(:log_extra_metadata_on_done).with(:long_running_stale_migration_container_repository_ids, [stale_migration.id]) + shared_examples 'handling long running migrations' do + before do + allow_next_found_instance_of(ContainerRepository) do |repository| + allow(repository).to receive(:migration_cancel).and_return(migration_cancel_response) + end + end - expect { subject } - .to not_change(pre_importing_migrations, :count) - .and not_change(pre_import_done_migrations, :count) - .and not_change(importing_migrations, :count) - .and not_change(import_done_migrations, :count) - .and not_change(import_aborted_migrations, :count) - .and not_change { stale_migration.reload.migration_state } - .and not_change { ongoing_migration.migration_state } + context 'migration is canceled' do + let(:migration_cancel_response) { { status: :ok } } + + it 'will not abort the migration' do + expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_long_running_migration_ids, [stale_migration.id]) + + expect { subject } + .to change(import_skipped_migrations, :count) + + expect(stale_migration.reload.migration_state).to eq('import_skipped') + expect(stale_migration.reload.migration_skipped_reason).to eq('migration_canceled') + end + end + + context 'migration cancelation fails with an error' do + let(:migration_cancel_response) { { status: :error } } + + it 'will abort the migration' do + expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_long_running_migration_ids, [stale_migration.id]) + + expect { subject } + .to change(import_aborted_migrations, :count).by(1) + .and change { stale_migration.reload.migration_state }.to('import_aborted') + .and not_change { ongoing_migration.migration_state } + end + end + + context 'migration receives bad request with a new status' do + let(:migration_cancel_response) { { status: :bad_request, migration_state: :import_done } } + + it 'will abort the migration' do + expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_long_running_migration_ids, [stale_migration.id]) + + expect { subject } + .to change(import_aborted_migrations, :count).by(1) + .and change { stale_migration.reload.migration_state }.to('import_aborted') + .and not_change { ongoing_migration.migration_state } + end end end @@ -86,7 +122,7 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do context 'the client returns pre_import_in_progress' do let(:import_status) { 'pre_import_in_progress' } - it_behaves_like 'not aborting any migration' + it_behaves_like 'handling long running migrations' end end @@ -141,7 +177,7 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do context 'the client returns import_in_progress' do let(:import_status) { 'import_in_progress' } - it_behaves_like 'not aborting any migration' + it_behaves_like 'handling long running migrations' end end end diff --git a/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb b/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb index 2663c650986..f3cf5450048 100644 --- a/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb +++ b/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb @@ -3,5 +3,5 @@ require 'spec_helper' RSpec.describe Database::BatchedBackgroundMigration::CiDatabaseWorker, :clean_gitlab_redis_shared_state do - it_behaves_like 'it runs batched background migration jobs', 'ci' + it_behaves_like 'it runs batched background migration jobs', 'ci', feature_flag: :execute_batched_migrations_on_schedule_ci_database end diff --git a/spec/workers/database/batched_background_migration_worker_spec.rb b/spec/workers/database/batched_background_migration_worker_spec.rb index a6c7db60abe..7f0883def3c 100644 --- a/spec/workers/database/batched_background_migration_worker_spec.rb +++ b/spec/workers/database/batched_background_migration_worker_spec.rb @@ -3,5 +3,5 @@ require 'spec_helper' RSpec.describe Database::BatchedBackgroundMigrationWorker do - it_behaves_like 'it runs batched background migration jobs', :main + it_behaves_like 'it runs batched background migration jobs', :main, feature_flag: :execute_batched_migrations_on_schedule end diff --git a/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb b/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb new file mode 100644 index 00000000000..116026ea8f7 --- /dev/null +++ b/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Database::CiNamespaceMirrorsConsistencyCheckWorker do + let(:worker) { described_class.new } + + describe '#perform' do + context 'feature flag is disabled' do + before do + stub_feature_flags(ci_namespace_mirrors_consistency_check: false) + end + + it 'does not perform the consistency check on namespaces' do + expect(Database::ConsistencyCheckService).not_to receive(:new) + expect(worker).not_to receive(:log_extra_metadata_on_done) + worker.perform + end + end + + context 'feature flag is enabled' do + before do + stub_feature_flags(ci_namespace_mirrors_consistency_check: true) + end + + it 'executes the consistency check on namespaces' do + expect(Database::ConsistencyCheckService).to receive(:new).and_call_original + expected_result = { batches: 0, matches: 0, mismatches: 0, mismatches_details: [] } + expect(worker).to receive(:log_extra_metadata_on_done).with(:results, expected_result) + worker.perform + end + end + + context 'logs should contain the detailed mismatches' do + let(:first_namespace) { Namespace.all.order(:id).limit(1).first } + let(:missing_namespace) { Namespace.all.order(:id).limit(2).last } + + before do + redis_shared_state_cleanup! + stub_feature_flags(ci_namespace_mirrors_consistency_check: true) + create_list(:namespace, 10) # This will also create Ci::NameSpaceMirror objects + missing_namespace.delete + + allow_next_instance_of(Database::ConsistencyCheckService) do |instance| + allow(instance).to receive(:random_start_id).and_return(Namespace.first.id) + end + end + + it 'reports the differences to the logs' do + expected_result = { + batches: 1, + matches: 9, + mismatches: 1, + mismatches_details: [{ + id: missing_namespace.id, + source_table: nil, + target_table: [missing_namespace.traversal_ids] + }], + start_id: first_namespace.id, + next_start_id: first_namespace.id # The batch size > number of namespaces + } + expect(worker).to receive(:log_extra_metadata_on_done).with(:results, expected_result) + worker.perform + end + end + end +end diff --git a/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb b/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb new file mode 100644 index 00000000000..b6bd825ffcd --- /dev/null +++ b/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Database::CiProjectMirrorsConsistencyCheckWorker do + let(:worker) { described_class.new } + + describe '#perform' do + context 'feature flag is disabled' do + before do + stub_feature_flags(ci_project_mirrors_consistency_check: false) + end + + it 'does not perform the consistency check on projects' do + expect(Database::ConsistencyCheckService).not_to receive(:new) + expect(worker).not_to receive(:log_extra_metadata_on_done) + worker.perform + end + end + + context 'feature flag is enabled' do + before do + stub_feature_flags(ci_project_mirrors_consistency_check: true) + end + + it 'executes the consistency check on projects' do + expect(Database::ConsistencyCheckService).to receive(:new).and_call_original + expected_result = { batches: 0, matches: 0, mismatches: 0, mismatches_details: [] } + expect(worker).to receive(:log_extra_metadata_on_done).with(:results, expected_result) + worker.perform + end + end + + context 'logs should contain the detailed mismatches' do + let(:first_project) { Project.all.order(:id).limit(1).first } + let(:missing_project) { Project.all.order(:id).limit(2).last } + + before do + redis_shared_state_cleanup! + stub_feature_flags(ci_project_mirrors_consistency_check: true) + create_list(:project, 10) # This will also create Ci::NameSpaceMirror objects + missing_project.delete + + allow_next_instance_of(Database::ConsistencyCheckService) do |instance| + allow(instance).to receive(:random_start_id).and_return(Project.first.id) + end + end + + it 'reports the differences to the logs' do + expected_result = { + batches: 1, + matches: 9, + mismatches: 1, + mismatches_details: [{ + id: missing_project.id, + source_table: nil, + target_table: [missing_project.namespace_id] + }], + start_id: first_project.id, + next_start_id: first_project.id # The batch size > number of projects + } + expect(worker).to receive(:log_extra_metadata_on_done).with(:results, expected_result) + worker.perform + end + end + end +end diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb index 47205943f70..0351b500747 100644 --- a/spec/workers/every_sidekiq_worker_spec.rb +++ b/spec/workers/every_sidekiq_worker_spec.rb @@ -54,7 +54,7 @@ RSpec.describe 'Every Sidekiq worker' do # All Sidekiq worker classes should declare a valid `feature_category` # or explicitly be excluded with the `feature_category_not_owned!` annotation. # Please see doc/development/sidekiq_style_guide.md#feature-categorization for more details. - it 'has a feature_category or feature_category_not_owned! attribute', :aggregate_failures do + it 'has a feature_category attribute', :aggregate_failures do workers_without_defaults.each do |worker| expect(worker.get_feature_category).to be_a(Symbol), "expected #{worker.inspect} to declare a feature_category or feature_category_not_owned!" end diff --git a/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb b/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb index 34073d0ea39..af15f465107 100644 --- a/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb +++ b/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::GithubImport::ImportDiffNoteWorker do describe '#import' do it 'imports a diff note' do - project = double(:project, full_path: 'foo/bar', id: 1) + project = double(:project, full_path: 'foo/bar', id: 1, import_state: nil) client = double(:client) importer = double(:importer) hash = { diff --git a/spec/workers/gitlab/github_import/import_issue_worker_spec.rb b/spec/workers/gitlab/github_import/import_issue_worker_spec.rb index dc0338eccad..29f21c1d184 100644 --- a/spec/workers/gitlab/github_import/import_issue_worker_spec.rb +++ b/spec/workers/gitlab/github_import/import_issue_worker_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::GithubImport::ImportIssueWorker do describe '#import' do it 'imports an issue' do - project = double(:project, full_path: 'foo/bar', id: 1) + project = double(:project, full_path: 'foo/bar', id: 1, import_state: nil) client = double(:client) importer = double(:importer) hash = { diff --git a/spec/workers/gitlab/github_import/import_note_worker_spec.rb b/spec/workers/gitlab/github_import/import_note_worker_spec.rb index bc254e6246d..f4598340938 100644 --- a/spec/workers/gitlab/github_import/import_note_worker_spec.rb +++ b/spec/workers/gitlab/github_import/import_note_worker_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::GithubImport::ImportNoteWorker do describe '#import' do it 'imports a note' do - project = double(:project, full_path: 'foo/bar', id: 1) + project = double(:project, full_path: 'foo/bar', id: 1, import_state: nil) client = double(:client) importer = double(:importer) hash = { diff --git a/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb b/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb index 6fe9741075f..faed2f8f340 100644 --- a/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb +++ b/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::GithubImport::ImportPullRequestWorker do describe '#import' do it 'imports a pull request' do - project = double(:project, full_path: 'foo/bar', id: 1) + project = double(:project, full_path: 'foo/bar', id: 1, import_state: nil) client = double(:client) importer = double(:importer) hash = { diff --git a/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb b/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb index f3ea14ad539..5e0b07067df 100644 --- a/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb +++ b/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb @@ -11,11 +11,9 @@ RSpec.describe MergeRequests::UpdateHeadPipelineWorker do let(:pipeline) { create(:ci_pipeline, project: project, ref: ref) } let(:event) { Ci::PipelineCreatedEvent.new(data: { pipeline_id: pipeline.id }) } - subject { consume_event(event) } + subject { consume_event(subscriber: described_class, event: event) } - def consume_event(event) - described_class.new.perform(event.class.name, event.data) - end + it_behaves_like 'subscribes to event' context 'when merge requests already exist for this source branch', :sidekiq_inline do let(:merge_request_1) do diff --git a/spec/workers/namespaces/invite_team_email_worker_spec.rb b/spec/workers/namespaces/invite_team_email_worker_spec.rb deleted file mode 100644 index 47fdff9a8ef..00000000000 --- a/spec/workers/namespaces/invite_team_email_worker_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Namespaces::InviteTeamEmailWorker do - let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } - - it 'sends the email' do - expect(Namespaces::InviteTeamEmailService).to receive(:send_email).with(user, group).once - subject.perform(group.id, user.id) - end - - context 'when user id is non-existent' do - it 'does not send the email' do - expect(Namespaces::InviteTeamEmailService).not_to receive(:send_email) - subject.perform(group.id, non_existing_record_id) - end - end - - context 'when group id is non-existent' do - it 'does not send the email' do - expect(Namespaces::InviteTeamEmailService).not_to receive(:send_email) - subject.perform(non_existing_record_id, user.id) - end - end -end diff --git a/spec/workers/namespaces/root_statistics_worker_spec.rb b/spec/workers/namespaces/root_statistics_worker_spec.rb index a97a850bbcf..7b774da0bdc 100644 --- a/spec/workers/namespaces/root_statistics_worker_spec.rb +++ b/spec/workers/namespaces/root_statistics_worker_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Namespaces::RootStatisticsWorker, '#perform' do context 'with a namespace' do it 'executes refresher service' do expect_any_instance_of(Namespaces::StatisticsRefresherService) - .to receive(:execute) + .to receive(:execute).and_call_original worker.perform(group.id) end diff --git a/spec/workers/namespaces/update_root_statistics_worker_spec.rb b/spec/workers/namespaces/update_root_statistics_worker_spec.rb index a525904b757..f2f633a39ca 100644 --- a/spec/workers/namespaces/update_root_statistics_worker_spec.rb +++ b/spec/workers/namespaces/update_root_statistics_worker_spec.rb @@ -9,11 +9,9 @@ RSpec.describe Namespaces::UpdateRootStatisticsWorker do Projects::ProjectDeletedEvent.new(data: { project_id: 1, namespace_id: namespace_id }) end - subject { consume_event(event) } + subject { consume_event(subscriber: described_class, event: event) } - def consume_event(event) - described_class.new.perform(event.class.name, event.data) - end + it_behaves_like 'subscribes to event' it 'enqueues ScheduleAggregationWorker' do expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async).with(namespace_id) diff --git a/spec/workers/packages/cleanup_package_file_worker_spec.rb b/spec/workers/packages/cleanup_package_file_worker_spec.rb index 33f89826312..380e8916d13 100644 --- a/spec/workers/packages/cleanup_package_file_worker_spec.rb +++ b/spec/workers/packages/cleanup_package_file_worker_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Packages::CleanupPackageFileWorker do - let_it_be(:package) { create(:package) } + let_it_be_with_reload(:package) { create(:package) } let(:worker) { described_class.new } @@ -23,24 +23,60 @@ RSpec.describe Packages::CleanupPackageFileWorker do expect(worker).to receive(:log_extra_metadata_on_done).twice expect { subject }.to change { Packages::PackageFile.count }.by(-1) - .and not_change { Packages::Package.count } + .and not_change { Packages::Package.count } + expect { package_file2.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context 'with a duplicated PyPI package file' do + let_it_be_with_reload(:duplicated_package_file) { create(:package_file, package: package) } + + before do + package.update!(package_type: :pypi, version: '1.2.3') + duplicated_package_file.update_column(:file_name, package_file2.file_name) + end + + it 'deletes one of the duplicates' do + expect { subject }.to change { Packages::PackageFile.count }.by(-1) + .and not_change { Packages::Package.count } + expect { package_file2.reload }.to raise_error(ActiveRecord::RecordNotFound) + end end end - context 'with an error during the destroy' do + context 'with a package file to destroy' do let_it_be(:package_file) { create(:package_file, :pending_destruction) } - before do - expect(worker).to receive(:log_metadata).and_raise('Error!') + context 'with an error during the destroy' do + before do + allow(worker).to receive(:log_metadata).and_raise('Error!') + end + + it 'handles the error' do + expect { subject }.to change { Packages::PackageFile.error.count }.from(0).to(1) + expect(package_file.reload).to be_error + end end - it 'handles the error' do - expect { subject }.to change { Packages::PackageFile.error.count }.from(0).to(1) - expect(package_file.reload).to be_error + context 'when trying to destroy a destroyed record' do + before do + allow_next_found_instance_of(Packages::PackageFile) do |package_file| + destroy_method = package_file.method(:destroy!) + + allow(package_file).to receive(:destroy!) do + destroy_method.call + + raise 'Error!' + end + end + end + + it 'handles the error' do + expect { subject }.to change { Packages::PackageFile.count }.by(-1) + end end end - context 'removing the last package file' do + describe 'removing the last package file' do let_it_be(:package_file) { create(:package_file, :pending_destruction, package: package) } it 'deletes the package file and the package' do @@ -65,12 +101,12 @@ RSpec.describe Packages::CleanupPackageFileWorker do end describe '#remaining_work_count' do - before(:context) do - create_list(:package_file, 3, :pending_destruction, package: package) + before_all do + create_list(:package_file, 2, :pending_destruction, package: package) end subject { worker.remaining_work_count } - it { is_expected.to eq(3) } + it { is_expected.to eq(2) } end end diff --git a/spec/workers/project_export_worker_spec.rb b/spec/workers/project_export_worker_spec.rb index 9923d8bde7f..dd0a921059d 100644 --- a/spec/workers/project_export_worker_spec.rb +++ b/spec/workers/project_export_worker_spec.rb @@ -4,4 +4,30 @@ require 'spec_helper' RSpec.describe ProjectExportWorker do it_behaves_like 'export worker' + + context 'exporters duration measuring' do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:worker) { described_class.new } + + subject { worker.perform(user.id, project.id) } + + before do + project.add_owner(user) + end + + it 'logs exporters execution duration' do + expect(worker).to receive(:log_extra_metadata_on_done).with(:version_saver_duration_s, anything) + expect(worker).to receive(:log_extra_metadata_on_done).with(:avatar_saver_duration_s, anything) + expect(worker).to receive(:log_extra_metadata_on_done).with(:tree_saver_duration_s, anything) + expect(worker).to receive(:log_extra_metadata_on_done).with(:uploads_saver_duration_s, anything) + expect(worker).to receive(:log_extra_metadata_on_done).with(:repo_saver_duration_s, anything) + expect(worker).to receive(:log_extra_metadata_on_done).with(:wiki_repo_saver_duration_s, anything) + expect(worker).to receive(:log_extra_metadata_on_done).with(:lfs_saver_duration_s, anything) + expect(worker).to receive(:log_extra_metadata_on_done).with(:snippets_repo_saver_duration_s, anything) + expect(worker).to receive(:log_extra_metadata_on_done).with(:design_repo_saver_duration_s, anything) + + subject + end + end end diff --git a/spec/workers/projects/post_creation_worker_spec.rb b/spec/workers/projects/post_creation_worker_spec.rb index 06acf601666..3158ac9fa27 100644 --- a/spec/workers/projects/post_creation_worker_spec.rb +++ b/spec/workers/projects/post_creation_worker_spec.rb @@ -63,7 +63,7 @@ RSpec.describe Projects::PostCreationWorker do end it 'cleans invalid record and logs warning', :aggregate_failures do - invalid_integration_record = build(:prometheus_integration, properties: { api_url: nil, manual_configuration: true }.to_json) + invalid_integration_record = build(:prometheus_integration, properties: { api_url: nil, manual_configuration: true }) allow(::Integrations::Prometheus).to receive(:new).and_return(invalid_integration_record) expect(Gitlab::ErrorTracking).to receive(:track_exception).with(an_instance_of(ActiveRecord::RecordInvalid), include(extra: { project_id: a_kind_of(Integer) })).twice diff --git a/spec/workers/projects/record_target_platforms_worker_spec.rb b/spec/workers/projects/record_target_platforms_worker_spec.rb new file mode 100644 index 00000000000..eb53e3f8608 --- /dev/null +++ b/spec/workers/projects/record_target_platforms_worker_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::RecordTargetPlatformsWorker do + include ExclusiveLeaseHelpers + + let_it_be(:swift) { create(:programming_language, name: 'Swift') } + let_it_be(:objective_c) { create(:programming_language, name: 'Objective-C') } + let_it_be(:project) { create(:project, :repository, detected_repository_languages: true) } + + let(:worker) { described_class.new } + let(:service_result) { %w(ios osx watchos) } + let(:service_double) { instance_double(Projects::RecordTargetPlatformsService, execute: service_result) } + let(:lease_key) { "#{described_class.name.underscore}:#{project.id}" } + let(:lease_timeout) { described_class::LEASE_TIMEOUT } + + subject(:perform) { worker.perform(project.id) } + + before do + stub_exclusive_lease(lease_key, timeout: lease_timeout) + end + + shared_examples 'performs detection' do + it 'creates and executes a Projects::RecordTargetPlatformService instance for the project', :aggregate_failures do + expect(Projects::RecordTargetPlatformsService).to receive(:new).with(project) { service_double } + expect(service_double).to receive(:execute) + + perform + end + + it 'logs extra metadata on done', :aggregate_failures do + expect(Projects::RecordTargetPlatformsService).to receive(:new).with(project) { service_double } + expect(worker).to receive(:log_extra_metadata_on_done).with(:target_platforms, service_result) + + perform + end + end + + shared_examples 'does nothing' do + it 'does nothing' do + expect(Projects::RecordTargetPlatformsService).not_to receive(:new) + + perform + end + end + + context 'when project uses Swift programming language' do + let!(:repository_language) { create(:repository_language, project: project, programming_language: swift) } + + include_examples 'performs detection' + end + + context 'when project uses Objective-C programming language' do + let!(:repository_language) { create(:repository_language, project: project, programming_language: objective_c) } + + include_examples 'performs detection' + end + + context 'when the project does not contain programming languages for Apple platforms' do + it_behaves_like 'does nothing' + end + + context 'when project is not found' do + it 'does nothing' do + expect(Projects::RecordTargetPlatformsService).not_to receive(:new) + + worker.perform(non_existing_record_id) + end + end + + context 'when exclusive lease cannot be obtained' do + before do + stub_exclusive_lease_taken(lease_key) + end + + it_behaves_like 'does nothing' + end + + it 'has the `until_executed` deduplicate strategy' do + expect(described_class.get_deduplicate_strategy).to eq(:until_executed) + end + + it 'overrides #lease_release? to return false' do + expect(worker.send(:lease_release?)).to eq false + end +end diff --git a/spec/workers/quality/test_data_cleanup_worker_spec.rb b/spec/workers/quality/test_data_cleanup_worker_spec.rb deleted file mode 100644 index a17e6e0cb1a..00000000000 --- a/spec/workers/quality/test_data_cleanup_worker_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Quality::TestDataCleanupWorker do - subject { described_class.new } - - shared_examples 'successful deletion' do - before do - allow(Gitlab).to receive(:staging?).and_return(true) - end - - it 'removes test groups' do - expect { subject.perform }.to change(Group, :count).by(-test_group_count) - end - end - - describe "#perform" do - context 'with multiple test groups to remove' do - let(:test_group_count) { 5 } - let!(:groups_to_remove) { create_list(:group, test_group_count, :test_group) } - let!(:group_to_keep) { create(:group, path: 'test-group-fulfillment-keep', created_at: 1.day.ago) } - let!(:non_test_group) { create(:group) } - let(:non_test_owner_group) { create(:group, path: 'test-group-fulfillment1234', created_at: 4.days.ago) } - - before do - non_test_owner_group.add_owner(create(:user)) - end - - it_behaves_like 'successful deletion' - end - - context 'with paid groups' do - let(:test_group_count) { 1 } - let!(:paid_group) { create(:group, :test_group) } - - before do - allow(paid_group).to receive(:paid?).and_return(true) - end - - it_behaves_like 'successful deletion' - end - end -end |