Merge remote-tracking branch 'origin/develop' into feat/whatsapp-embedded-signup
This commit is contained in:
@@ -14,6 +14,12 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
|
||||
it 'deletes the linear integration' do
|
||||
# Stub the HTTP call to Linear's revoke endpoint
|
||||
allow(HTTParty).to receive(:post).with(
|
||||
'https://api.linear.app/oauth/revoke',
|
||||
anything
|
||||
).and_return(instance_double(HTTParty::Response, success?: true))
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
@@ -113,7 +119,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
let(:created_issue) { { data: { identifier: 'ENG-123', title: 'Sample Issue' } } }
|
||||
|
||||
it 'returns the created issue' do
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(created_issue)
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys, agent).and_return(created_issue)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
|
||||
params: issue_params,
|
||||
@@ -125,7 +131,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
end
|
||||
|
||||
it 'creates activity message when conversation is provided' do
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(created_issue)
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys, agent).and_return(created_issue)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
|
||||
@@ -144,7 +150,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
|
||||
context 'when issue creation fails' do
|
||||
it 'returns error message and does not create activity message' do
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(error: 'error message')
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys, agent).and_return(error: 'error message')
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
|
||||
@@ -171,7 +177,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
let(:linked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } }
|
||||
|
||||
it 'returns the linked issue and creates activity message' do
|
||||
allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(linked_issue)
|
||||
allow(processor_service).to receive(:link_issue).with(link, issue_id, title, agent).and_return(linked_issue)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
|
||||
@@ -193,7 +199,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
|
||||
context 'when issue linking fails' do
|
||||
it 'returns error message and does not create activity message' do
|
||||
allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(error: 'error message')
|
||||
allow(processor_service).to receive(:link_issue).with(link, issue_id, title, agent).and_return(error: 'error message')
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Notion Authorization API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/notion/authorization' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/notion/authorization"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
post "/api/v1/accounts/#{account.id}/notion/authorization",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { email: administrator.email },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'creates a new authorization and returns the redirect url' do
|
||||
post "/api/v1/accounts/#{account.id}/notion/authorization",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: { email: administrator.email },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
# Validate URL components
|
||||
url = response.parsed_body['url']
|
||||
uri = URI.parse(url)
|
||||
params = CGI.parse(uri.query)
|
||||
|
||||
expect(url).to start_with('https://api.notion.com/v1/oauth/authorize')
|
||||
expect(params['response_type']).to eq(['code'])
|
||||
expect(params['owner']).to eq(['user'])
|
||||
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"])
|
||||
|
||||
# Validate state parameter exists and can be decoded back to the account
|
||||
expect(params['state']).to be_present
|
||||
decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default')
|
||||
expect(decoded_account).to eq(account)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe NotionConcern, type: :concern do
|
||||
let(:controller_class) do
|
||||
Class.new do
|
||||
include NotionConcern
|
||||
end
|
||||
end
|
||||
|
||||
let(:controller) { controller_class.new }
|
||||
|
||||
describe '#notion_client' do
|
||||
let(:client_id) { 'test_notion_client_id' }
|
||||
let(:client_secret) { 'test_notion_client_secret' }
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil).and_return(client_id)
|
||||
allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil).and_return(client_secret)
|
||||
end
|
||||
|
||||
it 'creates OAuth2 client with correct configuration' do
|
||||
expect(OAuth2::Client).to receive(:new).with(
|
||||
client_id,
|
||||
client_secret,
|
||||
{
|
||||
site: 'https://api.notion.com',
|
||||
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
|
||||
token_url: 'https://api.notion.com/v1/oauth/token',
|
||||
auth_scheme: :basic_auth
|
||||
}
|
||||
)
|
||||
|
||||
controller.notion_client
|
||||
end
|
||||
|
||||
it 'loads client credentials from GlobalConfigService' do
|
||||
expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil)
|
||||
expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil)
|
||||
|
||||
controller.notion_client
|
||||
end
|
||||
|
||||
it 'returns OAuth2::Client instance' do
|
||||
client = controller.notion_client
|
||||
expect(client).to be_an_instance_of(OAuth2::Client)
|
||||
end
|
||||
|
||||
it 'configures client with Notion-specific endpoints' do
|
||||
client = controller.notion_client
|
||||
expect(client.site).to eq('https://api.notion.com')
|
||||
expect(client.options[:authorize_url]).to eq('https://api.notion.com/v1/oauth/authorize')
|
||||
expect(client.options[:token_url]).to eq('https://api.notion.com/v1/oauth/token')
|
||||
expect(client.options[:auth_scheme]).to eq(:basic_auth)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Notion::CallbacksController, type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:state) { account.to_sgid.to_s }
|
||||
let(:oauth_code) { 'test_oauth_code' }
|
||||
let(:notion_redirect_uri) { "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion" }
|
||||
|
||||
let(:notion_response_body) do
|
||||
{
|
||||
'access_token' => 'notion_access_token_123',
|
||||
'token_type' => 'bearer',
|
||||
'workspace_name' => 'Test Workspace',
|
||||
'workspace_id' => 'workspace_123',
|
||||
'workspace_icon' => 'https://notion.so/icon.png',
|
||||
'bot_id' => 'bot_123',
|
||||
'owner' => {
|
||||
'type' => 'user',
|
||||
'user' => {
|
||||
'id' => 'user_123',
|
||||
'name' => 'Test User'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
describe 'GET /notion/callback' do
|
||||
before do
|
||||
account.enable_features('notion_integration')
|
||||
stub_const('ENV', ENV.to_hash.merge(
|
||||
'FRONTEND_URL' => 'http://localhost:3000',
|
||||
'NOTION_CLIENT_ID' => 'test_client_id',
|
||||
'NOTION_CLIENT_SECRET' => 'test_client_secret'
|
||||
))
|
||||
|
||||
controller = described_class.new
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
allow(controller).to receive(:notion_redirect_uri).and_return(notion_redirect_uri)
|
||||
allow(described_class).to receive(:new).and_return(controller)
|
||||
end
|
||||
|
||||
context 'when OAuth callback is successful' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.notion.com/v1/oauth/token')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: notion_response_body.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a new integration hook' do
|
||||
expect do
|
||||
get '/notion/callback', params: { code: oauth_code, state: state }
|
||||
end.to change(Integrations::Hook, :count).by(1)
|
||||
|
||||
hook = Integrations::Hook.last
|
||||
expect(hook.access_token).to eq('notion_access_token_123')
|
||||
expect(hook.app_id).to eq('notion')
|
||||
expect(hook.status).to eq('enabled')
|
||||
end
|
||||
|
||||
it 'sets correct hook attributes' do
|
||||
get '/notion/callback', params: { code: oauth_code, state: state }
|
||||
|
||||
hook = Integrations::Hook.last
|
||||
expect(hook.account).to eq(account)
|
||||
expect(hook.app_id).to eq('notion')
|
||||
expect(hook.access_token).to eq('notion_access_token_123')
|
||||
expect(hook.status).to eq('enabled')
|
||||
end
|
||||
|
||||
it 'stores notion workspace data in settings' do
|
||||
get '/notion/callback', params: { code: oauth_code, state: state }
|
||||
|
||||
hook = Integrations::Hook.last
|
||||
expect(hook.settings['token_type']).to eq('bearer')
|
||||
expect(hook.settings['workspace_name']).to eq('Test Workspace')
|
||||
expect(hook.settings['workspace_id']).to eq('workspace_123')
|
||||
expect(hook.settings['workspace_icon']).to eq('https://notion.so/icon.png')
|
||||
expect(hook.settings['bot_id']).to eq('bot_123')
|
||||
expect(hook.settings['owner']).to eq(notion_response_body['owner'])
|
||||
end
|
||||
|
||||
it 'handles successful callback and creates hook' do
|
||||
get '/notion/callback', params: { code: oauth_code, state: state }
|
||||
|
||||
# Due to controller mocking limitations in test,
|
||||
# the redirect URL construction fails but hook creation succeeds
|
||||
expect(Integrations::Hook.last.app_id).to eq('notion')
|
||||
expect(response).to be_redirect
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OAuth token request fails' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.notion.com/v1/oauth/token')
|
||||
.to_return(
|
||||
status: 400,
|
||||
body: { error: 'invalid_grant' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'redirects to home page on error' do
|
||||
get '/notion/callback', params: { code: oauth_code, state: state }
|
||||
|
||||
expect(response).to redirect_to('/')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -211,8 +211,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(chat_service).to have_received(:generate_response).with(
|
||||
additional_message: valid_params[:message_content],
|
||||
message_history: valid_params[:message_history]
|
||||
valid_params[:message_content],
|
||||
valid_params[:message_history]
|
||||
)
|
||||
expect(json_response[:content]).to eq('Assistant response')
|
||||
end
|
||||
@@ -232,8 +232,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(chat_service).to have_received(:generate_response).with(
|
||||
additional_message: params_without_history[:message_content],
|
||||
message_history: []
|
||||
params_without_history[:message_content],
|
||||
[]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
+29
-1
@@ -86,7 +86,28 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
end
|
||||
|
||||
context 'with valid params' do
|
||||
it 'returns error when usage limit is exceeded' do
|
||||
account.limits = { captain_responses: 2 }
|
||||
account.custom_attributes = { captain_responses_usage: 2 }
|
||||
account.save!
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
expect(CopilotMessage.last.message['content']).to eq(
|
||||
'You are out of Copilot credits. You can buy more credits from the billing section.'
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a new copilot thread with initial message' do
|
||||
account.limits = { captain_responses: 2 }
|
||||
account.custom_attributes = { captain_responses_usage: 0 }
|
||||
account.save!
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: valid_params,
|
||||
@@ -103,8 +124,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
expect(thread.assistant_id).to eq(assistant.id)
|
||||
|
||||
message = thread.copilot_messages.last
|
||||
expect(message.message_type).to eq('user')
|
||||
expect(message.message).to eq({ 'content' => valid_params[:message] })
|
||||
|
||||
expect(Captain::Copilot::ResponseJob).to have_been_enqueued.with(
|
||||
assistant: assistant,
|
||||
conversation_id: valid_params[:conversation_id],
|
||||
user_id: agent.id,
|
||||
copilot_thread_id: thread.id,
|
||||
message: valid_params[:message]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -199,7 +199,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
expected_response = {
|
||||
'id' => account.id,
|
||||
'limits' => {
|
||||
'agents' => {},
|
||||
'agents' => {
|
||||
'allowed' => account.usage_limits[:agents],
|
||||
'consumed' => account.users.count
|
||||
},
|
||||
'conversation' => {},
|
||||
'captain' => {
|
||||
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit },
|
||||
|
||||
@@ -30,30 +30,5 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
account.reload
|
||||
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
end
|
||||
|
||||
context 'when message contains an image' do
|
||||
let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') }
|
||||
let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') }
|
||||
|
||||
before do
|
||||
image_attachment
|
||||
end
|
||||
|
||||
it 'includes image URL directly in the message content for OpenAI vision analysis' do
|
||||
# Expect the generate_response to receive multimodal content with image URL
|
||||
expect(mock_llm_chat_service).to receive(:generate_response) do |**kwargs|
|
||||
history = kwargs[:message_history]
|
||||
last_entry = history.last
|
||||
expect(last_entry[:content]).to be_an(Array)
|
||||
expect(last_entry[:content].any? { |part| part[:type] == 'text' && part[:text] == 'Can you help with this error?' }).to be true
|
||||
expect(last_entry[:content].any? do |part|
|
||||
part[:type] == 'image_url' && part[:image_url][:url] == 'https://example.com/error.jpg'
|
||||
end).to be true
|
||||
{ 'response' => 'I can see the error in your image. It appears to be a database connection issue.' }
|
||||
end
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -74,4 +74,35 @@ RSpec.describe Conversation, type: :model do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'assignment capacity limits' do
|
||||
describe 'team assignment with inbox auto-assignment disabled' do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: false, auto_assignment_config: { max_assignment_limit: 1 }) }
|
||||
let(:team) { create(:team, account: account, allow_auto_assign: true) }
|
||||
let!(:agent1) { create(:user, account: account, role: :agent, auto_offline: false) }
|
||||
let!(:agent2) { create(:user, account: account, role: :agent, auto_offline: false) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent1)
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
create(:team_member, team: team, user: agent1)
|
||||
create(:team_member, team: team, user: agent2)
|
||||
# Both agents are over the limit (simulate by assigning open conversations)
|
||||
create_list(:conversation, 2, inbox: inbox, assignee: agent1, status: :open)
|
||||
create_list(:conversation, 2, inbox: inbox, assignee: agent2, status: :open)
|
||||
end
|
||||
|
||||
it 'does not enforce max_assignment_limit for team assignment when inbox auto-assignment is disabled' do
|
||||
conversation = create(:conversation, inbox: inbox, account: account, assignee: nil, status: :open)
|
||||
|
||||
# Assign to team to trigger the assignment logic
|
||||
conversation.update!(team: team)
|
||||
|
||||
# Should assign to a team member even if they are over the limit
|
||||
expect(conversation.reload.assignee).to be_present
|
||||
expect([agent1, agent2]).to include(conversation.reload.assignee)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::OpenAiMessageBuilderService do
|
||||
subject(:service) { described_class.new(message: message) }
|
||||
|
||||
let(:message) { create(:message, content: 'Hello world') }
|
||||
|
||||
describe '#generate_content' do
|
||||
context 'when message has only text content' do
|
||||
it 'returns the text content directly' do
|
||||
expect(service.generate_content).to eq('Hello world')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has no content and no attachments' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
it 'returns default message' do
|
||||
expect(service.generate_content).to eq('Message without content')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has text content and attachments' do
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'returns an array of content parts' do
|
||||
result = service.generate_content
|
||||
expect(result).to be_an(Array)
|
||||
expect(result).to include({ type: 'text', text: 'Hello world' })
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has only non-text attachments' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'returns an array of content parts without text' do
|
||||
result = service.generate_content
|
||||
expect(result).to be_an(Array)
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
expect(result).not_to include(hash_including(type: 'text', text: 'Hello world'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#attachment_parts' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
let(:attachments) { message.attachments }
|
||||
|
||||
context 'with image attachments' do
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'includes image parts' do
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with audio attachments' do
|
||||
let(:audio_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio transcription text' })
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes transcription text part' do
|
||||
audio_attachment # trigger creation
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'text', text: 'Audio transcription text' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with other file types' do
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'includes generic attachment message' do
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mixed attachment types' do
|
||||
let(:image_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:audio_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:document_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio text' })
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes all relevant parts' do
|
||||
image_attachment # trigger creation
|
||||
audio_attachment # trigger creation
|
||||
document_attachment # trigger creation
|
||||
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
expect(result).to include({ type: 'text', text: 'Audio text' })
|
||||
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#image_parts' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
context 'with valid image attachments' do
|
||||
let(:image1) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image1.jpg')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:image2) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image2.jpg')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
it 'returns image parts for all valid images' do
|
||||
image1 # trigger creation
|
||||
image2 # trigger creation
|
||||
|
||||
image_attachments = message.attachments.where(file_type: :image)
|
||||
result = service.send(:image_parts, image_attachments)
|
||||
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image1.jpg' } })
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image2.jpg' } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with image attachments without URLs' do
|
||||
let(:image_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: nil)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(image_attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
|
||||
end
|
||||
|
||||
it 'skips images without valid URLs' do
|
||||
image_attachment # trigger creation
|
||||
|
||||
image_attachments = message.attachments.where(file_type: :image)
|
||||
result = service.send(:image_parts, image_attachments)
|
||||
|
||||
expect(result).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_attachment_url' do
|
||||
let(:attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
context 'when attachment has external_url' do
|
||||
before { attachment.update(external_url: 'https://example.com/image.jpg') }
|
||||
|
||||
it 'returns external_url' do
|
||||
expect(service.send(:get_attachment_url, attachment)).to eq('https://example.com/image.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when attachment has attached file' do
|
||||
before do
|
||||
attachment.update(external_url: nil)
|
||||
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: true))
|
||||
allow(attachment).to receive(:file_url).and_return('https://local.com/file.jpg')
|
||||
end
|
||||
|
||||
it 'returns file_url' do
|
||||
expect(service.send(:get_attachment_url, attachment)).to eq('https://local.com/file.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when attachment has no URL or file' do
|
||||
before do
|
||||
attachment.update(external_url: nil)
|
||||
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(service.send(:get_attachment_url, attachment)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_audio_transcriptions' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
context 'with no audio attachments' do
|
||||
it 'returns empty string' do
|
||||
result = service.send(:extract_audio_transcriptions, message.attachments)
|
||||
expect(result).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with successful audio transcriptions' do
|
||||
let(:audio1) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:audio2) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio1).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'First audio text. ' })
|
||||
)
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio2).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Second audio text.' })
|
||||
)
|
||||
end
|
||||
|
||||
it 'concatenates all successful transcriptions' do
|
||||
audio1 # trigger creation
|
||||
audio2 # trigger creation
|
||||
|
||||
attachments = message.attachments
|
||||
result = service.send(:extract_audio_transcriptions, attachments)
|
||||
expect(result).to eq('First audio text. Second audio text.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with failed audio transcriptions' do
|
||||
let(:audio_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: false, transcriptions: nil })
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns empty string for failed transcriptions' do
|
||||
audio_attachment # trigger creation
|
||||
|
||||
attachments = message.attachments
|
||||
result = service.send(:extract_audio_transcriptions, attachments)
|
||||
expect(result).to eq('')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private helper methods' do
|
||||
describe '#text_part' do
|
||||
it 'returns correct text part format' do
|
||||
result = service.send(:text_part, 'Hello world')
|
||||
expect(result).to eq({ type: 'text', text: 'Hello world' })
|
||||
end
|
||||
end
|
||||
|
||||
describe '#image_part' do
|
||||
it 'returns correct image part format' do
|
||||
result = service.send(:image_part, 'https://example.com/image.jpg')
|
||||
expect(result).to eq({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,9 +3,37 @@ require 'rails_helper'
|
||||
describe MessageFormatHelper do
|
||||
describe '#transform_user_mention_content' do
|
||||
context 'when transform_user_mention_content called' do
|
||||
it 'return transormed text correctly' do
|
||||
it 'return transformed text correctly' do
|
||||
expect(helper.transform_user_mention_content('[@john](mention://user/1/John%20K), check this ticket')).to eq '@john, check this ticket'
|
||||
end
|
||||
|
||||
it 'handles emoji in display names correctly' do
|
||||
content = '[@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support), please help'
|
||||
expected = '@👍 customer support, please help'
|
||||
expect(helper.transform_user_mention_content(content)).to eq expected
|
||||
end
|
||||
|
||||
it 'handles multiple mentions with emojis and spaces' do
|
||||
content = 'Hey [@John Doe](mention://user/1/John%20Doe) and [@🚀 Dev Team](mention://team/2/%F0%9F%9A%80%20Dev%20Team)'
|
||||
expected = 'Hey @John Doe and @🚀 Dev Team'
|
||||
expect(helper.transform_user_mention_content(content)).to eq expected
|
||||
end
|
||||
|
||||
it 'handles emoji-only team names' do
|
||||
expect(helper.transform_user_mention_content('[@🔥](mention://team/3/%F0%9F%94%A5) urgent')).to eq '@🔥 urgent'
|
||||
end
|
||||
|
||||
it 'handles special characters in names' do
|
||||
expect(helper.transform_user_mention_content('[@user@domain.com](mention://user/4/user%40domain.com) check')).to eq '@user@domain.com check'
|
||||
end
|
||||
|
||||
it 'returns empty string for nil content' do
|
||||
expect(helper.transform_user_mention_content(nil)).to eq ''
|
||||
end
|
||||
|
||||
it 'returns empty string for empty content' do
|
||||
expect(helper.transform_user_mention_content('')).to eq ''
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -250,12 +250,45 @@ describe PortalHelper do
|
||||
describe '#thumbnail_bg_color' do
|
||||
it 'returns the correct color based on username length' do
|
||||
expect(helper.thumbnail_bg_color('')).to be_in(['#6D95BA', '#A4C3C3', '#E19191'])
|
||||
expect(helper.thumbnail_bg_color('Joe')).to eq('#6D95BA') # Length 3, so index is 0
|
||||
expect(helper.thumbnail_bg_color('John')).to eq('#A4C3C3') # Length 4, so index is 1
|
||||
expect(helper.thumbnail_bg_color('Jane james')).to eq('#A4C3C3') # Length 10, so index is 1
|
||||
expect(helper.thumbnail_bg_color('Jane_123')).to eq('#E19191') # Length 8, so index is 2
|
||||
expect(helper.thumbnail_bg_color('AlexanderTheGreat')).to eq('#E19191') # Length 17, so index is 2
|
||||
expect(helper.thumbnail_bg_color('Reginald John Sans')).to eq('#6D95BA') # Length 18, so index is 0
|
||||
expect(helper.thumbnail_bg_color('Joe')).to eq('#6D95BA')
|
||||
expect(helper.thumbnail_bg_color('John')).to eq('#A4C3C3')
|
||||
expect(helper.thumbnail_bg_color('Jane james')).to eq('#A4C3C3')
|
||||
expect(helper.thumbnail_bg_color('Jane_123')).to eq('#E19191')
|
||||
expect(helper.thumbnail_bg_color('AlexanderTheGreat')).to eq('#E19191')
|
||||
expect(helper.thumbnail_bg_color('Reginald John Sans')).to eq('#6D95BA')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#set_og_image_url' do
|
||||
let(:portal_name) { 'Chatwoot Portal' }
|
||||
let(:title) { 'Welcome to Chatwoot' }
|
||||
|
||||
context 'when CDN URL is present' do
|
||||
before do
|
||||
InstallationConfig.create!(name: 'OG_IMAGE_CDN_URL', value: 'https://cdn.example.com')
|
||||
InstallationConfig.create!(name: 'OG_IMAGE_CLIENT_REF', value: 'client-123')
|
||||
end
|
||||
|
||||
it 'returns the composed OG image URL with correct params' do
|
||||
result = helper.set_og_image_url(portal_name, title)
|
||||
uri = URI.parse(result)
|
||||
expect(uri.path).to eq('/og')
|
||||
params = Rack::Utils.parse_query(uri.query)
|
||||
expect(params['clientRef']).to eq('client-123')
|
||||
expect(params['title']).to eq(title)
|
||||
expect(params['portalName']).to eq(portal_name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when CDN URL is blank' do
|
||||
before do
|
||||
InstallationConfig.create!(name: 'OG_IMAGE_CDN_URL', value: '')
|
||||
InstallationConfig.create!(name: 'OG_IMAGE_CLIENT_REF', value: 'client-123')
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(helper.set_og_image_url(portal_name, title)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,4 +79,25 @@ RSpec.describe Webhooks::TwilioEventsJob do
|
||||
described_class.perform_now(params_with_media)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when location message is present' do
|
||||
let(:params_with_location) do
|
||||
{
|
||||
From: 'whatsapp:+1234567890',
|
||||
To: 'whatsapp:+0987654321',
|
||||
MessageType: 'location',
|
||||
Latitude: '12.160894393921',
|
||||
Longitude: '75.265205383301',
|
||||
AccountSid: 'AC123',
|
||||
SmsSid: 'SM123'
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes the location message' do
|
||||
service = double
|
||||
expect(Twilio::IncomingMessageService).to receive(:new).with(params: params_with_location).and_return(service)
|
||||
expect(service).to receive(:perform)
|
||||
described_class.perform_now(params_with_location)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -80,6 +80,7 @@ describe Integrations::Linear::ProcessorService do
|
||||
label_ids: %w[bug]
|
||||
}
|
||||
end
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
let(:issue_response) do
|
||||
{
|
||||
'issueCreate' => {
|
||||
@@ -94,7 +95,7 @@ describe Integrations::Linear::ProcessorService do
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed issue data with identifier' do
|
||||
allow(linear_client).to receive(:create_issue).with(params).and_return(issue_response)
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(issue_response)
|
||||
result = service.create_issue(params)
|
||||
expect(result).to eq({
|
||||
data: {
|
||||
@@ -104,13 +105,27 @@ describe Integrations::Linear::ProcessorService do
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'passes user to Linear client' do
|
||||
allow(linear_client).to receive(:create_issue).with(params, user).and_return(issue_response)
|
||||
result = service.create_issue(params, user)
|
||||
expect(result).to eq({
|
||||
data: {
|
||||
id: 'issue1',
|
||||
title: 'Issue title',
|
||||
identifier: 'ENG-123'
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:create_issue).with(params).and_return(error_response)
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(error_response)
|
||||
result = service.create_issue(params)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
@@ -121,22 +136,31 @@ describe Integrations::Linear::ProcessorService do
|
||||
let(:link) { 'https://example.com' }
|
||||
let(:issue_id) { 'issue1' }
|
||||
let(:title) { 'Title' }
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
let(:link_issue_response) { { id: issue_id, link: link, 'attachmentLinkURL': { 'attachment': { 'id': 'attachment1' } } } }
|
||||
let(:link_response) { { data: { id: issue_id, link: link, link_id: 'attachment1' } } }
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed link data' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(link_issue_response)
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(link_issue_response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
expect(result).to eq(link_response)
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'passes user to Linear client' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, user).and_return(link_issue_response)
|
||||
result = service.link_issue(link, issue_id, title, user)
|
||||
expect(result).to eq(link_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(error_response)
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(error_response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
@@ -237,7 +261,7 @@ describe Integrations::Linear::ProcessorService do
|
||||
}
|
||||
}
|
||||
|
||||
allow(linear_client).to receive(:create_issue).with(params).and_return(response)
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(response)
|
||||
result = service.create_issue(params)
|
||||
|
||||
expect(result[:data]).to have_key(:identifier)
|
||||
@@ -256,7 +280,7 @@ describe Integrations::Linear::ProcessorService do
|
||||
}
|
||||
}
|
||||
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(response)
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
|
||||
expect(result[:data][:id]).to eq(issue_id)
|
||||
|
||||
@@ -91,6 +91,7 @@ describe Linear do
|
||||
label_ids: ['bug']
|
||||
}
|
||||
end
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
@@ -103,6 +104,34 @@ describe Linear do
|
||||
expect(response).to eq({ 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } })
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'includes user attribution in the request' do
|
||||
allow(linear_client).to receive(:post) do |payload|
|
||||
expect(payload[:query]).to include('createAsUser: "John Doe"')
|
||||
expect(payload[:query]).to include('displayIconUrl: "https://example.com/avatar.jpg"')
|
||||
instance_double(HTTParty::Response, success?: true,
|
||||
parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } })
|
||||
end
|
||||
|
||||
linear_client.create_issue(params, user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has no avatar' do
|
||||
let(:user_no_avatar) { instance_double(User, name: 'Jane Doe', avatar_url: '') }
|
||||
|
||||
it 'includes only user name in the request' do
|
||||
allow(linear_client).to receive(:post) do |payload|
|
||||
expect(payload[:query]).to include('createAsUser: "Jane Doe"')
|
||||
expect(payload[:query]).not_to include('displayIconUrl')
|
||||
instance_double(HTTParty::Response, success?: true,
|
||||
parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } })
|
||||
end
|
||||
|
||||
linear_client.create_issue(params, user_no_avatar)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the priority is invalid' do
|
||||
let(:params) { { title: 'Title', team_id: 'team1', priority: 5 } }
|
||||
|
||||
@@ -182,6 +211,7 @@ describe Linear do
|
||||
let(:link) { 'https://example.com' }
|
||||
let(:issue_id) { 'issue1' }
|
||||
let(:title) { 'Title' }
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
@@ -194,6 +224,45 @@ describe Linear do
|
||||
expect(response).to eq({ 'attachmentLinkURL' => { 'id' => 'attachment1' } })
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'includes user attribution in the request' do
|
||||
expected_params = {
|
||||
issue_id: issue_id,
|
||||
link: link,
|
||||
title: title,
|
||||
user_name: 'John Doe',
|
||||
user_avatar_url: 'https://example.com/avatar.jpg'
|
||||
}
|
||||
|
||||
expect(Linear::Mutations).to receive(:issue_link).with(expected_params).and_call_original
|
||||
allow(linear_client).to receive(:post).and_return(
|
||||
instance_double(HTTParty::Response, success?: true, parsed_response: { 'data' => { 'attachmentLinkURL' => { 'id' => 'attachment1' } } })
|
||||
)
|
||||
|
||||
linear_client.link_issue(link, issue_id, title, user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has no avatar' do
|
||||
let(:user_no_avatar) { instance_double(User, name: 'Jane Doe', avatar_url: '') }
|
||||
|
||||
it 'includes only user name in the request' do
|
||||
expected_params = {
|
||||
issue_id: issue_id,
|
||||
link: link,
|
||||
title: title,
|
||||
user_name: 'Jane Doe'
|
||||
}
|
||||
|
||||
expect(Linear::Mutations).to receive(:issue_link).with(expected_params).and_call_original
|
||||
allow(linear_client).to receive(:post).and_return(
|
||||
instance_double(HTTParty::Response, success?: true, parsed_response: { 'data' => { 'attachmentLinkURL' => { 'id' => 'attachment1' } } })
|
||||
)
|
||||
|
||||
linear_client.link_issue(link, issue_id, title, user_no_avatar)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the link is missing' do
|
||||
let(:link) { '' }
|
||||
|
||||
|
||||
@@ -171,6 +171,16 @@ describe AutomationRuleListener do
|
||||
listener.message_created(event)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
|
||||
it 'passes conversation attributes to conditions filter service' do
|
||||
conversation.update!(status: :open, priority: :high)
|
||||
listener.message_created(event)
|
||||
expect(AutomationRules::ConditionsFilterService).to have_received(:new).with(
|
||||
automation_rule,
|
||||
conversation,
|
||||
{ message: message, changed_attributes: { content: %w[nil Hi] } }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -66,6 +66,34 @@ describe ReportingEventListener do
|
||||
end
|
||||
|
||||
describe '#reply_created' do
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
|
||||
def create_customer_message(conversation, created_at: Time.current)
|
||||
create(:message,
|
||||
message_type: 'incoming',
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
sender: contact,
|
||||
created_at: created_at)
|
||||
end
|
||||
|
||||
def create_agent_message(conversation, created_at: Time.current, sender: user)
|
||||
create(:message,
|
||||
message_type: 'outgoing',
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
sender: sender,
|
||||
created_at: created_at)
|
||||
end
|
||||
|
||||
def create_reply_event(agent_message, waiting_since, event_time = nil)
|
||||
Events::Base.new('reply.created', event_time || agent_message.created_at,
|
||||
waiting_since: waiting_since,
|
||||
message: agent_message)
|
||||
end
|
||||
|
||||
it 'creates reply created event' do
|
||||
event = Events::Base.new('reply.created', Time.zone.now, waiting_since: 2.hours.ago, message: message)
|
||||
listener.reply_created(event)
|
||||
@@ -74,6 +102,88 @@ describe ReportingEventListener do
|
||||
expect(events.length).to be 1
|
||||
expect(events.first.value).to be_within(1).of(7200)
|
||||
end
|
||||
|
||||
context 'when conversation is reopened' do
|
||||
let(:resolved_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user,
|
||||
status: 'resolved', contact: contact)
|
||||
end
|
||||
|
||||
context 'when customer sends message after resolution' do
|
||||
it 'calculates reply time from the reopening message' do
|
||||
customer_message_time = 3.hours.ago
|
||||
create_customer_message(resolved_conversation, created_at: customer_message_time)
|
||||
|
||||
resolved_conversation.reload
|
||||
expect(resolved_conversation.status).to eq('open')
|
||||
|
||||
agent_reply_time = 1.hour.ago
|
||||
agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time)
|
||||
|
||||
event = create_reply_event(agent_message, customer_message_time)
|
||||
listener.reply_created(event)
|
||||
|
||||
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
|
||||
expect(events.length).to be 1
|
||||
expect(events.first.value).to be_within(60).of(7200)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has multiple reopenings' do
|
||||
it 'tracks reply time correctly for each reopening' do
|
||||
create_customer_message(resolved_conversation, created_at: 5.hours.ago)
|
||||
first_agent_reply = create_agent_message(resolved_conversation, created_at: 4.hours.ago)
|
||||
|
||||
event = create_reply_event(first_agent_reply, 5.hours.ago)
|
||||
listener.reply_created(event)
|
||||
|
||||
resolved_conversation.update!(status: 'resolved')
|
||||
|
||||
create_customer_message(resolved_conversation, created_at: 2.hours.ago)
|
||||
second_agent_reply = create_agent_message(resolved_conversation, created_at: 1.5.hours.ago)
|
||||
|
||||
event = create_reply_event(second_agent_reply, 2.hours.ago)
|
||||
listener.reply_created(event)
|
||||
|
||||
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
|
||||
.order(created_at: :asc)
|
||||
expect(events.length).to be 2
|
||||
expect(events.first.value).to be_within(60).of(3600)
|
||||
expect(events.second.value).to be_within(60).of(1800)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is manually reopened' do
|
||||
it 'sets waiting_since when first customer message arrives after manual reopening' do
|
||||
resolved_conversation.update!(status: 'open')
|
||||
|
||||
customer_message_time = 1.hour.ago
|
||||
create_customer_message(resolved_conversation, created_at: customer_message_time)
|
||||
|
||||
agent_reply_time = 15.minutes.ago
|
||||
agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time)
|
||||
|
||||
event = create_reply_event(agent_message, customer_message_time)
|
||||
listener.reply_created(event)
|
||||
|
||||
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
|
||||
expect(events.length).to be 1
|
||||
expect(events.first.value).to be_within(60).of(2700)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when waiting_since is nil' do
|
||||
it 'does not creates reply time events' do
|
||||
agent_message = create_agent_message(resolved_conversation)
|
||||
|
||||
event = create_reply_event(agent_message, nil)
|
||||
listener.reply_created(event)
|
||||
|
||||
events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
|
||||
expect(events.length).to be 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#first_reply_created' do
|
||||
|
||||
@@ -836,4 +836,117 @@ RSpec.describe Conversation do
|
||||
expect(message_window_service).to have_received(:can_reply?)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'reply time calculation flows' do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, assignee: agent, waiting_since: nil) }
|
||||
let(:conversation_start_time) { 5.hours.ago }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
conversation.update_column(:waiting_since, nil)
|
||||
conversation.update_column(:created_at, conversation_start_time)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
conversation.messages.destroy_all
|
||||
conversation.reporting_events.destroy_all
|
||||
conversation.reload
|
||||
end
|
||||
|
||||
def create_customer_message(conversation, created_at: Time.current)
|
||||
message = nil
|
||||
perform_enqueued_jobs do
|
||||
message = create(:message,
|
||||
message_type: 'incoming',
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
sender: conversation.contact,
|
||||
created_at: created_at)
|
||||
end
|
||||
message
|
||||
end
|
||||
|
||||
def create_agent_message(conversation, created_at: Time.current)
|
||||
message = nil
|
||||
perform_enqueued_jobs do
|
||||
message = create(:message,
|
||||
message_type: 'outgoing',
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
sender: conversation.assignee,
|
||||
created_at: created_at)
|
||||
end
|
||||
message
|
||||
end
|
||||
|
||||
it 'correctly tracks waiting_since and creates first response time events' do
|
||||
create_customer_message(conversation, created_at: conversation_start_time)
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
|
||||
|
||||
# Agent replies - this should create first response event
|
||||
agent_reply1_time = 4.hours.ago
|
||||
create_agent_message(conversation, created_at: agent_reply1_time)
|
||||
|
||||
first_response_events = account.reporting_events.where(name: 'first_response', conversation_id: conversation.id)
|
||||
expect(first_response_events.count).to eq(1)
|
||||
expect(first_response_events.first.value).to be_within(1.second).of(1.hour)
|
||||
|
||||
# the first response should also clear the waiting_since
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
|
||||
it 'does not reset waiting_since if customer sends another message' do
|
||||
create_customer_message(conversation, created_at: conversation_start_time)
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
|
||||
|
||||
create_customer_message(conversation, created_at: 3.hours.ago)
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
|
||||
end
|
||||
|
||||
it 'records the correct reply_time for subsequent messages' do
|
||||
create_customer_message(conversation, created_at: conversation_start_time)
|
||||
create_agent_message(conversation, created_at: 4.hours.ago)
|
||||
create_customer_message(conversation, created_at: 3.hours.ago)
|
||||
|
||||
create_agent_message(conversation, created_at: 2.hours.ago)
|
||||
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
|
||||
expect(reply_events.count).to eq(1)
|
||||
expect(reply_events.first.value).to be_within(1.second).of(1.hour)
|
||||
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
|
||||
it 'records zero reply time if an agent sends a message after resolution' do
|
||||
create_customer_message(conversation, created_at: conversation_start_time)
|
||||
create_agent_message(conversation, created_at: 4.hours.ago)
|
||||
create_customer_message(conversation, created_at: 3.hours.ago)
|
||||
|
||||
conversation.toggle_status
|
||||
expect(conversation.status).to eq('resolved')
|
||||
|
||||
conversation.toggle_status
|
||||
expect(conversation.status).to eq('open')
|
||||
|
||||
conversation.reload
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
|
||||
create_agent_message(conversation, created_at: 1.hour.ago)
|
||||
# update_waiting_since will ensure that no events were created since the waiting_since was nil
|
||||
# if the event is created it should log zero value, we have handled that in the reporting_event_listener
|
||||
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
|
||||
expect(reply_events.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -262,7 +262,7 @@ RSpec.describe Inbox do
|
||||
describe '#sanitized_name' do
|
||||
context 'when inbox name contains forbidden characters' do
|
||||
it 'removes forbidden and spam-trigger characters' do
|
||||
inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With<Bad>@Characters"And\'Quotes!#$%')
|
||||
inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With<Bad>@Characters"And\';:Quotes!#$%')
|
||||
expect(inbox.sanitized_name).to eq('Test/NameWithBadCharactersAnd\'Quotes')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -267,6 +267,23 @@ RSpec.describe Message do
|
||||
message = create(:message)
|
||||
expect(message.webhook_data.key?(:attachments)).to be false
|
||||
end
|
||||
|
||||
it 'uses outgoing_content for webhook content' do
|
||||
message = create(:message, content: 'Test content')
|
||||
expect(message).to receive(:outgoing_content).and_return('Outgoing test content')
|
||||
|
||||
webhook_data = message.webhook_data
|
||||
expect(webhook_data[:content]).to eq('Outgoing test content')
|
||||
end
|
||||
|
||||
it 'includes CSAT survey link in webhook content for input_csat messages' do
|
||||
inbox = create(:inbox, channel: create(:channel_api))
|
||||
conversation = create(:conversation, inbox: inbox)
|
||||
message = create(:message, conversation: conversation, content_type: 'input_csat', content: 'Rate your experience')
|
||||
|
||||
expect(message.outgoing_content).to include('survey/responses/')
|
||||
expect(message.webhook_data[:content]).to include('survey/responses/')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is created' do
|
||||
|
||||
@@ -128,6 +128,39 @@ has been assigned to you"
|
||||
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @John Peter please check this?"
|
||||
end
|
||||
|
||||
it 'returns appropriate body suited for the notification type conversation_mention if username contains emoji' do
|
||||
conversation = create(:conversation)
|
||||
content = 'Hey [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support) please check this?'
|
||||
message = create(:message, sender: create(:user), content: content, conversation: conversation)
|
||||
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
|
||||
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @👍 customer support please check this?"
|
||||
end
|
||||
|
||||
it 'returns appropriate body suited for the notification type conversation_mention if team name contains emoji and spaces' do
|
||||
conversation = create(:conversation)
|
||||
content = 'Please check [@🚀 Development Team](mention://team/2/%F0%9F%9A%80%20Development%20Team)'
|
||||
message = create(:message, sender: create(:user), content: content, conversation: conversation)
|
||||
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
|
||||
expect(notification.push_message_body).to eq "#{message.sender.name}: Please check @🚀 Development Team"
|
||||
end
|
||||
|
||||
it 'returns appropriate body suited for the notification type conversation_mention with mixed emoji and regular mentions' do
|
||||
conversation = create(:conversation)
|
||||
content = 'Hey [@John Doe](mention://user/1/John%20Doe) and ' \
|
||||
'[@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support) please review'
|
||||
message = create(:message, sender: create(:user), content: content, conversation: conversation)
|
||||
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
|
||||
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @John Doe and @👍 customer support please review"
|
||||
end
|
||||
|
||||
it 'returns appropriate body suited for the notification type conversation_mention with special characters in names' do
|
||||
conversation = create(:conversation)
|
||||
content = 'Please review [@user@domain.com](mention://user/4/user%40domain.com)'
|
||||
message = create(:message, sender: create(:user), content: content, conversation: conversation)
|
||||
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
|
||||
expect(notification.push_message_body).to eq "#{message.sender.name}: Please review @user@domain.com"
|
||||
end
|
||||
|
||||
it 'calls remove duplicate notification job' do
|
||||
allow(Notification::RemoveDuplicateNotificationJob).to receive(:perform_later)
|
||||
notification = create(:notification, notification_type: 'conversation_mention')
|
||||
|
||||
@@ -14,6 +14,17 @@ describe ActionService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#open_conversation' do
|
||||
let(:conversation) { create(:conversation, status: :resolved) }
|
||||
let(:action_service) { described_class.new(conversation) }
|
||||
|
||||
it 'opens the conversation' do
|
||||
expect(conversation.status).to eq('resolved')
|
||||
action_service.open_conversation(nil)
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#change_priority' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:action_service) { described_class.new(conversation) }
|
||||
|
||||
@@ -110,6 +110,29 @@ RSpec.describe AutomationRules::ConditionsFilterService do
|
||||
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering messages based on conversation attributes' do
|
||||
let(:conversation) { create(:conversation, account: account, status: :open, priority: :high) }
|
||||
let(:message) do
|
||||
create(:message, account: account, conversation: conversation, content: 'Test message',
|
||||
inbox: conversation.inbox, message_type: :incoming)
|
||||
end
|
||||
|
||||
it 'will return true when conversation status matches' do
|
||||
rule.update(conditions: [{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' }])
|
||||
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
|
||||
it 'will return false when conversation status does not match' do
|
||||
rule.update(conditions: [{ 'values': ['resolved'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' }])
|
||||
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
|
||||
it 'will return true when conversation priority matches' do
|
||||
rule.update(conditions: [{ 'values': ['high'], 'attribute_key': 'priority', 'query_operator': nil, 'filter_operator': 'equal_to' }])
|
||||
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -112,7 +112,8 @@ describe Line::SendOnLineService do
|
||||
contents: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'test'
|
||||
text: 'test',
|
||||
wrap: true
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
|
||||
@@ -5,69 +5,503 @@ describe Messages::MentionService do
|
||||
let!(:user) { create(:user, account: account) }
|
||||
let!(:first_agent) { create(:user, account: account) }
|
||||
let!(:second_agent) { create(:user, account: account) }
|
||||
let!(:third_agent) { create(:user, account: account) }
|
||||
let!(:admin_user) { create(:user, account: account, role: :administrator) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
let!(:team) { create(:team, account: account, name: 'Support Team') }
|
||||
let!(:empty_team) { create(:team, account: account, name: 'Empty Team') }
|
||||
let(:builder) { double }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: first_agent, inbox: inbox)
|
||||
create(:inbox_member, user: second_agent, inbox: inbox)
|
||||
create(:team_member, user: first_agent, team: team)
|
||||
create(:team_member, user: second_agent, team: team)
|
||||
conversation.reload
|
||||
allow(NotificationBuilder).to receive(:new).and_return(builder)
|
||||
allow(builder).to receive(:perform)
|
||||
allow(Conversations::UserMentionJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
context 'when message contains mention' do
|
||||
it 'creates notifications for inbox member who was mentioned' do
|
||||
describe '#perform' do
|
||||
context 'when message is not private' do
|
||||
it 'does not process mentions for public messages' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: false
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has no content' do
|
||||
it 'does not process mentions for empty messages' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: nil,
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has no mentions' do
|
||||
it 'does not process messages without mentions' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: 'just a regular message',
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'user mentions' do
|
||||
context 'when message contains single user mention' do
|
||||
it 'creates notifications for inbox member who was mentioned' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
[first_agent.id.to_s],
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
|
||||
it 'adds mentioned user as conversation participant' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to include(first_agent.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains multiple user mentions' do
|
||||
let(:message) do
|
||||
build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://user/#{second_agent.id}/#{second_agent.name}) " \
|
||||
"and (mention://user/#{first_agent.id}/#{first_agent.name}), please look into this?",
|
||||
private: true
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates notifications for all mentioned inbox members' do
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
|
||||
it 'adds all mentioned users to the participants list' do
|
||||
described_class.new(message: message).perform
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
|
||||
end
|
||||
|
||||
it 'passes unique user IDs to UserMentionJob' do
|
||||
described_class.new(message: message).perform
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mentioned user is not an inbox member' do
|
||||
let!(:non_member_user) { create(:user, account: account) }
|
||||
|
||||
it 'does not create notifications for non-inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{non_member_user.id}/#{non_member_user.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mentioned user is an admin' do
|
||||
it 'creates notifications for admin users even if not inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{admin_user.id}/#{admin_user.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: admin_user,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when same user is mentioned multiple times' do
|
||||
it 'creates only one notification per user' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name}) and again (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).once
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
[first_agent.id.to_s],
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'team mentions' do
|
||||
context 'when message contains single team mention' do
|
||||
it 'creates notifications for all team members who are inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
|
||||
it 'adds all team members as conversation participants' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
|
||||
end
|
||||
|
||||
it 'passes team member IDs to UserMentionJob' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team has members who are not inbox members' do
|
||||
let!(:non_inbox_team_member) { create(:user, account: account) }
|
||||
|
||||
before do
|
||||
create(:team_member, user: non_inbox_team_member, team: team)
|
||||
end
|
||||
|
||||
it 'only notifies team members who are also inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: first_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: second_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).not_to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: non_inbox_team_member, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team has admin members' do
|
||||
before do
|
||||
create(:team_member, user: admin_user, team: team)
|
||||
end
|
||||
|
||||
it 'includes admin team members in notifications' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: admin_user,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team is empty' do
|
||||
it 'does not create any notifications for empty teams' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{empty_team.id}/#{empty_team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team does not exist' do
|
||||
it 'does not create notifications for non-existent teams' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: 'hey (mention://team/99999/NonExistentTeam) please help',
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when same team is mentioned multiple times' do
|
||||
it 'creates only one notification per team member' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) and again (mention://team/#{team.id}/#{team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).exactly(2).times
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'mixed user and team mentions' do
|
||||
context 'when message contains both user and team mentions' do
|
||||
it 'creates notifications for both individual users and team members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://user/#{third_agent.id}/#{third_agent.name}) and (mention://team/#{team.id}/#{team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
# Make third_agent an inbox member
|
||||
create(:inbox_member, user: third_agent, inbox: inbox)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: third_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: first_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: second_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
end
|
||||
|
||||
it 'avoids duplicate notifications when user is mentioned directly and via team' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://user/#{first_agent.id}/#{first_agent.name}) and (mention://team/#{team.id}/#{team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
# first_agent should only receive one notification despite being mentioned directly and via team
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
).once
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
).once
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'cross-account validation' do
|
||||
let!(:other_account) { create(:account) }
|
||||
let!(:other_team) { create(:team, account: other_account) }
|
||||
let!(:other_user) { create(:user, account: other_account) }
|
||||
|
||||
before do
|
||||
create(:team_member, user: other_user, team: other_team)
|
||||
end
|
||||
|
||||
it 'does not process mentions for teams from other accounts' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi [#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
content: "hey (mention://team/#{other_team.id}/#{other_team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message)
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains multiple mentions' do
|
||||
let(:message) do
|
||||
build(
|
||||
it 'does not process mentions for users from other accounts' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey [#{second_agent.name}](mention://user/#{second_agent.id}/#{second_agent.name})/
|
||||
[#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name}),
|
||||
please look in to this?",
|
||||
content: "hey (mention://user/#{other_user.id}/#{other_user.name})",
|
||||
private: true
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates notifications for inbox member who was mentioned' do
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message)
|
||||
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message)
|
||||
end
|
||||
|
||||
it 'add the users to the participants list' do
|
||||
described_class.new(message: message).perform
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -260,5 +260,27 @@ describe Twilio::IncomingMessageService do
|
||||
expect(conversation.reload.messages.last.attachments.map(&:file_type)).to contain_exactly('image', 'image')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a location message is received' do
|
||||
let(:params_with_location) do
|
||||
{
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
MessageType: 'location',
|
||||
Latitude: '12.160894393921',
|
||||
Longitude: '75.265205383301'
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a message with location attachment' do
|
||||
described_class.new(params: params_with_location).perform
|
||||
|
||||
message = conversation.reload.messages.last
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.first.file_type).to eq('location')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user