diff options
Diffstat (limited to 'spec/requests/api/graphql/mutations/work_items')
6 files changed, 914 insertions, 105 deletions
diff --git a/spec/requests/api/graphql/mutations/work_items/convert_spec.rb b/spec/requests/api/graphql/mutations/work_items/convert_spec.rb new file mode 100644 index 00000000000..97289597331 --- /dev/null +++ b/spec/requests/api/graphql/mutations/work_items/convert_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Converts a work item to a new type", feature_category: :team_planning do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } + let_it_be(:new_type) { create(:work_item_type, :incident, :default) } + let_it_be(:work_item, refind: true) do + create(:work_item, :task, project: project, milestone: create(:milestone, project: project)) + end + + let(:work_item_type_id) { new_type.to_global_id.to_s } + let(:mutation) { graphql_mutation(:workItemConvert, input) } + let(:mutation_response) { graphql_mutation_response(:work_item_convert) } + let(:input) do + { + 'id' => work_item.to_global_id.to_s, + 'work_item_type_id' => work_item_type_id + } + end + + context 'when user is not allowed to update a work item' do + let(:current_user) { create(:user) } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user has permissions to convert the work item type' do + let(:current_user) { developer } + + context 'when work item type does not exist' do + let(:work_item_type_id) { "gid://gitlab/WorkItems::Type/#{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' => "Work Item type with id #{non_existing_record_id} was not found") + ) + end + end + + it 'converts the work item', :aggregate_failures do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to change { work_item.reload.work_item_type }.to(new_type) + + expect(response).to have_gitlab_http_status(:success) + expect(work_item.reload.work_item_type.base_type).to eq('incident') + expect(mutation_response['workItem']).to include('id' => work_item.to_global_id.to_s) + expect(work_item.reload.milestone).to be_nil + end + + it_behaves_like 'has spam protection' do + let(:mutation_class) { ::Mutations::WorkItems::Convert } + end + end +end diff --git a/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb b/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb index 97bf060356a..6a6ad1b14fd 100644 --- a/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb @@ -23,7 +23,7 @@ RSpec.describe "Create a work item from a task in a work item's description", fe } end - let(:mutation) { graphql_mutation(:workItemCreateFromTask, input) } + let(:mutation) { graphql_mutation(:workItemCreateFromTask, input, nil, ['productAnalyticsState']) } let(:mutation_response) { graphql_mutation_response(:work_item_create_from_task) } context 'the user is not allowed to update a work item' do @@ -45,7 +45,6 @@ RSpec.describe "Create a work item from a task in a work item's description", fe expect(response).to have_gitlab_http_status(:success) expect(work_item.description).to eq("- [ ] #{created_work_item.to_reference}+") - expect(created_work_item.issue_type).to eq('task') expect(created_work_item.work_item_type.base_type).to eq('task') expect(created_work_item.work_item_parent).to eq(work_item) expect(created_work_item).to be_confidential diff --git a/spec/requests/api/graphql/mutations/work_items/create_spec.rb b/spec/requests/api/graphql/mutations/work_items/create_spec.rb index 16f78b67b5c..fca3c84e534 100644 --- a/spec/requests/api/graphql/mutations/work_items/create_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/create_spec.rb @@ -5,52 +5,43 @@ require 'spec_helper' RSpec.describe 'Create a work item', feature_category: :team_planning do include GraphqlHelpers - let_it_be(:project) { create(:project) } - let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:developer) { create(:user).tap { |user| group.add_developer(user) } } let(:input) do { 'title' => 'new title', 'description' => 'new description', 'confidential' => true, - 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_global_id.to_s + 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_gid.to_s } end - let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path)) } - + let(:fields) { nil } let(:mutation_response) { graphql_mutation_response(:work_item_create) } + let(:current_user) { developer } - context 'the user is not allowed to create a work item' do - let(:current_user) { create(:user) } - - it_behaves_like 'a mutation that returns a top-level access error' - end - - context 'when user has permissions to create a work item' do - let(:current_user) { developer } - + RSpec.shared_examples 'creates work item' do it 'creates the work item' do expect do post_graphql_mutation(mutation, current_user: current_user) end.to change(WorkItem, :count).by(1) created_work_item = WorkItem.last - expect(response).to have_gitlab_http_status(:success) - expect(created_work_item.issue_type).to eq('task') expect(created_work_item).to be_confidential expect(created_work_item.work_item_type.base_type).to eq('task') expect(mutation_response['workItem']).to include( input.except('workItemTypeId').merge( - 'id' => created_work_item.to_global_id.to_s, + 'id' => created_work_item.to_gid.to_s, 'workItemType' => hash_including('name' => 'Task') ) ) end context 'when input is invalid' do - let(:input) { { 'title' => '', 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_global_id.to_s } } + let(:input) { { 'title' => '', 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_gid.to_s } } it 'does not create and returns validation errors' do expect do @@ -90,16 +81,14 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do FIELDS end - let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } - context 'when setting parent' do - let_it_be(:parent) { create(:work_item, project: project) } + let_it_be(:parent) { create(:work_item, **container_params) } let(:input) do { title: 'item1', - workItemTypeId: WorkItems::Type.default_by_type(:task).to_global_id.to_s, - hierarchyWidget: { 'parentId' => parent.to_global_id.to_s } + workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s, + hierarchyWidget: { 'parentId' => parent.to_gid.to_s } } end @@ -110,14 +99,14 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do expect(widgets_response).to include( { 'children' => { 'edges' => [] }, - 'parent' => { 'id' => parent.to_global_id.to_s }, + 'parent' => { 'id' => parent.to_gid.to_s }, 'type' => 'HIERARCHY' } ) end context 'when parent work item type is invalid' do - let_it_be(:parent) { create(:work_item, :task, project: project) } + let_it_be(:parent) { create(:work_item, :task, **container_params) } it 'returns error' do post_graphql_mutation(mutation, current_user: current_user) @@ -137,6 +126,40 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do expect(graphql_errors.first['message']).to include('No object found for `parentId') end end + + context 'when adjacent is already in place' do + let_it_be(:adjacent) { create(:work_item, :task, **container_params) } + + let(:work_item) { WorkItem.last } + + let(:input) do + { + title: 'item1', + workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s, + hierarchyWidget: { 'parentId' => parent.to_gid.to_s } + } + end + + before(:all) do + create(:parent_link, work_item_parent: parent, work_item: adjacent, relative_position: 0) + end + + it 'creates work item and sets the relative position to be AFTER adjacent' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to change(WorkItem, :count).by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(widgets_response).to include( + { + 'children' => { 'edges' => [] }, + 'parent' => { 'id' => parent.to_gid.to_s }, + 'type' => 'HIERARCHY' + } + ) + expect(work_item.parent_link.relative_position).to be > adjacent.parent_link.relative_position + end + end end context 'when unsupported widget input is sent' do @@ -144,7 +167,7 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do { 'title' => 'new title', 'description' => 'new description', - 'workItemTypeId' => WorkItems::Type.default_by_type(:test_case).to_global_id.to_s, + 'workItemTypeId' => WorkItems::Type.default_by_type(:test_case).to_gid.to_s, 'hierarchyWidget' => {} } end @@ -172,17 +195,15 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do FIELDS end - let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } - context 'when setting milestone on work item creation' do let_it_be(:project_milestone) { create(:milestone, project: project) } - let_it_be(:group_milestone) { create(:milestone, project: project) } + let_it_be(:group_milestone) { create(:milestone, group: group) } let(:input) do { title: 'some WI', - workItemTypeId: WorkItems::Type.default_by_type(:task).to_global_id.to_s, - milestoneWidget: { 'milestoneId' => milestone.to_global_id.to_s } + workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s, + milestoneWidget: { 'milestoneId' => milestone.to_gid.to_s } } end @@ -196,13 +217,18 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do expect(widgets_response).to include( { 'type' => 'MILESTONE', - 'milestone' => { 'id' => milestone.to_global_id.to_s } + 'milestone' => { 'id' => milestone.to_gid.to_s } } ) end end context 'when assigning a project milestone' do + before do + group_work_item = container_params[:namespace].present? + skip('cannot set a project level milestone to a group level work item') if group_work_item + end + it_behaves_like "work item's milestone is set" do let(:milestone) { project_milestone } end @@ -216,4 +242,66 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do end end end + + context 'the user is not allowed to create a work item' do + let(:current_user) { create(:user) } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user has permissions to create a work item' do + context 'when creating work items in a project' do + context 'with projectPath' do + let_it_be(:container_params) { { project: project } } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } + + it_behaves_like 'creates work item' + end + + context 'with namespacePath' do + let_it_be(:container_params) { { project: project } } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge('namespacePath' => project.full_path), fields) } + + it_behaves_like 'creates work item' + end + end + + context 'when creating work items in a group' do + let_it_be(:container_params) { { namespace: group } } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge(namespacePath: group.full_path), fields) } + + it_behaves_like 'creates work item' + end + + context 'when both projectPath and namespacePath are passed' do + let_it_be(:container_params) { { project: project } } + let(:mutation) do + graphql_mutation( + :workItemCreate, + input.merge('projectPath' => project.full_path, 'namespacePath' => project.full_path), + fields + ) + end + + it_behaves_like 'a mutation that returns top-level errors', errors: [ + Mutations::WorkItems::Create::MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR + ] + end + + context 'when neither of projectPath nor namespacePath are passed' do + let_it_be(:container_params) { { project: project } } + let(:mutation) do + graphql_mutation( + :workItemCreate, + input, + fields + ) + end + + it_behaves_like 'a mutation that returns top-level errors', errors: [ + Mutations::WorkItems::Create::MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR + ] + end + end end diff --git a/spec/requests/api/graphql/mutations/work_items/export_spec.rb b/spec/requests/api/graphql/mutations/work_items/export_spec.rb new file mode 100644 index 00000000000..d5d07ea65f8 --- /dev/null +++ b/spec/requests/api/graphql/mutations/work_items/export_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Export work items', feature_category: :team_planning do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:reporter) { create(:user).tap { |user| project.add_reporter(user) } } + let_it_be(:guest) { create(:user).tap { |user| project.add_guest(user) } } + let_it_be(:work_item) { create(:work_item, project: project) } + + let(:input) { { 'projectPath' => project.full_path } } + let(:mutation) { graphql_mutation(:workItemExport, input) } + let(:mutation_response) { graphql_mutation_response(:work_item_export) } + + context 'when user is not allowed to export work items' do + let(:current_user) { guest } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when import_export_work_items_csv feature flag is disabled' do + let(:current_user) { reporter } + + before do + stub_feature_flags(import_export_work_items_csv: false) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: ['`import_export_work_items_csv` feature flag is disabled.'] + end + + context 'when user has permissions to export work items' do + let(:current_user) { reporter } + let(:input) do + super().merge( + 'selectedFields' => %w[TITLE DESCRIPTION AUTHOR TYPE AUTHOR_USERNAME CREATED_AT], + 'authorUsername' => 'admin', + 'iids' => [work_item.iid.to_s], + 'state' => 'opened', + 'types' => 'TASK', + 'search' => 'any', + 'in' => 'TITLE' + ) + end + + it 'schedules export job with given arguments', :aggregate_failures do + expected_arguments = { + selected_fields: ['title', 'description', 'author', 'type', 'author username', 'created_at'], + author_username: 'admin', + iids: [work_item.iid.to_s], + state: 'opened', + issue_types: ['task'], + search: 'any', + in: ['title'] + } + + expect(IssuableExportCsvWorker) + .to receive(:perform_async).with(:work_item, current_user.id, project.id, expected_arguments) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response['message']).to eq( + 'Your CSV export request has succeeded. The result will be emailed to ' \ + "#{reporter.notification_email_or_default}." + ) + expect(mutation_response['errors']).to be_empty + end + end +end diff --git a/spec/requests/api/graphql/mutations/work_items/update_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_spec.rb index ddd294e8f82..ce1c2c01faa 100644 --- a/spec/requests/api/graphql/mutations/work_items/update_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/update_spec.rb @@ -7,20 +7,21 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } + let_it_be(:author) { create(:user).tap { |user| project.add_reporter(user) } } let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } let_it_be(:reporter) { create(:user).tap { |user| project.add_reporter(user) } } let_it_be(:guest) { create(:user).tap { |user| project.add_guest(user) } } - let_it_be(:work_item, refind: true) { create(:work_item, project: project) } + let_it_be(:work_item, refind: true) { create(:work_item, project: project, author: author) } let(:work_item_event) { 'CLOSE' } let(:input) { { 'stateEvent' => work_item_event, 'title' => 'updated title' } } let(:fields) do <<~FIELDS - workItem { - state - title - } - errors + workItem { + state + title + } + errors FIELDS end @@ -81,10 +82,10 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'when updating confidentiality' do let(:fields) do <<~FIELDS - workItem { - confidential - } - errors + workItem { + confidential + } + errors FIELDS end @@ -126,18 +127,18 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'with description widget input' do let(:fields) do <<~FIELDS - workItem { - title - description - state - widgets { - type - ... on WorkItemWidgetDescription { - description + workItem { + title + description + state + widgets { + type + ... on WorkItemWidgetDescription { + description + } } } - } - errors + errors FIELDS end @@ -445,31 +446,84 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do let(:widgets_response) { mutation_response['workItem']['widgets'] } let(:fields) do <<~FIELDS - workItem { - description - widgets { - type - ... on WorkItemWidgetHierarchy { - parent { - id - } - children { - edges { - node { - id + workItem { + description + widgets { + type + ... on WorkItemWidgetHierarchy { + parent { + id + } + children { + edges { + node { + id + } } } } } } - } - errors + errors FIELDS end + let_it_be(:valid_parent) { create(:work_item, project: project) } + let_it_be(:valid_child1) { create(:work_item, :task, project: project, created_at: 5.minutes.ago) } + let_it_be(:valid_child2) { create(:work_item, :task, project: project, created_at: 5.minutes.from_now) } + let(:input_base) { { parentId: valid_parent.to_gid.to_s } } + let(:child1_ref) { { adjacentWorkItemId: valid_child1.to_global_id.to_s } } + let(:child2_ref) { { adjacentWorkItemId: valid_child2.to_global_id.to_s } } + let(:relative_range) { [valid_child1, valid_child2].map(&:parent_link).map(&:relative_position) } + + let(:invalid_relative_position_error) do + WorkItems::Widgets::HierarchyService::UpdateService::INVALID_RELATIVE_POSITION_ERROR + end + + shared_examples 'updates work item parent and sets the relative position' do + it do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change(work_item, :work_item_parent).from(nil).to(valid_parent) + + expect(response).to have_gitlab_http_status(:success) + expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] }, + 'parent' => { 'id' => valid_parent.to_global_id.to_s } }) + + expect(work_item.parent_link.relative_position).to be_between(*relative_range) + end + end + + shared_examples 'sets the relative position and does not update work item parent' do + it do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to not_change(work_item, :work_item_parent) + + expect(response).to have_gitlab_http_status(:success) + expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] }, + 'parent' => { 'id' => valid_parent.to_global_id.to_s } }) + + expect(work_item.parent_link.relative_position).to be_between(*relative_range) + end + end + + shared_examples 'returns "relative position is not valid" error message' do + it do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to not_change(work_item, :work_item_parent) + + expect(mutation_response['workItem']).to be_nil + expect(mutation_response['errors']).to match_array([invalid_relative_position_error]) + end + end + context 'when updating parent' do let_it_be(:work_item, reload: true) { create(:work_item, :task, project: project) } - let_it_be(:valid_parent) { create(:work_item, project: project) } let_it_be(:invalid_parent) { create(:work_item, :task, project: project) } context 'when parent work item type is invalid' do @@ -492,20 +546,15 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'when parent work item has a valid type' do let(:input) { { 'hierarchyWidget' => { 'parentId' => valid_parent.to_global_id.to_s } } } - it 'sets the parent for the work item' do + it 'updates work item parent' do expect do post_graphql_mutation(mutation, current_user: current_user) work_item.reload end.to change(work_item, :work_item_parent).from(nil).to(valid_parent) expect(response).to have_gitlab_http_status(:success) - expect(widgets_response).to include( - { - 'children' => { 'edges' => [] }, - 'parent' => { 'id' => valid_parent.to_global_id.to_s }, - 'type' => 'HIERARCHY' - } - ) + expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] }, + 'parent' => { 'id' => valid_parent.to_global_id.to_s } }) end context 'when a parent is already present' do @@ -522,6 +571,31 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do end.to change(work_item, :work_item_parent).from(existing_parent).to(valid_parent) end end + + context 'when updating relative position' do + before(:all) do + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child1) + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child2) + end + + context "when incomplete positioning arguments are given" do + let(:input) { { hierarchyWidget: input_base.merge(child1_ref) } } + + it_behaves_like 'returns "relative position is not valid" error message' + end + + context 'when moving after adjacent' do + let(:input) { { hierarchyWidget: input_base.merge(child1_ref).merge(relativePosition: 'AFTER') } } + + it_behaves_like 'updates work item parent and sets the relative position' + end + + context 'when moving before adjacent' do + let(:input) { { hierarchyWidget: input_base.merge(child2_ref).merge(relativePosition: 'BEFORE') } } + + it_behaves_like 'updates work item parent and sets the relative position' + end + end end context 'when parentId is null' do @@ -577,9 +651,37 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do end end + context 'when reordering existing child' do + let_it_be(:work_item, reload: true) { create(:work_item, :task, project: project) } + + context "when parent is already assigned" do + before(:all) do + create(:parent_link, work_item_parent: valid_parent, work_item: work_item) + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child1) + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child2) + end + + context "when incomplete positioning arguments are given" do + let(:input) { { hierarchyWidget: child1_ref } } + + it_behaves_like 'returns "relative position is not valid" error message' + end + + context 'when moving after adjacent' do + let(:input) { { hierarchyWidget: child1_ref.merge(relativePosition: 'AFTER') } } + + it_behaves_like 'sets the relative position and does not update work item parent' + end + + context 'when moving before adjacent' do + let(:input) { { hierarchyWidget: child2_ref.merge(relativePosition: 'BEFORE') } } + + it_behaves_like 'sets the relative position and does not update work item parent' + end + end + end + context 'when updating children' do - let_it_be(:valid_child1) { create(:work_item, :task, project: project) } - let_it_be(:valid_child2) { create(:work_item, :task, project: project) } let_it_be(:invalid_child) { create(:work_item, project: project) } let(:input) { { 'hierarchyWidget' => { 'childrenIds' => children_ids } } } @@ -639,23 +741,29 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'when updating assignees' do let(:fields) do <<~FIELDS - workItem { - widgets { - type - ... on WorkItemWidgetAssignees { - assignees { - nodes { - id - username + workItem { + title + workItemType { name } + widgets { + type + ... on WorkItemWidgetAssignees { + assignees { + nodes { + id + username + } } } - } - ... on WorkItemWidgetDescription { - description + ... on WorkItemWidgetDescription { + description + } + ... on WorkItemWidgetStartAndDueDate { + startDate + dueDate + } } } - } - errors + errors FIELDS end @@ -728,6 +836,79 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do ) end end + + context 'when changing work item type' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } + let(:description) { "/type Issue" } + + let(:input) { { 'descriptionWidget' => { 'description' => description } } } + + context 'with multiple commands' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } + + let(:description) { "Updating work item\n/type Issue\n/due tomorrow\n/title Foo" } + + it 'updates the work item type and other attributes' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change { work_item.work_item_type.base_type }.from('task').to('issue') + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['workItemType']['name']).to eq('Issue') + expect(mutation_response['workItem']['title']).to eq('Foo') + expect(mutation_response['workItem']['widgets']).to include( + 'type' => 'START_AND_DUE_DATE', + 'dueDate' => Date.tomorrow.strftime('%Y-%m-%d'), + 'startDate' => nil + ) + end + end + + context 'when conversion is not permitted' do + let_it_be(:issue) { create(:work_item, project: project) } + let_it_be(:link) { create(:parent_link, work_item_parent: issue, work_item: work_item) } + + let(:error_msg) { 'Work item type cannot be changed to Issue with Issue as parent type.' } + + it 'does not update the work item type' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.not_to change { work_item.work_item_type.base_type } + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['errors']).to include(error_msg) + end + end + + context 'when new type does not support a widget' do + before do + work_item.update!(start_date: Date.current, due_date: Date.tomorrow) + WorkItems::Type.default_by_type(:issue).widget_definitions + .find_by_widget_type(:start_and_due_date).update!(disabled: true) + end + + it 'updates the work item type and clear widget attributes' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change { work_item.work_item_type.base_type }.from('task').to('issue') + .and change { work_item.start_date }.to(nil) + .and change { work_item.start_date }.to(nil) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['workItemType']['name']).to eq('Issue') + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'START_AND_DUE_DATE', + 'startDate' => nil, + 'dueDate' => nil + } + ) + end + end + end end context 'when the work item type does not support the assignees widget' do @@ -766,17 +947,17 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do let(:fields) do <<~FIELDS - workItem { - widgets { - type - ... on WorkItemWidgetMilestone { - milestone { - id + workItem { + widgets { + type + ... on WorkItemWidgetMilestone { + milestone { + id + } } } } - } - errors + errors FIELDS end @@ -843,18 +1024,427 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do end end + context 'when updating notifications subscription' do + let_it_be(:current_user) { reporter } + let(:input) { { 'notificationsWidget' => { 'subscribed' => desired_state } } } + + let(:fields) do + <<~FIELDS + workItem { + widgets { + type + ... on WorkItemWidgetNotifications { + subscribed + } + } + } + errors + FIELDS + end + + subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) } + + shared_examples 'subscription updated successfully' do + let_it_be(:subscription) do + create( + :subscription, project: project, + user: current_user, + subscribable: work_item, + subscribed: !desired_state + ) + end + + it "updates existing work item's subscription state" do + expect do + update_work_item + subscription.reload + end.to change(subscription, :subscribed).to(desired_state) + .and(change { work_item.reload.subscribed?(reporter, project) }.to(desired_state)) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'subscribed' => desired_state, + 'type' => 'NOTIFICATIONS' + } + ) + end + end + + shared_examples 'subscription update ignored' do + context 'when user is subscribed with a subscription record' do + let_it_be(:subscription) do + create( + :subscription, project: project, + user: current_user, + subscribable: work_item, + subscribed: !desired_state + ) + end + + it 'ignores the update request' do + expect do + update_work_item + subscription.reload + end.to not_change(subscription, :subscribed) + .and(not_change { work_item.subscribed?(current_user, project) }) + + expect(response).to have_gitlab_http_status(:success) + end + end + + context 'when user is subscribed by being a participant' do + let_it_be(:current_user) { author } + + it 'ignores the update request' do + expect do + update_work_item + end.to not_change(Subscription, :count) + .and(not_change { work_item.subscribed?(current_user, project) }) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + + context 'when work item update fails' do + let_it_be(:desired_state) { false } + let(:input) { { 'title' => nil, 'notificationsWidget' => { 'subscribed' => desired_state } } } + + it_behaves_like 'subscription update ignored' + end + + context 'when user cannot update work item' do + let_it_be(:desired_state) { false } + + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(current_user, :update_subscription, work_item).and_return(false) + end + + it_behaves_like 'subscription update ignored' + end + + context 'when user can update work item' do + context 'when subscribing to notifications' do + let_it_be(:desired_state) { true } + + it_behaves_like 'subscription updated successfully' + end + + context 'when unsubscribing from notifications' do + let_it_be(:desired_state) { false } + + it_behaves_like 'subscription updated successfully' + + context 'when user is subscribed by being a participant' do + let_it_be(:current_user) { author } + + it 'creates a subscription with desired state' do + expect { update_work_item }.to change(Subscription, :count).by(1) + .and(change { work_item.reload.subscribed?(author, project) }.to(desired_state)) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'subscribed' => desired_state, + 'type' => 'NOTIFICATIONS' + } + ) + end + end + end + end + end + + context 'when updating currentUserTodos' do + let_it_be(:current_user) { reporter } + + let(:fields) do + <<~FIELDS + workItem { + widgets { + type + ... on WorkItemWidgetCurrentUserTodos { + currentUserTodos { + nodes { + id + state + } + } + } + } + } + errors + FIELDS + end + + subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) } + + context 'when adding a new todo' do + let(:input) { { 'currentUserTodosWidget' => { 'action' => 'ADD' } } } + + context 'when user has access to the work item' do + it 'adds a new todo for the user on the work item' do + expect { update_work_item }.to change { current_user.todos.count }.by(1) + + created_todo = current_user.todos.last + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'CURRENT_USER_TODOS', + 'currentUserTodos' => { + 'nodes' => [ + { 'id' => created_todo.to_global_id.to_s, 'state' => 'pending' } + ] + } + } + ) + end + end + + context 'when user has no access' do + let_it_be(:current_user) { create(:user) } + + it 'does not create a new todo' do + expect { update_work_item }.to change { Todo.count }.by(0) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + + context 'when marking all todos of the work item as done' do + let_it_be(:pending_todo1) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let_it_be(:pending_todo2) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let(:input) { { 'currentUserTodosWidget' => { 'action' => 'MARK_AS_DONE' } } } + + context 'when user has access' do + it 'marks all todos of the user on the work item as done' do + expect { update_work_item }.to change { current_user.todos.done.count }.by(2) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'CURRENT_USER_TODOS', + 'currentUserTodos' => { + 'nodes' => match_array([ + { 'id' => pending_todo1.to_global_id.to_s, 'state' => 'done' }, + { 'id' => pending_todo2.to_global_id.to_s, 'state' => 'done' } + ]) + } + } + ) + end + end + + context 'when user has no access' do + let_it_be(:current_user) { create(:user) } + + it 'does not mark todos as done' do + expect { update_work_item }.to change { Todo.done.count }.by(0) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + + context 'when marking one todo of the work item as done' do + let_it_be(:pending_todo1) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let_it_be(:pending_todo2) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let(:input) do + { 'currentUserTodosWidget' => { 'action' => 'MARK_AS_DONE', todo_id: global_id_of(pending_todo1) } } + end + + context 'when user has access' do + it 'marks the todo of the work item as done' do + expect { update_work_item }.to change { current_user.todos.done.count }.by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'CURRENT_USER_TODOS', + 'currentUserTodos' => { + 'nodes' => match_array([ + { 'id' => pending_todo1.to_global_id.to_s, 'state' => 'done' }, + { 'id' => pending_todo2.to_global_id.to_s, 'state' => 'pending' } + ]) + } + } + ) + end + end + + context 'when user has no access' do + let_it_be(:current_user) { create(:user) } + + it 'does not mark the todo as done' do + expect { update_work_item }.to change { Todo.done.count }.by(0) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + end + + context 'when updating awardEmoji' do + let_it_be(:current_user) { work_item.author } + let_it_be(:upvote) { create(:award_emoji, :upvote, awardable: work_item, user: current_user) } + let(:award_action) { 'ADD' } + let(:award_name) { 'star' } + let(:input) { { 'awardEmojiWidget' => { 'action' => award_action, 'name' => award_name } } } + + let(:fields) do + <<~FIELDS + workItem { + widgets { + type + ... on WorkItemWidgetAwardEmoji { + upvotes + downvotes + awardEmoji { + nodes { + name + user { id } + } + } + } + } + } + errors + FIELDS + end + + subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) } + + context 'when user cannot award work item' do + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(current_user, :award_emoji, work_item).and_return(false) + end + + it 'ignores the update request' do + expect do + update_work_item + end.to not_change(AwardEmoji, :count) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['errors']).to be_empty + expect(graphql_errors).to be_blank + end + end + + context 'when user can award work item' do + shared_examples 'request with error' do |message| + it 'ignores update and returns an error' do + expect do + update_work_item + end.not_to change(AwardEmoji, :count) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']).to be_nil + expect(mutation_response['errors'].first).to include(message) + end + end + + shared_examples 'request that removes emoji' do + it "updates work item's award emoji" do + expect do + update_work_item + end.to change(AwardEmoji, :count).by(-1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'upvotes' => 0, + 'downvotes' => 0, + 'awardEmoji' => { 'nodes' => [] }, + 'type' => 'AWARD_EMOJI' + } + ) + end + end + + shared_examples 'request that adds emoji' do + it "updates work item's award emoji" do + expect do + update_work_item + end.to change(AwardEmoji, :count).by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'upvotes' => 1, + 'downvotes' => 0, + 'awardEmoji' => { 'nodes' => [ + { 'name' => 'thumbsup', 'user' => { 'id' => current_user.to_gid.to_s } }, + { 'name' => award_name, 'user' => { 'id' => current_user.to_gid.to_s } } + ] }, + 'type' => 'AWARD_EMOJI' + } + ) + end + end + + context 'when adding award emoji' do + it_behaves_like 'request that adds emoji' + + context 'when the emoji name is not valid' do + let(:award_name) { 'xxqq' } + + it_behaves_like 'request with error', 'Name is not a valid emoji name' + end + end + + context 'when removing award emoji' do + let(:award_action) { 'REMOVE' } + + context 'when emoji was awarded by current user' do + let(:award_name) { 'thumbsup' } + + it_behaves_like 'request that removes emoji' + end + + context 'when emoji was awarded by a different user' do + let(:award_name) { 'thumbsdown' } + + before do + create(:award_emoji, :downvote, awardable: work_item) + end + + it_behaves_like 'request with error', + 'User has not awarded emoji of type thumbsdown on the awardable' + end + end + end + end + context 'when unsupported widget input is sent' do - let_it_be(:test_case) { create(:work_item_type, :default, :test_case) } - let_it_be(:work_item) { create(:work_item, work_item_type: test_case, project: project) } + let_it_be(:work_item) { create(:work_item, :test_case, project: project) } let(:input) do { - 'hierarchyWidget' => {} + 'assigneesWidget' => { 'assigneeIds' => [developer.to_gid.to_s] } } end it_behaves_like 'a mutation that returns top-level errors', - errors: ["Following widget keys are not supported by Test Case type: [:hierarchy_widget]"] + errors: ["Following widget keys are not supported by Test Case type: [:assignees_widget]"] end end end diff --git a/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb index 999c685ac6a..717de983871 100644 --- a/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb @@ -18,7 +18,7 @@ RSpec.describe 'Update a work item task', feature_category: :team_planning do let(:task_params) { { 'title' => 'UPDATED' } } let(:task_input) { { 'id' => task.to_global_id.to_s }.merge(task_params) } let(:input) { { 'id' => work_item.to_global_id.to_s, 'taskData' => task_input } } - let(:mutation) { graphql_mutation(:workItemUpdateTask, input) } + let(:mutation) { graphql_mutation(:workItemUpdateTask, input, nil, ['productAnalyticsState']) } let(:mutation_response) { graphql_mutation_response(:work_item_update_task) } context 'the user is not allowed to read a work item' do |