# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Gql::Mutations::Ticket::Create, :aggregate_failures, type: :graphql do
let(:query) do
<<~QUERY
mutation ticketCreate($input: TicketCreateInput!) {
ticketCreate(input: $input) {
ticket {
id
title
group {
name
}
priority {
name
}
customer {
fullname
}
owner {
fullname
}
objectAttributeValues {
attribute {
name
}
value
}
tags
}
errors {
message
field
}
}
}
QUERY
end
let(:agent) { create(:agent, groups: [ Group.find_by(name: 'Users')]) }
let(:customer) { create(:customer) }
let(:user) { agent }
let(:group) { agent.groups.first }
let(:priority) { Ticket::Priority.last }
let(:article_payload) { nil }
let(:input_base_payload) do
{
title: 'Ticket Create Mutation Test',
groupId: gql.id(group),
priorityId: gql.id(priority),
customer: { id: gql.id(customer) },
ownerId: gql.id(agent),
tags: %w[foo bar],
article: article_payload
# pending_time: 10.minutes.from_now,
# type: ...
}
end
let(:input_payload) { input_base_payload }
let(:variables) { { input: input_payload } }
let(:expected_base_response) do
{
'id' => gql.id(Ticket.last),
'title' => 'Ticket Create Mutation Test',
'owner' => { 'fullname' => agent.fullname },
'group' => { 'name' => agent.groups.first.name },
'customer' => { 'fullname' => customer.fullname },
'priority' => { 'name' => Ticket::Priority.last.name },
'tags' => %w[foo bar],
'objectAttributeValues' => [],
}
end
let(:expected_response) do
expected_base_response
end
def it_creates_ticket(articles: 0, stores: 0)
expect { gql.execute(query, variables: variables) }
.to change(Ticket, :count).by(1)
.and change(Ticket::Article, :count).by(articles)
.and change(Store, :count).by(stores)
end
def it_fails_to_create_ticket
expect { gql.execute(query, variables: variables) }
.not_to change(Ticket, :count)
end
context 'when creating a new ticket' do
context 'with an agent', authenticated_as: :agent do
it 'creates Ticket record' do
it_creates_ticket
expect(gql.result.data[:ticket]).to eq(expected_response)
end
context 'without title' do
let(:input_payload) { input_base_payload.tap { |h| h[:title] = ' ' } }
it 'fails validation' do
it_fails_to_create_ticket
expect(gql.result.error_message).to include('Variable $input of type TicketCreateInput! was provided invalid value for title')
end
end
context 'with custom object_attribute', db_strategy: :reset do
let(:object_attribute) do
screens = { create: { 'admin.organization': { shown: true, required: false } } }
create(:object_manager_attribute_text, object_name: 'Ticket', screens: screens).tap do |_oa|
ObjectManager::Attribute.migration_execute
end
end
let(:input_payload) do
input_base_payload.merge(
{
objectAttributeValues: [ { name: object_attribute.name, value: 'object_attribute_value' } ]
}
)
end
let(:expected_response) do
expected_base_response.merge(
{
'objectAttributeValues' => [{ 'attribute' => { 'name'=>object_attribute.name }, 'value' => 'object_attribute_value' }]
}
)
end
it 'creates the ticket' do
it_creates_ticket
expect(gql.result.data[:ticket]).to eq(expected_response)
end
end
context 'with links' do
let!(:other_ticket) { create(:ticket, group: agent.groups.first) }
let(:links) do
[
{ linkObjectId: gql.id(other_ticket), linkType: 'child' },
{ linkObjectId: gql.id(other_ticket), linkType: 'normal' },
]
end
let(:input_payload) { input_base_payload.merge(links:) }
it 'creates the ticket and adds links' do
it_creates_ticket
expect(Link.list(link_object: 'Ticket', link_object_value: Ticket.last.id)).to contain_exactly(
{ 'link_object' => 'Ticket', 'link_object_value' => other_ticket.id, 'link_type' => 'parent' },
{ 'link_object' => 'Ticket', 'link_object_value' => other_ticket.id, 'link_type' => 'normal' },
)
end
end
context 'with issue tracker links' do
let(:github_link) { 'https://github.com/issue/123' }
let(:input_payload) { input_base_payload.merge(externalReferences: { github: [github_link] }) }
before { Setting.set('github_integration', true) }
it 'creates the ticket and adds issue trackeer links' do
it_creates_ticket
expect(Ticket.last.preferences)
.to include('github' => include('issue_links' => contain_exactly(github_link)))
end
end
context 'when customer is provided as an email address' do
let(:email_address) { Faker::Internet.email }
let(:input_payload) { input_base_payload.merge(customer: { email: email_address }) }
context 'with valid email address' do
it 'creates the ticket and a new customer' do
it_creates_ticket
expect(User.find_by(email: email_address)).to be_present
expect(gql.result.data[:ticket][:customer][:fullname]).to eq(User.find_by(email: email_address).fullname)
end
end
context 'with invalid email address' do
let(:email_address) { 'invalid-email' }
it 'fails to create the ticket' do
it_fails_to_create_ticket
expect(gql.result.error_message).to include('The email address is invalid.')
end
end
context 'with valid email address of an existing customer' do
let(:email_address) { customer.email }
it 'creates the ticket' do
it_creates_ticket
expect(gql.result.data[:ticket][:customer][:fullname]).to eq(customer.fullname)
end
end
end
context 'when creating the ticket in a group with only :create permission' do
let(:group) { create(:group) }
let(:owner) { create(:agent, groups: [group]) }
let(:input_payload) { input_base_payload.merge(ownerId: gql.id(owner)) }
before do
user.groups << group
user.group_names_access_map = { user.groups.first.name => ['full'], group.name => ['create'] }
end
it 'creates the ticket in the correct group, but returns an error trying to access the new ticket' do
expect { gql.execute(query, variables: variables) }.to change(Ticket, :count).by(1)
expect(Ticket.last.group.id).to eq(group.id)
expect(gql.result.payload['data']['ticketCreate']).to eq({ 'ticket' => nil, 'errors' => nil }) # Mutation did run, but data retrieval was not authorized.
expect(gql.result.payload['errors'].first['message']).to eq('Access forbidden by Gql::Types::TicketType')
expect(gql.result.payload['errors'].first['extensions']['type']).to eq('Exceptions::Forbidden')
end
end
context 'when creating the ticket in a group without email address' do
let(:group) { create(:group, email_address: nil) }
let(:agent) { create(:agent, groups: [group]) }
let(:article_payload) { { body: 'dummy', type: 'email' } }
let(:input_payload) { input_base_payload.merge(groupId: gql.id(group)) }
it 'fails to create the ticket' do
it_fails_to_create_ticket
expect(gql.result.payload['data']['ticketCreate']).to eq(
{
'ticket' => nil,
'errors' => [
{
'message' => 'This group has no email address configured for outgoing communication.',
'field' => 'group_id'
}
]
}
)
end
end
context 'with no permission to the group' do
let(:group) { create(:group) }
it 'raises an error', :aggregate_failures do
it_fails_to_create_ticket
expect(gql.result.error_type).to eq(Exceptions::Forbidden)
expect(gql.result.error_message).to eq('Access forbidden by Gql::Types::GroupType')
end
end
context 'with article' do
before do
Group.find(agent.groups.first.id).update(email_address: create(:email_address))
end
context 'with inline attachments' do
let(:body) do
<<~BODY
This is a test article with inline attachments.
BODY
end
let(:article_payload) do
{
body: body,
contentType: 'text/html',
}
end
it 'creates a new ticket + a new article with inline attachments' do
it_creates_ticket(articles: 1, stores: 1)
expect(Store.last.filename).to eq('image1.png')
end
end
context 'with attachments' do
let(:article_payload) do
form_id = SecureRandom.uuid
file_name = 'file1.txt'
file_type = 'text/plain'
file_content = Base64.strict_encode64('file1')
UploadCache.new(form_id).tap do |cache|
cache.add(
data: file_content,
filename: file_name,
preferences: { 'Content-Type' => file_type },
created_by_id: agent.id
)
end
{
body: 'dummy',
contentType: 'text/html',
attachments: {
formId: form_id,
files: [
{
name: file_name,
type: file_type,
content: file_content,
},
],
},
}
end
it 'creates a new ticket + a new article with attachments' do
it_creates_ticket(articles: 1, stores: 1)
expect(Store.last.filename).to eq('file1.txt')
end
end
context 'with inline attachments + attachments' do
let(:body) do
<<~BODY
This is a test article with inline attachments.
BODY
end
let(:article_payload) do
form_id = SecureRandom.uuid
file_name = 'file1.txt'
file_type = 'text/plain'
file_content = Base64.strict_encode64('file1')
UploadCache.new(form_id).tap do |cache|
cache.add(
data: file_content,
filename: file_name,
preferences: { 'Content-Type' => file_type },
created_by_id: agent.id
)
end
{
body: body,
contentType: 'text/html',
attachments: {
formId: form_id,
files: [
{
name: file_name,
type: file_type,
content: file_content,
},
],
},
}
end
it 'creates a new ticket + a new article with inline attachments + attachments' do
it_creates_ticket(articles: 1, stores: 2)
expect(Store.last.filename).to eq('image1.png')
end
end
context 'with a specific sender' do
let(:article_payload) do
{
body: 'dummy',
sender: 'Agent',
}
end
it 'creates a new ticket + a new article with a specific sender' do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last.sender.name).to eq('Agent')
end
it 'sets correct "to" and "from" values', :aggregate_failures do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last)
.to have_attributes(
from: agent.fullname,
to: "#{customer.fullname} <#{customer.email}>"
)
end
end
context 'with no type' do
let(:article_payload) do
{
body: 'dummy',
}
end
it 'creates a new ticket + a new article, but falls back to type "note"' do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last.type.name).to eq('note')
end
end
context 'with a specific type' do
let(:article_payload) do
{
body: 'dummy',
type: Ticket::Article::Type.first.name,
to: 'dummy@example.org',
}
end
it 'creates a new ticket + a new article with a specific type' do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last.type.name).to eq(Ticket::Article::Type.first.name)
end
context 'with all integrations disabled' do
let(:article_payload) do
{
body: 'dummy',
to: ['to@example.com'],
type: 'email',
security: {
method: 'SMIME',
options: %w[encryption sign]
}
}
end
before do
Setting.set('smime_integration', false)
Setting.set('pgp_integration', false)
end
it 'doesn\'t set security if security integrations are not enabled', :aggregate_failures do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last.preferences[:security]).to be_nil
end
end
context 'with smime enabled' do
let(:article_payload) do
{
body: 'dummy',
to: ['to@example.com'],
type: 'email',
security: {
method: 'SMIME',
options: %w[encryption sign]
}
}
end
before do
Setting.set('smime_integration', true)
Setting.set('pgp_integration', false)
end
it 'creates a new ticket with correct security preferences', :aggregate_failures do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last.preferences[:security]).to eq(
'type' => 'S/MIME',
'encryption' => { 'success' => true },
'sign' => { 'success' => true },
)
end
end
context 'with pgp enabled' do
let(:article_payload) do
{
body: 'dummy',
to: ['to@example.com'],
type: 'email',
security: {
method: 'PGP',
options: %w[encryption sign]
}
}
end
before do
Setting.set('smime_integration', false)
Setting.set('pgp_integration', true)
end
it 'creates a new ticket with correct security preferences', :aggregate_failures do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last.preferences[:security]).to eq(
'type' => 'PGP',
'encryption' => { 'success' => true },
'sign' => { 'success' => true },
)
end
end
end
end
context 'with to: and cc: being string values' do
let(:article_payload) do
{
body: 'dummy',
to: 'to@example.com',
cc: 'cc@example.com',
}
end
it 'creates a new ticket + a new article and sets correct "to" and "cc" values', :aggregate_failures do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last).to have_attributes(to: 'to@example.com', cc: 'cc@example.com')
end
end
context 'with to: and cc: containing array values' do
let(:article_payload) do
{
body: 'dummy',
to: ['to@example.com', 'to2@example.com'],
cc: ['cc@example.com', 'cc2@example.com'],
}
end
it 'creates a new ticket + a new article and sets correct "to" and "cc" values', :aggregate_failures do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last).to have_attributes(to: 'to@example.com, to2@example.com', cc: 'cc@example.com, cc2@example.com')
end
end
context 'with a shared draft' do
let(:shared_draft) { create(:ticket_shared_draft_start, group:) }
let(:input_payload) do
input_base_payload
.merge(sharedDraftId: Gql::ZammadSchema.id_from_object(shared_draft))
end
it 'passed to ticket create service' do
expect_any_instance_of(Service::Ticket::Create)
.to receive(:execute)
.with(ticket_data: include(shared_draft:))
.and_call_original
gql.execute(query, variables: variables)
end
end
end
context 'with a customer', authenticated_as: :customer do
let(:input_payload) { input_base_payload.tap { |h| h.delete(:customer) } }
let(:expected_response) do
expected_base_response.merge(
{
'owner' => { 'fullname' => nil },
'priority' => { 'name' => Ticket::Priority.where(default_create: true).first.name },
'tags' => nil
}
)
end
it 'creates the ticket with filtered values' do
it_creates_ticket
expect(gql.result.data[:ticket]).to eq(expected_response)
end
context 'when sending a different customerId' do
let(:input_payload) { input_base_payload.tap { |h| h[:customer][:id] = gql.id(create(:customer)) } }
it 'fails creating a ticket with permission exception' do
it_fails_to_create_ticket
expect(gql.result.error_type).to eq(Exceptions::Forbidden)
expect(gql.result.error_message).to eq('Access forbidden by Gql::Types::UserType')
end
end
context 'with links' do
let!(:other_ticket) { create(:ticket, customer: customer) }
let(:links) do
[
{ linkObjectId: gql.id(other_ticket), linkType: 'child' },
{ linkObjectId: gql.id(other_ticket), linkType: 'normal' },
]
end
let(:input_payload) { input_base_payload.merge(links:) }
it 'creates the ticket without links' do
it_creates_ticket
expect(Link.list(link_object: 'Ticket', link_object_value: Ticket.last.id)).to eq([])
end
end
context 'with issue tracker links' do
let(:github_link) { 'https://github.com/issue/123' }
let(:input_payload) { input_base_payload.merge(externalReferences: { github: [github_link] }) }
before { Setting.set('github_integration', true) }
it 'creates the ticket and adds issue tracker links' do
it_creates_ticket
expect(Ticket.last.preferences).not_to include('github')
end
end
context 'with article' do
context 'with a forbidden sender' do
let(:article_payload) do
{
body: 'dummy',
sender: 'Agent',
}
end
it 'creates a new ticket + a new article, but falls back to "Customer" as sender' do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last.sender.name).to eq('Customer')
end
end
context 'with type "phone"' do
let(:article_payload) do
{
body: 'dummy',
type: 'phone',
}
end
it 'creates a new ticket + a new article, but falls back to "note" as type' do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last.type.name).to eq('note')
end
it 'sets correct "to" and "from" values', :aggregate_failures do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last)
.to have_attributes(
to: Ticket.last.group.name,
from: customer.fullname
)
end
end
context 'with an article flagged as internal' do
let(:article_payload) do
{
body: 'dummy',
internal: true,
}
end
it 'creates a new ticket + a new article, but flags it as not internal' do
it_creates_ticket(articles: 1)
expect(Ticket.last.articles.last.internal).to be(false)
end
end
end
end
context 'with an agent that has a specific role limited to create/update permission', authenticated_as: :user do
let(:user) { create(:user, roles: [api_role]) }
let(:api_role) do
role = create(:role, name: 'API', permission_names: ['ticket.agent'])
role.group_names_access_map = {
Group.first.name => %w[create],
}
role
end
let(:input_payload) do
{
title: 'Test title for issue #4647',
groupId: gql.id(Group.first),
customer: { id: gql.id(customer) },
article: article_payload,
}
end
let(:article_payload) do
{
type: 'web',
internal: false,
sender: 'Customer',
subject: 'Test subject',
body: SecureRandom.uuid,
}
end
before { Trigger.destroy_all } # triggers may cause additional articles to be created
it 'contains correct "origin_by" + "from" information' do
gql.execute(query, variables: variables)
expect(Ticket.last.articles.last).to have_attributes(
origin_by_id: customer.id,
from: "#{customer.fullname} <#{customer.email}>",
)
end
end
end
end