Merge remote-tracking branch 'origin/develop' into feat/whatsapp-embedded-signup

This commit is contained in:
Tanmay Deep Sharma
2025-07-08 11:56:52 +07:00
854 changed files with 7278 additions and 10468 deletions
@@ -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
@@ -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
+29 -1
View File
@@ -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
+39 -6
View File
@@ -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)
+69
View File
@@ -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
+113
View File
@@ -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
+1 -1
View File
@@ -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
+17
View File
@@ -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
+33
View File
@@ -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')
+11
View File
@@ -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',
+466 -32
View File
@@ -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