Merge remote-tracking branch 'origin/stripe_v2/frontend' into stripe_v2/final
This commit is contained in:
@@ -223,6 +223,69 @@ describe Messages::MessageBuilder do
|
||||
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when liquid templates are present in email content' do
|
||||
let(:contact) { create(:contact, name: 'John', email: 'john@example.com') }
|
||||
let(:conversation) { create(:conversation, inbox: channel_email.inbox, account: account, contact: contact) }
|
||||
|
||||
it 'processes liquid variables in email content' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Hello {{contact.name}}, your email is {{contact.email}}'
|
||||
})
|
||||
|
||||
message = described_class.new(user, conversation, params).perform
|
||||
|
||||
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('Hello John')
|
||||
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('john@example.com')
|
||||
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, your email is john@example.com'
|
||||
end
|
||||
|
||||
it 'does not process liquid in code blocks' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Hello {{contact.name}}, use this code: `{{contact.email}}`'
|
||||
})
|
||||
|
||||
message = described_class.new(user, conversation, params).perform
|
||||
|
||||
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, use this code: `{{contact.email}}`'
|
||||
end
|
||||
|
||||
it 'handles broken liquid syntax gracefully' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Hello {{contact.name} {{invalid}}'
|
||||
})
|
||||
|
||||
message = described_class.new(user, conversation, params).perform
|
||||
|
||||
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello {{contact.name} {{invalid}}'
|
||||
end
|
||||
|
||||
it 'does not process liquid for incoming messages' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Hello {{contact.name}}',
|
||||
message_type: 'incoming'
|
||||
})
|
||||
|
||||
api_channel = create(:channel_api, account: account)
|
||||
api_conversation = create(:conversation, inbox: api_channel.inbox, account: account, contact: contact)
|
||||
|
||||
message = described_class.new(user, api_conversation, params).perform
|
||||
|
||||
expect(message.content).to eq 'Hello {{contact.name}}'
|
||||
end
|
||||
|
||||
it 'does not process liquid for private messages' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Hello {{contact.name}}',
|
||||
private: true
|
||||
})
|
||||
|
||||
message = described_class.new(user, conversation, params).perform
|
||||
|
||||
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
|
||||
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,9 @@ describe V2::ReportBuilder do
|
||||
let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) }
|
||||
|
||||
describe '#timeseries' do
|
||||
before do
|
||||
# Use before_all to share expensive setup across all tests in this describe block
|
||||
# This runs once instead of 21 times, dramatically speeding up the suite
|
||||
before_all do
|
||||
travel_to(Time.zone.today) do
|
||||
user = create(:user, account: account)
|
||||
inbox = create(:inbox, account: account)
|
||||
|
||||
@@ -21,7 +21,7 @@ describe 'Markdown Embeds Configuration' do
|
||||
end
|
||||
|
||||
it 'contains expected embed types' do
|
||||
expected_types = %w[youtube loom vimeo mp4 arcade wistia bunny codepen github_gist]
|
||||
expected_types = %w[youtube loom vimeo mp4 arcade_tab arcade wistia bunny codepen github_gist]
|
||||
expect(config.keys).to match_array(expected_types)
|
||||
end
|
||||
end
|
||||
@@ -50,6 +50,12 @@ describe 'Markdown Embeds Configuration' do
|
||||
{ url: 'https://app.arcade.software/share/arcade123', expected: { 'video_id' => 'arcade123' } },
|
||||
{ url: 'https://www.app.arcade.software/share/demo456', expected: { 'video_id' => 'demo456' } }
|
||||
],
|
||||
'arcade_tab' => [
|
||||
{ url: 'https://app.arcade.software/share/arcade789?embed_mobile=tab', expected: { 'video_id' => 'arcade789' } },
|
||||
{ url: 'https://app.arcade.software/share/demo789?foo=bar&embed_mobile=tab', expected: { 'video_id' => 'demo789' } },
|
||||
{ url: 'https://app.arcade.software/share/demo-with-query?foo=bar&embed_mobile=tab?user_id=1',
|
||||
expected: { 'video_id' => 'demo-with-query' } }
|
||||
],
|
||||
'wistia' => [
|
||||
{ url: 'https://chatwoot.wistia.com/medias/kjwjeq6f9i', expected: { 'video_id' => 'kjwjeq6f9i' } },
|
||||
{ url: 'https://www.company.wistia.com/medias/abc123def', expected: { 'video_id' => 'abc123def' } }
|
||||
|
||||
@@ -36,7 +36,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload']['title']).to eql('MyTitle')
|
||||
expect(json_response['payload']['status']).to eql('draft')
|
||||
expect(json_response['payload']['status']).to eql('published')
|
||||
expect(json_response['payload']['position']).to be(3)
|
||||
end
|
||||
|
||||
@@ -59,10 +59,30 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload']['title']).to eql('MyTitle')
|
||||
expect(json_response['payload']['status']).to eql('draft')
|
||||
expect(json_response['payload']['status']).to eql('published')
|
||||
expect(json_response['payload']['position']).to be(3)
|
||||
end
|
||||
|
||||
it 'creates article as draft when status is not provided' do
|
||||
article_params = {
|
||||
article: {
|
||||
category_id: category.id,
|
||||
description: 'test description',
|
||||
title: 'DraftTitle',
|
||||
slug: 'draft-title',
|
||||
content: 'This is my draft content.',
|
||||
author_id: agent.id
|
||||
}
|
||||
}
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
|
||||
params: article_params,
|
||||
headers: admin.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload']['title']).to eql('DraftTitle')
|
||||
expect(json_response['payload']['status']).to eql('draft')
|
||||
end
|
||||
|
||||
it 'associate to the root article' do
|
||||
root_article = create(:article, category: category, slug: 'root-article', portal: portal, account_id: account.id, author_id: agent.id,
|
||||
associated_article_id: nil)
|
||||
|
||||
@@ -15,7 +15,7 @@ RSpec.describe 'Conversation Assignment API', type: :request do
|
||||
end
|
||||
|
||||
context 'when it is an authenticated bot with out access to the inbox' do
|
||||
let(:agent_bot) { create(:agent_bot, account: account) }
|
||||
let(:agent_bot) { create(:agent_bot) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
@@ -36,6 +36,7 @@ RSpec.describe 'Conversation Assignment API', type: :request do
|
||||
|
||||
context 'when it is an authenticated user with access to the inbox' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:agent_bot) { create(:agent_bot, account: account) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
|
||||
before do
|
||||
@@ -54,6 +55,25 @@ RSpec.describe 'Conversation Assignment API', type: :request do
|
||||
expect(conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
it 'assigns an agent bot to the conversation' do
|
||||
params = { assignee_id: agent_bot.id, assignee_type: 'AgentBot' }
|
||||
|
||||
expect(Conversations::AssignmentService).to receive(:new)
|
||||
.with(hash_including(conversation: conversation, assignee_id: agent_bot.id, assignee_type: 'AgentBot'))
|
||||
.and_call_original
|
||||
|
||||
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['name']).to eq(agent_bot.name)
|
||||
conversation.reload
|
||||
expect(conversation.assignee_agent_bot).to eq(agent_bot)
|
||||
expect(conversation.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'assigns a team to the conversation' do
|
||||
team_member = create(:user, account: account, role: :agent, auto_offline: false)
|
||||
create(:inbox_member, inbox: conversation.inbox, user: team_member)
|
||||
@@ -125,7 +145,7 @@ RSpec.describe 'Conversation Assignment API', type: :request do
|
||||
end
|
||||
|
||||
it 'unassigns the assignee from the conversation' do
|
||||
params = { assignee_id: 0 }
|
||||
params = { assignee_id: nil }
|
||||
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
|
||||
@@ -3,7 +3,7 @@ require 'rails_helper'
|
||||
RSpec.describe 'Webhooks API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com') }
|
||||
let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com', name: 'My Webhook') }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
@@ -49,6 +49,16 @@ RSpec.describe 'Webhooks API', type: :request do
|
||||
expect(response.parsed_body['payload']['webhook']['url']).to eql 'https://hello.com'
|
||||
end
|
||||
|
||||
it 'creates webhook with name' do
|
||||
post "/api/v1/accounts/#{account.id}/webhooks",
|
||||
params: { account_id: account.id, inbox_id: inbox.id, url: 'https://hello.com', name: 'My Webhook' },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
expect(response.parsed_body['payload']['webhook']['name']).to eql 'My Webhook'
|
||||
end
|
||||
|
||||
it 'throws error when invalid url provided' do
|
||||
post "/api/v1/accounts/#{account.id}/webhooks",
|
||||
params: { account_id: account.id, inbox_id: inbox.id, url: 'javascript:alert(1)' },
|
||||
@@ -103,11 +113,12 @@ RSpec.describe 'Webhooks API', type: :request do
|
||||
context 'when it is an authenticated admin user' do
|
||||
it 'updates webhook' do
|
||||
put "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
|
||||
params: { url: 'https://hello.com' },
|
||||
params: { url: 'https://hello.com', name: 'Another Webhook' },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']['webhook']['url']).to eql 'https://hello.com'
|
||||
expect(response.parsed_body['payload']['webhook']['name']).to eql 'Another Webhook'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,6 +9,13 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
let(:shopify_redirect_uri) { "#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify" }
|
||||
let(:oauth_client) { instance_double(OAuth2::Client) }
|
||||
let(:auth_code_strategy) { instance_double(OAuth2::Strategy::AuthCode) }
|
||||
let(:token_response) do
|
||||
instance_double(
|
||||
OAuth2::AccessToken,
|
||||
response: instance_double(OAuth2::Response, parsed: response_body),
|
||||
token: access_token
|
||||
)
|
||||
end
|
||||
|
||||
describe 'GET /shopify/callback' do
|
||||
let(:access_token) { SecureRandom.hex(10) }
|
||||
@@ -23,12 +30,24 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
stub_const('ENV', ENV.to_hash.merge('FRONTEND_URL' => frontend_url))
|
||||
end
|
||||
|
||||
context 'when successful' do
|
||||
shared_context 'with stubbed account' do
|
||||
before do
|
||||
controller = described_class.new
|
||||
allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
|
||||
allow(described_class).to receive(:new).and_return(controller)
|
||||
allow(described_class).to receive(:new).and_wrap_original do |original, *args|
|
||||
controller = original.call(*args)
|
||||
allow(controller).to receive(:verify_shopify_token).and_return(account.id)
|
||||
allow(controller).to receive(:oauth_client).and_return(oauth_client)
|
||||
controller
|
||||
end
|
||||
allow(Account).to receive(:find).and_return(account)
|
||||
|
||||
allow(oauth_client).to receive(:auth_code).and_return(auth_code_strategy)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when successful' do
|
||||
include_context 'with stubbed account'
|
||||
before do
|
||||
allow(auth_code_strategy).to receive(:get_token).and_return(token_response)
|
||||
stub_request(:post, "https://#{shop}/admin/oauth/access_token")
|
||||
.to_return(
|
||||
status: 200,
|
||||
@@ -55,12 +74,11 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
end
|
||||
|
||||
context 'when the code is missing' do
|
||||
include_context 'with stubbed account'
|
||||
before do
|
||||
controller = described_class.new
|
||||
allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
|
||||
allow(controller).to receive(:oauth_client).and_return(oauth_client)
|
||||
allow(oauth_client).to receive(:auth_code).and_raise(StandardError)
|
||||
allow(described_class).to receive(:new).and_return(controller)
|
||||
allow(auth_code_strategy).to receive(:get_token).and_raise(StandardError)
|
||||
stub_request(:post, "https://#{shop}/admin/oauth/access_token")
|
||||
.to_return(status: 400, body: { error: 'invalid_grant' }.to_json)
|
||||
end
|
||||
|
||||
it 'redirects to the shopify_redirect_uri with error' do
|
||||
@@ -70,11 +88,8 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
end
|
||||
|
||||
context 'when the token is invalid' do
|
||||
include_context 'with stubbed account'
|
||||
before do
|
||||
controller = described_class.new
|
||||
allow(controller).to receive(:verify_shopify_token).with(state).and_return(account.id)
|
||||
allow(controller).to receive(:oauth_client).and_return(oauth_client)
|
||||
allow(oauth_client).to receive(:auth_code).and_return(auth_code_strategy)
|
||||
allow(auth_code_strategy).to receive(:get_token).and_raise(
|
||||
OAuth2::Error.new(
|
||||
OpenStruct.new(
|
||||
@@ -83,7 +98,9 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
)
|
||||
)
|
||||
)
|
||||
allow(described_class).to receive(:new).and_return(controller)
|
||||
|
||||
stub_request(:post, "https://#{shop}/admin/oauth/access_token")
|
||||
.to_return(status: 400, body: { error: 'invalid_grant' }.to_json)
|
||||
end
|
||||
|
||||
it 'redirects to the shopify_redirect_uri with error' do
|
||||
@@ -94,10 +111,11 @@ RSpec.describe Shopify::CallbacksController, type: :request do
|
||||
|
||||
context 'when state parameter is invalid' do
|
||||
before do
|
||||
controller = described_class.new
|
||||
allow(controller).to receive(:verify_shopify_token).with(state).and_return(nil)
|
||||
allow(controller).to receive(:account).and_return(nil)
|
||||
allow(described_class).to receive(:new).and_return(controller)
|
||||
# rubocop:disable RSpec/AnyInstance, RSpec/DescribedClass
|
||||
# Explicit class name and any_instance required for parallel CI stability
|
||||
allow_any_instance_of(Shopify::CallbacksController).to receive(:verify_shopify_token).and_return(nil)
|
||||
allow_any_instance_of(Shopify::CallbacksController).to receive(:account).and_return(nil)
|
||||
# rubocop:enable RSpec/AnyInstance, RSpec/DescribedClass
|
||||
end
|
||||
|
||||
it 'redirects to the frontend URL with error' do
|
||||
|
||||
@@ -25,10 +25,150 @@ RSpec.describe 'Companies API', type: :request do
|
||||
expect(response_body['payload'].size).to eq(2)
|
||||
expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
|
||||
end
|
||||
|
||||
it 'returns companies with pagination' do
|
||||
create_list(:company, 30, account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
params: { page: 1 },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(25)
|
||||
expect(response_body['meta']['total_count']).to eq(32)
|
||||
expect(response_body['meta']['page']).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns second page of companies' do
|
||||
create_list(:company, 30, account: account)
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
params: { page: 2 },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(7)
|
||||
expect(response_body['meta']['total_count']).to eq(32)
|
||||
expect(response_body['meta']['page']).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns companies with contacts_count' do
|
||||
company_with_contacts = create(:company, name: 'Company With Contacts', account: account)
|
||||
create_list(:contact, 5, company: company_with_contacts, account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
company_data = response_body['payload'].find { |c| c['id'] == company_with_contacts.id }
|
||||
expect(company_data['contacts_count']).to eq(5)
|
||||
end
|
||||
|
||||
it 'does not return companies from other accounts' do
|
||||
other_account = create(:account)
|
||||
create(:company, name: 'Other Account Company', account: other_account)
|
||||
create(:company, name: 'My Company', account: account)
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(3)
|
||||
expect(response_body['payload'].map { |c| c['name'] }).not_to include('Other Account Company')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/companies/search' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/companies/search"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns error when q parameter is missing' do
|
||||
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Specify search string with parameter q')
|
||||
end
|
||||
|
||||
it 'searches companies by name' do
|
||||
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||
create(:company, name: 'Tech Solutions', domain: 'tech.com', account: account)
|
||||
create(:company, name: 'Global Inc', domain: 'global.com', account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||
params: { q: 'tech' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(1)
|
||||
expect(response_body['payload'].first['name']).to eq('Tech Solutions')
|
||||
end
|
||||
|
||||
it 'searches companies by domain' do
|
||||
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||
create(:company, name: 'Tech Solutions', domain: 'tech.com', account: account)
|
||||
create(:company, name: 'Global Inc', domain: 'global.com', account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||
params: { q: 'acme.com' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(1)
|
||||
expect(response_body['payload'].first['domain']).to eq('acme.com')
|
||||
end
|
||||
|
||||
it 'search is case insensitive' do
|
||||
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||
params: { q: 'ACME' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
|
||||
expect(response_body['payload'].size).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns empty array when no companies match search' do
|
||||
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||
params: { q: 'nonexistent' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(0)
|
||||
expect(response_body['meta']['total_count']).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
company = create(:company, account: account)
|
||||
get "/api/v1/accounts/#{account.id}/companies/#{company.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
@@ -46,6 +186,13 @@ RSpec.describe 'Companies API', type: :request do
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/companies' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/companies"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:valid_params) do
|
||||
@@ -85,6 +232,14 @@ RSpec.describe 'Companies API', type: :request do
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
company = create(:company, account: account)
|
||||
patch "/api/v1/accounts/#{account.id}/companies/#{company.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
@@ -111,6 +266,14 @@ RSpec.describe 'Companies API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
company = create(:company, account: account)
|
||||
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
|
||||
@@ -102,4 +102,146 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/conversations/:id/reporting_events' do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:inbox) { conversation.inbox }
|
||||
let(:agent) { administrator }
|
||||
|
||||
before do
|
||||
# Create reporting events for this conversation
|
||||
@event1 = create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'first_response',
|
||||
value: 120,
|
||||
created_at: 3.hours.ago)
|
||||
|
||||
@event2 = create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'reply_time',
|
||||
value: 45,
|
||||
created_at: 2.hours.ago)
|
||||
|
||||
@event3 = create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'resolution',
|
||||
value: 300,
|
||||
created_at: 1.hour.ago)
|
||||
|
||||
# Create an event for a different conversation (should not be included)
|
||||
other_conversation = create(:conversation, account: account)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: other_conversation,
|
||||
inbox: other_conversation.inbox,
|
||||
user: agent,
|
||||
name: 'other_conversation_event',
|
||||
value: 60)
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user with conversation access' do
|
||||
it 'returns all reporting events for the conversation' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
# Should return array directly (no pagination)
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.size).to eq(3)
|
||||
|
||||
# Check they are sorted by created_at asc (oldest first)
|
||||
expect(json_response.first['name']).to eq('first_response')
|
||||
expect(json_response.last['name']).to eq('resolution')
|
||||
|
||||
# Verify it doesn't include events from other conversations
|
||||
event_names = json_response.map { |e| e['name'] }
|
||||
expect(event_names).not_to include('other_conversation_event')
|
||||
end
|
||||
|
||||
it 'returns empty array when conversation has no reporting events' do
|
||||
conversation_without_events = create(:conversation, account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation_without_events.display_id}/reporting_events",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent has limited access' do
|
||||
let(:limited_agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized for unassigned conversation without permission' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: limited_agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns reporting events when agent is assigned to the conversation' do
|
||||
conversation.update!(assignee: limited_agent)
|
||||
# Also create inbox member for the agent
|
||||
create(:inbox_member, user: limited_agent, inbox: conversation.inbox)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: limited_agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.size).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent has team access' do
|
||||
let(:team_agent) { create(:user, account: account, role: :agent) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
|
||||
before do
|
||||
create(:team_member, team: team, user: team_agent)
|
||||
conversation.update!(team: team)
|
||||
end
|
||||
|
||||
it 'allows accessing conversation reporting events via team membership' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: team_agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.size).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Reporting Events API', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/reporting_events' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated normal agent user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin user' do
|
||||
before do
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'first_response',
|
||||
value: 120,
|
||||
created_at: 3.days.ago)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'resolution',
|
||||
value: 300,
|
||||
created_at: 2.days.ago)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'reply_time',
|
||||
value: 45,
|
||||
created_at: 1.day.ago)
|
||||
end
|
||||
|
||||
it 'fetches reporting events with pagination' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
# Check structure and pagination
|
||||
expect(json_response).to have_key('payload')
|
||||
expect(json_response).to have_key('meta')
|
||||
expect(json_response['meta']['count']).to eq(3)
|
||||
|
||||
# Check events are sorted by created_at desc (newest first)
|
||||
events = json_response['payload']
|
||||
expect(events.size).to eq(3)
|
||||
expect(events.first['name']).to eq('reply_time')
|
||||
expect(events.last['name']).to eq('first_response')
|
||||
end
|
||||
|
||||
it 'filters reporting events by date range using since and until' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { since: 2.5.days.ago.to_time.to_i.to_s, until: 1.5.days.ago.to_time.to_i.to_s },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(1)
|
||||
expect(json_response['payload'].first['name']).to eq('resolution')
|
||||
end
|
||||
|
||||
it 'filters reporting events by inbox_id' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: other_conversation,
|
||||
inbox: other_inbox,
|
||||
user: agent,
|
||||
name: 'other_inbox_event')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { inbox_id: inbox.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(3)
|
||||
expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_inbox_event')
|
||||
end
|
||||
|
||||
it 'filters reporting events by user_id (agent)' do
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: other_agent,
|
||||
name: 'other_agent_event')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { user_id: agent.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(3)
|
||||
expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_agent_event')
|
||||
end
|
||||
|
||||
it 'filters reporting events by name' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { name: 'first_response' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(1)
|
||||
expect(json_response['payload'].first['name']).to eq('first_response')
|
||||
end
|
||||
|
||||
it 'supports combining multiple filters' do
|
||||
# Create more test data
|
||||
other_conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: other_conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'first_response',
|
||||
value: 90,
|
||||
created_at: 2.days.ago)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: {
|
||||
inbox_id: inbox.id,
|
||||
user_id: agent.id,
|
||||
name: 'first_response',
|
||||
since: 4.days.ago.to_time.to_i.to_s,
|
||||
until: Time.zone.now.to_time.to_i.to_s
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(2)
|
||||
expect(json_response['payload'].map { |e| e['name'] }).to all(eq('first_response'))
|
||||
end
|
||||
|
||||
context 'with pagination' do
|
||||
before do
|
||||
# Create more events to test pagination
|
||||
30.times do |i|
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: "event_#{i}",
|
||||
created_at: i.hours.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns 25 events per page by default' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload'].size).to eq(25)
|
||||
expect(json_response['meta']['count']).to eq(33) # 30 + 3 original events
|
||||
expect(json_response['meta']['current_page']).to eq(1)
|
||||
end
|
||||
|
||||
it 'supports page navigation' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { page: 2 },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload'].size).to eq(8) # Remaining events
|
||||
expect(json_response['meta']['current_page']).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -18,36 +18,94 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
let(:from_number) { '+15550003333' }
|
||||
let(:to_number) { channel.phone_number }
|
||||
|
||||
it 'invokes Voice::InboundCallBuilder with expected params and renders its TwiML' do
|
||||
builder_double = instance_double(Voice::InboundCallBuilder)
|
||||
expect(Voice::InboundCallBuilder).to receive(:new).with(
|
||||
hash_including(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
call_sid: call_sid
|
||||
)
|
||||
).and_return(builder_double)
|
||||
expect(builder_double).to receive(:perform).and_return(builder_double)
|
||||
expect(builder_double).to receive(:twiml_response).and_return('<Response/>')
|
||||
it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
|
||||
instance_double(Voice::InboundCallBuilder)
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
|
||||
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
call_sid: call_sid
|
||||
).and_return(conversation)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number
|
||||
'To' => to_number,
|
||||
'Direction' => 'inbound'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('<Response>')
|
||||
expect(response.body).to include('<Dial>')
|
||||
end
|
||||
|
||||
it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: call_sid)
|
||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||
|
||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||
hash_including(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid,
|
||||
message_call_sid: conversation.identifier,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
direction: 'outbound'
|
||||
}
|
||||
)
|
||||
).and_return(sync_double)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'Direction' => 'outbound-api'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('<Response>')
|
||||
end
|
||||
|
||||
it 'uses the parent call SID when syncing outbound-dial legs' do
|
||||
parent_sid = 'CA_parent'
|
||||
child_sid = 'CA_child'
|
||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: parent_sid)
|
||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||
|
||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||
hash_including(
|
||||
conversation: conversation,
|
||||
call_sid: child_sid,
|
||||
message_call_sid: parent_sid,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
direction: 'outbound'
|
||||
}
|
||||
)
|
||||
).and_return(sync_double)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => child_sid,
|
||||
'ParentCallSid' => parent_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'Direction' => 'outbound-dial'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to eq('<Response/>')
|
||||
end
|
||||
|
||||
it 'raises not found when inbox is not present' do
|
||||
expect(Voice::InboundCallBuilder).not_to receive(:new)
|
||||
expect(Voice::InboundCallBuilder).not_to receive(:perform!)
|
||||
post '/twilio/voice/call/19998887777', params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number
|
||||
'To' => to_number,
|
||||
'Direction' => 'inbound'
|
||||
}
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
@@ -62,7 +120,8 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
hash_including(
|
||||
account: account,
|
||||
call_sid: call_sid,
|
||||
call_status: 'completed'
|
||||
call_status: 'completed',
|
||||
payload: hash_including('CallSid' => call_sid, 'CallStatus' => 'completed')
|
||||
)
|
||||
).and_return(service_double)
|
||||
expect(service_double).to receive(:perform)
|
||||
|
||||
@@ -28,7 +28,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
end
|
||||
|
||||
it 'uses Captain::Llm::AssistantChatService' do
|
||||
expect(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant)
|
||||
expect(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant, conversation_id: conversation.id)
|
||||
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
@@ -18,7 +18,9 @@ RSpec.describe Captain::Copilot::ResponseJob, type: :job do
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
conversation_id: conversation_id
|
||||
).and_return(chat_service)
|
||||
allow(chat_service).to receive(:generate_response).with(message)
|
||||
# When copilot_thread_id is present, message is already in previous_history
|
||||
# so nil is passed to avoid duplicate
|
||||
allow(chat_service).to receive(:generate_response).with(nil)
|
||||
end
|
||||
|
||||
it 'initializes ChatService with correct parameters and calls generate_response' do
|
||||
@@ -28,7 +30,9 @@ RSpec.describe Captain::Copilot::ResponseJob, type: :job do
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
conversation_id: conversation_id
|
||||
)
|
||||
expect(chat_service).to receive(:generate_response).with(message)
|
||||
# Message is already persisted in copilot_thread.previous_history,
|
||||
# so we pass nil to prevent duplicate user messages
|
||||
expect(chat_service).to receive(:generate_response).with(nil)
|
||||
described_class.perform_now(
|
||||
assistant: assistant,
|
||||
conversation_id: conversation_id,
|
||||
|
||||
@@ -23,8 +23,8 @@ RSpec.describe Captain::Scenario, type: :model do
|
||||
enabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: true)
|
||||
disabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: false)
|
||||
|
||||
expect(described_class.enabled).to include(enabled_scenario)
|
||||
expect(described_class.enabled).not_to include(disabled_scenario)
|
||||
expect(described_class.enabled.pluck(:id)).to include(enabled_scenario.id)
|
||||
expect(described_class.enabled.pluck(:id)).not_to include(disabled_scenario.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,6 +45,25 @@ RSpec.describe Contacts::CompanyAssociationService, type: :service do
|
||||
contact.reload
|
||||
expect(contact.company).to eq(existing_company)
|
||||
end
|
||||
|
||||
it 'increments company contacts_count when associating contact' do
|
||||
# Create contact without email to avoid auto-association
|
||||
contact = create(:contact, email: nil, account: account)
|
||||
# Manually set email to bypass callbacks
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
contact.update_column(:email, 'jane@techcorp.com')
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false)
|
||||
allow(ValidEmail2::Address).to receive(:new).with('jane@techcorp.com').and_return(valid_email_address)
|
||||
allow(EmailProviderInfo).to receive(:call).with('jane@techcorp.com').and_return(nil)
|
||||
|
||||
service.associate_company_from_email(contact)
|
||||
|
||||
contact.reload
|
||||
expect(contact.company).to be_present
|
||||
expect(contact.company.contacts_count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact already has a company' do
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
|
||||
let(:account) { create(:account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:agent1) { create(:user, account: account, name: 'Agent 1') }
|
||||
let(:agent2) { create(:user, account: account, name: 'Agent 2') }
|
||||
let(:assignment_service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
|
||||
|
||||
before do
|
||||
# Create inbox members
|
||||
create(:inbox_member, inbox: inbox, user: agent1)
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
|
||||
# Link inbox to assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
|
||||
# Set agents as online
|
||||
OnlineStatusTracker.update_presence(account.id, 'User', agent1.id)
|
||||
OnlineStatusTracker.set_status(account.id, agent1.id, 'online')
|
||||
OnlineStatusTracker.update_presence(account.id, 'User', agent2.id)
|
||||
OnlineStatusTracker.set_status(account.id, agent2.id, 'online')
|
||||
end
|
||||
|
||||
describe 'exclusion rules' do
|
||||
let(:capacity_policy) { create(:agent_capacity_policy, account: account) }
|
||||
let(:label1) { create(:label, account: account, title: 'high-priority') }
|
||||
let(:label2) { create(:label, account: account, title: 'vip') }
|
||||
|
||||
before do
|
||||
create(:inbox_capacity_limit, inbox: inbox, agent_capacity_policy: capacity_policy, conversation_limit: 10)
|
||||
inbox.enable_auto_assignment = true
|
||||
inbox.save!
|
||||
end
|
||||
|
||||
context 'when excluding conversations by label' do
|
||||
let!(:conversation_with_label) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
let!(:conversation_without_label) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
conversation_with_label.update_labels([label1.title])
|
||||
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'excluded_labels' => [label1.title]
|
||||
})
|
||||
end
|
||||
|
||||
it 'excludes conversations with specified labels' do
|
||||
# First check conversations are unassigned
|
||||
expect(conversation_with_label.assignee).to be_nil
|
||||
expect(conversation_without_label.assignee).to be_nil
|
||||
|
||||
# Run bulk assignment
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only the conversation without label should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation_with_label.reload.assignee).to be_nil
|
||||
expect(conversation_without_label.reload.assignee).to be_present
|
||||
end
|
||||
|
||||
it 'handles bulk assignment correctly' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only 1 conversation should be assigned (the one without label)
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation_with_label.reload.assignee).to be_nil
|
||||
expect(conversation_without_label.reload.assignee).to be_present
|
||||
end
|
||||
|
||||
it 'excludes conversations with multiple labels' do
|
||||
conversation_without_label.update_labels([label2.title])
|
||||
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'excluded_labels' => [label1.title, label2.title]
|
||||
})
|
||||
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Both conversations should be excluded
|
||||
expect(assigned_count).to eq(0)
|
||||
expect(conversation_with_label.reload.assignee).to be_nil
|
||||
expect(conversation_without_label.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when excluding conversations by age' do
|
||||
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago) }
|
||||
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) }
|
||||
|
||||
before do
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'exclude_older_than_hours' => 24
|
||||
})
|
||||
end
|
||||
|
||||
it 'excludes conversations older than specified hours' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only recent conversation should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(old_conversation.reload.assignee).to be_nil
|
||||
expect(recent_conversation.reload.assignee).to be_present
|
||||
end
|
||||
|
||||
it 'handles different time thresholds' do
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'exclude_older_than_hours' => 2
|
||||
})
|
||||
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only conversation created within 2 hours should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(recent_conversation.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when combining exclusion rules' do
|
||||
it 'applies both exclusion rules' do
|
||||
# Create conversations
|
||||
old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
|
||||
old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
|
||||
recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
|
||||
recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
|
||||
|
||||
# Add labels
|
||||
old_conversation_with_label.update_labels([label1.title])
|
||||
recent_conversation_with_label.update_labels([label1.title])
|
||||
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'excluded_labels' => [label1.title],
|
||||
'exclude_older_than_hours' => 24
|
||||
})
|
||||
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only recent conversation without label should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(old_conversation_with_label.reload.assignee).to be_nil
|
||||
expect(old_conversation_without_label.reload.assignee).to be_nil
|
||||
expect(recent_conversation_with_label.reload.assignee).to be_nil
|
||||
expect(recent_conversation_without_label.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exclusion rules are empty' do
|
||||
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
capacity_policy.update!(exclusion_rules: {})
|
||||
end
|
||||
|
||||
it 'assigns all eligible conversations' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(2)
|
||||
expect(conversation1.reload.assignee).to be_present
|
||||
expect(conversation2.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no capacity policy exists' do
|
||||
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
InboxCapacityLimit.destroy_all
|
||||
end
|
||||
|
||||
it 'assigns all eligible conversations without exclusions' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(2)
|
||||
expect(conversation1.reload.assignee).to be_present
|
||||
expect(conversation2.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::AutoAssignment::BalancedSelector do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:selector) { described_class.new(inbox: inbox) }
|
||||
let(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent3) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:member1) { create(:inbox_member, inbox: inbox, user: agent1) }
|
||||
let(:member2) { create(:inbox_member, inbox: inbox, user: agent2) }
|
||||
let(:member3) { create(:inbox_member, inbox: inbox, user: agent3) }
|
||||
|
||||
describe '#select_agent' do
|
||||
context 'when selecting based on workload' do
|
||||
let(:available_agents) { [member1, member2, member3] }
|
||||
|
||||
it 'selects the agent with least open conversations' do
|
||||
# Agent1 has 3 open conversations
|
||||
3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'open') }
|
||||
|
||||
# Agent2 has 1 open conversation
|
||||
create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
|
||||
|
||||
# Agent3 has 2 open conversations
|
||||
2.times { create(:conversation, inbox: inbox, assignee: agent3, status: 'open') }
|
||||
|
||||
selected_agent = selector.select_agent(available_agents)
|
||||
|
||||
# Should select agent2 as they have the least conversations
|
||||
expect(selected_agent).to eq(agent2)
|
||||
end
|
||||
|
||||
it 'considers only open conversations' do
|
||||
# Agent1 has 1 open and 3 resolved conversations
|
||||
create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
|
||||
3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'resolved') }
|
||||
|
||||
# Agent2 has 2 open conversations
|
||||
2.times { create(:conversation, inbox: inbox, assignee: agent2, status: 'open') }
|
||||
|
||||
selected_agent = selector.select_agent([member1, member2])
|
||||
|
||||
# Should select agent1 as they have fewer open conversations
|
||||
expect(selected_agent).to eq(agent1)
|
||||
end
|
||||
|
||||
it 'selects any agent when agents have equal workload' do
|
||||
# All agents have same number of conversations
|
||||
[member1, member2, member3].each do |member|
|
||||
create(:conversation, inbox: inbox, assignee: member.user, status: 'open')
|
||||
end
|
||||
|
||||
selected_agent = selector.select_agent(available_agents)
|
||||
|
||||
# Should select one of the agents (when equal, min_by returns the first one it finds)
|
||||
expect([agent1, agent2, agent3]).to include(selected_agent)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no agents are available' do
|
||||
it 'returns nil' do
|
||||
selected_agent = selector.select_agent([])
|
||||
expect(selected_agent).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when one agent is available' do
|
||||
it 'returns that agent' do
|
||||
selected_agent = selector.select_agent([member1])
|
||||
expect(selected_agent).to eq(agent1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with new agents (no conversations)' do
|
||||
it 'prioritizes agents with no conversations' do
|
||||
# Agent1 and 2 have conversations
|
||||
create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
|
||||
create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
|
||||
|
||||
# Agent3 is new with no conversations
|
||||
selected_agent = selector.select_agent([member1, member2, member3])
|
||||
|
||||
expect(selected_agent).to eq(agent3)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
|
||||
|
||||
# Assignment policy with rate limiting
|
||||
let(:assignment_policy) do
|
||||
create(:assignment_policy,
|
||||
account: account,
|
||||
enabled: true,
|
||||
fair_distribution_limit: 5,
|
||||
fair_distribution_window: 3600)
|
||||
end
|
||||
|
||||
# Agent capacity policy
|
||||
let(:agent_capacity_policy) do
|
||||
create(:agent_capacity_policy, account: account, name: 'Limited Capacity')
|
||||
end
|
||||
|
||||
# Agents with different capacity settings
|
||||
let(:agent_with_capacity) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent_without_capacity) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent_at_capacity) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
|
||||
before do
|
||||
# Create inbox assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
|
||||
# Set inbox capacity limit
|
||||
create(:inbox_capacity_limit,
|
||||
agent_capacity_policy: agent_capacity_policy,
|
||||
inbox: inbox,
|
||||
conversation_limit: 3)
|
||||
|
||||
# Assign capacity policy to specific agents
|
||||
agent_with_capacity.account_users.find_by(account: account)
|
||||
.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
|
||||
agent_at_capacity.account_users.find_by(account: account)
|
||||
.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
|
||||
# Create inbox members
|
||||
create(:inbox_member, inbox: inbox, user: agent_with_capacity)
|
||||
create(:inbox_member, inbox: inbox, user: agent_without_capacity)
|
||||
create(:inbox_member, inbox: inbox, user: agent_at_capacity)
|
||||
|
||||
# Mock online status
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
|
||||
agent_with_capacity.id.to_s => 'online',
|
||||
agent_without_capacity.id.to_s => 'online',
|
||||
agent_at_capacity.id.to_s => 'online'
|
||||
})
|
||||
|
||||
# Enable assignment_v2 feature
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
|
||||
# Create existing assignments for agent_at_capacity (at limit)
|
||||
3.times do
|
||||
create(:conversation, inbox: inbox, assignee: agent_at_capacity, status: :open)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'capacity filtering' do
|
||||
it 'excludes agents at capacity' do
|
||||
# Get available agents respecting capacity
|
||||
capacity_service = described_class.new
|
||||
online_agents = inbox.available_agents
|
||||
filtered_agents = online_agents.select do |inbox_member|
|
||||
capacity_service.agent_has_capacity?(inbox_member.user, inbox)
|
||||
end
|
||||
available_users = filtered_agents.map(&:user)
|
||||
|
||||
expect(available_users).to include(agent_with_capacity)
|
||||
expect(available_users).to include(agent_without_capacity) # No capacity policy = unlimited
|
||||
expect(available_users).not_to include(agent_at_capacity) # At capacity limit
|
||||
end
|
||||
|
||||
it 'respects inbox-specific capacity limits' do
|
||||
capacity_service = described_class.new
|
||||
|
||||
expect(capacity_service.agent_has_capacity?(agent_with_capacity, inbox)).to be true
|
||||
expect(capacity_service.agent_has_capacity?(agent_without_capacity, inbox)).to be true
|
||||
expect(capacity_service.agent_has_capacity?(agent_at_capacity, inbox)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'assignment with capacity' do
|
||||
let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
|
||||
|
||||
it 'assigns to agents with available capacity' do
|
||||
# Create conversation before assignment
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open)
|
||||
|
||||
# Mock the selector to prefer agent_at_capacity (but should skip due to capacity)
|
||||
selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(selector)
|
||||
allow(selector).to receive(:select_agent) do |agents|
|
||||
agents.map(&:user).find { |u| [agent_with_capacity, agent_without_capacity].include?(u) }
|
||||
end
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 1)
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation.reload.assignee).to be_in([agent_with_capacity, agent_without_capacity])
|
||||
expect(conversation.reload.assignee).not_to eq(agent_at_capacity)
|
||||
end
|
||||
|
||||
it 'returns false when all agents are at capacity' do
|
||||
# Fill up remaining agents
|
||||
3.times { create(:conversation, inbox: inbox, assignee: agent_with_capacity, status: :open) }
|
||||
|
||||
# agent_without_capacity has no limit, so should still be available
|
||||
conversation2 = create(:conversation, inbox: inbox, assignee: nil, status: :open)
|
||||
assigned_count = service.perform_bulk_assignment(limit: 1)
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation2.reload.assignee).to eq(agent_without_capacity)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,54 +4,118 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Voice::InboundCallBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230001') }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') }
|
||||
let(:inbox) { channel.inbox }
|
||||
|
||||
let(:from_number) { '+15550001111' }
|
||||
let(:to_number) { channel.phone_number }
|
||||
let(:call_sid) { 'CA1234567890abcdef' }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
|
||||
end
|
||||
|
||||
def build_and_perform
|
||||
described_class.new(
|
||||
def perform_builder
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
call_sid: call_sid
|
||||
).perform
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a new conversation with inbound ringing attributes' do
|
||||
builder = build_and_perform
|
||||
conversation = builder.conversation
|
||||
expect(conversation).to be_present
|
||||
expect(conversation.account_id).to eq(account.id)
|
||||
expect(conversation.inbox_id).to eq(inbox.id)
|
||||
expect(conversation.identifier).to eq(call_sid)
|
||||
expect(conversation.additional_attributes['call_direction']).to eq('inbound')
|
||||
expect(conversation.additional_attributes['call_status']).to eq('ringing')
|
||||
context 'when no existing conversation matches call_sid' do
|
||||
it 'creates a new inbound conversation with ringing status' do
|
||||
conversation = nil
|
||||
expect { conversation = perform_builder }.to change(account.conversations, :count).by(1)
|
||||
|
||||
attrs = conversation.additional_attributes
|
||||
expect(conversation.identifier).to eq(call_sid)
|
||||
expect(attrs['call_direction']).to eq('inbound')
|
||||
expect(attrs['call_status']).to eq('ringing')
|
||||
expect(attrs['conference_sid']).to be_present
|
||||
expect(attrs.dig('meta', 'initiated_at')).to be_present
|
||||
expect(conversation.contact.phone_number).to eq(from_number)
|
||||
end
|
||||
|
||||
it 'creates a single voice_call message marked as incoming' do
|
||||
conversation = perform_builder
|
||||
voice_message = conversation.messages.voice_calls.last
|
||||
|
||||
expect(voice_message).to be_present
|
||||
expect(voice_message.message_type).to eq('incoming')
|
||||
data = voice_message.content_attributes['data']
|
||||
expect(data).to include(
|
||||
'call_sid' => call_sid,
|
||||
'status' => 'ringing',
|
||||
'call_direction' => 'inbound',
|
||||
'conference_sid' => conversation.additional_attributes['conference_sid'],
|
||||
'from_number' => from_number,
|
||||
'to_number' => inbox.channel.phone_number
|
||||
)
|
||||
expect(data['meta']['created_at']).to be_present
|
||||
expect(data['meta']['ringing_at']).to be_present
|
||||
end
|
||||
|
||||
it 'sets the contact name to the phone number for new callers' do
|
||||
conversation = perform_builder
|
||||
|
||||
expect(conversation.contact.name).to eq(from_number)
|
||||
end
|
||||
|
||||
it 'ensures the conversation has a display_id before building the conference SID' do
|
||||
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
|
||||
expect(conversation.display_id).to be_present
|
||||
original.call(conversation)
|
||||
end
|
||||
|
||||
perform_builder
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a voice_call message with ringing status' do
|
||||
builder = build_and_perform
|
||||
conversation = builder.conversation
|
||||
msg = conversation.messages.voice_calls.last
|
||||
expect(msg).to be_present
|
||||
expect(msg.message_type).to eq('incoming')
|
||||
expect(msg.content_type).to eq('voice_call')
|
||||
expect(msg.content_attributes.dig('data', 'call_sid')).to eq(call_sid)
|
||||
expect(msg.content_attributes.dig('data', 'status')).to eq('ringing')
|
||||
end
|
||||
context 'when a conversation already exists for the call_sid' do
|
||||
let(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
||||
let!(:existing_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox,
|
||||
identifier: call_sid,
|
||||
additional_attributes: { 'call_direction' => 'outbound', 'conference_sid' => nil }
|
||||
)
|
||||
end
|
||||
let(:existing_message) do
|
||||
create(
|
||||
:message,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: existing_conversation,
|
||||
message_type: :incoming,
|
||||
content_type: :voice_call,
|
||||
sender: contact,
|
||||
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'queued' } }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns TwiML that informs the caller we are connecting' do
|
||||
builder = build_and_perform
|
||||
xml = builder.twiml_response
|
||||
expect(xml).to include('Please wait while we connect you to an agent')
|
||||
expect(xml).to include('<Say')
|
||||
it 'reuses the conversation without creating a duplicate' do
|
||||
existing_message
|
||||
expect { perform_builder }.not_to change(account.conversations, :count)
|
||||
existing_conversation.reload
|
||||
expect(existing_conversation.additional_attributes['call_direction']).to eq('inbound')
|
||||
expect(existing_conversation.additional_attributes['call_status']).to eq('ringing')
|
||||
end
|
||||
|
||||
it 'updates the existing voice call message instead of creating a new one' do
|
||||
existing_message
|
||||
expect { perform_builder }.not_to(change { existing_conversation.reload.messages.voice_calls.count })
|
||||
updated_message = existing_conversation.reload.messages.voice_calls.last
|
||||
|
||||
data = updated_message.content_attributes['data']
|
||||
expect(data['status']).to eq('ringing')
|
||||
expect(data['call_direction']).to eq('inbound')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Voice::OutboundCallBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
|
||||
let(:call_sid) { 'CA1234567890abcdef' }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
|
||||
allow(inbox).to receive(:channel).and_return(channel)
|
||||
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
|
||||
allow(Voice::Conference::Name).to receive(:for).and_call_original
|
||||
end
|
||||
|
||||
describe '.perform!' do
|
||||
it 'creates a conversation and voice call message' do
|
||||
conversation_count = account.conversations.count
|
||||
inbox_link_count = contact.contact_inboxes.where(inbox_id: inbox.id).count
|
||||
|
||||
result = described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
|
||||
expect(account.conversations.count).to eq(conversation_count + 1)
|
||||
expect(contact.contact_inboxes.where(inbox_id: inbox.id).count).to eq(inbox_link_count + 1)
|
||||
|
||||
conversation = result[:conversation].reload
|
||||
attrs = conversation.additional_attributes
|
||||
|
||||
aggregate_failures do
|
||||
expect(result[:call_sid]).to eq(call_sid)
|
||||
expect(conversation.identifier).to eq(call_sid)
|
||||
expect(attrs).to include('call_direction' => 'outbound', 'call_status' => 'ringing')
|
||||
expect(attrs['agent_id']).to eq(user.id)
|
||||
expect(attrs['conference_sid']).to be_present
|
||||
|
||||
voice_message = conversation.messages.voice_calls.last
|
||||
expect(voice_message.message_type).to eq('outgoing')
|
||||
|
||||
message_data = voice_message.content_attributes['data']
|
||||
expect(message_data).to include(
|
||||
'call_sid' => call_sid,
|
||||
'conference_sid' => attrs['conference_sid'],
|
||||
'from_number' => channel.phone_number,
|
||||
'to_number' => contact.phone_number
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises an error when contact is missing a phone number' do
|
||||
contact.update!(phone_number: nil)
|
||||
|
||||
expect do
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
end.to raise_error(ArgumentError, 'Contact phone number required')
|
||||
end
|
||||
|
||||
it 'raises an error when user is nil' do
|
||||
expect do
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: nil,
|
||||
contact: contact
|
||||
)
|
||||
end.to raise_error(ArgumentError, 'Agent required')
|
||||
end
|
||||
|
||||
it 'ensures the conversation has a display_id before building the conference SID' do
|
||||
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
|
||||
expect(conversation.display_id).to be_present
|
||||
original.call(conversation)
|
||||
end
|
||||
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -55,6 +55,23 @@ RSpec.describe Voice::StatusUpdateService do
|
||||
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
|
||||
end
|
||||
|
||||
it 'normalizes busy to no-answer' do
|
||||
conversation
|
||||
message
|
||||
|
||||
described_class.new(
|
||||
account: account,
|
||||
call_sid: call_sid,
|
||||
call_status: 'busy'
|
||||
).perform
|
||||
|
||||
conversation.reload
|
||||
message.reload
|
||||
|
||||
expect(conversation.additional_attributes['call_status']).to eq('no-answer')
|
||||
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
|
||||
end
|
||||
|
||||
it 'no-ops when conversation not found' do
|
||||
expect do
|
||||
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
FactoryBot.define do
|
||||
factory :reporting_event do
|
||||
name { 'MyString' }
|
||||
name { 'first_response' }
|
||||
value { 1.5 }
|
||||
value_in_business_hours { 1 }
|
||||
account_id { 1 }
|
||||
inbox_id { 1 }
|
||||
user_id { 1 }
|
||||
conversation_id { 1 }
|
||||
account
|
||||
inbox { association :inbox, account: account }
|
||||
user { association :user, account: account }
|
||||
conversation { association :conversation, account: account, inbox: inbox }
|
||||
event_start_time { 2.hours.ago }
|
||||
event_end_time { 1.hour.ago }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ FactoryBot.define do
|
||||
account_id { 1 }
|
||||
inbox_id { 1 }
|
||||
url { 'https://api.chatwoot.com' }
|
||||
name { 'My Webhook' }
|
||||
subscriptions do
|
||||
%w[
|
||||
conversation_status_changed
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::AssignmentJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
|
||||
let(:agent) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when inbox exists' do
|
||||
context 'when auto assignment is enabled' do
|
||||
it 'calls the assignment service' do
|
||||
service = instance_double(AutoAssignment::AssignmentService)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
|
||||
expect(service).to receive(:perform_bulk_assignment).with(limit: 100).and_return(5)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
|
||||
it 'logs the assignment count' do
|
||||
service = instance_double(AutoAssignment::AssignmentService)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
|
||||
allow(service).to receive(:perform_bulk_assignment).and_return(3)
|
||||
|
||||
expect(Rails.logger).to receive(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
|
||||
it 'uses custom bulk limit from environment' do
|
||||
allow(ENV).to receive(:fetch).with('AUTO_ASSIGNMENT_BULK_LIMIT', 100).and_return('50')
|
||||
|
||||
service = instance_double(AutoAssignment::AssignmentService)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
|
||||
expect(service).to receive(:perform_bulk_assignment).with(limit: 50).and_return(2)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when auto assignment is disabled' do
|
||||
before { inbox.update!(enable_auto_assignment: false) }
|
||||
|
||||
it 'calls the service which handles the disabled state' do
|
||||
service = instance_double(AutoAssignment::AssignmentService)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
|
||||
expect(service).to receive(:perform_bulk_assignment).with(limit: 100).and_return(0)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox does not exist' do
|
||||
it 'returns early without processing' do
|
||||
expect(AutoAssignment::AssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.new.perform(inbox_id: 999_999)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an error occurs' do
|
||||
it 'logs the error and re-raises in test environment' do
|
||||
service = instance_double(AutoAssignment::AssignmentService)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
|
||||
allow(service).to receive(:perform_bulk_assignment).and_raise(StandardError, 'Something went wrong')
|
||||
|
||||
expect(Rails.logger).to receive(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
|
||||
|
||||
expect do
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end.to raise_error(StandardError, 'Something went wrong')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'is queued in the default queue' do
|
||||
expect(described_class.queue_name).to eq('default')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when account has assignment_v2 feature enabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account])
|
||||
end
|
||||
|
||||
context 'when inbox has auto_assignment_v2 enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
inbox_relation = instance_double(ActiveRecord::Relation)
|
||||
allow(account).to receive(:inboxes).and_return(inbox_relation)
|
||||
allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
|
||||
allow(inbox_relation).to receive(:find_in_batches).and_yield([inbox])
|
||||
end
|
||||
|
||||
it 'queues assignment job for eligible inboxes' do
|
||||
inbox_assignment_policy # ensure it exists
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
|
||||
it 'processes multiple accounts' do
|
||||
inbox_assignment_policy # ensure it exists
|
||||
account2 = create(:account)
|
||||
inbox2 = create(:inbox, account: account2, enable_auto_assignment: true)
|
||||
policy2 = create(:assignment_policy, account: account2)
|
||||
create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2)
|
||||
|
||||
allow(account2).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
allow(inbox2).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
|
||||
inbox_relation2 = instance_double(ActiveRecord::Relation)
|
||||
allow(account2).to receive(:inboxes).and_return(inbox_relation2)
|
||||
allow(inbox_relation2).to receive(:joins).with(:assignment_policy).and_return(inbox_relation2)
|
||||
allow(inbox_relation2).to receive(:find_in_batches).and_yield([inbox2])
|
||||
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account]).and_yield([account2])
|
||||
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox2.id)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox does not have auto_assignment_v2 enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not queue assignment job' do
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account does not have assignment_v2 feature enabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(false)
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account])
|
||||
end
|
||||
|
||||
it 'does not process the account' do
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'with batch processing' do
|
||||
it 'processes accounts in batches' do
|
||||
accounts = []
|
||||
# Create multiple accounts
|
||||
5.times do |_i|
|
||||
acc = create(:account)
|
||||
inb = create(:inbox, account: acc, enable_auto_assignment: true)
|
||||
policy = create(:assignment_policy, account: acc)
|
||||
create(:inbox_assignment_policy, inbox: inb, assignment_policy: policy)
|
||||
allow(acc).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
allow(inb).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
|
||||
inbox_relation = instance_double(ActiveRecord::Relation)
|
||||
allow(acc).to receive(:inboxes).and_return(inbox_relation)
|
||||
allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
|
||||
allow(inbox_relation).to receive(:find_in_batches).and_yield([inb])
|
||||
|
||||
accounts << acc
|
||||
end
|
||||
|
||||
allow(Account).to receive(:find_in_batches) do |&block|
|
||||
accounts.each { |acc| block.call([acc]) }
|
||||
end
|
||||
|
||||
expect(Account).to receive(:find_in_batches).and_call_original
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'is queued in the scheduled_jobs queue' do
|
||||
expect(described_class.queue_name).to eq('scheduled_jobs')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -24,11 +24,12 @@ RSpec.describe DeleteObjectJob, type: :job do
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(Conversation.where(id: conv_ids)).to be_empty
|
||||
expect(ContactInbox.where(id: ci_ids)).to be_empty
|
||||
expect(ReportingEvent.where(id: re_ids)).to be_empty
|
||||
# Reload associations to ensure database state is current
|
||||
expect(Conversation.where(id: conv_ids).reload).to be_empty
|
||||
expect(ContactInbox.where(id: ci_ids).reload).to be_empty
|
||||
expect(ReportingEvent.where(id: re_ids).reload).to be_empty
|
||||
# Contacts should not be deleted for inbox destroy
|
||||
expect(Contact.where(id: contact_ids)).not_to be_empty
|
||||
expect(Contact.where(id: contact_ids).reload).not_to be_empty
|
||||
expect { inbox.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
@@ -53,10 +54,11 @@ RSpec.describe DeleteObjectJob, type: :job do
|
||||
|
||||
described_class.perform_now(account)
|
||||
|
||||
expect(Conversation.where(id: conv_ids)).to be_empty
|
||||
expect(Contact.where(id: contact_ids)).to be_empty
|
||||
expect(Inbox.where(id: inbox_ids)).to be_empty
|
||||
expect(ReportingEvent.where(id: re_ids)).to be_empty
|
||||
# Reload associations to ensure database state is current
|
||||
expect(Conversation.where(id: conv_ids).reload).to be_empty
|
||||
expect(Contact.where(id: contact_ids).reload).to be_empty
|
||||
expect(Inbox.where(id: inbox_ids).reload).to be_empty
|
||||
expect(ReportingEvent.where(id: re_ids).reload).to be_empty
|
||||
expect { account.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,8 +32,7 @@ RSpec.describe MutexApplicationJob do
|
||||
described_class.new.send(:with_lock, lock_key) do
|
||||
# Do nothing
|
||||
end
|
||||
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
|
||||
expect(lock_manager).not_to receive(:unlock)
|
||||
end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
|
||||
end
|
||||
|
||||
it 'raises StandardError if it execution raises it' do
|
||||
|
||||
@@ -138,7 +138,28 @@ describe CustomMarkdownRenderer do
|
||||
|
||||
it 'wraps iframe in responsive container' do
|
||||
output = render_markdown_link(arcade_url)
|
||||
expect(output).to include('position: relative; padding-bottom: 62.5%; height: 0;')
|
||||
expect(output).to include('position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;')
|
||||
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is an Arcade tab URL' do
|
||||
let(:arcade_tab_url) { 'https://app.arcade.software/share/ARCADE_TAB_ID?embed_mobile=tab' }
|
||||
|
||||
it 'renders an iframe with Arcade tab embed code' do
|
||||
output = render_markdown_link(arcade_tab_url)
|
||||
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_TAB_ID?embed&embed_mobile=tab"')
|
||||
end
|
||||
|
||||
it 'supports additional query params after embed_mobile' do
|
||||
url = 'https://app.arcade.software/share/ARCADE_TAB_ID?foo=bar&embed_mobile=tab?user_id=1'
|
||||
output = render_markdown_link(url)
|
||||
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_TAB_ID?embed&embed_mobile=tab"')
|
||||
end
|
||||
|
||||
it 'wraps iframe in responsive container' do
|
||||
output = render_markdown_link(arcade_tab_url)
|
||||
expect(output).to include('position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;')
|
||||
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::LlmInstrumentation do
|
||||
let(:test_class) do
|
||||
Class.new do
|
||||
include Integrations::LlmInstrumentation
|
||||
end
|
||||
end
|
||||
|
||||
let(:instance) { test_class.new }
|
||||
let!(:otel_config) do
|
||||
InstallationConfig.find_or_create_by(name: 'OTEL_PROVIDER') do |config|
|
||||
config.value = 'langfuse'
|
||||
end
|
||||
end
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
span_name: 'llm.test',
|
||||
account_id: 123,
|
||||
conversation_id: 456,
|
||||
feature_name: 'reply_suggestion',
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ 'role' => 'user', 'content' => 'Hello' }],
|
||||
temperature: 0.7
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
InstallationConfig.find_or_create_by(name: 'LANGFUSE_SECRET_KEY') do |config|
|
||||
config.value = 'test-secret-key'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#instrument_llm_call' do
|
||||
context 'when OTEL provider is not configured' do
|
||||
before { otel_config.update(value: '') }
|
||||
|
||||
it 'executes the block without tracing' do
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTEL provider is configured' do
|
||||
it 'executes the block and returns the result' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'creates a tracing span with the provided span name' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
instance.instrument_llm_call(params) { 'result' }
|
||||
|
||||
expect(mock_tracer).to have_received(:in_span).with('llm.test')
|
||||
end
|
||||
|
||||
it 'returns the block result even if instrumentation has errors' do
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_raise(StandardError.new('Instrumentation failed'))
|
||||
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'handles errors gracefully and captures exceptions' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
allow(mock_span).to receive(:set_attribute).and_raise(StandardError.new('Span error'))
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
expect(ChatwootExceptionTracker).to have_received(:new)
|
||||
end
|
||||
|
||||
it 'sets correct request attributes on the span' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
instance.instrument_llm_call(params) { 'result' }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.provider.name', 'openai')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.model', 'gpt-4o-mini')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.temperature', 0.7)
|
||||
end
|
||||
|
||||
it 'sets correct prompt message attributes' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
custom_params = params.merge(
|
||||
messages: [
|
||||
{ 'role' => 'system', 'content' => 'You are a helpful assistant' },
|
||||
{ 'role' => 'user', 'content' => 'Hello' }
|
||||
]
|
||||
)
|
||||
|
||||
instance.instrument_llm_call(custom_params) { 'result' }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.0.role', 'system')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.0.content', 'You are a helpful assistant')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.1.role', 'user')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.1.content', 'Hello')
|
||||
end
|
||||
|
||||
it 'sets correct metadata attributes' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
instance.instrument_llm_call(params) { 'result' }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.user.id', '123')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', '["reply_suggestion"]')
|
||||
end
|
||||
|
||||
it 'sets completion message attributes when result contains message' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) do
|
||||
{ message: 'AI response here' }
|
||||
end
|
||||
|
||||
expect(result).to eq({ message: 'AI response here' })
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.completion.0.role', 'assistant')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.completion.0.content', 'AI response here')
|
||||
end
|
||||
|
||||
it 'sets usage metrics when result contains usage data' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) do
|
||||
{
|
||||
usage: {
|
||||
'prompt_tokens' => 150,
|
||||
'completion_tokens' => 200,
|
||||
'total_tokens' => 350
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
expect(result[:usage]['prompt_tokens']).to eq(150)
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.usage.input_tokens', 150)
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.usage.output_tokens', 200)
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.usage.total_tokens', 350)
|
||||
end
|
||||
|
||||
it 'sets error attributes when result contains error' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
mock_status = instance_double(OpenTelemetry::Trace::Status)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
allow(OpenTelemetry::Trace::Status).to receive(:error).and_return(mock_status)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) do
|
||||
{
|
||||
error: { message: 'API rate limit exceeded' },
|
||||
error_code: 'rate_limit_exceeded'
|
||||
}
|
||||
end
|
||||
|
||||
expect(result[:error_code]).to eq('rate_limit_exceeded')
|
||||
expect(mock_span).to have_received(:set_attribute)
|
||||
.with('gen_ai.response.error', '{"message":"API rate limit exceeded"}')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.response.error_code', 'rate_limit_exceeded')
|
||||
expect(mock_span).to have_received(:status=).with(mock_status)
|
||||
expect(OpenTelemetry::Trace::Status).to have_received(:error).with('API Error: rate_limit_exceeded')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#instrument_agent_session' do
|
||||
context 'when OTEL provider is not configured' do
|
||||
before { otel_config.update(value: '') }
|
||||
|
||||
it 'executes the block without tracing' do
|
||||
result = instance.instrument_agent_session(params) { 'my_result' }
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTEL provider is configured' do
|
||||
let(:mock_span) { instance_double(OpenTelemetry::Trace::Span) }
|
||||
let(:mock_tracer) { instance_double(OpenTelemetry::Trace::Tracer) }
|
||||
|
||||
before do
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
end
|
||||
|
||||
it 'executes the block and returns the result' do
|
||||
result = instance.instrument_agent_session(params) { 'my_result' }
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'returns the block result even if instrumentation has errors' do
|
||||
allow(mock_tracer).to receive(:in_span).and_raise(StandardError.new('Instrumentation failed'))
|
||||
|
||||
result = instance.instrument_agent_session(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'sets trace input and output attributes' do
|
||||
result_data = { content: 'AI response' }
|
||||
instance.instrument_agent_session(params) { result_data }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.input', params[:messages].to_json)
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', result_data.to_json)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#instrument_tool_call' do
|
||||
let(:tool_name) { 'search_documents' }
|
||||
let(:arguments) { { query: 'test query' } }
|
||||
|
||||
context 'when OTEL provider is not configured' do
|
||||
before { otel_config.update(value: '') }
|
||||
|
||||
it 'executes the block without tracing' do
|
||||
result = instance.instrument_tool_call(tool_name, arguments) { 'tool_result' }
|
||||
expect(result).to eq('tool_result')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTEL provider is configured' do
|
||||
let(:mock_span) { instance_double(OpenTelemetry::Trace::Span) }
|
||||
let(:mock_tracer) { instance_double(OpenTelemetry::Trace::Tracer) }
|
||||
|
||||
before do
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
end
|
||||
|
||||
it 'executes the block and returns the result' do
|
||||
result = instance.instrument_tool_call(tool_name, arguments) { 'tool_result' }
|
||||
expect(result).to eq('tool_result')
|
||||
end
|
||||
|
||||
it 'propagates instrumentation errors' do
|
||||
allow(mock_tracer).to receive(:in_span).and_raise(StandardError.new('Instrumentation failed'))
|
||||
|
||||
expect do
|
||||
instance.instrument_tool_call(tool_name, arguments) { 'tool_result' }
|
||||
end.to raise_error(StandardError, 'Instrumentation failed')
|
||||
end
|
||||
|
||||
it 'creates a span with tool name and sets observation attributes' do
|
||||
tool_result = { documents: ['doc1'] }
|
||||
instance.instrument_tool_call(tool_name, arguments) { tool_result }
|
||||
|
||||
expect(mock_tracer).to have_received(:in_span).with('tool.search_documents')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.input', arguments.to_json)
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', tool_result.to_json)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,303 +1,230 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::Openai::ProcessorService do
|
||||
subject { described_class.new(hook: hook, event: event) }
|
||||
subject(:service) { described_class.new(hook: hook, event: event) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:hook) { create(:integrations_hook, :openai, account: account) }
|
||||
let(:expected_headers) { { 'Authorization' => "Bearer #{hook.settings['api_key']}" } }
|
||||
let(:openai_response) do
|
||||
{
|
||||
'choices' => [
|
||||
{
|
||||
'message' => {
|
||||
'content' => 'This is a reply from openai.'
|
||||
}
|
||||
}
|
||||
]
|
||||
'choices' => [{ 'message' => { 'content' => 'This is a reply from openai.' } }]
|
||||
}.to_json
|
||||
end
|
||||
let!(:conversation) { create(:conversation, account: account) }
|
||||
let!(:customer_message) { create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent') }
|
||||
let!(:agent_message) { create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer') }
|
||||
let!(:summary_prompt) do
|
||||
if ChatwootApp.enterprise?
|
||||
Rails.root.join('enterprise/lib/enterprise/integrations/openai_prompts/summary.txt').read
|
||||
else
|
||||
'Please summarize the key points from the following conversation between support agents and customer as bullet points for the next ' \
|
||||
"support agent looking into the conversation. Reply in the user's language."
|
||||
end
|
||||
let(:openai_response_with_usage) do
|
||||
{
|
||||
'choices' => [{ 'message' => { 'content' => 'This is a reply from openai.' } }],
|
||||
'usage' => {
|
||||
'prompt_tokens' => 50,
|
||||
'completion_tokens' => 20,
|
||||
'total_tokens' => 70
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when event name is rephrase' do
|
||||
let(:event) { { 'name' => 'rephrase', 'data' => { 'tone' => 'friendly', 'content' => 'This is a test message' } } }
|
||||
|
||||
it 'returns the rephrased message using the tone in data' do
|
||||
request_body = {
|
||||
shared_examples 'text transformation operation' do |event_name, system_prompt|
|
||||
let(:event) { { 'name' => event_name, 'data' => { 'content' => 'This is a test' } } }
|
||||
let(:expected_request_body) do
|
||||
{
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
{
|
||||
'role' => 'system',
|
||||
'content' => 'You are a helpful support agent. ' \
|
||||
'Please rephrase the following response. ' \
|
||||
'Ensure that the reply should be in user language.'
|
||||
},
|
||||
{ 'role' => 'user', 'content' => event['data']['content'] }
|
||||
{ 'role' => 'system', 'content' => system_prompt },
|
||||
{ 'role' => 'user', 'content' => 'This is a test' }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
it "returns the #{event_name.tr('_', ' ')} text" do
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
.with(body: expected_request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response)
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
result = service.perform
|
||||
expect(result[:message]).to eq('This is a reply from openai.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is reply_suggestion' do
|
||||
let(:event) { { 'name' => 'reply_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
it 'returns the suggested reply' do
|
||||
request_body = {
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
{ role: 'system',
|
||||
content: Rails.root.join('lib/integrations/openai/openai_prompts/reply.txt').read },
|
||||
{ role: 'user', content: customer_message.content },
|
||||
{ role: 'assistant', content: agent_message.content }
|
||||
]
|
||||
}.to_json
|
||||
|
||||
# Update the stub_request with the correct messages order
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
shared_examples 'successful openai response' do
|
||||
it 'returns the expected message' do
|
||||
result = service.perform
|
||||
expect(result[:message]).to eq('This is a reply from openai.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is summarize' do
|
||||
let(:event) { { 'name' => 'summarize', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
let(:conversation_messages) do
|
||||
"Customer #{customer_message.sender.name} : #{customer_message.content}\nAgent #{agent_message.sender.name} : #{agent_message.content}\n"
|
||||
describe 'text transformation operations' do
|
||||
base_prompt = 'You are a helpful support agent. '
|
||||
language_suffix = 'Ensure that the reply should be in user language.'
|
||||
|
||||
it_behaves_like 'text transformation operation', 'rephrase',
|
||||
"#{base_prompt}Please rephrase the following response. #{language_suffix}"
|
||||
it_behaves_like 'text transformation operation', 'fix_spelling_grammar',
|
||||
"#{base_prompt}Please fix the spelling and grammar of the following response. #{language_suffix}"
|
||||
it_behaves_like 'text transformation operation', 'shorten',
|
||||
"#{base_prompt}Please shorten the following response. #{language_suffix}"
|
||||
it_behaves_like 'text transformation operation', 'expand',
|
||||
"#{base_prompt}Please expand the following response. #{language_suffix}"
|
||||
it_behaves_like 'text transformation operation', 'make_friendly',
|
||||
"#{base_prompt}Please make the following response more friendly. #{language_suffix}"
|
||||
it_behaves_like 'text transformation operation', 'make_formal',
|
||||
"#{base_prompt}Please make the following response more formal. #{language_suffix}"
|
||||
it_behaves_like 'text transformation operation', 'simplify',
|
||||
"#{base_prompt}Please simplify the following response. #{language_suffix}"
|
||||
end
|
||||
|
||||
describe 'conversation-based operations' do
|
||||
let!(:conversation) { create(:conversation, account: account) }
|
||||
let!(:customer_message) do
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent')
|
||||
end
|
||||
let!(:agent_message) do
|
||||
create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer')
|
||||
end
|
||||
|
||||
it 'returns the summarized message' do
|
||||
request_body = {
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
{ 'role' => 'system',
|
||||
'content' => summary_prompt },
|
||||
{ 'role' => 'user', 'content' => conversation_messages }
|
||||
]
|
||||
}.to_json
|
||||
context 'with reply_suggestion event' do
|
||||
let(:event) { { 'name' => 'reply_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
let(:expected_request_body) do
|
||||
{
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
{ role: 'system', content: Rails.root.join('lib/integrations/openai/openai_prompts/reply.txt').read },
|
||||
{ role: 'user', content: customer_message.content },
|
||||
{ role: 'assistant', content: agent_message.content }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
before do
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: expected_request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response)
|
||||
end
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
it_behaves_like 'successful openai response'
|
||||
end
|
||||
|
||||
context 'with summarize event' do
|
||||
let(:event) { { 'name' => 'summarize', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
let(:conversation_messages) do
|
||||
"Customer #{customer_message.sender.name} : #{customer_message.content}\n" \
|
||||
"Agent #{agent_message.sender.name} : #{agent_message.content}\n"
|
||||
end
|
||||
let(:summary_prompt) do
|
||||
if ChatwootApp.enterprise?
|
||||
Rails.root.join('enterprise/lib/enterprise/integrations/openai_prompts/summary.txt').read
|
||||
else
|
||||
'Please summarize the key points from the following conversation between support agents and customer as bullet points ' \
|
||||
"for the next support agent looking into the conversation. Reply in the user's language."
|
||||
end
|
||||
end
|
||||
let(:expected_request_body) do
|
||||
{
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
{ 'role' => 'system', 'content' => summary_prompt },
|
||||
{ 'role' => 'user', 'content' => conversation_messages }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: expected_request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response)
|
||||
end
|
||||
|
||||
it_behaves_like 'successful openai response'
|
||||
end
|
||||
|
||||
context 'with label_suggestion event and no labels' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is label_suggestion with no labels' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
describe 'edge cases' do
|
||||
context 'with unknown event name' do
|
||||
let(:event) { { 'name' => 'unknown', 'data' => {} } }
|
||||
|
||||
it 'returns nil' do
|
||||
result = subject.perform
|
||||
expect(result).to be_nil
|
||||
it 'returns nil' do
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is not one that can be processed' do
|
||||
let(:event) { { 'name' => 'unknown', 'data' => {} } }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(subject.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is fix_spelling_grammar' do
|
||||
let(:event) { { 'name' => 'fix_spelling_grammar', 'data' => { 'content' => 'This is a test' } } }
|
||||
|
||||
it 'returns the corrected text' do
|
||||
request_body = {
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please fix the spelling and grammar of the following response. ' \
|
||||
'Ensure that the reply should be in user language.' },
|
||||
{ 'role' => 'user', 'content' => event['data']['content'] }
|
||||
]
|
||||
}.to_json
|
||||
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is shorten' do
|
||||
let(:event) { { 'name' => 'shorten', 'data' => { 'content' => 'This is a test' } } }
|
||||
|
||||
it 'returns the shortened text' do
|
||||
request_body = {
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please shorten the following response. ' \
|
||||
'Ensure that the reply should be in user language.' },
|
||||
{ 'role' => 'user', 'content' => event['data']['content'] }
|
||||
]
|
||||
}.to_json
|
||||
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is expand' do
|
||||
let(:event) { { 'name' => 'expand', 'data' => { 'content' => 'help you' } } }
|
||||
|
||||
it 'returns the expanded text' do
|
||||
request_body = {
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please expand the following response. ' \
|
||||
'Ensure that the reply should be in user language.' },
|
||||
{ 'role' => 'user', 'content' => event['data']['content'] }
|
||||
]
|
||||
}.to_json
|
||||
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is make_friendly' do
|
||||
let(:event) { { 'name' => 'make_friendly', 'data' => { 'content' => 'This is a test' } } }
|
||||
|
||||
it 'returns the friendly text' do
|
||||
request_body = {
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please make the following response more friendly. ' \
|
||||
'Ensure that the reply should be in user language.' },
|
||||
{ 'role' => 'user', 'content' => event['data']['content'] }
|
||||
]
|
||||
}.to_json
|
||||
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is make_formal' do
|
||||
let(:event) { { 'name' => 'make_formal', 'data' => { 'content' => 'This is a test' } } }
|
||||
|
||||
it 'returns the formal text' do
|
||||
request_body = {
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please make the following response more formal. ' \
|
||||
'Ensure that the reply should be in user language.' },
|
||||
{ 'role' => 'user', 'content' => event['data']['content'] }
|
||||
]
|
||||
}.to_json
|
||||
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is simplify' do
|
||||
let(:event) { { 'name' => 'simplify', 'data' => { 'content' => 'This is a test' } } }
|
||||
|
||||
it 'returns the simplified text' do
|
||||
request_body = {
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please simplify the following response. ' \
|
||||
'Ensure that the reply should be in user language.' },
|
||||
{ 'role' => 'user', 'content' => event['data']['content'] }
|
||||
]
|
||||
}.to_json
|
||||
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: request_body, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when testing endpoint configuration' do
|
||||
describe 'response structure' do
|
||||
let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } }
|
||||
|
||||
context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do
|
||||
it 'uses default OpenAI endpoint' do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
|
||||
|
||||
context 'when response includes usage data' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: anything, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
.to_return(status: 200, body: openai_response_with_usage)
|
||||
end
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
it 'returns message, usage, and request_messages' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('This is a reply from openai.')
|
||||
expect(result[:usage]).to eq({
|
||||
'prompt_tokens' => 50,
|
||||
'completion_tokens' => 20,
|
||||
'total_tokens' => 70
|
||||
})
|
||||
expect(result[:request_messages]).to be_an(Array)
|
||||
expect(result[:request_messages].length).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when CAPTAIN_OPEN_AI_ENDPOINT is configured' do
|
||||
context 'when response does not include usage data' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/')
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: anything, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response)
|
||||
end
|
||||
|
||||
it 'uses custom endpoint' do
|
||||
stub_request(:post, 'https://custom.azure.com/v1/chat/completions')
|
||||
.with(body: anything, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
it 'returns message and request_messages with nil usage' do
|
||||
result = service.perform
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
expect(result[:message]).to eq('This is a reply from openai.')
|
||||
expect(result[:usage]).to be_nil
|
||||
expect(result[:request_messages]).to be_an(Array)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'endpoint configuration' do
|
||||
let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } }
|
||||
|
||||
shared_examples 'endpoint request' do |endpoint_url|
|
||||
it "makes request to #{endpoint_url}" do
|
||||
stub_request(:post, "#{endpoint_url}/v1/chat/completions")
|
||||
.with(body: anything, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response)
|
||||
|
||||
result = service.perform
|
||||
expect(result[:message]).to eq('This is a reply from openai.')
|
||||
expect(result[:request_messages]).to be_an(Array)
|
||||
expect(result[:usage]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when CAPTAIN_OPEN_AI_ENDPOINT has trailing slash' do
|
||||
context 'without CAPTAIN_OPEN_AI_ENDPOINT configured' do
|
||||
before { InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy }
|
||||
|
||||
it_behaves_like 'endpoint request', 'https://api.openai.com'
|
||||
end
|
||||
|
||||
context 'with CAPTAIN_OPEN_AI_ENDPOINT configured' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/')
|
||||
end
|
||||
|
||||
it 'properly handles trailing slash' do
|
||||
stub_request(:post, 'https://custom.azure.com/v1/chat/completions')
|
||||
.with(body: anything, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
end
|
||||
it_behaves_like 'endpoint request', 'https://custom.azure.com'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,9 +13,12 @@ describe Webhooks::Trigger do
|
||||
let(:webhook_type) { :api_inbox_webhook }
|
||||
let!(:url) { 'https://test.com' }
|
||||
let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') }
|
||||
let(:default_timeout) { 5 }
|
||||
let(:webhook_timeout) { default_timeout }
|
||||
|
||||
before do
|
||||
ActiveJob::Base.queue_adapter = :test
|
||||
allow(GlobalConfig).to receive(:get_value).with('WEBHOOK_TIMEOUT').and_return(webhook_timeout)
|
||||
end
|
||||
|
||||
after do
|
||||
@@ -33,7 +36,7 @@ describe Webhooks::Trigger do
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: 5
|
||||
timeout: webhook_timeout
|
||||
).once
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end
|
||||
@@ -47,7 +50,7 @@ describe Webhooks::Trigger do
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: 5
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
|
||||
@@ -62,7 +65,7 @@ describe Webhooks::Trigger do
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: 5
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
|
||||
end
|
||||
@@ -80,7 +83,7 @@ describe Webhooks::Trigger do
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: 5
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
expect do
|
||||
@@ -105,7 +108,7 @@ describe Webhooks::Trigger do
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: 5
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
expect do
|
||||
@@ -128,9 +131,47 @@ describe Webhooks::Trigger do
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: 5
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
expect { trigger.execute(url, payload, webhook_type) }.not_to(change { message.reload.status })
|
||||
end
|
||||
|
||||
context 'when webhook timeout configuration is blank' do
|
||||
let(:webhook_timeout) { nil }
|
||||
|
||||
it 'falls back to default timeout' do
|
||||
payload = { hello: :hello }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: default_timeout
|
||||
).once
|
||||
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook timeout configuration is invalid' do
|
||||
let(:webhook_timeout) { -1 }
|
||||
|
||||
it 'falls back to default timeout' do
|
||||
payload = { hello: :hello }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: default_timeout
|
||||
).once
|
||||
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,6 +36,24 @@ describe AgentBotListener do
|
||||
expect(AgentBots::WebhookJob).not_to receive(:perform_later)
|
||||
listener.message_created(event)
|
||||
end
|
||||
|
||||
context 'when conversation has a different assignee agent bot' do
|
||||
let!(:conversation_bot) { create(:agent_bot) }
|
||||
|
||||
before do
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||
conversation.update!(assignee_agent_bot: conversation_bot, assignee: nil)
|
||||
end
|
||||
|
||||
it 'sends message to both bots exactly once' do
|
||||
payload = message.webhook_data.merge(event: 'message_created')
|
||||
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, payload).once
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(conversation_bot.outgoing_url, payload).once
|
||||
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -41,28 +41,31 @@ RSpec.describe ApplicationMailbox do
|
||||
describe 'Support' do
|
||||
let!(:channel_email) { create(:channel_email) }
|
||||
|
||||
it 'routes support emails to Support Mailbox when mail is to channel email' do
|
||||
it 'routes support emails to Reply Mailbox when mail is to channel email' do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
# With NewConversationStrategy, all channel emails route to ReplyMailbox
|
||||
channel_email.update(email: 'care@example.com')
|
||||
dbl = double
|
||||
expect(SupportMailbox).to receive(:new).and_return(dbl)
|
||||
expect(ReplyMailbox).to receive(:new).and_return(dbl)
|
||||
expect(dbl).to receive(:perform_processing).and_return(true)
|
||||
described_class.route support_mail
|
||||
end
|
||||
|
||||
it 'routes support emails to Support Mailbox when mail is to channel forward to email' do
|
||||
it 'routes support emails to Reply Mailbox when mail is to channel forward to email' do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
# With NewConversationStrategy, all channel emails route to ReplyMailbox
|
||||
channel_email.update(forward_to_email: 'care@example.com')
|
||||
dbl = double
|
||||
expect(SupportMailbox).to receive(:new).and_return(dbl)
|
||||
expect(ReplyMailbox).to receive(:new).and_return(dbl)
|
||||
expect(dbl).to receive(:perform_processing).and_return(true)
|
||||
described_class.route support_mail
|
||||
end
|
||||
|
||||
it 'routes support emails to Support Mailbox with cc email' do
|
||||
it 'routes support emails to Reply Mailbox with cc email' do
|
||||
# With NewConversationStrategy, all channel emails route to ReplyMailbox
|
||||
channel_email.update(email: 'test@example.com')
|
||||
dbl = double
|
||||
expect(SupportMailbox).to receive(:new).and_return(dbl)
|
||||
expect(ReplyMailbox).to receive(:new).and_return(dbl)
|
||||
expect(dbl).to receive(:perform_processing).and_return(true)
|
||||
described_class.route reply_cc_mail
|
||||
end
|
||||
|
||||
@@ -264,5 +264,54 @@ RSpec.describe Imap::ImapMailbox do
|
||||
expect(conversation.additional_attributes['in_reply_to']).to eq(multiple_in_reply_to_mail.in_reply_to.first)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a reply to a conversation started by an agent' do
|
||||
let(:agent_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
|
||||
let(:reply_mail_with_fallback_reference) do
|
||||
# Simulate an email reply with a reference that matches FALLBACK_PATTERN
|
||||
reference_id = "account/#{account.id}/conversation/#{agent_conversation.uuid}@chatwoot.com"
|
||||
create_inbound_email_from_mail(
|
||||
from: 'email@gmail.com',
|
||||
to: 'imap@gmail.com',
|
||||
subject: 'Re: Agent started conversation',
|
||||
references: [reference_id]
|
||||
)
|
||||
end
|
||||
|
||||
it 'appends email to the existing conversation using FALLBACK_PATTERN' do
|
||||
expect(agent_conversation.messages.size).to eq(0)
|
||||
|
||||
class_instance.process(reply_mail_with_fallback_reference.mail, channel)
|
||||
|
||||
agent_conversation.reload
|
||||
expect(agent_conversation.messages.size).to eq(1)
|
||||
expect(agent_conversation.messages.last.content_attributes['email']['from']).to eq(reply_mail_with_fallback_reference.mail.from)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references contain both message and fallback patterns' do
|
||||
let(:agent_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
|
||||
let(:reply_mail_with_multiple_references) do
|
||||
# Multiple references including both patterns
|
||||
fallback_reference = "account/#{account.id}/conversation/#{agent_conversation.uuid}@chatwoot.com"
|
||||
other_reference = 'some-other-message-id@example.com'
|
||||
create_inbound_email_from_mail(
|
||||
from: 'email@gmail.com',
|
||||
to: 'imap@gmail.com',
|
||||
subject: 'Re: Multiple references',
|
||||
references: [other_reference, fallback_reference]
|
||||
)
|
||||
end
|
||||
|
||||
it 'finds conversation using fallback pattern when message lookup fails' do
|
||||
expect(agent_conversation.messages.size).to eq(0)
|
||||
|
||||
class_instance.process(reply_mail_with_multiple_references.mail, channel)
|
||||
|
||||
agent_conversation.reload
|
||||
expect(agent_conversation.messages.size).to eq(1)
|
||||
expect(agent_conversation.messages.last.content_attributes['email']['from']).to eq(reply_mail_with_multiple_references.mail.from)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -248,5 +248,448 @@ RSpec.describe ReplyMailbox do
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with references header' do
|
||||
let(:reply_mail_with_references) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation_1) do
|
||||
create(
|
||||
:conversation,
|
||||
assignee: agent,
|
||||
inbox: email_channel.inbox,
|
||||
account: account,
|
||||
additional_attributes: { mail_subject: "Discussion: Let's debate these attachments" }
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
conversation_1.update!(uuid: '6bdc3f4d-0bec-4515-a284-5d916fdde489')
|
||||
end
|
||||
|
||||
context 'with message-specific pattern in references' do
|
||||
before do
|
||||
reply_mail_with_references.mail['References'] = '<conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123@test.com>'
|
||||
end
|
||||
|
||||
it 'finds conversation from references header with message pattern' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation fallback pattern in references' do
|
||||
before do
|
||||
reply_mail_with_references.mail['References'] = "<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>"
|
||||
end
|
||||
|
||||
it 'finds conversation from references header with fallback pattern' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple references including conversation pattern' do
|
||||
before do
|
||||
reply_mail_with_references.mail['References'] = [
|
||||
'<some-random-message-id@gmail.com>',
|
||||
"<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>",
|
||||
'<another-random-message-id@outlook.com>'
|
||||
].join("\r\n ")
|
||||
end
|
||||
|
||||
it 'finds conversation from any reference in the chain' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with message source_id in references' do
|
||||
before do
|
||||
conversation_1.messages.create!(
|
||||
source_id: 'original-message-id@test.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Original message'
|
||||
)
|
||||
reply_mail_with_references.mail['References'] = '<original-message-id@test.com>'
|
||||
end
|
||||
|
||||
it 'finds conversation from message source_id in references' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation from different channel in references' do
|
||||
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
|
||||
let(:other_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
assignee: agent,
|
||||
inbox: other_email_channel.inbox,
|
||||
account: account
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
reply_mail_with_references.mail['References'] = '<conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@test.com>'
|
||||
end
|
||||
|
||||
it 'does not use conversation from different channel' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(other_conversation.messages.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when a chatwoot notification email is received' do
|
||||
let(:account) { create(:account) }
|
||||
let!(:channel_email) { create(:channel_email, email: 'sojan@chatwoot.com', account: account) }
|
||||
let(:notification_mail) { create_inbound_email_from_fixture('notification.eml') }
|
||||
let(:described_subject) { described_class.receive notification_mail }
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
it 'shouldnt create a conversation in the channel' do
|
||||
described_subject
|
||||
expect(conversation.present?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when bounced email with out a sender is recieved' do
|
||||
let(:account) { create(:account) }
|
||||
let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
|
||||
let(:described_subject) { described_class.receive bounced_email }
|
||||
|
||||
it 'shouldnt throw an error' do
|
||||
create(:channel_email, email: 'support@example.com', account: account)
|
||||
expect { described_subject }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when an account is suspended' do
|
||||
let(:account) { create(:account, status: :suspended) }
|
||||
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
||||
let!(:channel_email) { create(:channel_email, account: account) }
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
before do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
channel_email.email = 'care@example.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
it 'shouldnt create a conversation in the channel' do
|
||||
described_subject
|
||||
expect(conversation.present?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'add mail as a new ticket in the email inbox' do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
||||
let!(:channel_email) { create(:channel_email, account: account) }
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
|
||||
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
let(:serialized_attributes) do
|
||||
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
|
||||
text_content to auto_reply]
|
||||
end
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
before do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
channel_email.email = 'care@example.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
describe 'covers email address format' do
|
||||
before do
|
||||
described_class.receive support_in_reply_to_mail
|
||||
end
|
||||
|
||||
it 'creates contact with proper email address' do
|
||||
expect(support_in_reply_to_mail.mail['reply_to'].try(:value)).to eq('Sony Mathew <sony@chatwoot.com>')
|
||||
expect(conversation.contact.email).to eq('sony@chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'covers basic ticket creation' do
|
||||
before do
|
||||
described_subject
|
||||
end
|
||||
|
||||
it 'create the conversation in the inbox of the email channel' do
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
expect(conversation.additional_attributes['source']).to eq('email')
|
||||
expect(conversation.contact.email).to eq(support_mail.mail.from.first)
|
||||
end
|
||||
|
||||
it 'create a new contact as the sender of the email' do
|
||||
email_sender = Mail::Address.new(support_mail.mail[:from].value).name
|
||||
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
|
||||
it 'add the mail content as new message on the conversation' do
|
||||
expect(conversation.messages.last.content).to eq("Let's talk about these images:")
|
||||
end
|
||||
|
||||
it 'add the attachments' do
|
||||
expect(conversation.messages.last.attachments.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'have proper content_attributes with details of email' do
|
||||
expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes)
|
||||
end
|
||||
|
||||
it 'set proper content_type' do
|
||||
expect(conversation.messages.last.content_type).to eq('incoming_email')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'email with references header' do
|
||||
let(:mail_with_references) { create_inbound_email_from_fixture('mail_with_references.eml') }
|
||||
let(:described_subject) { described_class.receive mail_with_references }
|
||||
|
||||
before do
|
||||
# reuse the existing channel_email that's already set to 'care@example.com'
|
||||
described_subject
|
||||
end
|
||||
|
||||
it 'includes references in the message content_attributes' do
|
||||
message = conversation.messages.last
|
||||
email_attributes = message.content_attributes['email']
|
||||
|
||||
expect(email_attributes['references']).to be_present
|
||||
expect(email_attributes['references']).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
|
||||
end
|
||||
|
||||
it 'includes references in serialized email attributes' do
|
||||
message = conversation.messages.last
|
||||
expect(message.content_attributes['email'].keys).to include('references')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender without name' do
|
||||
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail_without_sender_name }
|
||||
|
||||
it 'create a new contact with the email' do
|
||||
described_subject
|
||||
email_sender = support_mail_without_sender_name.mail.from.first.split('@').first
|
||||
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender with upcase mail address' do
|
||||
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail_without_sender_name }
|
||||
|
||||
it 'create a new inbox with the email case insensitive' do
|
||||
described_subject
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'handle inbox contacts' do
|
||||
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
|
||||
|
||||
it 'does not create new contact if that contact exists in the inbox' do
|
||||
expect do
|
||||
described_subject
|
||||
end
|
||||
.to(not_change { Contact.count }
|
||||
.and(not_change { ContactInbox.count }))
|
||||
|
||||
expect(conversation.messages.last.sender.id).to eq(contact.id)
|
||||
expect(conversation.contact_inbox).to eq(contact_inbox)
|
||||
end
|
||||
|
||||
context 'with uppercase reply-to' do
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support_uppercase.eml') }
|
||||
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
|
||||
|
||||
it 'does not create new contact if that contact exists in the inbox' do
|
||||
expect do
|
||||
described_subject
|
||||
end
|
||||
.to(not_change { Contact.count }
|
||||
.and(not_change { ContactInbox.count }))
|
||||
|
||||
expect(conversation.messages.last.sender.id).to eq(contact.id)
|
||||
expect(conversation.contact_inbox).to eq(contact_inbox)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'group email sender' do
|
||||
let(:group_sender_support_mail) { create_inbound_email_from_fixture('group_sender_support.eml') }
|
||||
let(:described_subject) { described_class.receive group_sender_support_mail }
|
||||
|
||||
before do
|
||||
# this email is hardcoded eml fixture file that's why we are updating this
|
||||
channel_email.email = 'support@chatwoot.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
it 'create new contact with original sender' do
|
||||
described_subject
|
||||
email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name
|
||||
|
||||
expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when mail has in reply to email' do
|
||||
let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
|
||||
let(:described_subject) { described_class.receive reply_mail_without_uuid }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
|
||||
before do
|
||||
email_channel
|
||||
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
|
||||
end
|
||||
|
||||
it 'create channel with reply to mail' do
|
||||
described_subject
|
||||
conversation_1 = Conversation.last
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
end
|
||||
|
||||
it 'append message to email conversation with same in reply to' do
|
||||
described_subject
|
||||
conversation_1 = Conversation.last
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
expect(conversation_1.messages.count).to eq(1)
|
||||
|
||||
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
|
||||
reply_mail_without_uuid.mail['Message-Id'] = '0CB459E0-0336-41DA-BC88-E6E28C697SFC@chatwoot.com'
|
||||
|
||||
described_class.receive reply_mail_without_uuid
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
expect(conversation_1.messages.count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender with reply_to email address' do
|
||||
let(:reply_to_mail) { create_inbound_email_from_fixture('reply_to.eml') }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
|
||||
it 'prefer reply-to over from address' do
|
||||
email_channel
|
||||
described_class.receive reply_to_mail
|
||||
|
||||
conversation_1 = Conversation.last
|
||||
email = conversation_1.messages.last.content_attributes['email']
|
||||
|
||||
expect(reply_to_mail.mail['From'].value).to be_present
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(reply_to_mail.mail['Reply-To'].value).to include(email['from'][0])
|
||||
expect(reply_to_mail.mail['Reply-To'].value).to include(conversation_1.contact.email)
|
||||
expect(reply_to_mail.mail['From'].value).not_to include(conversation_1.contact.email)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when mail part is not present' do
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support_1.eml') }
|
||||
let(:only_text) { create_inbound_email_from_fixture('only_text.eml') }
|
||||
let(:only_html) { create_inbound_email_from_fixture('only_html.eml') }
|
||||
let(:only_attachments) { create_inbound_email_from_fixture('only_attachments.eml') }
|
||||
let(:html_and_attachments) { create_inbound_email_from_fixture('html_and_attachments.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
|
||||
it 'Considers raw html mail body' do
|
||||
described_subject
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content_attributes['email']['html_content']['reply']).to include(
|
||||
<<~BODY.chomp
|
||||
Hi,
|
||||
|
||||
We are providing you platform from here you can sell paid posts on your website.
|
||||
|
||||
Chatwoot | CS team | [C](https://d33wubrfki0l68.cloudfront.net/973467c532160fd8b940300a43fa85fa2d060307/dc9a0/static/brand-73f58cdefae282ae74cebfa74c1d7003.svg)
|
||||
|
||||
Skype: live:.cid.something
|
||||
|
||||
[]
|
||||
BODY
|
||||
)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('Get Paid to post an article')
|
||||
end
|
||||
|
||||
it 'Considers only text body' do
|
||||
described_class.receive only_text
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq('text only mail')
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test text only mail')
|
||||
end
|
||||
|
||||
it 'Considers only html body' do
|
||||
described_class.receive only_html
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq(
|
||||
<<~BODY.chomp
|
||||
This is html only mail
|
||||
BODY
|
||||
)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test html only mail')
|
||||
end
|
||||
|
||||
it 'Considers only attachments' do
|
||||
described_class.receive only_attachments
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to be_nil
|
||||
expect(conversation.messages.last.attachments.count).to eq(1)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('only attachments')
|
||||
end
|
||||
|
||||
it 'Considers html and attachments' do
|
||||
described_class.receive html_and_attachments
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq('This is html and attachments only mail')
|
||||
expect(conversation.messages.last.attachments.count).to eq(1)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('attachment with html')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when BCC processing is disabled for account' do
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(account.id.to_s)
|
||||
end
|
||||
|
||||
it 'does not process BCC-only emails' do
|
||||
bcc_mail = create_inbound_email_from_fixture('support.eml')
|
||||
bcc_mail.mail['to'] = nil
|
||||
bcc_mail.mail['bcc'] = 'care@example.com'
|
||||
|
||||
described_class.receive bcc_mail
|
||||
expect(conversation.present?).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SupportMailbox do
|
||||
include ActionMailbox::TestHelper
|
||||
|
||||
describe 'when a chatwoot notification email is received' do
|
||||
let(:account) { create(:account) }
|
||||
let!(:channel_email) { create(:channel_email, email: 'sojan@chatwoot.com', account: account) }
|
||||
let(:notification_mail) { create_inbound_email_from_fixture('notification.eml') }
|
||||
let(:described_subject) { described_class.receive notification_mail }
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
it 'shouldnt create a conversation in the channel' do
|
||||
described_subject
|
||||
expect(conversation.present?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when bounced email with out a sender is recieved' do
|
||||
let(:account) { create(:account) }
|
||||
let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
|
||||
let(:described_subject) { described_class.receive bounced_email }
|
||||
|
||||
it 'shouldnt throw an error' do
|
||||
create(:channel_email, email: 'support@example.com', account: account)
|
||||
expect { described_subject }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when an account is suspended' do
|
||||
let(:account) { create(:account, status: :suspended) }
|
||||
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
||||
let!(:channel_email) { create(:channel_email, account: account) }
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
before do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
channel_email.email = 'care@example.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
it 'shouldnt create a conversation in the channel' do
|
||||
described_subject
|
||||
expect(conversation.present?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'add mail as a new ticket in the email inbox' do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
||||
let!(:channel_email) { create(:channel_email, account: account) }
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
|
||||
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
let(:serialized_attributes) do
|
||||
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
|
||||
text_content to auto_reply]
|
||||
end
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
before do
|
||||
# this email is hardcoded in the support.eml, that's why we are updating this
|
||||
channel_email.email = 'care@example.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
describe 'covers email address format' do
|
||||
before do
|
||||
described_class.receive support_in_reply_to_mail
|
||||
end
|
||||
|
||||
it 'creates contact with proper email address' do
|
||||
expect(support_in_reply_to_mail.mail['reply_to'].try(:value)).to eq('Sony Mathew <sony@chatwoot.com>')
|
||||
expect(conversation.contact.email).to eq('sony@chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'covers basic ticket creation' do
|
||||
before do
|
||||
described_subject
|
||||
end
|
||||
|
||||
it 'create the conversation in the inbox of the email channel' do
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
expect(conversation.additional_attributes['source']).to eq('email')
|
||||
expect(conversation.contact.email).to eq(support_mail.mail.from.first)
|
||||
end
|
||||
|
||||
it 'create a new contact as the sender of the email' do
|
||||
email_sender = Mail::Address.new(support_mail.mail[:from].value).name
|
||||
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
|
||||
it 'add the mail content as new message on the conversation' do
|
||||
expect(conversation.messages.last.content).to eq("Let's talk about these images:")
|
||||
end
|
||||
|
||||
it 'add the attachments' do
|
||||
expect(conversation.messages.last.attachments.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'have proper content_attributes with details of email' do
|
||||
expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes)
|
||||
end
|
||||
|
||||
it 'set proper content_type' do
|
||||
expect(conversation.messages.last.content_type).to eq('incoming_email')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'email with references header' do
|
||||
let(:mail_with_references) { create_inbound_email_from_fixture('mail_with_references.eml') }
|
||||
let(:described_subject) { described_class.receive mail_with_references }
|
||||
|
||||
before do
|
||||
# reuse the existing channel_email that's already set to 'care@example.com'
|
||||
described_subject
|
||||
end
|
||||
|
||||
it 'includes references in the message content_attributes' do
|
||||
message = conversation.messages.last
|
||||
email_attributes = message.content_attributes['email']
|
||||
|
||||
expect(email_attributes['references']).to be_present
|
||||
expect(email_attributes['references']).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
|
||||
end
|
||||
|
||||
it 'includes references in serialized email attributes' do
|
||||
message = conversation.messages.last
|
||||
expect(message.content_attributes['email'].keys).to include('references')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender without name' do
|
||||
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail_without_sender_name }
|
||||
|
||||
it 'create a new contact with the email' do
|
||||
described_subject
|
||||
email_sender = support_mail_without_sender_name.mail.from.first.split('@').first
|
||||
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender with upcase mail address' do
|
||||
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail_without_sender_name }
|
||||
|
||||
it 'create a new inbox with the email case insensitive' do
|
||||
described_subject
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'handle inbox contacts' do
|
||||
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
|
||||
|
||||
it 'does not create new contact if that contact exists in the inbox' do
|
||||
expect do
|
||||
described_subject
|
||||
end
|
||||
.to(not_change { Contact.count }
|
||||
.and(not_change { ContactInbox.count }))
|
||||
|
||||
expect(conversation.messages.last.sender.id).to eq(contact.id)
|
||||
expect(conversation.contact_inbox).to eq(contact_inbox)
|
||||
end
|
||||
|
||||
context 'with uppercase reply-to' do
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support_uppercase.eml') }
|
||||
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
|
||||
|
||||
it 'does not create new contact if that contact exists in the inbox' do
|
||||
expect do
|
||||
described_subject
|
||||
end
|
||||
.to(not_change { Contact.count }
|
||||
.and(not_change { ContactInbox.count }))
|
||||
|
||||
expect(conversation.messages.last.sender.id).to eq(contact.id)
|
||||
expect(conversation.contact_inbox).to eq(contact_inbox)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'group email sender' do
|
||||
let(:group_sender_support_mail) { create_inbound_email_from_fixture('group_sender_support.eml') }
|
||||
let(:described_subject) { described_class.receive group_sender_support_mail }
|
||||
|
||||
before do
|
||||
# this email is hardcoded eml fixture file that's why we are updating this
|
||||
channel_email.email = 'support@chatwoot.com'
|
||||
channel_email.save!
|
||||
end
|
||||
|
||||
it 'create new contact with original sender' do
|
||||
described_subject
|
||||
email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name
|
||||
|
||||
expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value)
|
||||
expect(conversation.contact.name).to eq(email_sender)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when mail has in reply to email' do
|
||||
let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
|
||||
let(:described_subject) { described_class.receive reply_mail_without_uuid }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
|
||||
before do
|
||||
email_channel
|
||||
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
|
||||
end
|
||||
|
||||
it 'create channel with reply to mail' do
|
||||
described_subject
|
||||
conversation_1 = Conversation.last
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
end
|
||||
|
||||
it 'append message to email conversation with same in reply to' do
|
||||
described_subject
|
||||
conversation_1 = Conversation.last
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
expect(conversation_1.messages.count).to eq(1)
|
||||
|
||||
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
|
||||
reply_mail_without_uuid.mail['Message-Id'] = '0CB459E0-0336-41DA-BC88-E6E28C697SFC@chatwoot.com'
|
||||
|
||||
described_class.receive reply_mail_without_uuid
|
||||
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
|
||||
expect(conversation_1.messages.count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Sender with reply_to email address' do
|
||||
let(:reply_to_mail) { create_inbound_email_from_fixture('reply_to.eml') }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
|
||||
it 'prefer reply-to over from address' do
|
||||
email_channel
|
||||
described_class.receive reply_to_mail
|
||||
|
||||
conversation_1 = Conversation.last
|
||||
email = conversation_1.messages.last.content_attributes['email']
|
||||
|
||||
expect(reply_to_mail.mail['From'].value).to be_present
|
||||
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
|
||||
expect(reply_to_mail.mail['Reply-To'].value).to include(email['from'][0])
|
||||
expect(reply_to_mail.mail['Reply-To'].value).to include(conversation_1.contact.email)
|
||||
expect(reply_to_mail.mail['From'].value).not_to include(conversation_1.contact.email)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when mail part is not present' do
|
||||
let(:support_mail) { create_inbound_email_from_fixture('support_1.eml') }
|
||||
let(:only_text) { create_inbound_email_from_fixture('only_text.eml') }
|
||||
let(:only_html) { create_inbound_email_from_fixture('only_html.eml') }
|
||||
let(:only_attachments) { create_inbound_email_from_fixture('only_attachments.eml') }
|
||||
let(:html_and_attachments) { create_inbound_email_from_fixture('html_and_attachments.eml') }
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
|
||||
it 'Considers raw html mail body' do
|
||||
described_subject
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content_attributes['email']['html_content']['reply']).to include(
|
||||
<<~BODY.chomp
|
||||
Hi,
|
||||
|
||||
We are providing you platform from here you can sell paid posts on your website.
|
||||
|
||||
Chatwoot | CS team | [C](https://d33wubrfki0l68.cloudfront.net/973467c532160fd8b940300a43fa85fa2d060307/dc9a0/static/brand-73f58cdefae282ae74cebfa74c1d7003.svg)
|
||||
|
||||
Skype: live:.cid.something
|
||||
|
||||
[]
|
||||
BODY
|
||||
)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('Get Paid to post an article')
|
||||
end
|
||||
|
||||
it 'Considers only text body' do
|
||||
described_class.receive only_text
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq('text only mail')
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test text only mail')
|
||||
end
|
||||
|
||||
it 'Considers only html body' do
|
||||
described_class.receive only_html
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq(
|
||||
<<~BODY.chomp
|
||||
This is html only mail
|
||||
BODY
|
||||
)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test html only mail')
|
||||
end
|
||||
|
||||
it 'Considers only attachments' do
|
||||
described_class.receive only_attachments
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to be_nil
|
||||
expect(conversation.messages.last.attachments.count).to eq(1)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('only attachments')
|
||||
end
|
||||
|
||||
it 'Considers html and attachments' do
|
||||
described_class.receive html_and_attachments
|
||||
|
||||
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
|
||||
|
||||
expect(conversation.messages.last.content).to eq('This is html and attachments only mail')
|
||||
expect(conversation.messages.last.attachments.count).to eq(1)
|
||||
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('attachment with html')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when BCC processing is disabled for account' do
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(account.id.to_s)
|
||||
end
|
||||
|
||||
it 'does not process BCC-only emails' do
|
||||
bcc_mail = create_inbound_email_from_fixture('support.eml')
|
||||
bcc_mail.mail['to'] = nil
|
||||
bcc_mail.mail['bcc'] = 'care@example.com'
|
||||
|
||||
expect { described_class.receive bcc_mail }.to raise_error('Email channel/inbox not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -28,7 +28,7 @@ RSpec.describe AdministratorNotifications::AccountNotificationMailer do
|
||||
|
||||
describe '#format_deletion_date' do
|
||||
it 'formats a valid date string' do
|
||||
date_str = '2024-12-31T23:59:59Z'
|
||||
date_str = '2024-12-31T12:00:00Z'
|
||||
formatted = described_class.new.send(:format_deletion_date, date_str)
|
||||
expect(formatted).to eq('December 31, 2024')
|
||||
end
|
||||
|
||||
@@ -22,6 +22,12 @@ RSpec.describe Account do
|
||||
describe 'length validations' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'validates name presence' do
|
||||
account.name = ''
|
||||
account.valid?
|
||||
expect(account.errors[:name]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'validates name length' do
|
||||
account.name = 'a' * 256
|
||||
account.valid?
|
||||
@@ -203,12 +209,12 @@ RSpec.describe Account do
|
||||
context 'when using with_auto_resolve scope' do
|
||||
it 'finds accounts with auto_resolve_after set' do
|
||||
account.update(auto_resolve_after: 40 * 24 * 60)
|
||||
expect(described_class.with_auto_resolve).to include(account)
|
||||
expect(described_class.with_auto_resolve.pluck(:id)).to include(account.id)
|
||||
end
|
||||
|
||||
it 'does not find accounts without auto_resolve_after' do
|
||||
account.update(auto_resolve_after: nil)
|
||||
expect(described_class.with_auto_resolve).not_to include(account)
|
||||
expect(described_class.with_auto_resolve.pluck(:id)).not_to include(account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -154,4 +154,39 @@ RSpec.describe Attachment do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'file size validation' do
|
||||
let(:attachment) { message.attachments.new(account_id: message.account_id, file_type: :image) }
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).and_call_original
|
||||
end
|
||||
|
||||
it 'respects configured limit' do
|
||||
allow(GlobalConfigService).to receive(:load)
|
||||
.with('MAXIMUM_FILE_UPLOAD_SIZE', 40)
|
||||
.and_return('5')
|
||||
|
||||
attachment.errors.clear
|
||||
attachment.send(:validate_file_size, 4.megabytes)
|
||||
|
||||
expect(attachment.errors[:file]).to be_empty
|
||||
|
||||
attachment.errors.clear
|
||||
attachment.send(:validate_file_size, 6.megabytes)
|
||||
|
||||
expect(attachment.errors[:file]).to include('size is too big')
|
||||
end
|
||||
|
||||
it 'falls back to default when configured limit is invalid' do
|
||||
allow(GlobalConfigService).to receive(:load)
|
||||
.with('MAXIMUM_FILE_UPLOAD_SIZE', 40)
|
||||
.and_return('-10')
|
||||
|
||||
attachment.errors.clear
|
||||
attachment.send(:validate_file_size, 41.megabytes)
|
||||
|
||||
expect(attachment.errors[:file]).to include('size is too big')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,10 @@ require 'rails_helper'
|
||||
shared_examples_for 'avatarable' do
|
||||
let(:avatarable) { create(described_class.to_s.underscore) }
|
||||
|
||||
it { is_expected.to have_one_attached(:avatar) }
|
||||
it 'has avatar attachment defined' do
|
||||
expect(avatarable).to respond_to(:avatar)
|
||||
expect(avatarable.avatar).to respond_to(:attach)
|
||||
end
|
||||
|
||||
it 'add avatar_url method' do
|
||||
expect(avatarable.respond_to?(:avatar_url)).to be true
|
||||
|
||||
@@ -81,14 +81,15 @@ RSpec.describe 'SwitchLocale Concern', type: :controller do
|
||||
end
|
||||
|
||||
describe '#switch_locale_using_account_locale' do
|
||||
before do
|
||||
routes.draw { get 'account_locale' => 'anonymous#account_locale' }
|
||||
end
|
||||
|
||||
it 'sets locale from account' do
|
||||
controller.instance_variable_set(:@current_account, account)
|
||||
get :account_locale
|
||||
expect(response.body).to eq('es')
|
||||
|
||||
result = nil
|
||||
controller.send(:switch_locale_using_account_locale) do
|
||||
result = I18n.locale.to_s
|
||||
end
|
||||
|
||||
expect(result).to eq('es')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,6 +40,18 @@ RSpec.describe ContactInbox do
|
||||
|
||||
describe 'validations' do
|
||||
context 'when source_id' do
|
||||
it 'allows source_id longer than 255 characters for channels without format restrictions' do
|
||||
long_source_id = 'a' * 300
|
||||
email_inbox = create(:inbox, channel: create(:channel_email))
|
||||
contact = create(:contact, account: email_inbox.account)
|
||||
contact_inbox = build(:contact_inbox, contact: contact, inbox: email_inbox, source_id: long_source_id)
|
||||
|
||||
expect(contact_inbox.valid?).to be(true)
|
||||
expect { contact_inbox.save! }.not_to raise_error
|
||||
expect(contact_inbox.reload.source_id).to eq(long_source_id)
|
||||
expect(contact_inbox.source_id.length).to eq(300)
|
||||
end
|
||||
|
||||
it 'validates whatsapp channel source_id' do
|
||||
whatsapp_inbox = create(:channel_whatsapp, sync_templates: false, validate_provider_config: false).inbox
|
||||
contact = create(:contact)
|
||||
|
||||
@@ -525,8 +525,9 @@ RSpec.describe Conversation do
|
||||
additional_attributes: {},
|
||||
meta: {
|
||||
sender: conversation.contact.push_event_data,
|
||||
assignee: conversation.assignee,
|
||||
team: conversation.team,
|
||||
assignee: conversation.assigned_entity&.push_event_data,
|
||||
assignee_type: conversation.assignee_type,
|
||||
team: conversation.team&.push_event_data,
|
||||
hmac_verified: conversation.contact_inbox.hmac_verified
|
||||
},
|
||||
id: conversation.display_id,
|
||||
|
||||
@@ -68,13 +68,13 @@ RSpec.describe Integrations::Hook do
|
||||
end
|
||||
|
||||
it 'returns account hooks' do
|
||||
expect(described_class.account_hooks).to include(account_hook)
|
||||
expect(described_class.account_hooks).not_to include(inbox_hook)
|
||||
expect(described_class.account_hooks.pluck(:id)).to include(account_hook.id)
|
||||
expect(described_class.account_hooks.pluck(:id)).not_to include(inbox_hook.id)
|
||||
end
|
||||
|
||||
it 'returns inbox hooks' do
|
||||
expect(described_class.inbox_hooks).to include(inbox_hook)
|
||||
expect(described_class.inbox_hooks).not_to include(account_hook)
|
||||
expect(described_class.inbox_hooks.pluck(:id)).to include(inbox_hook.id)
|
||||
expect(described_class.inbox_hooks.pluck(:id)).not_to include(account_hook.id)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -45,6 +45,48 @@ RSpec.describe Message do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it validates source_id length' do
|
||||
it 'valid when source_id is within text limit (20000 chars)' do
|
||||
long_source_id = 'a' * 10_000
|
||||
message.source_id = long_source_id
|
||||
expect(message.valid?).to be true
|
||||
end
|
||||
|
||||
it 'valid when source_id is exactly 20000 characters' do
|
||||
long_source_id = 'a' * 20_000
|
||||
message.source_id = long_source_id
|
||||
expect(message.valid?).to be true
|
||||
end
|
||||
|
||||
it 'invalid when source_id exceeds text limit (20000 chars)' do
|
||||
long_source_id = 'a' * 20_001
|
||||
message.source_id = long_source_id
|
||||
message.valid?
|
||||
|
||||
expect(message.errors[:source_id]).to include('is too long (maximum is 20000 characters)')
|
||||
end
|
||||
|
||||
it 'handles long email Message-ID headers correctly' do
|
||||
# Simulate a long Message-ID like some email systems generate
|
||||
long_message_id = "msg-#{SecureRandom.hex(240)}@verylongdomainname.example.com"[0...500]
|
||||
message.source_id = long_message_id
|
||||
message.content_type = 'incoming_email'
|
||||
|
||||
expect(message.valid?).to be true
|
||||
expect(message.source_id.length).to eq(500)
|
||||
end
|
||||
|
||||
it 'allows nil source_id' do
|
||||
message.source_id = nil
|
||||
expect(message.valid?).to be true
|
||||
end
|
||||
|
||||
it 'allows empty string source_id' do
|
||||
message.source_id = ''
|
||||
expect(message.valid?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'concerns' do
|
||||
|
||||
@@ -16,8 +16,8 @@ RSpec.describe Notification do
|
||||
create(:notification)
|
||||
notification3 = create(:notification)
|
||||
|
||||
expect(described_class.all.first).to eq notification1
|
||||
expect(described_class.all.last).to eq notification3
|
||||
expect(described_class.all.first.id).to eq notification1.id
|
||||
expect(described_class.all.last.id).to eq notification3.id
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ RSpec.describe Conversations::EventDataPresenter do
|
||||
additional_attributes: {},
|
||||
meta: {
|
||||
sender: conversation.contact.push_event_data,
|
||||
assignee: conversation.assignee,
|
||||
team: conversation.team,
|
||||
assignee: conversation.assigned_entity&.push_event_data,
|
||||
assignee_type: conversation.assignee_type,
|
||||
team: conversation.team&.push_event_data,
|
||||
hmac_verified: conversation.contact_inbox.hmac_verified
|
||||
},
|
||||
id: conversation.display_id,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::BaseController', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
end
|
||||
|
||||
context 'when agent bot belongs to the account' do
|
||||
let(:agent_bot) { create(:agent_bot, account: account) }
|
||||
|
||||
it 'allows assignments via API' do
|
||||
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
headers: { api_access_token: agent_bot.access_token.token },
|
||||
params: { assignee_id: agent.id },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent bot belongs to another account' do
|
||||
let(:other_account) { create(:account) }
|
||||
let(:external_bot) { create(:agent_bot, account: other_account) }
|
||||
|
||||
it 'rejects assignment' do
|
||||
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
headers: { api_access_token: external_bot.access_token.token },
|
||||
params: { assignee_id: agent.id },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent bot is global' do
|
||||
let(:global_bot) { create(:agent_bot, account: nil) }
|
||||
|
||||
it 'rejects requests without inbox mapping' do
|
||||
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
headers: { api_access_token: global_bot.access_token.token },
|
||||
params: { assignee_id: agent.id },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'allows requests when inbox mapping exists' do
|
||||
create(:agent_bot_inbox, agent_bot: global_bot, inbox: inbox)
|
||||
|
||||
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
headers: { api_access_token: global_bot.access_token.token },
|
||||
params: { assignee_id: agent.id },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,310 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::AssignmentService do
|
||||
let(:account) { create(:account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
|
||||
let(:service) { described_class.new(inbox: inbox) }
|
||||
let(:agent) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
# Enable assignment_v2 feature for the account
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
# Link inbox to assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
end
|
||||
|
||||
describe '#perform_bulk_assignment' do
|
||||
context 'when auto assignment is enabled' do
|
||||
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
|
||||
|
||||
before do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
|
||||
|
||||
# Mock RoundRobinSelector to return the agent
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
|
||||
|
||||
# Mock RateLimiter to allow all assignments by default
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
|
||||
allow(rate_limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(rate_limiter).to receive(:track_assignment)
|
||||
end
|
||||
|
||||
it 'assigns conversations to available agents' do
|
||||
# Create conversation and ensure it's unassigned
|
||||
conv = create(:conversation, inbox: inbox, status: 'open')
|
||||
conv.update!(assignee_id: nil)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conv.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
it 'returns 0 when no agents are online' do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(assigned_count).to eq(0)
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'respects the limit parameter' do
|
||||
3.times do
|
||||
conv = create(:conversation, inbox: inbox, status: 'open')
|
||||
conv.update!(assignee_id: nil)
|
||||
end
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 2)
|
||||
|
||||
expect(assigned_count).to eq(2)
|
||||
expect(inbox.conversations.unassigned.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'only assigns open conversations' do
|
||||
conversation # ensure it exists
|
||||
conversation.update!(assignee_id: nil)
|
||||
resolved_conversation = create(:conversation, inbox: inbox, status: 'resolved')
|
||||
resolved_conversation.update!(assignee_id: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(conversation.reload.assignee).to eq(agent)
|
||||
expect(resolved_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'does not reassign already assigned conversations' do
|
||||
conversation # ensure it exists
|
||||
conversation.update!(assignee_id: nil)
|
||||
assigned_conversation = create(:conversation, inbox: inbox, assignee: agent)
|
||||
unassigned_conversation = create(:conversation, inbox: inbox, status: 'open')
|
||||
unassigned_conversation.update!(assignee_id: nil)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(2) # conversation + unassigned_conversation
|
||||
expect(assigned_conversation.reload.assignee).to eq(agent)
|
||||
expect(unassigned_conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
it 'dispatches assignee changed event' do
|
||||
conversation # ensure it exists
|
||||
conversation.update!(assignee_id: nil)
|
||||
|
||||
# The conversation model also dispatches a conversation.updated event
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
|
||||
Events::Types::ASSIGNEE_CHANGED,
|
||||
anything,
|
||||
hash_including(conversation: conversation, user: agent)
|
||||
)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when auto assignment is disabled' do
|
||||
before { assignment_policy.update!(enabled: false) }
|
||||
|
||||
it 'returns 0 without processing' do
|
||||
assigned_count = service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(0)
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation priority' do
|
||||
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
|
||||
|
||||
before do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
|
||||
|
||||
# Mock RoundRobinSelector to return the agent
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
|
||||
|
||||
# Mock RateLimiter to allow all assignments by default
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
|
||||
allow(rate_limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(rate_limiter).to receive(:track_assignment)
|
||||
end
|
||||
|
||||
context 'when priority is longest_waiting' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_config).and_return({ 'conversation_priority' => 'longest_waiting' })
|
||||
end
|
||||
|
||||
it 'assigns conversations with oldest last_activity_at first' do
|
||||
old_conversation = create(:conversation,
|
||||
inbox: inbox,
|
||||
status: 'open',
|
||||
created_at: 2.hours.ago,
|
||||
last_activity_at: 2.hours.ago)
|
||||
old_conversation.update!(assignee_id: nil)
|
||||
new_conversation = create(:conversation,
|
||||
inbox: inbox,
|
||||
status: 'open',
|
||||
created_at: 1.hour.ago,
|
||||
last_activity_at: 1.hour.ago)
|
||||
new_conversation.update!(assignee_id: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(old_conversation.reload.assignee).to eq(agent)
|
||||
expect(new_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when priority is default' do
|
||||
it 'assigns conversations by created_at' do
|
||||
old_conversation = create(:conversation, inbox: inbox, status: 'open', created_at: 2.hours.ago)
|
||||
old_conversation.update!(assignee_id: nil)
|
||||
new_conversation = create(:conversation, inbox: inbox, status: 'open', created_at: 1.hour.ago)
|
||||
new_conversation.update!(assignee_id: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(old_conversation.reload.assignee).to eq(agent)
|
||||
expect(new_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with fair distribution' do
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
|
||||
agent.id.to_s => 'online',
|
||||
agent2.id.to_s => 'online'
|
||||
})
|
||||
end
|
||||
|
||||
context 'when fair distribution is enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_config).and_return({
|
||||
'fair_distribution_limit' => 2,
|
||||
'fair_distribution_window' => 3600
|
||||
})
|
||||
end
|
||||
|
||||
it 'respects the assignment limit per agent' do
|
||||
# Mock RoundRobinSelector to select agent2
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent2)
|
||||
|
||||
# Mock agent1 at limit, agent2 not at limit
|
||||
agent1_limiter = instance_double(AutoAssignment::RateLimiter)
|
||||
agent2_limiter = instance_double(AutoAssignment::RateLimiter)
|
||||
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).with(inbox: inbox, agent: agent).and_return(agent1_limiter)
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).with(inbox: inbox, agent: agent2).and_return(agent2_limiter)
|
||||
|
||||
allow(agent1_limiter).to receive(:within_limit?).and_return(false)
|
||||
allow(agent2_limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(agent2_limiter).to receive(:track_assignment)
|
||||
|
||||
unassigned_conversation = create(:conversation, inbox: inbox, status: 'open')
|
||||
unassigned_conversation.update!(assignee_id: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(unassigned_conversation.reload.assignee).to eq(agent2)
|
||||
end
|
||||
|
||||
it 'tracks assignments in Redis' do
|
||||
conversation # ensure it exists
|
||||
conversation.update!(assignee_id: nil)
|
||||
|
||||
# Mock RoundRobinSelector
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
|
||||
|
||||
limiter = instance_double(AutoAssignment::RateLimiter)
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
|
||||
allow(limiter).to receive(:within_limit?).and_return(true)
|
||||
expect(limiter).to receive(:track_assignment)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
end
|
||||
|
||||
it 'allows assignments after window expires' do
|
||||
# Mock RoundRobinSelector
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent, agent2)
|
||||
|
||||
# Mock RateLimiter to allow all
|
||||
limiter = instance_double(AutoAssignment::RateLimiter)
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
|
||||
allow(limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(limiter).to receive(:track_assignment)
|
||||
|
||||
# Simulate time passing for rate limit window
|
||||
freeze_time do
|
||||
2.times do
|
||||
conversation_new = create(:conversation, inbox: inbox, status: 'open')
|
||||
conversation_new.update!(assignee_id: nil)
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
expect(conversation_new.reload.assignee).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
# Move forward past the window
|
||||
travel_to(2.hours.from_now) do
|
||||
new_conversation = create(:conversation, inbox: inbox, status: 'open')
|
||||
new_conversation.update!(assignee_id: nil)
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
expect(new_conversation.reload.assignee).not_to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when fair distribution is disabled' do
|
||||
it 'assigns without rate limiting' do
|
||||
5.times do
|
||||
conv = create(:conversation, inbox: inbox, status: 'open')
|
||||
conv.update!(assignee_id: nil)
|
||||
end
|
||||
|
||||
# Mock RoundRobinSelector
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
|
||||
|
||||
# Mock RateLimiter to allow all
|
||||
limiter = instance_double(AutoAssignment::RateLimiter)
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
|
||||
allow(limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(limiter).to receive(:track_assignment)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 5)
|
||||
expect(assigned_count).to eq(5)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with round robin assignment' do
|
||||
it 'distributes conversations evenly among agents' do
|
||||
conversations = Array.new(4) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
service.perform_bulk_assignment(limit: 4)
|
||||
|
||||
agent1_count = conversations.count { |c| c.reload.assignee == agent }
|
||||
agent2_count = conversations.count { |c| c.reload.assignee == agent2 }
|
||||
|
||||
# Should be distributed evenly (2 each) or close to even (3 and 1)
|
||||
expect([agent1_count, agent2_count].sort).to eq([2, 2]).or(eq([1, 3]))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,167 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::RateLimiter do
|
||||
# Stub Math methods for testing when assignment_policy is nil
|
||||
# rubocop:disable RSpec/BeforeAfterAll, RSpec/InstanceVariable
|
||||
before(:all) do
|
||||
@math_had_positive = Math.respond_to?(:positive?)
|
||||
Math.define_singleton_method(:positive?) { false } unless @math_had_positive
|
||||
end
|
||||
|
||||
after(:all) do
|
||||
Math.singleton_class.send(:remove_method, :positive?) unless @math_had_positive
|
||||
end
|
||||
# rubocop:enable RSpec/BeforeAfterAll, RSpec/InstanceVariable
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox) }
|
||||
let(:rate_limiter) { described_class.new(inbox: inbox, agent: agent) }
|
||||
|
||||
describe '#within_limit?' do
|
||||
context 'when rate limiting is not enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(nil)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(rate_limiter.within_limit?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rate limiting is enabled' do
|
||||
let(:assignment_policy) do
|
||||
instance_double(AssignmentPolicy,
|
||||
fair_distribution_limit: 5,
|
||||
fair_distribution_window: 3600)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
|
||||
end
|
||||
|
||||
it 'returns true when under the limit' do
|
||||
allow(rate_limiter).to receive(:current_count).and_return(3)
|
||||
expect(rate_limiter.within_limit?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when at or over the limit' do
|
||||
allow(rate_limiter).to receive(:current_count).and_return(5)
|
||||
expect(rate_limiter.within_limit?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#track_assignment' do
|
||||
context 'when rate limiting is not enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(nil)
|
||||
end
|
||||
|
||||
it 'does not track the assignment' do
|
||||
expect(Redis::Alfred).not_to receive(:set)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rate limiting is enabled' do
|
||||
let(:assignment_policy) do
|
||||
instance_double(AssignmentPolicy,
|
||||
fair_distribution_limit: 5,
|
||||
fair_distribution_window: 3600)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
|
||||
end
|
||||
|
||||
it 'creates a Redis key with correct expiry' do
|
||||
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
|
||||
expect(Redis::Alfred).to receive(:set).with(
|
||||
expected_key,
|
||||
conversation.id.to_s,
|
||||
ex: 3600
|
||||
)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#current_count' do
|
||||
context 'when rate limiting is not enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(nil)
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
expect(rate_limiter.current_count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rate limiting is enabled' do
|
||||
let(:assignment_policy) do
|
||||
instance_double(AssignmentPolicy,
|
||||
fair_distribution_limit: 5,
|
||||
fair_distribution_window: 3600)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
|
||||
end
|
||||
|
||||
it 'counts matching Redis keys' do
|
||||
pattern = format(Redis::RedisKeys::ASSIGNMENT_KEY_PATTERN, inbox_id: inbox.id, agent_id: agent.id)
|
||||
allow(Redis::Alfred).to receive(:keys_count).with(pattern).and_return(3)
|
||||
|
||||
expect(rate_limiter.current_count).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'configuration' do
|
||||
context 'with custom window' do
|
||||
let(:assignment_policy) do
|
||||
instance_double(AssignmentPolicy,
|
||||
fair_distribution_limit: 10,
|
||||
fair_distribution_window: 7200)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
|
||||
end
|
||||
|
||||
it 'uses the custom window value' do
|
||||
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
|
||||
expect(Redis::Alfred).to receive(:set).with(
|
||||
expected_key,
|
||||
conversation.id.to_s,
|
||||
ex: 7200
|
||||
)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without custom window' do
|
||||
let(:assignment_policy) do
|
||||
instance_double(AssignmentPolicy,
|
||||
fair_distribution_limit: 10,
|
||||
fair_distribution_window: nil)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
|
||||
end
|
||||
|
||||
it 'uses the default window value of 24 hours' do
|
||||
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
|
||||
expect(Redis::Alfred).to receive(:set).with(
|
||||
expected_key,
|
||||
conversation.id.to_s,
|
||||
ex: 86_400
|
||||
)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::RoundRobinSelector do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:selector) { described_class.new(inbox: inbox) }
|
||||
let(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent3) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
|
||||
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
|
||||
|
||||
let(:member1) do
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
|
||||
allow(round_robin_service).to receive(:add_agent_to_queue)
|
||||
create(:inbox_member, inbox: inbox, user: agent1)
|
||||
end
|
||||
|
||||
let(:member2) do
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
|
||||
allow(round_robin_service).to receive(:add_agent_to_queue)
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
end
|
||||
|
||||
let(:member3) do
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
|
||||
allow(round_robin_service).to receive(:add_agent_to_queue)
|
||||
create(:inbox_member, inbox: inbox, user: agent3)
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock the round robin service to avoid Redis calls
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
|
||||
allow(round_robin_service).to receive(:add_agent_to_queue)
|
||||
allow(round_robin_service).to receive(:reset_queue)
|
||||
allow(round_robin_service).to receive(:validate_queue?).and_return(true)
|
||||
end
|
||||
|
||||
describe '#select_agent' do
|
||||
context 'when agents are available' do
|
||||
let(:available_agents) { [member1, member2, member3] }
|
||||
|
||||
it 'returns an agent from the available list' do
|
||||
allow(round_robin_service).to receive(:available_agent).and_return(agent1)
|
||||
|
||||
selected_agent = selector.select_agent(available_agents)
|
||||
|
||||
expect(selected_agent).not_to be_nil
|
||||
expect([agent1, agent2, agent3]).to include(selected_agent)
|
||||
end
|
||||
|
||||
it 'uses round robin service for selection' do
|
||||
expect(round_robin_service).to receive(:available_agent).with(
|
||||
allowed_agent_ids: [agent1.id.to_s, agent2.id.to_s, agent3.id.to_s]
|
||||
).and_return(agent1)
|
||||
|
||||
selected_agent = selector.select_agent(available_agents)
|
||||
expect(selected_agent).to eq(agent1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no agents are available' do
|
||||
it 'returns nil' do
|
||||
selected_agent = selector.select_agent([])
|
||||
expect(selected_agent).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when one agent is available' do
|
||||
it 'returns that agent' do
|
||||
allow(round_robin_service).to receive(:available_agent).and_return(agent1)
|
||||
|
||||
selected_agent = selector.select_agent([member1])
|
||||
expect(selected_agent).to eq(agent1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Conversations::AssignmentService do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:agent_bot) { create(:agent_bot, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when assignee_id is blank' do
|
||||
before do
|
||||
conversation.update!(assignee: agent, assignee_agent_bot: agent_bot)
|
||||
end
|
||||
|
||||
it 'clears both human and bot assignees' do
|
||||
described_class.new(conversation: conversation, assignee_id: nil).perform
|
||||
|
||||
conversation.reload
|
||||
expect(conversation.assignee_id).to be_nil
|
||||
expect(conversation.assignee_agent_bot_id).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assigning a user' do
|
||||
before do
|
||||
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil)
|
||||
end
|
||||
|
||||
it 'sets the agent and clears agent bot' do
|
||||
result = described_class.new(conversation: conversation, assignee_id: agent.id).perform
|
||||
|
||||
conversation.reload
|
||||
expect(result).to eq(agent)
|
||||
expect(conversation.assignee_id).to eq(agent.id)
|
||||
expect(conversation.assignee_agent_bot_id).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assigning an agent bot' do
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
conversation: conversation,
|
||||
assignee_id: agent_bot.id,
|
||||
assignee_type: 'AgentBot'
|
||||
)
|
||||
end
|
||||
|
||||
it 'sets the agent bot and clears human assignee' do
|
||||
conversation.update!(assignee: agent, assignee_agent_bot: nil)
|
||||
|
||||
result = service.perform
|
||||
|
||||
conversation.reload
|
||||
expect(result).to eq(agent_bot)
|
||||
expect(conversation.assignee_agent_bot_id).to eq(agent_bot.id)
|
||||
expect(conversation.assignee_id).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -63,10 +63,10 @@ RSpec.describe Crm::Leadsquared::Api::BaseClient do
|
||||
end
|
||||
|
||||
it 'raises ApiError with error message' do
|
||||
expect { client.get(path, params) }.to raise_error(
|
||||
Crm::Leadsquared::Api::BaseClient::ApiError,
|
||||
'Invalid lead ID'
|
||||
)
|
||||
expect { client.get(path, params) }.to raise_error do |error|
|
||||
expect(error.class.name).to eq(described_class::ApiError.name)
|
||||
expect(error.message).to eq('Invalid lead ID')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -82,7 +82,7 @@ RSpec.describe Crm::Leadsquared::Api::BaseClient do
|
||||
|
||||
it 'raises ApiError with status code' do
|
||||
expect { client.get(path, params) }.to raise_error do |error|
|
||||
expect(error).to be_a(Crm::Leadsquared::Api::BaseClient::ApiError)
|
||||
expect(error.class.name).to eq(described_class::ApiError.name)
|
||||
expect(error.message).to include('Not Found')
|
||||
expect(error.code).to eq(404)
|
||||
end
|
||||
@@ -134,10 +134,10 @@ RSpec.describe Crm::Leadsquared::Api::BaseClient do
|
||||
end
|
||||
|
||||
it 'raises ApiError with error message' do
|
||||
expect { client.post(path, params, body) }.to raise_error(
|
||||
Crm::Leadsquared::Api::BaseClient::ApiError,
|
||||
'Invalid data'
|
||||
)
|
||||
expect { client.post(path, params, body) }.to raise_error do |error|
|
||||
expect(error.class.name).to eq(described_class::ApiError.name)
|
||||
expect(error.message).to eq('Invalid data')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -158,7 +158,7 @@ RSpec.describe Crm::Leadsquared::Api::BaseClient do
|
||||
|
||||
it 'raises ApiError for invalid JSON' do
|
||||
expect { client.post(path, params, body) }.to raise_error do |error|
|
||||
expect(error).to be_a(Crm::Leadsquared::Api::BaseClient::ApiError)
|
||||
expect(error.class.name).to eq(described_class::ApiError.name)
|
||||
expect(error.message).to include('Failed to parse')
|
||||
end
|
||||
end
|
||||
@@ -177,7 +177,7 @@ RSpec.describe Crm::Leadsquared::Api::BaseClient do
|
||||
|
||||
it 'raises ApiError with status code' do
|
||||
expect { client.post(path, params, body) }.to raise_error do |error|
|
||||
expect(error).to be_a(Crm::Leadsquared::Api::BaseClient::ApiError)
|
||||
expect(error.class.name).to eq(described_class::ApiError.name)
|
||||
expect(error.message).to include('Internal Server Error')
|
||||
expect(error.code).to eq(500)
|
||||
end
|
||||
|
||||
@@ -149,7 +149,7 @@ RSpec.describe Crm::Leadsquared::Api::LeadClient do
|
||||
|
||||
it 'raises ApiError' do
|
||||
expect { client.create_or_update_lead(lead_data) }
|
||||
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
|
||||
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -223,7 +223,7 @@ RSpec.describe Crm::Leadsquared::Api::LeadClient do
|
||||
|
||||
it 'raises ApiError' do
|
||||
expect { client.update_lead(lead_data, lead_id) }
|
||||
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
|
||||
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -108,6 +108,13 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
system_message
|
||||
end
|
||||
|
||||
def formatted_line_for(msg, hook_for_tz)
|
||||
tz = Time.find_zone(hook_for_tz.settings['timezone']) || Time.zone
|
||||
ts = msg.created_at.in_time_zone(tz).strftime('%Y-%m-%d %H:%M')
|
||||
sender = msg.sender&.name.presence || (msg.sender.present? ? "#{msg.sender_type} #{msg.sender_id}" : 'System')
|
||||
"[#{ts}] #{sender}: #{msg.content.presence || I18n.t('crm.no_content')}"
|
||||
end
|
||||
|
||||
it 'generates transcript with messages in reverse chronological order' do
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
|
||||
@@ -115,13 +122,15 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
expect(result).to include('Channel: Test Inbox')
|
||||
|
||||
# Check that messages appear in reverse order (newest first)
|
||||
newer = formatted_line_for(message2, hook)
|
||||
older = formatted_line_for(message1, hook)
|
||||
message_positions = {
|
||||
'[2024-01-01 10:00] John Doe: Hello' => result.index('[2024-01-01 10:00] John Doe: Hello'),
|
||||
'[2024-01-01 10:01] Jane Smith: Hi there' => result.index('[2024-01-01 10:01] Jane Smith: Hi there')
|
||||
newer => result.index(newer),
|
||||
older => result.index(older)
|
||||
}
|
||||
|
||||
# Latest message (10:01) should come before older message (10:00)
|
||||
expect(message_positions['[2024-01-01 10:01] Jane Smith: Hi there']).to be < message_positions['[2024-01-01 10:00] John Doe: Hello']
|
||||
expect(message_positions[newer]).to be < message_positions[older]
|
||||
end
|
||||
|
||||
it 'formats message times according to hook timezone setting' do
|
||||
@@ -210,13 +219,15 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
sender: user,
|
||||
content: "#{long_message_content} #{i}",
|
||||
message_type: :outgoing,
|
||||
created_at: Time.zone.parse("2024-01-01 #{10 + i}:00:00"))
|
||||
created_at: Time.zone.parse('2024-01-01 10:00:00') + i.hours)
|
||||
end
|
||||
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
|
||||
# Verify latest message is included (message 14)
|
||||
expect(result).to include("[2024-01-02 00:00] John Doe: #{long_message_content} 14")
|
||||
tz = Time.find_zone(hook.settings['timezone']) || Time.zone
|
||||
latest_label = "[#{messages.last.created_at.in_time_zone(tz).strftime('%Y-%m-%d %H:%M')}] John Doe: #{long_message_content} 14"
|
||||
expect(result).to include(latest_label)
|
||||
|
||||
# Calculate the expected character count of the formatted messages
|
||||
messages.map do |msg|
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinder do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when receiver uuid strategy finds conversation' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
finder = described_class.new(mail)
|
||||
expect(finder.find).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'logs which strategy succeeded' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with('Conversation found via receiver_uuid_strategy strategy')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to strategy finds conversation' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
finder = described_class.new(mail)
|
||||
expect(finder.find).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'logs which strategy succeeded' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with('Conversation found via in_reply_to_strategy strategy')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references strategy finds conversation' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
finder = described_class.new(mail)
|
||||
expect(finder.find).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'logs which strategy succeeded' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with('Conversation found via references_strategy strategy')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no strategy finds conversation' do
|
||||
# With NewConversationStrategy in default strategies, this scenario only happens
|
||||
# when using custom strategies that exclude NewConversationStrategy
|
||||
let(:finding_strategies) do
|
||||
[
|
||||
Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy,
|
||||
Mailbox::ConversationFinderStrategies::InReplyToStrategy,
|
||||
Mailbox::ConversationFinderStrategies::ReferencesStrategy
|
||||
]
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
finder = described_class.new(mail, strategies: finding_strategies)
|
||||
expect(finder.find).to be_nil
|
||||
end
|
||||
|
||||
it 'logs that no conversation was found' do
|
||||
allow(Rails.logger).to receive(:error)
|
||||
finder = described_class.new(mail, strategies: finding_strategies)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:error).with('No conversation found via any strategy (NewConversationStrategy missing?)')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with custom strategies' do
|
||||
let(:custom_strategy_class) do
|
||||
Class.new(Mailbox::ConversationFinderStrategies::BaseStrategy) do
|
||||
def find
|
||||
# Always return nil for testing
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses provided strategies instead of defaults' do
|
||||
finder = described_class.new(mail, strategies: [custom_strategy_class])
|
||||
expect(finder.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with strategy execution order' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
|
||||
# Set up mail so all strategies could match
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
|
||||
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/456@example.com'
|
||||
end
|
||||
|
||||
it 'returns conversation from first matching strategy' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
result = finder.find
|
||||
|
||||
expect(result).to eq(conversation)
|
||||
# Should only log the first strategy that succeeded (ReceiverUuidStrategy)
|
||||
expect(Rails.logger).to have_received(:info).once.with('Conversation found via receiver_uuid_strategy strategy')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::InReplyToStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when in_reply_to has message-specific pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to has conversation fallback pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
mail.in_reply_to = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to matches message source_id' do
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
source_id: 'original-message-id@example.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Original message'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
message # Create the message
|
||||
mail.in_reply_to = 'original-message-id@example.com'
|
||||
end
|
||||
|
||||
it 'finds conversation from message source_id' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to has multiple values' do
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
source_id: 'message-123@example.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Test message'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
message # Create the message
|
||||
mail.in_reply_to = ['some-other-id@example.com', 'message-123@example.com']
|
||||
end
|
||||
|
||||
it 'finds conversation from any in_reply_to value' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to is blank' do
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to does not match any pattern or source_id' do
|
||||
before do
|
||||
mail.in_reply_to = 'random-message-id@gmail.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when UUID exists but conversation does not' do
|
||||
before do
|
||||
mail.in_reply_to = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with malformed in_reply_to pattern' do
|
||||
before do
|
||||
mail.in_reply_to = 'conversation/not-a-uuid/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::NewConversationStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
before do
|
||||
mail.to = [email_channel.email]
|
||||
mail.from = 'sender@example.com'
|
||||
mail.subject = 'Test Subject'
|
||||
mail.message_id = '<test@example.com>'
|
||||
end
|
||||
|
||||
describe '#find' do
|
||||
context 'when channel is found' do
|
||||
context 'with new contact' do
|
||||
it 'builds a new conversation with new contact' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
conversation = strategy.find
|
||||
expect(conversation).to be_a(Conversation)
|
||||
expect(conversation.new_record?).to be(true) # Not persisted yet
|
||||
expect(conversation.inbox).to eq(email_channel.inbox)
|
||||
expect(conversation.account).to eq(account)
|
||||
end.to not_change(Conversation, :count) # No conversation created yet
|
||||
.and change(Contact, :count).by(1) # Contact is created
|
||||
.and change(ContactInbox, :count).by(1)
|
||||
end
|
||||
|
||||
it 'sets conversation attributes correctly' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.additional_attributes['source']).to eq('email')
|
||||
expect(conversation.additional_attributes['mail_subject']).to eq('Test Subject')
|
||||
expect(conversation.additional_attributes['initiated_at']).to have_key('timestamp')
|
||||
end
|
||||
|
||||
it 'sets contact attributes correctly' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.contact.email).to eq('sender@example.com')
|
||||
expect(conversation.contact.name).to eq('sender')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with existing contact' do
|
||||
let!(:existing_contact) { create(:contact, email: 'sender@example.com', account: account) }
|
||||
|
||||
before do
|
||||
create(:contact_inbox, contact: existing_contact, inbox: email_channel.inbox)
|
||||
end
|
||||
|
||||
it 'builds conversation with existing contact' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
conversation = strategy.find
|
||||
expect(conversation).to be_a(Conversation)
|
||||
expect(conversation.new_record?).to be(true) # Not persisted yet
|
||||
expect(conversation.contact).to eq(existing_contact)
|
||||
end.to not_change(Conversation, :count) # No conversation created yet
|
||||
.and not_change(Contact, :count)
|
||||
.and not_change(ContactInbox, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has In-Reply-To header' do
|
||||
before do
|
||||
mail['In-Reply-To'] = '<previous-message@example.com>'
|
||||
end
|
||||
|
||||
it 'stores in_reply_to in additional_attributes' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.additional_attributes['in_reply_to']).to eq('<previous-message@example.com>')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail is auto reply' do
|
||||
before do
|
||||
mail['X-Autoreply'] = 'yes'
|
||||
end
|
||||
|
||||
it 'marks conversation as auto_reply' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.additional_attributes['auto_reply']).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when sender has name in From header' do
|
||||
before do
|
||||
mail.from = 'John Doe <john@example.com>'
|
||||
end
|
||||
|
||||
it 'uses sender name from mail' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.contact.name).to eq('John Doe')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is not found' do
|
||||
before do
|
||||
mail.to = ['nonexistent@example.com']
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
result = strategy.find
|
||||
expect(result).to be_nil
|
||||
end.not_to change(Conversation, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact creation fails' do
|
||||
before do
|
||||
builder = instance_double(ContactInboxWithContactBuilder)
|
||||
allow(ContactInboxWithContactBuilder).to receive(:new).and_return(builder)
|
||||
allow(builder).to receive(:perform).and_raise(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
|
||||
it 'rolls back the transaction' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
strategy.find
|
||||
end.to raise_error(ActiveRecord::RecordInvalid)
|
||||
.and not_change(Conversation, :count)
|
||||
.and not_change(Contact, :count)
|
||||
.and not_change(ContactInbox, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation creation fails' do
|
||||
before do
|
||||
# Make conversation build fail with invalid attributes
|
||||
allow(Conversation).to receive(:new).and_return(Conversation.new)
|
||||
end
|
||||
|
||||
it 'returns invalid conversation object' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
conversation = strategy.find
|
||||
expect(conversation).to be_a(Conversation)
|
||||
expect(conversation.new_record?).to be(true)
|
||||
expect(conversation.valid?).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when mail has valid reply+uuid format' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has uppercase UUID' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@EXAMPLE.COM'
|
||||
end
|
||||
|
||||
it 'returns the conversation (case-insensitive matching)' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has multiple recipients with valid UUID' do
|
||||
before do
|
||||
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
mail.to = ['other@example.com', 'reply+aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com']
|
||||
end
|
||||
|
||||
it 'extracts UUID from any recipient' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when UUID does not exist in database' do
|
||||
before do
|
||||
mail.to = 'reply+99999999-9999-9999-9999-999999999999@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has no recipients' do
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail recipient has malformed UUID' do
|
||||
before do
|
||||
mail.to = 'reply+not-a-valid-uuid@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail recipient has no reply+ prefix' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test+12345678-1234-1234-1234-123456789012@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail recipient has additional text after UUID' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012-extra@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil (UUID must be exact)' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,208 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::ReferencesStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when references has message-specific pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references has conversation fallback pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references matches message source_id' do
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
source_id: 'original-message-id@example.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Original message'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
message # Create the message
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'original-message-id@example.com'
|
||||
end
|
||||
|
||||
it 'finds conversation from message source_id' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references has multiple values' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = [
|
||||
'some-random-message@gmail.com',
|
||||
'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com',
|
||||
'another-message@outlook.com'
|
||||
]
|
||||
end
|
||||
|
||||
it 'finds conversation from any reference' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references is blank' do
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references does not match any pattern or source_id' do
|
||||
before do
|
||||
mail.references = 'random-message-id@gmail.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with channel validation' do
|
||||
context 'when conversation belongs to the correct channel' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation belongs to a different channel' do
|
||||
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
|
||||
let(:other_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
inbox: other_email_channel.inbox,
|
||||
account: account
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
# Mail is addressed to test@example.com but references conversation from other@example.com
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil (prevents cross-channel hijacking)' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel cannot be determined from mail' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'unknown@example.com' # Email not associated with any channel
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has multiple recipients including correct channel' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = ['other@example.com', 'test@example.com']
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'finds the correct channel and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when UUID exists but conversation does not' do
|
||||
before do
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with malformed references pattern' do
|
||||
before do
|
||||
mail.references = 'conversation/not-a-uuid/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when first reference fails channel validation but second succeeds' do
|
||||
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
|
||||
let(:other_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
inbox: other_email_channel.inbox,
|
||||
account: account
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = [
|
||||
'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com', # Wrong channel
|
||||
'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com' # Correct channel
|
||||
]
|
||||
end
|
||||
|
||||
it 'skips invalid reference and returns conversation from valid reference' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,7 @@ describe Widget::TokenService do
|
||||
|
||||
describe 'inheritance' do
|
||||
it 'inherits from BaseTokenService' do
|
||||
expect(described_class.superclass).to eq(BaseTokenService)
|
||||
expect(described_class.superclass.name).to eq('BaseTokenService')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user