spec/requests/api/graphql/work_item_spec.rb (1,520 lines of code) (raw):
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Query.work_item(id)', feature_category: :team_planning do
include GraphqlHelpers
let_it_be(:group) { create(:group) }
let_it_be_with_reload(:project) { create(:project, :repository, :private, group: group) }
let_it_be(:developer) { create(:user, developer_of: group) }
let_it_be(:guest) { create(:user, guest_of: group) }
let_it_be(:work_item) do
create(
:work_item,
project: project,
description: '- [x] List item',
start_date: Date.today,
due_date: 1.week.from_now,
created_at: 1.week.ago,
last_edited_at: 1.day.ago,
last_edited_by: guest,
user_agent_detail: create(:user_agent_detail)
).tap do |work_item|
create_list(:discussion_note_on_issue, 3, noteable: work_item, project: project)
end
end
let_it_be(:child_item1) { create(:work_item, :task, project: project, id: 1200) }
let_it_be(:child_item2) { create(:work_item, :task, confidential: true, project: project, id: 1400) }
let_it_be(:child_link1) { create(:parent_link, work_item_parent: work_item, work_item: child_item1) }
let_it_be(:child_link2) { create(:parent_link, work_item_parent: work_item, work_item: child_item2) }
let(:current_user) { developer }
let(:work_item_data) { graphql_data['workItem'] }
let(:work_item_fields) { all_graphql_fields_for('WorkItem', max_depth: 2) }
let(:global_id) { work_item.to_gid.to_s }
let(:query) do
graphql_query_for('workItem', { 'id' => global_id }, work_item_fields)
end
context 'when project is archived' do
before do
project.update!(archived: true)
post_graphql(query, current_user: current_user)
end
it 'returns the correct value in the archived field' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'iid' => work_item.iid.to_s,
'archived' => true
)
end
end
context "for showPlanUpgradePromotion field" do
context "when the namespace is in a free plan" do
before do
post_graphql(query, current_user: current_user)
end
it "returns true" do
# For FOSS/ce version the api will always return true
expect(work_item_data).to include('showPlanUpgradePromotion' => true)
end
end
end
context 'when the user can read the work item' do
let(:incoming_email_token) { current_user.incoming_email_token }
let(:work_item_email) do
"p+#{project.full_path_slug}-#{project.project_id}-#{incoming_email_token}-issue-#{work_item.iid}@gl.ab"
end
before do
stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab")
post_graphql(query, current_user: current_user)
end
it_behaves_like 'a working graphql query'
it 'returns all fields' do
expect(work_item_data).to include(
'description' => work_item.description,
'id' => work_item.to_gid.to_s,
'iid' => work_item.iid.to_s,
'lockVersion' => work_item.lock_version,
'state' => "OPEN",
'title' => work_item.title,
'confidential' => work_item.confidential,
'userDiscussionsCount' => 3,
'workItemType' => hash_including('id' => work_item.work_item_type.to_gid.to_s),
'reference' => work_item.to_reference,
'createNoteEmail' => work_item_email,
'archived' => false,
'hidden' => false,
'userPermissions' => hash_including(
'readWorkItem' => true,
'updateWorkItem' => true,
'deleteWorkItem' => false,
'adminWorkItem' => true,
'adminParentLink' => true,
'setWorkItemMetadata' => true,
'createNote' => true,
'adminWorkItemLink' => true,
'markNoteAsInternal' => true,
'moveWorkItem' => true,
'cloneWorkItem' => true,
'reportSpam' => false,
'summarizeComments' => false
),
'project' => hash_including('id' => project.to_gid.to_s, 'fullPath' => project.full_path)
)
end
context 'when querying work item type information' do
include_context 'with work item types request context'
let(:work_item_fields) { "workItemType { #{work_item_type_fields} }" }
it 'returns work item type information' do
expect(work_item_data['workItemType']).to match(
expected_work_item_type_response(work_item.resource_parent, current_user, work_item.work_item_type).first
)
end
end
context 'when querying widgets' do
describe 'description widget' do
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetDescription {
description
descriptionHtml
edited
lastEditedBy {
webPath
username
}
lastEditedAt
taskCompletionStatus {
completedCount
count
}
}
}
GRAPHQL
end
it 'returns widget information' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'DESCRIPTION',
'description' => work_item.description,
'descriptionHtml' => ::MarkupHelper.markdown_field(work_item, :description, {}),
'edited' => true,
'lastEditedAt' => work_item.last_edited_at.iso8601,
'lastEditedBy' => {
'webPath' => "/#{guest.full_path}",
'username' => guest.username
},
'taskCompletionStatus' => {
'completedCount' => 1,
'count' => 1
}
)
)
)
end
end
describe 'hierarchy widget' do
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetHierarchy {
parent {
id
}
children {
nodes {
id
}
}
hasChildren
hasParent
rolledUpCountsByType {
workItemType {
name
}
countsByState {
all
opened
closed
}
}
depthLimitReachedByType {
workItemType {
name
}
depthLimitReached
}
}
}
GRAPHQL
end
it 'returns widget information' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'HIERARCHY',
'parent' => nil,
'children' => { 'nodes' => match_array(
[
hash_including('id' => child_link1.work_item.to_gid.to_s),
hash_including('id' => child_link2.work_item.to_gid.to_s)
]) },
'hasChildren' => true,
'hasParent' => false,
'rolledUpCountsByType' => match_array([
hash_including(
'workItemType' => hash_including('name' => 'Task'),
'countsByState' => {
'all' => 2,
'opened' => 2,
'closed' => 0
}
)
]),
'depthLimitReachedByType' => match_array([
hash_including(
'workItemType' => hash_including('name' => 'Task'),
'depthLimitReached' => false
)
])
)
)
)
end
it 'avoids N+1 queries' do
post_graphql(query, current_user: current_user) # warm up
control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
post_graphql(query, current_user: current_user)
end
create_list(:parent_link, 3, work_item_parent: work_item)
expect do
post_graphql(query, current_user: current_user)
end.not_to exceed_all_query_limit(control)
end
context 'when user is guest' do
let(:current_user) { guest }
it 'filters out not accessible children or parent' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'HIERARCHY',
'parent' => nil,
'children' => { 'nodes' => match_array(
[
hash_including('id' => child_link1.work_item.to_gid.to_s)
]) },
'hasChildren' => true,
'hasParent' => false
)
)
)
end
end
context 'when requesting child item' do
let_it_be(:work_item) { create(:work_item, :task, project: project, description: '- List item') }
let_it_be(:parent_link) { create(:parent_link, work_item: work_item) }
it 'returns parent information' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'HIERARCHY',
'parent' => hash_including('id' => parent_link.work_item_parent.to_gid.to_s),
'children' => { 'nodes' => match_array([]) },
'hasChildren' => false,
'hasParent' => true
)
)
)
end
end
context 'when ordered by default by work_item_id' do
let_it_be(:newest_child) { create(:work_item, :task, project: project, id: 2000) }
let_it_be(:oldest_child) { create(:work_item, :task, project: project, id: 1000) }
let_it_be(:newest_link) { create(:parent_link, work_item_parent: work_item, work_item: newest_child) }
let_it_be(:oldest_link) { create(:parent_link, work_item_parent: work_item, work_item: oldest_child) }
let(:hierarchy_widget) { work_item_data['widgets'].find { |widget| widget['type'] == 'HIERARCHY' } }
let(:hierarchy_children) { hierarchy_widget['children']['nodes'] }
it 'places the oldest child item to the beginning of the children list' do
expect(hierarchy_children.first['id']).to eq(oldest_child.to_gid.to_s)
end
it 'places the newest child item to the end of the children list' do
expect(hierarchy_children.last['id']).to eq(newest_child.to_gid.to_s)
end
context 'when relative position is set' do
let_it_be(:first_child) { create(:work_item, :task, project: project, id: 3000) }
let_it_be(:first_link) do
create(:parent_link, work_item_parent: work_item, work_item: first_child, relative_position: 1)
end
it 'places children according to relative_position at the beginning of the children list' do
ordered_list = [first_child, oldest_child, child_item1, child_item2, newest_child]
expect(hierarchy_children.pluck('id')).to eq(ordered_list.map(&:to_gid).map(&:to_s))
end
end
end
end
describe 'assignees widget' do
let(:work_item) { create(:work_item, project: project, assignees: assignees) }
let(:assignees) do
[
create(:user, name: 'BBB'),
create(:user, name: 'AAA'),
create(:user, name: 'BBB')
]
end
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetAssignees {
allowsMultipleAssignees
canInviteMembers
assignees {
nodes {
id
username
}
}
}
}
GRAPHQL
end
it 'returns widget information, assignees are ordered by name ASC id DESC' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'ASSIGNEES',
'allowsMultipleAssignees' => boolean,
'canInviteMembers' => boolean,
'assignees' => {
'nodes' => [
{ 'id' => assignees[1].to_gid.to_s, 'username' => assignees[1].username },
{ 'id' => assignees[2].to_gid.to_s, 'username' => assignees[2].username },
{ 'id' => assignees[0].to_gid.to_s, 'username' => assignees[0].username }
]
}
)
)
)
end
end
describe 'labels widget' do
let(:labels) { create_list(:label, 2, project: project) }
let(:work_item) { create(:work_item, project: project, labels: labels) }
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetLabels {
labels {
nodes {
id
title
}
}
}
}
GRAPHQL
end
it 'returns widget information' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'LABELS',
'labels' => {
'nodes' => match_array(
labels.map { |a| { 'id' => a.to_gid.to_s, 'title' => a.title } }
)
}
)
)
)
end
end
describe 'start and due date widget' do
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetStartAndDueDate {
startDate
dueDate
}
}
GRAPHQL
end
it 'returns widget information' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'START_AND_DUE_DATE',
'startDate' => work_item.start_date.to_s,
'dueDate' => work_item.due_date.to_s
)
)
)
end
end
describe 'milestone widget' do
let_it_be(:milestone) { create(:milestone, project: project) }
let(:work_item) { create(:work_item, project: project, milestone: milestone) }
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetMilestone {
milestone {
id
}
}
}
GRAPHQL
end
it 'returns widget information' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'MILESTONE',
'milestone' => {
'id' => work_item.milestone.to_gid.to_s
}
)
)
)
end
end
describe 'notifications widget' do
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetNotifications {
subscribed
}
}
GRAPHQL
end
it 'returns widget information' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'NOTIFICATIONS',
'subscribed' => work_item.subscribed?(current_user, project)
)
)
)
end
end
describe 'currentUserTodos widget' do
let_it_be(:current_user) { developer }
let_it_be(:other_todo) { create(:todo, state: :pending, user: current_user) }
let_it_be(:done_todo) do
create(:todo, state: :done, target: work_item, target_type: work_item.class.name, user: current_user)
end
let_it_be(:pending_todo) do
create(:todo, state: :pending, target: work_item, target_type: work_item.class.name, user: current_user)
end
let_it_be(:other_user_todo) do
create(:todo, state: :pending, target: work_item, target_type: work_item.class.name, user: create(:user))
end
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetCurrentUserTodos {
currentUserTodos {
nodes {
id
state
}
}
}
}
GRAPHQL
end
context 'with access' do
it 'returns widget information' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'CURRENT_USER_TODOS',
'currentUserTodos' => {
'nodes' => match_array(
[done_todo, pending_todo].map { |t| { 'id' => t.to_gid.to_s, 'state' => t.state } }
)
}
)
)
)
end
end
context 'with filter' do
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetCurrentUserTodos {
currentUserTodos(state: done) {
nodes {
id
state
}
}
}
}
GRAPHQL
end
it 'returns widget information' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'CURRENT_USER_TODOS',
'currentUserTodos' => {
'nodes' => match_array(
[done_todo].map { |t| { 'id' => t.to_gid.to_s, 'state' => t.state } }
)
}
)
)
)
end
end
end
describe 'award emoji widget' do
let_it_be(:emoji) { create(:award_emoji, name: 'star', awardable: work_item) }
let_it_be(:upvote) { create(:award_emoji, :upvote, awardable: work_item) }
let_it_be(:downvote) { create(:award_emoji, :downvote, awardable: work_item) }
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetAwardEmoji {
upvotes
downvotes
newCustomEmojiPath
awardEmoji {
nodes {
name
}
}
}
}
GRAPHQL
end
it 'returns widget information' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'AWARD_EMOJI',
'upvotes' => work_item.upvotes,
'downvotes' => work_item.downvotes,
'newCustomEmojiPath' => Gitlab::Routing.url_helpers.new_group_custom_emoji_path(group),
'awardEmoji' => {
'nodes' => match_array(
[emoji, upvote, downvote].map { |e| { 'name' => e.name } }
)
}
)
)
)
end
end
describe 'linked items widget' do
let_it_be(:related_item) { create(:work_item, project: project) }
let_it_be(:blocked_item) { create(:work_item, project: project) }
let_it_be(:link1) do
create(:work_item_link, source: work_item, target: related_item, link_type: 'relates_to',
created_at: Time.current + 1.day)
end
let_it_be(:link2) do
create(:work_item_link, source: work_item, target: blocked_item, link_type: 'blocks',
created_at: Time.current + 2.days)
end
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetLinkedItems {
linkedItems {
nodes {
linkId
linkType
linkCreatedAt
linkUpdatedAt
workItem {
id
}
}
}
}
}
GRAPHQL
end
it 'returns widget information' do
expect(work_item_data).to include(
'widgets' => include(
hash_including(
'type' => 'LINKED_ITEMS',
'linkedItems' => { 'nodes' => match_array(
[
hash_including(
'linkId' => link1.to_gid.to_s, 'linkType' => 'relates_to',
'linkCreatedAt' => link1.created_at.iso8601, 'linkUpdatedAt' => link1.updated_at.iso8601,
'workItem' => { 'id' => related_item.to_gid.to_s }
),
hash_including(
'linkId' => link2.to_gid.to_s, 'linkType' => 'blocks',
'linkCreatedAt' => link2.created_at.iso8601, 'linkUpdatedAt' => link2.updated_at.iso8601,
'workItem' => { 'id' => blocked_item.to_gid.to_s }
)
]
) }
)
)
)
end
context 'when inaccessible links are present' do
let_it_be(:no_access_item) { create(:work_item, title: "PRIVATE", project: create(:project, :private)) }
before do
create(:work_item_link, source: work_item, target: no_access_item, link_type: 'relates_to')
end
it 'returns only items that the user has access to' do
expect(graphql_dig_at(work_item_data, :widgets, "linkedItems", "nodes", "linkId"))
.to match_array([link1.to_gid.to_s, link2.to_gid.to_s])
end
end
context 'when limiting the number of results' do
it_behaves_like 'sorted paginated query' do
include_context 'no sort argument'
let(:first_param) { 1 }
let(:all_records) { [link2, link1] }
let(:data_path) { %w[workItem widgets linkedItems] }
def widget_fields(args)
query_graphql_field(
:widgets, {}, query_graphql_field(
'... on WorkItemWidgetLinkedItems', {}, query_graphql_field(
'linkedItems', args, "#{page_info} nodes { linkId }"
)
)
)
end
def pagination_query(params)
graphql_query_for('workItem', { 'id' => global_id }, widget_fields(params))
end
def pagination_results_data(nodes)
nodes.map { |item| GlobalID::Locator.locate(item['linkId']) }
end
end
end
context 'when filtering by link type' do
let(:work_item_fields) do
<<~GRAPHQL
widgets {
type
... on WorkItemWidgetLinkedItems {
linkedItems(filter: RELATED) {
nodes {
linkType
}
}
}
}
GRAPHQL
end
it 'returns items with specified type' do
widget_data = work_item_data["widgets"].find { |widget| widget.key?("linkedItems") }["linkedItems"]
expect(widget_data["nodes"].size).to eq(1)
expect(widget_data.dig("nodes", 0, "linkType")).to eq('relates_to')
end
end
end
describe 'linked resources widget' do
let_it_be(:linked_resources_type) { create(:work_item_type, :non_default, widgets: [:linked_resources]) }
let_it_be(:work_item) { create(:work_item, project: project, work_item_type: linked_resources_type) }
let_it_be(:resource1) do
create(:zoom_meeting, issue_id: work_item.id, project: project, url: 'https://zoom.us/j/123456789')
end
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetLinkedResources {
linkedResources {
nodes {
url
}
}
}
}
GRAPHQL
end
it 'returns widget information' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => include(
hash_including(
'type' => 'LINKED_RESOURCES',
'linkedResources' => {
'nodes' => containing_exactly(
hash_including(
'url' => resource1.url
)
)
}
)
)
)
end
end
context 'when filtering' do
context 'when selecting widgets' do
let(:work_item_fields) do
<<~GRAPHQL
id
widgets(onlyTypes: [DESCRIPTION]) {
type
}
GRAPHQL
end
it 'only returns selected widgets' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => [{
'type' => 'DESCRIPTION'
}]
)
end
end
context 'when excluding widgets' do
let(:work_item_fields) do
<<~GRAPHQL
id
widgets(exceptTypes: [DESCRIPTION]) {
type
}
GRAPHQL
end
it 'does not return excluded widgets' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'widgets' => [
{ "type" => "ASSIGNEES" },
{ "type" => "AWARD_EMOJI" },
{ "type" => "CRM_CONTACTS" },
{ "type" => "CURRENT_USER_TODOS" },
{ "type" => "DESIGNS" },
{ "type" => "DEVELOPMENT" },
{ "type" => "EMAIL_PARTICIPANTS" },
{ "type" => "ERROR_TRACKING" },
{ "type" => "HIERARCHY" },
{ "type" => "LABELS" },
{ "type" => "LINKED_ITEMS" },
{ "type" => "MILESTONE" },
{ "type" => "NOTES" },
{ "type" => "NOTIFICATIONS" },
{ "type" => "PARTICIPANTS" },
{ "type" => "START_AND_DUE_DATE" },
{ "type" => "TIME_TRACKING" }
]
)
end
end
end
end
describe 'notes widget' do
context 'when fetching award emoji from notes' do
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetNotes {
discussions(filter: ALL_NOTES, first: 10) {
nodes {
id
resolvable
resolvedBy {
username
}
userPermissions {
resolveNote
}
notes {
nodes {
id
system
body
maxAccessLevelOfAuthor
authorIsContributor
awardEmoji {
nodes {
name
user {
name
}
}
}
discussion {
id
}
}
}
}
}
notes(last: 1) {
nodes {
id
body
}
}
}
}
GRAPHQL
end
let_it_be(:note) { create(:note, project: work_item.project, noteable: work_item, author: developer) }
before_all do
create(:award_emoji, awardable: note, name: 'rocket', user: developer)
end
context 'when fetching resolvable notes data' do
context 'with system notes' do
let_it_be(:comment1) { create(:discussion_note_on_issue, project: work_item.project, noteable: work_item) }
let_it_be(:comment2) { create(:note, discussion_id: comment1.discussion_id) }
let_it_be(:sys_note1) { create(:system_note, project: work_item.project, noteable: work_item) }
let_it_be(:sys_note2) { create(:resource_state_event, user: developer, issue: work_item, state: :closed) }
it 'returns resolve note permission' do
all_widgets = graphql_dig_at(work_item_data, :widgets)
notes_widget = all_widgets.find { |x| x['type'] == 'NOTES' }
discussions = graphql_dig_at(notes_widget['discussions'], :nodes)
expect(discussions).to include(
hash_including(
'id' => note.discussion.to_global_id.to_s,
'resolvable' => false,
'userPermissions' => {
'resolveNote' => true
}
),
hash_including(
'id' => comment1.discussion.to_global_id.to_s,
'resolvable' => true,
'userPermissions' => {
'resolveNote' => true
}
),
hash_including(
'id' => sys_note1.discussion.to_global_id.to_s,
'resolvable' => false,
'userPermissions' => {
'resolveNote' => false
}
),
hash_including(
'id' => sys_note2.work_item_synthetic_system_note.discussion.to_global_id.to_s,
'resolvable' => false,
'userPermissions' => {
'resolveNote' => false
}
)
)
end
end
end
it 'returns award emoji data' do
all_widgets = graphql_dig_at(work_item_data, :widgets)
notes_widget = all_widgets.find { |x| x['type'] == 'NOTES' }
notes = graphql_dig_at(notes_widget['discussions'], :nodes).flat_map { |d| d['notes']['nodes'] }
note_with_emoji = notes.find { |n| n['id'] == note.to_gid.to_s }
expect(note_with_emoji).to include(
'awardEmoji' => {
'nodes' => include(
hash_including(
'name' => 'rocket',
'user' => {
'name' => developer.name
}
)
)
}
)
end
it 'returns author contributor status and max access level' do
all_widgets = graphql_dig_at(work_item_data, :widgets)
notes_widget = all_widgets.find { |x| x['type'] == 'NOTES' }
notes = graphql_dig_at(notes_widget['discussions'], :nodes).flat_map { |d| d['notes']['nodes'] }
expect(notes).to include(
hash_including('maxAccessLevelOfAuthor' => 'Developer', 'authorIsContributor' => false)
)
end
it 'can return the latest note' do
latest_note = create(:note, project: work_item.project, noteable: work_item, note: 'Last note')
post_graphql(query, current_user: developer)
all_widgets = graphql_dig_at(work_item_data, :widgets)
notes_widget = all_widgets.find { |x| x['type'] == 'NOTES' }
note = graphql_dig_at(notes_widget['notes'], :nodes).last
expect(note).to include(
'id' => latest_note.to_gid.to_s,
'body' => latest_note.note
)
end
it 'avoids N+1 queries' do
another_user = create(:user, developer_of: note.resource_parent)
create(:note, project: note.project, noteable: work_item, author: another_user)
post_graphql(query, current_user: developer)
control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: developer) }
expect_graphql_errors_to_be_empty
another_note = create(:discussion_note, project: work_item.project, noteable: work_item)
create(:note, project: work_item.project, noteable: work_item, in_reply_to: another_note)
create(:award_emoji, awardable: another_note, name: 'star', user: guest)
another_user = create(:user, developer_of: note.resource_parent)
note_with_different_user = create(:note, project: note.project, noteable: work_item, author: another_user)
create(:award_emoji, awardable: note_with_different_user, name: 'star', user: developer)
expect { post_graphql(query, current_user: developer) }.not_to exceed_query_limit(control)
expect_graphql_errors_to_be_empty
end
end
end
describe 'designs widget' do
include DesignManagementTestHelpers
let(:work_item_fields) do
query_graphql_field(
:widgets, {}, query_graphql_field(
'type ... on WorkItemWidgetDesigns', {}, query_graphql_field(
:design_collection, nil, design_collection_fields
)
)
)
end
let(:design_collection_fields) { nil }
let(:post_query) { post_graphql(query, current_user: current_user) }
let(:design_collection_data) { work_item_data['widgets'].find { |w| w['type'] == 'DESIGNS' }['designCollection'] }
before do
project.add_developer(developer)
enable_design_management
end
def id_hash(object)
a_graphql_entity_for(object)
end
shared_examples 'fetch a design-like object by ID' do
let(:design) { design_a }
let(:design_fields) do
[
:filename,
query_graphql_field(:project, :id)
]
end
let(:design_collection_fields) do
query_graphql_field(object_field_name, object_params, object_fields)
end
let(:object_fields) { design_fields }
context 'when the ID is passed' do
let(:object_params) { { id: global_id_of(object) } }
let(:result_fields) { {} }
it 'retrieves the object' do
post_query
data = design_collection_data[GraphqlHelpers.fieldnamerize(object_field_name)]
expect(data).to match(
a_hash_including(
result_fields.merge({ 'filename' => design.filename, 'project' => id_hash(project) })
)
)
end
context 'when the user is unauthorized' do
let(:current_user) { create(:user) }
it_behaves_like 'a failure to find anything'
end
context 'without parameters' do
let(:object_params) { nil }
it 'raises an error' do
post_query
expect(graphql_errors).to include(no_argument_error)
end
end
end
context 'when attempting to retrieve an object from a different issue' do
let(:object_params) { { id: global_id_of(object_on_other_issue) } }
it_behaves_like 'a failure to find anything'
end
end
context 'when work item is an issue' do
let_it_be(:issue_work_item) { create(:work_item, :issue, project: project) }
let_it_be(:issue_work_item1) { create(:work_item, :issue, project: project) }
let_it_be(:design_a) { create(:design, issue: issue_work_item) }
let_it_be(:version_a) { create(:design_version, issue: issue_work_item, created_designs: [design_a]) }
let_it_be(:global_id) { issue_work_item.to_gid.to_s }
describe '.designs' do
let(:design_collection_fields) do
query_graphql_field('designs', {}, "nodes { id event filename }")
end
it 'returns design data' do
post_query
expect(design_collection_data).to include(
'designs' => include(
'nodes' => include(
hash_including(
'id' => design_a.to_gid.to_s,
'event' => 'CREATION',
'filename' => design_a.filename
)
)
)
)
end
end
describe 'copy_state' do
let(:design_collection_fields) do
'copyState'
end
it 'returns copyState of designCollection' do
post_query
expect(design_collection_data).to include(
'copyState' => 'READY'
)
end
end
describe '.versions' do
let(:design_collection_fields) do
query_graphql_field('versions', {}, "nodes { id sha createdAt }")
end
it 'returns versions data' do
post_query
expect(design_collection_data).to include(
'versions' => include(
'nodes' => include(
hash_including(
'id' => version_a.to_gid.to_s,
'sha' => version_a.sha,
'createdAt' => version_a.created_at.iso8601
)
)
)
)
end
end
describe '.version' do
let(:version) { version_a }
let(:design_collection_fields) do
query_graphql_field(:version, version_params, 'id sha')
end
context 'with no parameters' do
let(:version_params) { nil }
it 'raises an error' do
post_query
expect(graphql_errors).to include(a_hash_including("message" => "one of id or sha is required"))
end
end
shared_examples 'a successful query for a version' do
it 'finds the version' do
post_query
data = design_collection_data['version']
expect(data).to match a_graphql_entity_for(version, :sha)
end
end
context 'with (sha: STRING_TYPE)' do
let(:version_params) { { sha: version.sha } }
it_behaves_like 'a successful query for a version'
end
context 'with (id: ID_TYPE)' do
let(:version_params) { { id: global_id_of(version) } }
it_behaves_like 'a successful query for a version'
end
end
describe '.design' do
it_behaves_like 'fetch a design-like object by ID' do
let(:object) { design }
let(:object_field_name) { :design }
let(:no_argument_error) do
a_hash_including("message" => "one of id or filename must be passed")
end
let_it_be(:object_on_other_issue) { create(:design, issue: issue_work_item1) }
end
end
describe '.designAtVersion' do
it_behaves_like 'fetch a design-like object by ID' do
let(:object) { build(:design_at_version, design: design, version: version) }
let(:object_field_name) { :design_at_version }
let(:version) { version_a }
let(:result_fields) { { 'version' => id_hash(version) } }
let(:object_fields) do
design_fields + [query_graphql_field(:version, :id)]
end
let(:no_argument_error) do
a_hash_including("message" => "Field 'designAtVersion' is missing required arguments: id")
end
let(:object_on_other_issue) { build(:design_at_version, issue: issue_work_item1) }
end
end
describe 'N+1 query check' do
let(:design_collection_fields) do
query_graphql_field('designs', {}, "nodes { id event filename}")
end
it 'avoids N+1 queries', :use_sql_query_cache do
post_query # warmup
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
post_query
end
create_list(:work_item, 3, namespace: group) do |item|
create(:design, :with_file, issue: item)
end
expect do
post_query
end.to issue_same_number_of_queries_as(control_count)
expect_graphql_errors_to_be_empty
end
end
end
context 'when work item base type is non issue' do
let_it_be(:epic) { create(:work_item, :task, project: project) }
let_it_be(:global_id) { epic.to_gid.to_s }
it 'returns without design' do
post_query
expect(epic&.work_item_type&.base_type).not_to match('issue')
expect(work_item_data['widgets']).not_to include(
hash_including(
'type' => 'DESIGNS'
)
)
end
end
end
describe 'development widget' do
let_it_be_with_reload(:merge_request1) { create(:merge_request, source_project: project) }
let_it_be_with_reload(:merge_request2) { create(:merge_request, source_project: project, target_branch: 'feat2') }
context 'when fetching related merge requests' do
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetDevelopment {
relatedMergeRequests {
nodes {
id
iid
author { id username }
}
}
}
}
GRAPHQL
end
before_all do
update_params = { description: "References #{work_item.to_reference}" }
[merge_request1, merge_request2].each do |merge_request|
::MergeRequests::UpdateService
.new(project: merge_request.project, current_user: developer, params: update_params)
.execute(merge_request)
end
end
context 'when user is developer' do
let(:current_user) { developer }
it 'returns related merge requests in the response' do
post_graphql(query, current_user: current_user)
expect(work_item_data).to include(
'id' => work_item.to_global_id.to_s,
'widgets' => array_including(
hash_including(
'type' => 'DEVELOPMENT',
'relatedMergeRequests' => {
'nodes' => [
hash_including('id' => merge_request2.to_gid.to_s, 'iid' => merge_request2.iid.to_s),
hash_including('id' => merge_request1.to_gid.to_s, 'iid' => merge_request1.iid.to_s)
]
}
)
)
)
end
it 'prevents N+1 queries' do
post_graphql(query, current_user: current_user) # warm up
control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
post_graphql(query, current_user: current_user)
end
merge_request3 = create(:merge_request, source_project: project, target_branch: 'feat3')
::MergeRequests::UpdateService.new(
project: merge_request3.project,
current_user: developer,
params: { description: "References #{work_item.to_reference}" }
).execute(merge_request3)
expect do
post_graphql(query, current_user: current_user)
end.not_to exceed_all_query_limit(control)
end
end
end
context 'when fetching closing merge requests' do
let_it_be(:private_project) { create(:project, :repository, :private) }
let_it_be(:private_merge_request) { create(:merge_request, source_project: private_project) }
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetDevelopment {
willAutoCloseByMergeRequest
closingMergeRequests {
count
nodes {
id
fromMrDescription
mergeRequest { id }
}
}
}
}
GRAPHQL
end
let_it_be(:mr_closing_issue1) do
create(
:merge_requests_closing_issues,
merge_request: merge_request1,
issue: work_item,
from_mr_description: false
)
end
let_it_be(:mr_closing_issue2) do
create(
:merge_requests_closing_issues,
merge_request: merge_request2,
issue: work_item,
from_mr_description: true
)
end
before do
post_graphql(query, current_user: current_user)
end
context 'when user is developer' do
let(:current_user) { developer }
it 'returns related merge requests in the response' do
expect(work_item_data).to include(
'id' => work_item.to_global_id.to_s,
'widgets' => array_including(
hash_including(
'type' => 'DEVELOPMENT',
'willAutoCloseByMergeRequest' => true,
'closingMergeRequests' => {
'count' => 2,
'nodes' => containing_exactly(
hash_including(
'id' => mr_closing_issue1.to_gid.to_s,
'mergeRequest' => { 'id' => merge_request1.to_global_id.to_s },
'fromMrDescription' => false
),
hash_including(
'id' => mr_closing_issue2.to_gid.to_s,
'mergeRequest' => { 'id' => merge_request2.to_global_id.to_s },
'fromMrDescription' => true
)
)
}
)
)
)
end
it 'avoids N + 1 queries', :use_sql_query_cache do
# warm-up already done in the before block
control = ActiveRecord::QueryRecorder.new do
post_graphql(query, current_user: current_user)
end
expect(graphql_errors).to be_blank
create(
:merge_requests_closing_issues,
merge_request: create(:merge_request, source_project: project, target_branch: 'feature3'),
issue: work_item
)
expect do
post_graphql(query, current_user: current_user)
end.to issue_same_number_of_queries_as(control)
expect(graphql_errors).to be_blank
end
end
end
context 'when fetching related branches' do
let_it_be(:branch_name) { "#{work_item.iid}-another-branch" }
let_it_be(:pipeline1) { create(:ci_pipeline, :success, project: project, ref: work_item.to_branch_name) }
let_it_be(:pipeline2) { create(:ci_pipeline, :success, project: project, ref: branch_name) }
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetDevelopment {
relatedBranches {
nodes {
name
comparePath
pipelineStatus { name label favicon }
}
}
}
}
GRAPHQL
end
before_all do
project.repository.create_branch(work_item.to_branch_name, pipeline1.sha)
project.repository.create_branch(branch_name, pipeline2.sha)
project.repository.create_branch("#{work_item.iid}doesnt-match", project.repository.root_ref)
project.repository.create_branch("#{work_item.iid}-0-stable", project.repository.root_ref)
project.repository.add_tag(developer, work_item.to_branch_name, pipeline1.sha)
create(
:merge_request,
source_project: work_item.project,
source_branch: work_item.to_branch_name,
description: "Related to #{work_item.to_reference}"
).tap { |merge_request| merge_request.create_cross_references!(developer) }
end
before do
post_graphql(query, current_user: current_user)
end
context 'when user is developer' do
let(:current_user) { developer }
it 'returns related branches not referenced in merge requests' do
brach_compare_path = Gitlab::Routing.url_helpers.project_compare_path(
project,
from: project.default_branch,
to: branch_name
)
expect(work_item_data).to include(
'id' => work_item.to_global_id.to_s,
'widgets' => array_including(
hash_including(
'type' => 'DEVELOPMENT',
'relatedBranches' => {
'nodes' => containing_exactly(
hash_including(
'name' => branch_name,
'comparePath' => brach_compare_path,
'pipelineStatus' => {
'name' => 'SUCCESS',
'label' => 'passed',
'favicon' => 'favicon_status_success'
}
)
)
}
)
)
)
end
end
end
end
describe 'email participants widget' do
let_it_be(:email) { 'user@example.com' }
let_it_be(:obfuscated_email) { 'us*****@e*****.c**' }
let_it_be(:issue_email_participant) { create(:issue_email_participant, issue_id: work_item.id, email: email) }
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetEmailParticipants {
emailParticipants {
nodes {
email
}
}
}
}
GRAPHQL
end
it 'contains the email' do
expect(work_item_data).to include(
'widgets' => array_including(
hash_including(
'type' => 'EMAIL_PARTICIPANTS',
'emailParticipants' => {
'nodes' => containing_exactly(
hash_including(
'email' => email
)
)
}
)
)
)
end
context 'when user has the guest role' do
let(:current_user) { guest }
it 'contains the obfuscated email' do
expect(work_item_data).to include(
'widgets' => array_including(
hash_including(
'type' => 'EMAIL_PARTICIPANTS',
'emailParticipants' => {
'nodes' => containing_exactly(
hash_including(
'email' => obfuscated_email
)
)
}
)
)
)
end
end
end
describe 'contacts widget' do
let(:work_item_fields) do
<<~GRAPHQL
id
widgets {
type
... on WorkItemWidgetCrmContacts {
contactsAvailable
contacts {
nodes {
firstName
}
}
}
}
GRAPHQL
end
context 'when no contacts are available' do
it 'returns expected data' do
expect(work_item_data).to include(
'widgets' => array_including(
hash_including(
'type' => 'CRM_CONTACTS',
'contactsAvailable' => false,
'contacts' => {
'nodes' => be_empty
}
)
)
)
end
end
context 'when contacts are available' do
let_it_be(:contact) { create(:contact, group: work_item.project.group) }
let_it_be(:issue_contact) { create(:issue_customer_relations_contact, issue: work_item, contact: contact) }
it 'returns expected data' do
expect(work_item_data).to include(
'widgets' => array_including(
hash_including(
'type' => 'CRM_CONTACTS',
'contactsAvailable' => true,
'contacts' => {
'nodes' => containing_exactly(
hash_including(
'firstName' => contact.first_name
)
)
}
)
)
)
end
end
end
context 'when an Issue Global ID is provided' do
let(:global_id) { Issue.find(work_item.id).to_gid.to_s }
it 'allows an Issue GID as input' do
expect(work_item_data).to include('id' => work_item.to_gid.to_s)
end
end
end
context 'when the user can not read the work item' do
let(:current_user) { create(:user) }
before do
post_graphql(query)
end
it 'returns an access error' do
expect(work_item_data).to be_nil
expect(graphql_errors).to contain_exactly(
hash_including('message' => ::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)
)
end
end
context 'when the user cannot set work item metadata' do
let(:current_user) { guest }
before do
post_graphql(query, current_user: current_user)
end
it 'returns correct user permission' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'userPermissions' =>
hash_including(
'setWorkItemMetadata' => false
)
)
end
end
context 'when the user can submit a work item as spam' do
let(:current_user) { create(:user, :admin) }
before do
stub_application_setting(akismet_enabled: true)
post_graphql(query, current_user: current_user)
end
it 'returns correct user permission' do
expect(work_item_data).to include(
'id' => work_item.to_gid.to_s,
'userPermissions' =>
hash_including(
'reportSpam' => true
)
)
end
end
end