diff options
Diffstat (limited to 'spec/requests/api/graphql')
25 files changed, 1162 insertions, 211 deletions
diff --git a/spec/requests/api/graphql/ci/groups_spec.rb b/spec/requests/api/graphql/ci/groups_spec.rb index 9e81358a152..d1a4395d2c9 100644 --- a/spec/requests/api/graphql/ci/groups_spec.rb +++ b/spec/requests/api/graphql/ci/groups_spec.rb @@ -4,10 +4,15 @@ require 'spec_helper' RSpec.describe 'Query.project.pipeline.stages.groups' do include GraphqlHelpers - let(:project) { create(:project, :repository, :public) } - let(:user) { create(:user) } - let(:pipeline) { create(:ci_pipeline, project: project, user: user) } - let(:group_graphql_data) { graphql_data.dig('project', 'pipeline', 'stages', 'nodes', 0, 'groups', 'nodes') } + let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:user) { create(:user) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) } + let(:group_graphql_data) { graphql_data_at(:project, :pipeline, :stages, :nodes, 0, :groups, :nodes) } + + let_it_be(:ref) { 'master' } + let_it_be(:job_a) { create(:commit_status, pipeline: pipeline, name: 'rspec 0 2', ref: ref) } + let_it_be(:job_b) { create(:ci_build, pipeline: pipeline, name: 'rspec 0 1', ref: ref) } + let_it_be(:job_c) { create(:ci_bridge, pipeline: pipeline, name: 'spinach 0 1', ref: ref) } let(:params) { {} } @@ -38,18 +43,15 @@ RSpec.describe 'Query.project.pipeline.stages.groups' do end before do - create(:commit_status, pipeline: pipeline, name: 'rspec 0 2') - create(:commit_status, pipeline: pipeline, name: 'rspec 0 1') - create(:commit_status, pipeline: pipeline, name: 'spinach 0 1') post_graphql(query, current_user: user) end it_behaves_like 'a working graphql query' it 'returns a array of jobs belonging to a pipeline' do - expect(group_graphql_data.map { |g| g.slice('name', 'size') }).to eq([ - { 'name' => 'rspec', 'size' => 2 }, - { 'name' => 'spinach', 'size' => 1 } - ]) + expect(group_graphql_data).to contain_exactly( + a_hash_including('name' => 'rspec', 'size' => 2), + a_hash_including('name' => 'spinach', 'size' => 1) + ) end end diff --git a/spec/requests/api/graphql/ci/job_spec.rb b/spec/requests/api/graphql/ci/job_spec.rb new file mode 100644 index 00000000000..78f7d3e149b --- /dev/null +++ b/spec/requests/api/graphql/ci/job_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Query.project(fullPath).pipelines.job(id)' do + include GraphqlHelpers + + let_it_be(:user) { create_default(:user) } + let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + + let_it_be(:prepare_stage) { create(:ci_stage_entity, pipeline: pipeline, project: project, name: 'prepare') } + let_it_be(:test_stage) { create(:ci_stage_entity, pipeline: pipeline, project: project, name: 'test') } + + let_it_be(:job_1) { create(:ci_build, pipeline: pipeline, stage: 'prepare', name: 'Job 1') } + let_it_be(:job_2) { create(:ci_build, pipeline: pipeline, stage: 'test', name: 'Job 2') } + let_it_be(:job_3) { create(:ci_build, pipeline: pipeline, stage: 'test', name: 'Job 3') } + + let(:path_to_job) do + [ + [:project, { full_path: project.full_path }], + [:pipelines, { first: 1 }], + [:nodes, nil], + [:job, { id: global_id_of(job_2) }] + ] + end + + let(:query) do + wrap_fields(query_graphql_path(query_path, all_graphql_fields_for(terminal_type))) + end + + describe 'scalar fields' do + let(:path) { [:project, :pipelines, :nodes, 0, :job] } + let(:query_path) { path_to_job } + let(:terminal_type) { 'CiJob' } + + it 'retrieves scalar fields' do + post_graphql(query, current_user: user) + + expect(graphql_data_at(*path)).to match a_hash_including( + 'id' => global_id_of(job_2), + 'name' => job_2.name, + 'allowFailure' => job_2.allow_failure, + 'duration' => job_2.duration, + 'status' => job_2.status.upcase + ) + end + + context 'when fetching by name' do + before do + query_path.last[1] = { name: job_2.name } + end + + it 'retrieves scalar fields' do + post_graphql(query, current_user: user) + + expect(graphql_data_at(*path)).to match a_hash_including( + 'id' => global_id_of(job_2), + 'name' => job_2.name + ) + end + end + end + + describe '.detailedStatus' do + let(:path) { [:project, :pipelines, :nodes, 0, :job, :detailed_status] } + let(:query_path) { path_to_job + [:detailed_status] } + let(:terminal_type) { 'DetailedStatus' } + + it 'retrieves detailed status' do + post_graphql(query, current_user: user) + + expect(graphql_data_at(*path)).to match a_hash_including( + 'text' => 'pending', + 'label' => 'pending', + 'action' => a_hash_including('buttonTitle' => 'Cancel this job', 'icon' => 'cancel') + ) + end + end + + describe '.stage' do + let(:path) { [:project, :pipelines, :nodes, 0, :job, :stage] } + let(:query_path) { path_to_job + [:stage] } + let(:terminal_type) { 'CiStage' } + + it 'returns appropriate data' do + post_graphql(query, current_user: user) + + expect(graphql_data_at(*path)).to match a_hash_including( + 'name' => test_stage.name, + 'jobs' => a_hash_including( + 'nodes' => contain_exactly( + a_hash_including('id' => global_id_of(job_2)), + a_hash_including('id' => global_id_of(job_3)) + ) + ) + ) + end + end +end diff --git a/spec/requests/api/graphql/custom_emoji_query_spec.rb b/spec/requests/api/graphql/custom_emoji_query_spec.rb index d5a423d0eba..874357d9eef 100644 --- a/spec/requests/api/graphql/custom_emoji_query_spec.rb +++ b/spec/requests/api/graphql/custom_emoji_query_spec.rb @@ -16,7 +16,15 @@ RSpec.describe 'getting custom emoji within namespace' do describe "Query CustomEmoji on Group" do def custom_emoji_query(group) - graphql_query_for('group', 'fullPath' => group.full_path) + fields = all_graphql_fields_for('Group') + # TODO: Set required timelogs args elsewhere https://gitlab.com/gitlab-org/gitlab/-/issues/325499 + fields.selection['timelogs(startDate: "2021-03-01" endDate: "2021-03-30")'] = fields.selection.delete('timelogs') + + graphql_query_for( + 'group', + { fullPath: group.full_path }, + fields + ) end it 'returns emojis when authorised' do diff --git a/spec/requests/api/graphql/gitlab_schema_spec.rb b/spec/requests/api/graphql/gitlab_schema_spec.rb index fe1c7c15de2..b41d851439b 100644 --- a/spec/requests/api/graphql/gitlab_schema_spec.rb +++ b/spec/requests/api/graphql/gitlab_schema_spec.rb @@ -125,9 +125,9 @@ RSpec.describe 'GitlabSchema configurations' do subject do queries = [ - { query: graphql_query_for('project', { 'fullPath' => '$fullPath' }, %w(id name description)) }, - { query: graphql_query_for('echo', { 'text' => "$test" }, []), variables: { "test" => "Hello world" } }, - { query: graphql_query_for('project', { 'fullPath' => project.full_path }, "userPermissions { createIssue }") } + { query: graphql_query_for('project', { 'fullPath' => '$fullPath' }, %w(id name description)) }, # Complexity 4 + { query: graphql_query_for('echo', { 'text' => "$test" }, []), variables: { "test" => "Hello world" } }, # Complexity 1 + { query: graphql_query_for('project', { 'fullPath' => project.full_path }, "userPermissions { createIssue }") } # Complexity 3 ] post_multiplex(queries, current_user: current_user) @@ -139,10 +139,9 @@ RSpec.describe 'GitlabSchema configurations' do expect(json_response.last['data']['project']).to be_nil end - it_behaves_like 'imposing query limits' do - it 'fails all queries when only one of the queries is too complex' do - # The `project` query above has a complexity of 5 - allow(GitlabSchema).to receive(:max_query_complexity).and_return 4 + shared_examples 'query is too complex' do |description, max_complexity| + it description, :aggregate_failures do + allow(GitlabSchema).to receive(:max_query_complexity).and_return max_complexity subject @@ -155,11 +154,17 @@ RSpec.describe 'GitlabSchema configurations' do # Expect errors for each query expect(graphql_errors.size).to eq(3) graphql_errors.each do |single_query_errors| - expect_graphql_errors_to_include(/which exceeds max complexity of 4/) + expect_graphql_errors_to_include(/Query has complexity of 8, which exceeds max complexity of #{max_complexity}/) end end end + it_behaves_like 'imposing query limits' do + # The total complexity of the multiplex query above is 8 + it_behaves_like 'query is too complex', 'fails all queries when only one of the queries is too complex', 4 + it_behaves_like 'query is too complex', 'fails when all queries combined are too complex', 7 + end + context 'authentication' do let(:current_user) { project.owner } @@ -191,6 +196,7 @@ RSpec.describe 'GitlabSchema configurations' do complexity: 181, depth: 13, duration_s: 7, + operation_name: 'IntrospectionQuery', used_fields: an_instance_of(Array), used_deprecated_fields: an_instance_of(Array) } diff --git a/spec/requests/api/graphql/group/milestones_spec.rb b/spec/requests/api/graphql/group/milestones_spec.rb index 380eaea17f8..a5b489d72fd 100644 --- a/spec/requests/api/graphql/group/milestones_spec.rb +++ b/spec/requests/api/graphql/group/milestones_spec.rb @@ -9,12 +9,14 @@ RSpec.describe 'Milestones through GroupQuery' do let_it_be(:now) { Time.now } describe 'Get list of milestones from a group' do - let_it_be(:group) { create(:group) } + let_it_be(:parent_group) { create(:group) } + let_it_be(:group) { create(:group, parent: parent_group) } let_it_be(:milestone_1) { create(:milestone, group: group) } let_it_be(:milestone_2) { create(:milestone, group: group, state: :closed, start_date: now, due_date: now + 1.day) } let_it_be(:milestone_3) { create(:milestone, group: group, start_date: now, due_date: now + 2.days) } let_it_be(:milestone_4) { create(:milestone, group: group, state: :closed, start_date: now - 2.days, due_date: now - 1.day) } let_it_be(:milestone_from_other_group) { create(:milestone, group: create(:group)) } + let_it_be(:parent_milestone) { create(:milestone, group: parent_group) } let(:milestone_data) { graphql_data['group']['milestones']['edges'] } @@ -64,14 +66,32 @@ RSpec.describe 'Milestones through GroupQuery' do accessible_group.add_developer(user) end - it 'returns milestones also from subgroups and subprojects visible to user' do - fetch_milestones(user, args) + context 'when including decendants' do + let(:args) { { include_descendants: true } } + + it 'returns milestones also from subgroups and subprojects visible to user' do + fetch_milestones(user, args) + + expect_array_response( + milestone_1.to_global_id.to_s, milestone_2.to_global_id.to_s, + milestone_3.to_global_id.to_s, milestone_4.to_global_id.to_s, + submilestone_1.to_global_id.to_s, submilestone_2.to_global_id.to_s + ) + end + end + + context 'when including ancestors' do + let(:args) { { include_ancestors: true } } - expect_array_response( - milestone_1.to_global_id.to_s, milestone_2.to_global_id.to_s, - milestone_3.to_global_id.to_s, milestone_4.to_global_id.to_s, - submilestone_1.to_global_id.to_s, submilestone_2.to_global_id.to_s - ) + it 'returns milestones from ancestor groups' do + fetch_milestones(user, args) + + expect_array_response( + milestone_1.to_global_id.to_s, milestone_2.to_global_id.to_s, + milestone_3.to_global_id.to_s, milestone_4.to_global_id.to_s, + parent_milestone.to_global_id.to_s + ) + end end end diff --git a/spec/requests/api/graphql/group/timelogs_spec.rb b/spec/requests/api/graphql/group/timelogs_spec.rb new file mode 100644 index 00000000000..6e21a73afa9 --- /dev/null +++ b/spec/requests/api/graphql/group/timelogs_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Timelogs through GroupQuery' do + include GraphqlHelpers + + describe 'Get list of timelogs from a group issues' do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :public, group: group) } + let_it_be(:milestone) { create(:milestone, group: group) } + let_it_be(:issue) { create(:issue, project: project, milestone: milestone) } + let_it_be(:timelog1) { create(:timelog, issue: issue, user: user, spent_at: '2019-08-13 14:00:00') } + let_it_be(:timelog2) { create(:timelog, issue: issue, user: user, spent_at: '2019-08-10 08:00:00') } + let_it_be(:params) { { startTime: '2019-08-10 12:00:00', endTime: '2019-08-21 12:00:00' } } + + let(:timelogs_data) { graphql_data['group']['timelogs']['nodes'] } + + before do + group.add_developer(user) + end + + context 'when the request is correct' do + before do + post_graphql(query, current_user: user) + end + + it_behaves_like 'a working graphql query' + + it 'returns timelogs successfully' do + expect(response).to have_gitlab_http_status(:ok) + expect(graphql_errors).to be_nil + expect(timelog_array.size).to eq 1 + end + + it 'contains correct data', :aggregate_failures do + username = timelog_array.map { |data| data['user']['username'] } + spent_at = timelog_array.map { |data| data['spentAt'].to_time } + time_spent = timelog_array.map { |data| data['timeSpent'] } + issue_title = timelog_array.map { |data| data['issue']['title'] } + milestone_title = timelog_array.map { |data| data['issue']['milestone']['title'] } + + expect(username).to eq([user.username]) + expect(spent_at.first).to be_like_time(timelog1.spent_at) + expect(time_spent).to eq([timelog1.time_spent]) + expect(issue_title).to eq([issue.title]) + expect(milestone_title).to eq([milestone.title]) + end + + context 'when arguments with no time are present' do + let!(:timelog3) { create(:timelog, issue: issue, user: user, spent_at: '2019-08-10 15:00:00') } + let!(:timelog4) { create(:timelog, issue: issue, user: user, spent_at: '2019-08-21 15:00:00') } + let(:params) { { startDate: '2019-08-10', endDate: '2019-08-21' } } + + it 'sets times as start of day and end of day' do + expect(response).to have_gitlab_http_status(:ok) + expect(timelog_array.size).to eq 2 + end + end + end + + context 'when requests has errors' do + context 'when there are no timelogs present' do + before do + Timelog.delete_all + end + + it 'returns empty result' do + post_graphql(query, current_user: user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_nil + expect(timelogs_data).to be_empty + end + end + + context 'when user has no permission to read group timelogs' do + it 'returns empty result' do + guest = create(:user) + group.add_guest(guest) + post_graphql(query, current_user: guest) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to be_nil + expect(timelogs_data).to be_empty + end + end + end + end + + def timelog_array(extract_attribute = nil) + timelogs_data.map do |item| + extract_attribute ? item[extract_attribute] : item + end + end + + def query(timelog_params = params) + timelog_nodes = <<~NODE + nodes { + spentAt + timeSpent + user { + username + } + issue { + title + milestone { + title + } + } + } + NODE + + graphql_query_for( + :group, + { full_path: group.full_path }, + query_graphql_field(:timelogs, timelog_params, timelog_nodes) + ) + end +end diff --git a/spec/requests/api/graphql/group_query_spec.rb b/spec/requests/api/graphql/group_query_spec.rb index 391bae4cfcf..8e4f808f794 100644 --- a/spec/requests/api/graphql/group_query_spec.rb +++ b/spec/requests/api/graphql/group_query_spec.rb @@ -17,7 +17,15 @@ RSpec.describe 'getting group information' do # similar to the API "GET /groups/:id" describe "Query group(fullPath)" do def group_query(group) - graphql_query_for('group', 'fullPath' => group.full_path) + fields = all_graphql_fields_for('Group') + # TODO: Set required timelogs args elsewhere https://gitlab.com/gitlab-org/gitlab/-/issues/325499 + fields.selection['timelogs(startDate: "2021-03-01" endDate: "2021-03-30")'] = fields.selection.delete('timelogs') + + graphql_query_for( + 'group', + { fullPath: group.full_path }, + fields + ) end it_behaves_like 'a working graphql query' do diff --git a/spec/requests/api/graphql/mutations/boards/issues/issue_move_list_spec.rb b/spec/requests/api/graphql/mutations/boards/issues/issue_move_list_spec.rb index e24ab0b07f2..46ec22e7ef8 100644 --- a/spec/requests/api/graphql/mutations/boards/issues/issue_move_list_spec.rb +++ b/spec/requests/api/graphql/mutations/boards/issues/issue_move_list_spec.rb @@ -21,7 +21,8 @@ RSpec.describe 'Reposition and move issue within board lists' do let(:mutation_name) { mutation_class.graphql_name } let(:mutation_result_identifier) { mutation_name.camelize(:lower) } let(:current_user) { user } - let(:params) { { board_id: board.to_global_id.to_s, project_path: project.full_path, iid: issue1.iid.to_s } } + let(:board_id) { global_id_of(board) } + let(:params) { { board_id: board_id, project_path: project.full_path, iid: issue1.iid.to_s } } let(:issue_move_params) do { from_list_id: list1.id, @@ -34,16 +35,44 @@ RSpec.describe 'Reposition and move issue within board lists' do end shared_examples 'returns an error' do - it 'fails with error' do - message = "The resource that you are attempting to access does not exist or you don't have "\ - "permission to perform this action" + let(:message) do + "The resource that you are attempting to access does not exist or you don't have " \ + "permission to perform this action" + end + it 'fails with error' do post_graphql_mutation(mutation(params), current_user: current_user) expect(graphql_errors).to include(a_hash_including('message' => message)) end end + context 'when the board_id is not a board' do + let(:board_id) { global_id_of(project) } + let(:issue_move_params) do + { move_after_id: existing_issue1.id, move_before_id: existing_issue2.id } + end + + it_behaves_like 'returns an error' do + let(:message) { include('does not represent an instance of') } + end + end + + # This test aims to distinguish between the failures to authorize + # :read_issue_board and :update_issue + context 'when the user cannot read the issue board' do + let(:issue_move_params) do + { move_after_id: existing_issue1.id, move_before_id: existing_issue2.id } + end + + before do + allow(Ability).to receive(:allowed?).with(any_args).and_return(true) + allow(Ability).to receive(:allowed?).with(current_user, :read_issue_board, board).and_return(false) + end + + it_behaves_like 'returns an error' + end + context 'when user has access to resources' do context 'when repositioning an issue' do let(:issue_move_params) { { move_after_id: existing_issue1.id, move_before_id: existing_issue2.id } } diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb index 97873b01338..bcede4d37dd 100644 --- a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb +++ b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb @@ -5,11 +5,12 @@ require 'spec_helper' RSpec.describe 'Setting assignees of a merge request' do include GraphqlHelpers - let(:current_user) { create(:user) } - let(:merge_request) { create(:merge_request) } - let(:project) { merge_request.project } - let(:assignee) { create(:user) } - let(:assignee2) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:current_user) { create(:user, developer_projects: [project]) } + let_it_be(:assignee) { create(:user) } + let_it_be(:assignee2) { create(:user) } + let_it_be_with_reload(:merge_request) { create(:merge_request, source_project: project) } + let(:input) { { assignee_usernames: [assignee.username] } } let(:expected_result) do [{ 'username' => assignee.username }] @@ -44,10 +45,19 @@ RSpec.describe 'Setting assignees of a merge request' do mutation_response['mergeRequest']['assignees']['nodes'] end + def run_mutation! + recorder = ActiveRecord::QueryRecorder.new do + post_graphql_mutation(mutation, current_user: current_user) + end + + expect(recorder.count).to be <= db_query_limit + end + before do - project.add_developer(current_user) project.add_developer(assignee) project.add_developer(assignee2) + + merge_request.update!(assignees: []) end it 'returns an error if the user is not allowed to update the merge request' do @@ -56,23 +66,29 @@ RSpec.describe 'Setting assignees of a merge request' do expect(graphql_errors).not_to be_empty end - it 'does not allow members without the right permission to add assignees' do - user = create(:user) - project.add_guest(user) + context 'when the current user does not have permission to add assignees' do + let(:current_user) { create(:user) } + let(:db_query_limit) { 27 } - post_graphql_mutation(mutation, current_user: user) + it 'does not change the assignees' do + project.add_guest(current_user) - expect(graphql_errors).not_to be_empty + expect { run_mutation! }.not_to change { merge_request.reset.assignees.pluck(:id) } + + expect(graphql_errors).not_to be_empty + end end context 'with assignees already assigned' do + let(:db_query_limit) { 39 } + before do merge_request.assignees = [assignee2] merge_request.save! end it 'replaces the assignee' do - post_graphql_mutation(mutation, current_user: current_user) + run_mutation! expect(response).to have_gitlab_http_status(:success) expect(mutation_assignee_nodes).to match_array(expected_result) @@ -80,6 +96,7 @@ RSpec.describe 'Setting assignees of a merge request' do end context 'when passing an empty list of assignees' do + let(:db_query_limit) { 31 } let(:input) { { assignee_usernames: [] } } before do @@ -88,7 +105,7 @@ RSpec.describe 'Setting assignees of a merge request' do end it 'removes assignee' do - post_graphql_mutation(mutation, current_user: current_user) + run_mutation! expect(response).to have_gitlab_http_status(:success) expect(mutation_assignee_nodes).to eq([]) @@ -96,7 +113,9 @@ RSpec.describe 'Setting assignees of a merge request' do end context 'when passing append as true' do - let(:input) { { assignee_usernames: [assignee2.username], operation_mode: Types::MutationOperationModeEnum.enum[:append] } } + let(:mode) { Types::MutationOperationModeEnum.enum[:append] } + let(:input) { { assignee_usernames: [assignee2.username], operation_mode: mode } } + let(:db_query_limit) { 20 } before do # In CE, APPEND is a NOOP as you can't have multiple assignees @@ -108,7 +127,7 @@ RSpec.describe 'Setting assignees of a merge request' do end it 'does not replace the assignee in CE' do - post_graphql_mutation(mutation, current_user: current_user) + run_mutation! expect(response).to have_gitlab_http_status(:success) expect(mutation_assignee_nodes).to match_array(expected_result) @@ -116,7 +135,9 @@ RSpec.describe 'Setting assignees of a merge request' do end context 'when passing remove as true' do - let(:input) { { assignee_usernames: [assignee.username], operation_mode: Types::MutationOperationModeEnum.enum[:remove] } } + let(:db_query_limit) { 31 } + let(:mode) { Types::MutationOperationModeEnum.enum[:remove] } + let(:input) { { assignee_usernames: [assignee.username], operation_mode: mode } } let(:expected_result) { [] } before do @@ -125,7 +146,7 @@ RSpec.describe 'Setting assignees of a merge request' do end it 'removes the users in the list, while adding none' do - post_graphql_mutation(mutation, current_user: current_user) + run_mutation! expect(response).to have_gitlab_http_status(:success) expect(mutation_assignee_nodes).to match_array(expected_result) 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 34d347c76fd..0d0cc66c52a 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 @@ -52,7 +52,7 @@ RSpec.describe 'Setting labels of a merge request' do end it 'sets the merge request labels, removing existing ones' do - merge_request.update(labels: [label2]) + merge_request.update!(labels: [label2]) post_graphql_mutation(mutation, current_user: current_user) diff --git a/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb new file mode 100644 index 00000000000..57489c82ec2 --- /dev/null +++ b/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Deletes a release asset link' do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :private, :repository) } + let_it_be(:release) { create(:release, project: project) } + let_it_be(:maintainer) { create(:user).tap { |u| project.add_maintainer(u) } } + let_it_be(:release_link) { create(:release_link, release: release) } + + let(:current_user) { maintainer } + let(:mutation_name) { :release_asset_link_delete } + let(:mutation_arguments) { { id: release_link.to_global_id.to_s } } + + let(:mutation) do + graphql_mutation(mutation_name, mutation_arguments, <<~FIELDS) + link { + id + name + url + linkType + directAssetUrl + external + } + errors + FIELDS + end + + let(:delete_link) { post_graphql_mutation(mutation, current_user: current_user) } + let(:mutation_response) { graphql_mutation_response(mutation_name)&.with_indifferent_access } + + it 'deletes the release asset link and returns the deleted link', :aggregate_failures do + delete_link + + expected_response = { + id: release_link.to_global_id.to_s, + name: release_link.name, + url: release_link.url, + linkType: release_link.link_type.upcase, + directAssetUrl: end_with(release_link.filepath), + external: true + }.with_indifferent_access + + expect(mutation_response[:link]).to match(expected_response) + expect(mutation_response[:errors]).to eq([]) + end +end diff --git a/spec/requests/api/graphql/mutations/snippets/create_spec.rb b/spec/requests/api/graphql/mutations/snippets/create_spec.rb index 1c2260070ec..d944c9e9e57 100644 --- a/spec/requests/api/graphql/mutations/snippets/create_spec.rb +++ b/spec/requests/api/graphql/mutations/snippets/create_spec.rb @@ -211,5 +211,9 @@ RSpec.describe 'Creating a Snippet' do end end end + + it_behaves_like 'has spam protection' do + let(:mutation_class) { ::Mutations::Snippets::Create } + end end end diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb index 43dc8d8bc44..28ab593526a 100644 --- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb +++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb @@ -157,6 +157,9 @@ RSpec.describe 'Updating a Snippet' do it_behaves_like 'graphql update actions' it_behaves_like 'when the snippet is not found' it_behaves_like 'snippet edit usage data counters' + it_behaves_like 'has spam protection' do + let(:mutation_class) { ::Mutations::Snippets::Update } + end end describe 'ProjectSnippet' do @@ -201,6 +204,10 @@ RSpec.describe 'Updating a Snippet' do end it_behaves_like 'snippet edit usage data counters' + + it_behaves_like 'has spam protection' do + let(:mutation_class) { ::Mutations::Snippets::Update } + end end it_behaves_like 'when the snippet is not found' diff --git a/spec/requests/api/graphql/packages/package_spec.rb b/spec/requests/api/graphql/packages/package_spec.rb index 654215041cb..a0131c7733e 100644 --- a/spec/requests/api/graphql/packages/package_spec.rb +++ b/spec/requests/api/graphql/packages/package_spec.rb @@ -2,33 +2,47 @@ require 'spec_helper' RSpec.describe 'package details' do - using RSpec::Parameterized::TableSyntax include GraphqlHelpers let_it_be(:project) { create(:project) } - let_it_be(:package) { create(:composer_package, project: project) } + let_it_be(:composer_package) { create(:composer_package, project: project) } let_it_be(:composer_json) { { name: 'name', type: 'type', license: 'license', version: 1 } } let_it_be(:composer_metadatum) do # we are forced to manually create the metadatum, without using the factory to force the sha to be a string # and avoid an error where gitaly can't find the repository - create(:composer_metadatum, package: package, target_sha: 'foo_sha', composer_json: composer_json) + create(:composer_metadatum, package: composer_package, target_sha: 'foo_sha', composer_json: composer_json) end let(:depth) { 3 } - let(:excluded) { %w[metadata apiFuzzingCiConfiguration pipeline] } + let(:excluded) { %w[metadata apiFuzzingCiConfiguration pipeline packageFiles] } + let(:metadata) { query_graphql_fragment('ComposerMetadata') } + let(:package_files) {all_graphql_fields_for('PackageFile')} + let(:package_files_metadata) {query_graphql_fragment('ConanFileMetadata')} let(:query) do graphql_query_for(:package, { id: package_global_id }, <<~FIELDS) - #{all_graphql_fields_for('Package', max_depth: depth, excluded: excluded)} + #{all_graphql_fields_for('PackageDetailsType', max_depth: depth, excluded: excluded)} metadata { - #{query_graphql_fragment('ComposerMetadata')} + #{metadata} + } + packageFiles { + nodes { + #{package_files} + fileMetadata { + #{package_files_metadata} + } + } } FIELDS end let(:user) { project.owner } - let(:package_global_id) { global_id_of(package) } + let(:package_global_id) { global_id_of(composer_package) } let(:package_details) { graphql_data_at(:package) } + let(:metadata_response) { graphql_data_at(:package, :metadata) } + let(:package_files_response) { graphql_data_at(:package, :package_files, :nodes) } + let(:first_file_response) { graphql_data_at(:package, :package_files, :nodes, 0)} + let(:first_file_response_metadata) { graphql_data_at(:package, :package_files, :nodes, 0, :file_metadata)} subject { post_graphql(query, current_user: user) } @@ -40,15 +54,68 @@ RSpec.describe 'package details' do it 'matches the JSON schema' do expect(package_details).to match_schema('graphql/packages/package_details') end + end + + describe 'Packages Metadata' do + before do + subject + end - it 'includes the fields of the correct package' do - expect(package_details).to include( - 'id' => package_global_id, - 'metadata' => { + describe 'Composer' do + it 'has the correct metadata' do + expect(metadata_response).to include( 'targetSha' => 'foo_sha', 'composerJson' => composer_json.transform_keys(&:to_s).transform_values(&:to_s) - } - ) + ) + end + + it 'does not have files' do + expect(package_files_response).to be_empty + end + end + + describe 'Conan' do + let_it_be(:conan_package) { create(:conan_package, project: project) } + + let(:package_global_id) { global_id_of(conan_package) } + let(:metadata) { query_graphql_fragment('ConanMetadata') } + let(:first_file) { conan_package.package_files.find { |f| global_id_of(f) == first_file_response['id'] } } + + it 'has the correct metadata' do + expect(metadata_response).to include( + 'id' => global_id_of(conan_package.conan_metadatum), + 'recipe' => conan_package.conan_metadatum.recipe, + 'packageChannel' => conan_package.conan_metadatum.package_channel, + 'packageUsername' => conan_package.conan_metadatum.package_username, + 'recipePath' => conan_package.conan_metadatum.recipe_path + ) + end + + it 'has the right amount of files' do + expect(package_files_response.length).to be(conan_package.package_files.length) + end + + it 'has the basic package files data' do + expect(first_file_response).to include( + 'id' => global_id_of(first_file), + 'fileName' => first_file.file_name, + 'size' => first_file.size.to_s, + 'downloadPath' => first_file.download_path, + 'fileSha1' => first_file.file_sha1, + 'fileMd5' => first_file.file_md5, + 'fileSha256' => first_file.file_sha256 + ) + end + + it 'has the correct file metadata' do + expect(first_file_response_metadata).to include( + 'id' => global_id_of(first_file.conan_file_metadatum), + 'packageRevision' => first_file.conan_file_metadatum.package_revision, + 'conanPackageReference' => first_file.conan_file_metadatum.conan_package_reference, + 'recipeRevision' => first_file.conan_file_metadatum.recipe_revision, + 'conanFileType' => first_file.conan_file_metadatum.conan_file_type.upcase + ) + end end end @@ -56,7 +123,7 @@ RSpec.describe 'package details' do let(:depth) { 3 } let(:excluded) { %w[metadata project tags pipelines] } # to limit the query complexity - let_it_be(:siblings) { create_list(:composer_package, 2, project: project, name: package.name) } + let_it_be(:siblings) { create_list(:composer_package, 2, project: project, name: composer_package.name) } it 'includes the sibling versions' do subject @@ -73,8 +140,32 @@ RSpec.describe 'package details' do subject expect(graphql_data_at(:package, :versions, :nodes, :version)).to be_present - expect(graphql_data_at(:package, :versions, :nodes, :versions)).not_to be_present + expect(graphql_data_at(:package, :versions, :nodes, :versions, :nodes)).to be_empty end end end + + context 'with a batched query' do + let_it_be(:conan_package) { create(:conan_package, project: project) } + + let(:batch_query) do + <<~QUERY + { + a: package(id: "#{global_id_of(composer_package)}") { name } + b: package(id: "#{global_id_of(conan_package)}") { name } + } + QUERY + end + + let(:a_packages_names) { graphql_data_at(:a, :packages, :nodes, :name) } + + it 'returns an error for the second package and data for the first' do + post_graphql(batch_query, current_user: user) + + expect(graphql_data_at(:a, :name)).to eq(composer_package.name) + + expect_graphql_errors_to_include [/Package details can be requested only for one package at a time/] + expect(graphql_data_at(:b)).to be(nil) + end + end end diff --git a/spec/requests/api/graphql/project/alert_management/alert/assignees_spec.rb b/spec/requests/api/graphql/project/alert_management/alert/assignees_spec.rb index 9ab94f1d749..a59402208ec 100644 --- a/spec/requests/api/graphql/project/alert_management/alert/assignees_spec.rb +++ b/spec/requests/api/graphql/project/alert_management/alert/assignees_spec.rb @@ -34,7 +34,7 @@ RSpec.describe 'getting Alert Management Alert Assignees' do end let(:alerts) { graphql_data.dig('project', 'alertManagementAlerts', 'nodes') } - let(:assignees) { alerts.map { |alert| [alert['iid'], alert['assignees']['nodes']] }.to_h } + let(:assignees) { alerts.to_h { |alert| [alert['iid'], alert['assignees']['nodes']] } } let(:first_assignees) { assignees[first_alert.iid.to_s] } let(:second_assignees) { assignees[second_alert.iid.to_s] } diff --git a/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb b/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb index 5d46f370756..72d185144ef 100644 --- a/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb +++ b/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb @@ -38,7 +38,7 @@ RSpec.describe 'getting Alert Management Alert Notes' do end let(:alerts_result) { graphql_data.dig('project', 'alertManagementAlerts', 'nodes') } - let(:notes_result) { alerts_result.map { |alert| [alert['iid'], alert['notes']['nodes']] }.to_h } + let(:notes_result) { alerts_result.to_h { |alert| [alert['iid'], alert['notes']['nodes']] } } let(:first_notes_result) { notes_result[first_alert.iid.to_s] } let(:second_notes_result) { notes_result[second_alert.iid.to_s] } diff --git a/spec/requests/api/graphql/project/alert_management/alert/todos_spec.rb b/spec/requests/api/graphql/project/alert_management/alert/todos_spec.rb index 3a9077061ad..ca58079fdfe 100644 --- a/spec/requests/api/graphql/project/alert_management/alert/todos_spec.rb +++ b/spec/requests/api/graphql/project/alert_management/alert/todos_spec.rb @@ -34,7 +34,7 @@ RSpec.describe 'getting Alert Management Alert Assignees' do end let(:gql_alerts) { graphql_data.dig('project', 'alertManagementAlerts', 'nodes') } - let(:gql_todos) { gql_alerts.map { |gql_alert| [gql_alert['iid'], gql_alert['todos']['nodes']] }.to_h } + let(:gql_todos) { gql_alerts.to_h { |gql_alert| [gql_alert['iid'], gql_alert['todos']['nodes']] } } let(:gql_alert_todo) { gql_todos[alert.iid.to_s].first } let(:gql_other_alert_todo) { gql_todos[other_alert.iid.to_s].first } diff --git a/spec/requests/api/graphql/project/alert_management/integrations_spec.rb b/spec/requests/api/graphql/project/alert_management/integrations_spec.rb index b13805a61ce..0e029aee9e8 100644 --- a/spec/requests/api/graphql/project/alert_management/integrations_spec.rb +++ b/spec/requests/api/graphql/project/alert_management/integrations_spec.rb @@ -13,6 +13,8 @@ RSpec.describe 'getting Alert Management Integrations' do let_it_be(:inactive_http_integration) { create(:alert_management_http_integration, :inactive, project: project) } let_it_be(:other_project_http_integration) { create(:alert_management_http_integration) } + let(:params) { {} } + let(:fields) do <<~QUERY nodes { @@ -25,7 +27,7 @@ RSpec.describe 'getting Alert Management Integrations' do graphql_query_for( 'project', { 'fullPath' => project.full_path }, - query_graphql_field('alertManagementIntegrations', {}, fields) + query_graphql_field('alertManagementIntegrations', params, fields) ) end @@ -50,34 +52,78 @@ RSpec.describe 'getting Alert Management Integrations' do post_graphql(query, current_user: current_user) end - let(:http_integration) { integrations.first } - let(:prometheus_integration) { integrations.second } + context 'when no extra params given' do + let(:http_integration) { integrations.first } + let(:prometheus_integration) { integrations.second } - it_behaves_like 'a working graphql query' + it_behaves_like 'a working graphql query' + + it { expect(integrations.size).to eq(2) } + + it 'returns the correct properties of the integrations' do + expect(http_integration).to include( + 'id' => global_id_of(active_http_integration), + 'type' => 'HTTP', + 'name' => active_http_integration.name, + 'active' => active_http_integration.active, + 'token' => active_http_integration.token, + 'url' => active_http_integration.url, + 'apiUrl' => nil + ) - it { expect(integrations.size).to eq(2) } - - it 'returns the correct properties of the integrations' do - expect(http_integration).to include( - 'id' => GitlabSchema.id_from_object(active_http_integration).to_s, - 'type' => 'HTTP', - 'name' => active_http_integration.name, - 'active' => active_http_integration.active, - 'token' => active_http_integration.token, - 'url' => active_http_integration.url, - 'apiUrl' => nil - ) - - expect(prometheus_integration).to include( - 'id' => GitlabSchema.id_from_object(prometheus_service).to_s, - 'type' => 'PROMETHEUS', - 'name' => 'Prometheus', - 'active' => prometheus_service.manual_configuration?, - 'token' => project_alerting_setting.token, - 'url' => "http://localhost/#{project.full_path}/prometheus/alerts/notify.json", - 'apiUrl' => prometheus_service.api_url - ) + expect(prometheus_integration).to include( + 'id' => global_id_of(prometheus_service), + 'type' => 'PROMETHEUS', + 'name' => 'Prometheus', + 'active' => prometheus_service.manual_configuration?, + 'token' => project_alerting_setting.token, + 'url' => "http://localhost/#{project.full_path}/prometheus/alerts/notify.json", + 'apiUrl' => prometheus_service.api_url + ) + end end + + context 'when HTTP Integration ID is given' do + let(:params) { { id: global_id_of(active_http_integration) } } + + it_behaves_like 'a working graphql query' + + it { expect(integrations).to be_one } + + it 'returns the correct properties of the HTTP integration' do + expect(integrations.first).to include( + 'id' => global_id_of(active_http_integration), + 'type' => 'HTTP', + 'name' => active_http_integration.name, + 'active' => active_http_integration.active, + 'token' => active_http_integration.token, + 'url' => active_http_integration.url, + 'apiUrl' => nil + ) + end + end + + context 'when Prometheus Integration ID is given' do + let(:params) { { id: global_id_of(prometheus_service) } } + + it_behaves_like 'a working graphql query' + + it { expect(integrations).to be_one } + + it 'returns the correct properties of the Prometheus Integration' do + expect(integrations.first).to include( + 'id' => global_id_of(prometheus_service), + 'type' => 'PROMETHEUS', + 'name' => 'Prometheus', + 'active' => prometheus_service.manual_configuration?, + 'token' => project_alerting_setting.token, + 'url' => "http://localhost/#{project.full_path}/prometheus/alerts/notify.json", + 'apiUrl' => prometheus_service.api_url + ) + end + end + + it_behaves_like 'GraphQL query with several integrations requested', graphql_query_name: 'alertManagementIntegrations' end end end diff --git a/spec/requests/api/graphql/project/container_repositories_spec.rb b/spec/requests/api/graphql/project/container_repositories_spec.rb index 5ffd48a7bc4..3ad56223b61 100644 --- a/spec/requests/api/graphql/project/container_repositories_spec.rb +++ b/spec/requests/api/graphql/project/container_repositories_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'getting container repositories in a project' do <<~GQL edges { node { - #{all_graphql_fields_for('container_repositories'.classify, excluded: ['pipeline'])} + #{all_graphql_fields_for('container_repositories'.classify, excluded: %w(pipeline jobs))} } } GQL diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb index 9c915075c42..dd9d44136e5 100644 --- a/spec/requests/api/graphql/project/issues_spec.rb +++ b/spec/requests/api/graphql/project/issues_spec.rb @@ -5,14 +5,15 @@ require 'spec_helper' RSpec.describe 'getting an issue list for a project' do include GraphqlHelpers - let(:issues_data) { graphql_data['project']['issues']['edges'] } - let_it_be(:project) { create(:project, :repository, :public) } let_it_be(:current_user) { create(:user) } let_it_be(:issue_a, reload: true) { create(:issue, project: project, discussion_locked: true) } let_it_be(:issue_b, reload: true) { create(:issue, :with_alert, project: project) } let_it_be(:issues, reload: true) { [issue_a, issue_b] } + let(:issues_data) { graphql_data['project']['issues']['edges'] } + let(:issue_filter_params) { {} } + let(:fields) do <<~QUERY edges { @@ -27,7 +28,7 @@ RSpec.describe 'getting an issue list for a project' do graphql_query_for( 'project', { 'fullPath' => project.full_path }, - query_graphql_field('issues', {}, fields) + query_graphql_field('issues', issue_filter_params, fields) ) end @@ -50,6 +51,16 @@ RSpec.describe 'getting an issue list for a project' do expect(issues_data[1]['node']['discussionLocked']).to eq(true) end + context 'when both assignee_username filters are provided' do + let(:issue_filter_params) { { assignee_username: current_user.username, assignee_usernames: [current_user.username] } } + + it 'returns a mutually exclusive param error' do + post_graphql(query, current_user: current_user) + + expect_graphql_errors_to_include('only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.') + end + end + context 'when limiting the number of results' do let(:query) do <<~GQL @@ -76,7 +87,7 @@ RSpec.describe 'getting an issue list for a project' do end end - context 'no limit is provided' do + context 'when no limit is provided' do let(:issue_limit) { nil } it 'returns all issues' do @@ -143,13 +154,15 @@ RSpec.describe 'getting an issue list for a project' do let_it_be(:data_path) { [:project, :issues] } def pagination_query(params) - graphql_query_for(:project, { full_path: sort_project.full_path }, + graphql_query_for( + :project, + { full_path: sort_project.full_path }, query_graphql_field(:issues, params, "#{page_info} nodes { iid }") ) end def pagination_results_data(data) - data.map { |issue| issue.dig('iid').to_i } + data.map { |issue| issue['iid'].to_i } end context 'when sorting by due date' do @@ -189,27 +202,38 @@ RSpec.describe 'getting an issue list for a project' do it_behaves_like 'sorted paginated query' do let(:sort_param) { :RELATIVE_POSITION_ASC } let(:first_param) { 2 } - let(:expected_results) { [relative_issue5.iid, relative_issue3.iid, relative_issue1.iid, relative_issue4.iid, relative_issue2.iid] } + let(:expected_results) do + [ + relative_issue5.iid, relative_issue3.iid, relative_issue1.iid, + relative_issue4.iid, relative_issue2.iid + ] + end end end end context 'when sorting by priority' do let_it_be(:sort_project) { create(:project, :public) } - let_it_be(:early_milestone) { create(:milestone, project: sort_project, due_date: 10.days.from_now) } - let_it_be(:late_milestone) { create(:milestone, project: sort_project, due_date: 30.days.from_now) } - let_it_be(:priority_label1) { create(:label, project: sort_project, priority: 1) } - let_it_be(:priority_label2) { create(:label, project: sort_project, priority: 5) } - let_it_be(:priority_issue1) { create(:issue, project: sort_project, labels: [priority_label1], milestone: late_milestone) } - let_it_be(:priority_issue2) { create(:issue, project: sort_project, labels: [priority_label2]) } - let_it_be(:priority_issue3) { create(:issue, project: sort_project, milestone: early_milestone) } - let_it_be(:priority_issue4) { create(:issue, project: sort_project) } + let_it_be(:on_project) { { project: sort_project } } + let_it_be(:early_milestone) { create(:milestone, **on_project, due_date: 10.days.from_now) } + let_it_be(:late_milestone) { create(:milestone, **on_project, due_date: 30.days.from_now) } + let_it_be(:priority_1) { create(:label, **on_project, priority: 1) } + let_it_be(:priority_2) { create(:label, **on_project, priority: 5) } + let_it_be(:priority_issue1) { create(:issue, **on_project, labels: [priority_1], milestone: late_milestone) } + let_it_be(:priority_issue2) { create(:issue, **on_project, labels: [priority_2]) } + let_it_be(:priority_issue3) { create(:issue, **on_project, milestone: early_milestone) } + let_it_be(:priority_issue4) { create(:issue, **on_project) } context 'when ascending' do it_behaves_like 'sorted paginated query' do let(:sort_param) { :PRIORITY_ASC } let(:first_param) { 2 } - let(:expected_results) { [priority_issue3.iid, priority_issue1.iid, priority_issue2.iid, priority_issue4.iid] } + let(:expected_results) do + [ + priority_issue3.iid, priority_issue1.iid, + priority_issue2.iid, priority_issue4.iid + ] + end end end @@ -217,7 +241,9 @@ RSpec.describe 'getting an issue list for a project' do it_behaves_like 'sorted paginated query' do let(:sort_param) { :PRIORITY_DESC } let(:first_param) { 2 } - let(:expected_results) { [priority_issue1.iid, priority_issue3.iid, priority_issue2.iid, priority_issue4.iid] } + let(:expected_results) do + [priority_issue1.iid, priority_issue3.iid, priority_issue2.iid, priority_issue4.iid] + end end end end @@ -275,7 +301,7 @@ RSpec.describe 'getting an issue list for a project' do end end - context 'fetching alert management alert' do + context 'when fetching alert management alert' do let(:fields) do <<~QUERY edges { @@ -297,7 +323,7 @@ RSpec.describe 'getting an issue list for a project' do it 'avoids N+1 queries' do control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) } - create(:alert_management_alert, :with_issue, project: project ) + create(:alert_management_alert, :with_issue, project: project) expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control) end @@ -312,7 +338,7 @@ RSpec.describe 'getting an issue list for a project' do end end - context 'fetching labels' do + context 'when fetching labels' do let(:fields) do <<~QUERY edges { @@ -362,7 +388,7 @@ RSpec.describe 'getting an issue list for a project' do end end - context 'fetching assignees' do + context 'when fetching assignees' do let(:fields) do <<~QUERY edges { @@ -420,9 +446,10 @@ RSpec.describe 'getting an issue list for a project' do query = graphql_query_for( :project, { full_path: project.full_path }, - query_graphql_field(:issues, search_params, [ + query_graphql_field( + :issues, search_params, query_graphql_field(:nodes, nil, requested_fields) - ]) + ) ) post_graphql(query, current_user: current_user) end @@ -448,5 +475,16 @@ RSpec.describe 'getting an issue list for a project' do include_examples 'N+1 query check' end + + context 'when requesting `timelogs`' do + let(:requested_fields) { 'timelogs { nodes { timeSpent } }' } + + before do + create_list(:issue_timelog, 2, issue: issue_a) + create(:issue_timelog, issue: issue_b) + end + + include_examples 'N+1 query check' + end end end diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb index e32899c600e..15551005502 100644 --- a/spec/requests/api/graphql/project/merge_request_spec.rb +++ b/spec/requests/api/graphql/project/merge_request_spec.rb @@ -5,21 +5,25 @@ require 'spec_helper' RSpec.describe 'getting merge request information nested in a project' do include GraphqlHelpers - let(:project) { create(:project, :repository, :public) } - let(:current_user) { create(:user) } - let(:merge_request_graphql_data) { graphql_data['project']['mergeRequest'] } - let!(:merge_request) { create(:merge_request, source_project: project) } - let(:mr_fields) { all_graphql_fields_for('MergeRequest', excluded: ['pipeline']) } + let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:current_user) { create(:user) } + let_it_be_with_reload(:merge_request) { create(:merge_request, source_project: project) } + + let(:merge_request_graphql_data) { graphql_data_at(:project, :merge_request) } + let(:mr_fields) { all_graphql_fields_for('MergeRequest', max_depth: 1) } let(:query) do graphql_query_for( - 'project', - { 'fullPath' => project.full_path }, - query_graphql_field('mergeRequest', { iid: merge_request.iid.to_s }, mr_fields) + :project, + { full_path: project.full_path }, + query_graphql_field(:merge_request, { iid: merge_request.iid.to_s }, mr_fields) ) end it_behaves_like 'a working graphql query' do + # we exclude Project.pipeline because it needs arguments + let(:mr_fields) { all_graphql_fields_for('MergeRequest', excluded: %w[jobs pipeline]) } + before do post_graphql(query, current_user: current_user) end @@ -38,13 +42,17 @@ RSpec.describe 'getting merge request information nested in a project' do expect(merge_request_graphql_data['webUrl']).to be_present end - it 'includes author' do - post_graphql(query, current_user: current_user) + context 'when selecting author' do + let(:mr_fields) { 'author { username }' } - expect(merge_request_graphql_data['author']['username']).to eq(merge_request.author.username) + it 'includes author' do + post_graphql(query, current_user: current_user) + + expect(merge_request_graphql_data['author']['username']).to eq(merge_request.author.username) + end end - context 'the merge_request has reviewers' do + context 'when the merge_request has reviewers' do let(:mr_fields) do <<~SELECT reviewers { nodes { id username } } @@ -68,63 +76,76 @@ RSpec.describe 'getting merge request information nested in a project' do end end - it 'includes diff stats' do - be_natural = an_instance_of(Integer).and(be >= 0) - - post_graphql(query, current_user: current_user) - - sums = merge_request_graphql_data['diffStats'].reduce([0, 0, 0]) do |(a, d, c), node| - a_, d_ = node.values_at('additions', 'deletions') - [a + a_, d + d_, c + a_ + d_] + describe 'diffStats' do + let(:mr_fields) do + <<~FIELDS + diffStats { #{all_graphql_fields_for('DiffStats')} } + diffStatsSummary { #{all_graphql_fields_for('DiffStatsSummary')} } + FIELDS end - expect(merge_request_graphql_data).to include( - 'diffStats' => all(a_hash_including('path' => String, 'additions' => be_natural, 'deletions' => be_natural)), - 'diffStatsSummary' => a_hash_including( - 'fileCount' => merge_request.diff_stats.count, - 'additions' => be_natural, - 'deletions' => be_natural, - 'changes' => be_natural - ) - ) + it 'includes diff stats' do + be_natural = an_instance_of(Integer).and(be >= 0) - # diff_stats is consistent with summary - expect(merge_request_graphql_data['diffStatsSummary'] - .values_at('additions', 'deletions', 'changes')).to eq(sums) - - # diff_stats_summary is internally consistent - expect(merge_request_graphql_data['diffStatsSummary'] - .values_at('additions', 'deletions').sum) - .to eq(merge_request_graphql_data.dig('diffStatsSummary', 'changes')) - .and be_positive - end + post_graphql(query, current_user: current_user) - context 'requesting a specific diff stat' do - let(:diff_stat) { merge_request.diff_stats.first } + sums = merge_request_graphql_data['diffStats'].reduce([0, 0, 0]) do |(a, d, c), node| + a_, d_ = node.values_at('additions', 'deletions') + [a + a_, d + d_, c + a_ + d_] + end - let(:query) do - graphql_query_for(:project, { full_path: project.full_path }, - query_graphql_field(:merge_request, { iid: merge_request.iid.to_s }, [ - query_graphql_field(:diff_stats, { path: diff_stat.path }, all_graphql_fields_for('DiffStats')) - ]) + expect(merge_request_graphql_data).to include( + 'diffStats' => all(a_hash_including('path' => String, 'additions' => be_natural, 'deletions' => be_natural)), + 'diffStatsSummary' => a_hash_including( + 'fileCount' => merge_request.diff_stats.count, + 'additions' => be_natural, + 'deletions' => be_natural, + 'changes' => be_natural + ) ) + + # diff_stats is consistent with summary + expect(merge_request_graphql_data['diffStatsSummary'] + .values_at('additions', 'deletions', 'changes')).to eq(sums) + + # diff_stats_summary is internally consistent + expect(merge_request_graphql_data['diffStatsSummary'] + .values_at('additions', 'deletions').sum) + .to eq(merge_request_graphql_data.dig('diffStatsSummary', 'changes')) + .and be_positive end - it 'includes only the requested stats' do - post_graphql(query, current_user: current_user) + context 'when requesting a specific diff stat' do + let(:diff_stat) { merge_request.diff_stats.first } - expect(merge_request_graphql_data).to include( - 'diffStats' => contain_exactly( - a_hash_including('path' => diff_stat.path, 'additions' => diff_stat.additions, 'deletions' => diff_stat.deletions) + let(:mr_fields) do + query_graphql_field( + :diff_stats, + { path: diff_stat.path }, + all_graphql_fields_for('DiffStats') ) - ) + end + + it 'includes only the requested stats' do + post_graphql(query, current_user: current_user) + + expect(merge_request_graphql_data).to include( + 'diffStats' => contain_exactly( + a_hash_including( + 'path' => diff_stat.path, + 'additions' => diff_stat.additions, + 'deletions' => diff_stat.deletions + ) + ) + ) + end end end it 'includes correct mergedAt value when merged' do time = 1.week.ago merge_request.mark_as_merged - merge_request.metrics.update_columns(merged_at: time) + merge_request.metrics.update!(merged_at: time) post_graphql(query, current_user: current_user) retrieved = merge_request_graphql_data['mergedAt'] @@ -139,7 +160,11 @@ RSpec.describe 'getting merge request information nested in a project' do expect(retrieved).to be_nil end - context 'permissions on the merge request' do + describe 'permissions on the merge request' do + let(:mr_fields) do + "userPermissions { #{all_graphql_fields_for('MergeRequestPermissions')} }" + end + it 'includes the permissions for the current user on a public project' do expected_permissions = { 'readMergeRequest' => true, @@ -162,8 +187,6 @@ RSpec.describe 'getting merge request information nested in a project' do end context 'when the user does not have access to the merge request' do - let(:project) { create(:project, :public, :repository) } - it 'returns nil' do project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE) @@ -174,13 +197,23 @@ RSpec.describe 'getting merge request information nested in a project' do end context 'when there are pipelines' do - before do + let_it_be(:pipeline) do create( :ci_pipeline, project: merge_request.source_project, ref: merge_request.source_branch, sha: merge_request.diff_head_sha ) + end + + let(:mr_fields) do + <<~FIELDS + headPipeline { id } + pipelines { nodes { id } } + FIELDS + end + + before do merge_request.update_head_pipeline end @@ -193,20 +226,12 @@ RSpec.describe 'getting merge request information nested in a project' do it 'has pipeline connections' do post_graphql(query, current_user: current_user) - expect(merge_request_graphql_data['pipelines']['edges'].size).to eq(1) + expect(merge_request_graphql_data['pipelines']['nodes']).to be_one end end context 'when limiting the number of results' do - let(:merge_requests_graphql_data) { graphql_data['project']['mergeRequests']['edges'] } - - let!(:merge_requests) do - [ - create(:merge_request, source_project: project, source_branch: 'branch-1'), - create(:merge_request, source_project: project, source_branch: 'branch-2'), - create(:merge_request, source_project: project, source_branch: 'branch-3') - ] - end + let(:merge_requests_graphql_data) { graphql_data_at(:project, :merge_requests, :edges) } let(:fields) do <<~QUERY @@ -228,6 +253,10 @@ RSpec.describe 'getting merge request information nested in a project' do end it 'returns the correct number of results' do + create(:merge_request, source_project: project, source_branch: 'branch-1') + create(:merge_request, source_project: project, source_branch: 'branch-2') + create(:merge_request, source_project: project, source_branch: 'branch-3') + post_graphql(query, current_user: current_user) expect(merge_requests_graphql_data.size).to eq 2 @@ -281,4 +310,129 @@ RSpec.describe 'getting merge request information nested in a project' do ) end end + + context 'when requesting information about MR interactions' do + let_it_be(:user) { create(:user) } + + let(:selected_fields) { all_graphql_fields_for('UserMergeRequestInteraction') } + + let(:mr_fields) do + query_nodes( + :reviewers, + query_graphql_field(:merge_request_interaction, nil, selected_fields) + ) + end + + def interaction_data + graphql_data_at(:project, :merge_request, :reviewers, :nodes, :merge_request_interaction) + end + + context 'when the user does not have interactions' do + it 'returns null data' do + post_graphql(query) + + expect(interaction_data).to be_empty + end + end + + context 'when the user is a reviewer, but has not reviewed' do + before do + project.add_guest(user) + merge_request.merge_request_reviewers.create!(reviewer: user) + end + + it 'returns falsey values' do + post_graphql(query) + + expect(interaction_data).to contain_exactly a_hash_including( + 'canMerge' => false, + 'canUpdate' => false, + 'reviewState' => 'UNREVIEWED', + 'reviewed' => false, + 'approved' => false + ) + end + end + + context 'when the user has interacted' do + before do + project.add_maintainer(user) + merge_request.merge_request_reviewers.create!(reviewer: user, state: 'reviewed') + merge_request.approved_by_users << user + end + + it 'returns appropriate data' do + post_graphql(query) + enum = ::Types::MergeRequestReviewStateEnum.values['REVIEWED'] + + expect(interaction_data).to contain_exactly a_hash_including( + 'canMerge' => true, + 'canUpdate' => true, + 'reviewState' => enum.graphql_name, + 'reviewed' => true, + 'approved' => true + ) + end + end + + describe 'scalability' do + let_it_be(:other_users) { create_list(:user, 3) } + + let(:unreviewed) do + { 'reviewState' => 'UNREVIEWED' } + end + + let(:reviewed) do + { 'reviewState' => 'REVIEWED' } + end + + shared_examples 'scalable query for interaction fields' do + before do + ([user] + other_users).each { project.add_guest(_1) } + end + + it 'does not suffer from N+1' do + merge_request.merge_request_reviewers.create!(reviewer: user, state: 'reviewed') + + baseline = ActiveRecord::QueryRecorder.new do + post_graphql(query) + end + + expect(interaction_data).to contain_exactly(include(reviewed)) + + other_users.each do |user| + merge_request.merge_request_reviewers.create!(reviewer: user) + end + + expect { post_graphql(query) }.not_to exceed_query_limit(baseline) + + expect(interaction_data).to contain_exactly( + include(unreviewed), + include(unreviewed), + include(unreviewed), + include(reviewed) + ) + end + end + + context 'when selecting only known scalable fields' do + let(:not_scalable) { %w[canUpdate canMerge] } + let(:selected_fields) do + all_graphql_fields_for('UserMergeRequestInteraction', excluded: not_scalable) + end + + it_behaves_like 'scalable query for interaction fields' + end + + context 'when selecting all fields' do + before do + pending "See: https://gitlab.com/gitlab-org/gitlab/-/issues/322549" + end + + let(:selected_fields) { all_graphql_fields_for('UserMergeRequestInteraction') } + + it_behaves_like 'scalable query for interaction fields' + end + end + end end diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb index d97a0ed9399..7fc1ef05fa7 100644 --- a/spec/requests/api/graphql/project/merge_requests_spec.rb +++ b/spec/requests/api/graphql/project/merge_requests_spec.rb @@ -47,10 +47,10 @@ RSpec.describe 'getting merge request listings nested in a project' do end before do - # We cannot call the whitelist here, since the transaction does not + # We cannot disable SQL query limiting here, since the transaction does not # begin until we enter the controller. headers = { - 'X-GITLAB-QUERY-WHITELIST-ISSUE' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/322979' + 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/322979' } post_graphql(query, current_user: current_user, headers: headers) @@ -299,6 +299,7 @@ RSpec.describe 'getting merge request listings nested in a project' do reviewers { nodes { username } } participants { nodes { username } } headPipeline { status } + timelogs { nodes { timeSpent } } SELECT end @@ -307,7 +308,7 @@ RSpec.describe 'getting merge request listings nested in a project' do query($first: Int) { project(fullPath: "#{project.full_path}") { mergeRequests(first: $first) { - nodes { #{mr_fields} } + nodes { iid #{mr_fields} } } } } @@ -324,6 +325,7 @@ RSpec.describe 'getting merge request listings nested in a project' do mr.assignees << current_user mr.reviewers << create(:user) mr.reviewers << current_user + mr.timelogs << create(:merge_request_timelog, merge_request: mr) end end @@ -345,7 +347,7 @@ RSpec.describe 'getting merge request listings nested in a project' do end def user_collection - { 'nodes' => all(match(a_hash_including('username' => be_present))) } + { 'nodes' => be_present.and(all(match(a_hash_including('username' => be_present)))) } end it 'returns appropriate results' do @@ -358,7 +360,8 @@ RSpec.describe 'getting merge request listings nested in a project' do 'assignees' => user_collection, 'reviewers' => user_collection, 'participants' => user_collection, - 'headPipeline' => { 'status' => be_present } + 'headPipeline' => { 'status' => be_present }, + 'timelogs' => { 'nodes' => be_one } ))) end diff --git a/spec/requests/api/graphql/project/pipeline_spec.rb b/spec/requests/api/graphql/project/pipeline_spec.rb index cc028ff2ff9..0a5bcc7a965 100644 --- a/spec/requests/api/graphql/project/pipeline_spec.rb +++ b/spec/requests/api/graphql/project/pipeline_spec.rb @@ -5,24 +5,28 @@ require 'spec_helper' RSpec.describe 'getting pipeline information nested in a project' do include GraphqlHelpers - let!(:project) { create(:project, :repository, :public) } - let!(:pipeline) { create(:ci_pipeline, project: project) } - let!(:current_user) { create(:user) } - let(:pipeline_graphql_data) { graphql_data['project']['pipeline'] } - - let!(:query) do - %( - query { - project(fullPath: "#{project.full_path}") { - pipeline(iid: "#{pipeline.iid}") { - configSource - } - } - } + let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:current_user) { create(:user) } + let_it_be(:build_job) { create(:ci_build, :trace_with_sections, name: 'build-a', pipeline: pipeline) } + let_it_be(:failed_build) { create(:ci_build, :failed, name: 'failed-build', pipeline: pipeline) } + let_it_be(:bridge) { create(:ci_bridge, name: 'ci-bridge-example', pipeline: pipeline) } + + let(:path) { %i[project pipeline] } + let(:pipeline_graphql_data) { graphql_data_at(*path) } + let(:depth) { 3 } + let(:excluded) { %w[job project] } # Project is very expensive, due to the number of fields + let(:fields) { all_graphql_fields_for('Pipeline', excluded: excluded, max_depth: depth) } + + let(:query) do + graphql_query_for( + :project, + { full_path: project.full_path }, + query_graphql_field(:pipeline, { iid: pipeline.iid.to_s }, fields) ) end - it_behaves_like 'a working graphql query' do + it_behaves_like 'a working graphql query', :use_clean_rails_memory_store_caching, :request_store do before do post_graphql(query, current_user: current_user) end @@ -37,14 +41,18 @@ RSpec.describe 'getting pipeline information nested in a project' do it 'contains configSource' do post_graphql(query, current_user: current_user) - expect(pipeline_graphql_data.dig('configSource')).to eq('UNKNOWN_SOURCE') + expect(pipeline_graphql_data['configSource']).to eq('UNKNOWN_SOURCE') end - context 'batching' do - let!(:pipeline2) { create(:ci_pipeline, project: project, user: current_user, builds: [create(:ci_build, :success)]) } - let!(:pipeline3) { create(:ci_pipeline, project: project, user: current_user, builds: [create(:ci_build, :success)]) } + context 'when batching' do + let!(:pipeline2) { successful_pipeline } + let!(:pipeline3) { successful_pipeline } let!(:query) { build_query_to_find_pipeline_shas(pipeline, pipeline2, pipeline3) } + def successful_pipeline + create(:ci_pipeline, project: project, user: current_user, builds: [create(:ci_build, :success)]) + end + it 'executes the finder once' do mock = double(Ci::PipelinesFinder) opts = { iids: [pipeline.iid, pipeline2.iid, pipeline3.iid].map(&:to_s) } @@ -80,4 +88,198 @@ RSpec.describe 'getting pipeline information nested in a project' do graphql_query_for('project', { 'fullPath' => project.full_path }, pipeline_fields) end + + context 'when enough data is requested' do + let(:fields) do + query_graphql_field(:jobs, nil, + query_graphql_field(:nodes, {}, all_graphql_fields_for('CiJob', max_depth: 3))) + end + + it 'contains jobs' do + post_graphql(query, current_user: current_user) + + expect(graphql_data_at(*path, :jobs, :nodes)).to contain_exactly( + a_hash_including( + 'name' => build_job.name, + 'status' => build_job.status.upcase, + 'duration' => build_job.duration + ), + a_hash_including( + 'id' => global_id_of(failed_build), + 'status' => failed_build.status.upcase + ), + a_hash_including( + 'id' => global_id_of(bridge), + 'status' => bridge.status.upcase + ) + ) + end + end + + context 'when requesting only builds with certain statuses' do + let(:variables) do + { + path: project.full_path, + pipelineIID: pipeline.iid.to_s, + status: :FAILED + } + end + + let(:query) do + <<~GQL + query($path: ID!, $pipelineIID: ID!, $status: CiJobStatus!) { + project(fullPath: $path) { + pipeline(iid: $pipelineIID) { + jobs(statuses: [$status]) { + nodes { + #{all_graphql_fields_for('CiJob', max_depth: 1)} + } + } + } + } + } + GQL + end + + it 'can filter build jobs by status' do + post_graphql(query, current_user: current_user, variables: variables) + + expect(graphql_data_at(*path, :jobs, :nodes)) + .to contain_exactly(a_hash_including('id' => global_id_of(failed_build))) + end + end + + context 'when requesting a specific job' do + let(:variables) do + { + path: project.full_path, + pipelineIID: pipeline.iid.to_s + } + end + + let(:build_fields) do + all_graphql_fields_for('CiJob', max_depth: 1) + end + + let(:query) do + <<~GQL + query($path: ID!, $pipelineIID: ID!, $jobName: String, $jobID: JobID) { + project(fullPath: $path) { + pipeline(iid: $pipelineIID) { + job(id: $jobID, name: $jobName) { + #{build_fields} + } + } + } + } + GQL + end + + let(:the_job) do + a_hash_including('name' => build_job.name, 'id' => global_id_of(build_job)) + end + + it 'can request a build by name' do + vars = variables.merge(jobName: build_job.name) + + post_graphql(query, current_user: current_user, variables: vars) + + expect(graphql_data_at(*path, :job)).to match(the_job) + end + + it 'can request a build by ID' do + vars = variables.merge(jobID: global_id_of(build_job)) + + post_graphql(query, current_user: current_user, variables: vars) + + expect(graphql_data_at(*path, :job)).to match(the_job) + end + + context 'when we request nested fields of the build' do + let_it_be(:needy) { create(:ci_build, :dependent, pipeline: pipeline) } + + let(:build_fields) { 'needs { nodes { name } }' } + let(:vars) { variables.merge(jobID: global_id_of(needy)) } + + it 'returns the nested data' do + post_graphql(query, current_user: current_user, variables: vars) + + expect(graphql_data_at(*path, :job, :needs, :nodes)).to contain_exactly( + a_hash_including('name' => needy.needs.first.name) + ) + end + + it 'requires a constant number of queries' do + fst_user = create(:user) + snd_user = create(:user) + path = %i[project pipeline job needs nodes name] + + baseline = ActiveRecord::QueryRecorder.new do + post_graphql(query, current_user: fst_user, variables: vars) + end + + expect(baseline.count).to be > 0 + dep_names = graphql_dig_at(graphql_data(fresh_response_data), *path) + + deps = create_list(:ci_build, 3, :unique_name, pipeline: pipeline) + deps.each { |d| create(:ci_build_need, build: needy, name: d.name) } + + expect do + post_graphql(query, current_user: snd_user, variables: vars) + end.not_to exceed_query_limit(baseline) + + more_names = graphql_dig_at(graphql_data(fresh_response_data), *path) + + expect(more_names).to include(*dep_names) + expect(more_names.count).to be > dep_names.count + end + end + end + + context 'when requesting a specific test suite' do + let_it_be(:pipeline) { create(:ci_pipeline, :with_test_reports, project: project) } + let(:suite_name) { 'test' } + let_it_be(:build_ids) { pipeline.latest_builds.pluck(:id) } + + let(:variables) do + { + path: project.full_path, + pipelineIID: pipeline.iid.to_s + } + end + + let(:query) do + <<~GQL + query($path: ID!, $pipelineIID: ID!, $buildIds: [ID!]!) { + project(fullPath: $path) { + pipeline(iid: $pipelineIID) { + testSuite(buildIds: $buildIds) { + name + } + } + } + } + GQL + end + + it 'can request a test suite by an array of build_ids' do + vars = variables.merge(buildIds: build_ids) + + post_graphql(query, current_user: current_user, variables: vars) + + expect(graphql_data_at(:project, :pipeline, :testSuite, :name)).to eq(suite_name) + end + + context 'when pipeline has no builds that matches the given build_ids' do + let_it_be(:build_ids) { [non_existing_record_id] } + + it 'returns nil' do + vars = variables.merge(buildIds: build_ids) + + post_graphql(query, current_user: current_user, variables: vars) + + expect(graphql_data_at(*path, :test_suite)).to be_nil + end + end + end end diff --git a/spec/requests/api/graphql/project/repository/blobs_spec.rb b/spec/requests/api/graphql/project/repository/blobs_spec.rb new file mode 100644 index 00000000000..12f6fbd793e --- /dev/null +++ b/spec/requests/api/graphql/project/repository/blobs_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe 'getting blobs in a project repository' do + include GraphqlHelpers + + let(:project) { create(:project, :repository) } + let(:current_user) { project.owner } + let(:paths) { ["CONTRIBUTING.md", "README.md"] } + let(:ref) { project.default_branch } + let(:fields) do + <<~QUERY + blobs(paths:#{paths.inspect}, ref:#{ref.inspect}) { + nodes { + #{all_graphql_fields_for('repository_blob'.classify)} + } + } + QUERY + end + + let(:query) do + graphql_query_for( + 'project', + { 'fullPath' => project.full_path }, + query_graphql_field('repository', {}, fields) + ) + end + + subject(:blobs) { graphql_data_at(:project, :repository, :blobs, :nodes) } + + it 'returns the blob' do + post_graphql(query, current_user: current_user) + + expect(blobs).to match_array(paths.map { |path| a_hash_including('path' => path) }) + end +end diff --git a/spec/requests/api/graphql/user_spec.rb b/spec/requests/api/graphql/user_spec.rb index d2d6b1fca66..a3b2b750bc3 100644 --- a/spec/requests/api/graphql/user_spec.rb +++ b/spec/requests/api/graphql/user_spec.rb @@ -42,7 +42,13 @@ RSpec.describe 'User' do end context 'when username and id parameter are used' do - let_it_be(:query) { graphql_query_for(:user, { id: current_user.to_global_id.to_s, username: current_user.username }, 'id') } + let_it_be(:query) do + graphql_query_for( + :user, + { id: current_user.to_global_id.to_s, username: current_user.username }, + 'id' + ) + end it 'displays an error' do post_graphql(query) |