Merge branch 'develop' into feat/github-integration

This commit is contained in:
Muhsin Keloth
2025-10-28 19:27:09 +05:30
committed by GitHub
623 changed files with 34053 additions and 3965 deletions
@@ -179,6 +179,63 @@ describe Messages::MessageBuilder do
expect(message.content_attributes[:cc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
expect(message.content_attributes[:bcc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
end
context 'when custom email content is provided' do
before do
account.enable_features('quoted_email_reply')
end
it 'creates message with custom HTML email content' do
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom <strong>HTML</strong> content</p>'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content', 'full')).to eq '<p>Custom <strong>HTML</strong> content</p>'
expect(message.content_attributes.dig('email', 'html_content', 'reply')).to eq '<p>Custom <strong>HTML</strong> content</p>'
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular message content'
expect(message.content_attributes.dig('email', 'text_content', 'reply')).to eq 'Regular message content'
end
it 'does not process custom email content when quoted_email_reply feature is disabled' do
account.disable_features('quoted_email_reply')
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom HTML content</p>'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
it 'does not process custom email content for private messages' do
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom HTML content</p>',
private: true
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
it 'falls back to default behavior when no custom email content is provided' do
params = ActionController::Parameters.new({
content: 'Regular **markdown** content'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('<strong>markdown</strong>')
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content'
end
end
end
end
end
@@ -5,6 +5,29 @@ RSpec.describe 'API Base', type: :request do
let!(:user) { create(:user, account: account) }
describe 'request with api_access_token for user' do
context 'when accessing an account scoped resource' do
let!(:admin) { create(:user, :administrator, account: account) }
let!(:conversation) { create(:conversation, account: account) }
it 'sets Current attributes for the request and then returns the response' do
# expect Current.account_user is set to the admin's account_user
allow(Current).to receive(:user=).and_call_original
allow(Current).to receive(:account=).and_call_original
allow(Current).to receive(:account_user=).and_call_original
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: { api_access_token: admin.access_token.token },
as: :json
expect(Current).to have_received(:user=).with(admin).at_least(:once)
expect(Current).to have_received(:account=).with(account).at_least(:once)
expect(Current).to have_received(:account_user=).with(admin.account_users.first).at_least(:once)
expect(response).to have_http_status(:success)
expect(response.parsed_body['id']).to eq(conversation.display_id)
end
end
context 'when it is an invalid api_access_token' do
it 'returns unauthorized' do
get '/api/v1/profile',
@@ -93,9 +93,9 @@ RSpec.describe 'Custom Filters API', type: :request do
expect(json_response['name']).to eq 'vip-customers'
end
it 'gives the error for 51st record' do
it 'gives the error for 1001st record' do
CustomFilter.delete_all
CustomFilter::MAX_FILTER_PER_USER.times do
Limits::MAX_CUSTOM_FILTERS_PER_USER.times do
create(:custom_filter, user: user, account: account)
end
@@ -107,7 +107,7 @@ RSpec.describe 'Custom Filters API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to include(
'Account Limit reached. The maximum number of allowed custom filters for a user per account is 50.'
'Account Limit reached. The maximum number of allowed custom filters for a user per account is 1000.'
)
end
end
@@ -10,7 +10,7 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
context 'when authenticated and authorized' do
before do
# Create conversations across 24 hours at different times
base_time = Time.utc(2024, 1, 15, 0, 0) # Start at midnight UTC
base_time = Time.utc(2024, 1, 14, 23, 0) # Start at 23:00 to span 2 days
# Create conversations every 4 hours across 24 hours
6.times do |i|
@@ -23,36 +23,38 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
end
it 'timezone_offset affects data grouping and timestamps correctly' do
Time.use_zone('UTC') do
base_time = Time.utc(2024, 1, 15, 0, 0)
base_params = {
metric: 'conversations_count',
type: 'account',
since: (base_time - 1.day).to_i.to_s,
until: (base_time + 2.days).to_i.to_s,
group_by: 'day'
}
travel_to Time.utc(2024, 1, 15, 12, 0) do
Time.use_zone('UTC') do
base_time = Time.utc(2024, 1, 14, 23, 0) # Start at 23:00 to span 2 days
base_params = {
metric: 'conversations_count',
type: 'account',
since: (base_time - 1.day).to_i.to_s,
until: (base_time + 2.days).to_i.to_s,
group_by: 'day'
}
responses = [0, -8, 9].map do |offset|
get "/api/v2/accounts/#{account.id}/reports",
params: base_params.merge(timezone_offset: offset),
headers: admin.create_new_auth_token, as: :json
response.parsed_body
responses = [0, -8, 9].map do |offset|
get "/api/v2/accounts/#{account.id}/reports",
params: base_params.merge(timezone_offset: offset),
headers: admin.create_new_auth_token, as: :json
response.parsed_body
end
data_entries = responses.map { |r| r.select { |e| e['value'] > 0 } }
totals = responses.map { |r| r.sum { |e| e['value'] } }
timestamps = responses.map { |r| r.map { |e| e['timestamp'] } }
# Data conservation and redistribution
expect(totals.uniq).to eq([6])
expect(data_entries[0].map { |e| e['value'] }).to eq([1, 5])
expect(data_entries[1].map { |e| e['value'] }).to eq([3, 3])
expect(data_entries[2].map { |e| e['value'] }).to eq([4, 2])
# Timestamp differences
expect(timestamps.uniq.size).to eq(3)
timestamps[0].zip(timestamps[1]).each { |utc, pst| expect(utc - pst).to eq(-28_800) }
end
data_entries = responses.map { |r| r.select { |e| e['value'] > 0 } }
totals = responses.map { |r| r.sum { |e| e['value'] } }
timestamps = responses.map { |r| r.map { |e| e['timestamp'] } }
# Data conservation and redistribution
expect(totals.uniq).to eq([6])
expect(data_entries[0].map { |e| e['value'] }).to eq([1, 5])
expect(data_entries[1].map { |e| e['value'] }).to eq([3, 3])
expect(data_entries[2].map { |e| e['value'] }).to eq([4, 2])
# Timestamp differences
expect(timestamps.uniq.size).to eq(3)
timestamps[0].zip(timestamps[1]).each { |utc, pst| expect(utc - pst).to eq(-28_800) }
end
end
@@ -0,0 +1,281 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools' do
context 'when it is an un-authenticated user' do
it 'returns unauthorized status' do
get "/api/v1/accounts/#{account.id}/captain/custom_tools"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns success status' do
create_list(:captain_custom_tool, 3, account: account)
get "/api/v1/accounts/#{account.id}/captain/custom_tools",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:payload].length).to eq(3)
end
end
context 'when it is an admin' do
it 'returns success status and custom tools' do
create_list(:captain_custom_tool, 5, account: account)
get "/api/v1/accounts/#{account.id}/captain/custom_tools",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:payload].length).to eq(5)
end
it 'returns only enabled custom tools' do
create(:captain_custom_tool, account: account, enabled: true)
create(:captain_custom_tool, account: account, enabled: false)
get "/api/v1/accounts/#{account.id}/captain/custom_tools",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:payload].length).to eq(1)
expect(json_response[:payload].first[:enabled]).to be(true)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
let(:custom_tool) { create(:captain_custom_tool, account: account) }
context 'when it is an un-authenticated user' do
it 'returns unauthorized status' do
get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns success status and custom tool' do
get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:id]).to eq(custom_tool.id)
expect(json_response[:title]).to eq(custom_tool.title)
end
end
context 'when custom tool does not exist' do
it 'returns not found status' do
get "/api/v1/accounts/#{account.id}/captain/custom_tools/999999",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:not_found)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/custom_tools' do
let(:valid_attributes) do
{
custom_tool: {
title: 'Fetch Order Status',
description: 'Fetches order status from external API',
endpoint_url: 'https://api.example.com/orders/{{ order_id }}',
http_method: 'GET',
enabled: true,
param_schema: [
{ name: 'order_id', type: 'string', description: 'The order ID', required: true }
]
}
}
end
context 'when it is an un-authenticated user' do
it 'returns unauthorized status' do
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
params: valid_attributes
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns unauthorized status' do
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
params: valid_attributes,
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
it 'creates a new custom tool and returns success status' do
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
params: valid_attributes,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:title]).to eq('Fetch Order Status')
expect(json_response[:description]).to eq('Fetches order status from external API')
expect(json_response[:enabled]).to be(true)
expect(json_response[:slug]).to eq('custom_fetch_order_status')
expect(json_response[:param_schema]).to eq([
{ name: 'order_id', type: 'string', description: 'The order ID', required: true }
])
end
context 'with invalid parameters' do
let(:invalid_attributes) do
{
custom_tool: {
title: '',
endpoint_url: ''
}
}
end
it 'returns unprocessable entity status' do
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
params: invalid_attributes,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'with invalid endpoint URL' do
let(:invalid_url_attributes) do
{
custom_tool: {
title: 'Test Tool',
endpoint_url: 'http://localhost/api',
http_method: 'GET'
}
}
end
it 'returns unprocessable entity status' do
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
params: invalid_url_attributes,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
let(:custom_tool) { create(:captain_custom_tool, account: account) }
let(:update_attributes) do
{
custom_tool: {
title: 'Updated Tool Title',
enabled: false
}
}
end
context 'when it is an un-authenticated user' do
it 'returns unauthorized status' do
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
params: update_attributes
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns unauthorized status' do
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
params: update_attributes,
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
it 'updates the custom tool and returns success status' do
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
params: update_attributes,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:title]).to eq('Updated Tool Title')
expect(json_response[:enabled]).to be(false)
end
context 'with invalid parameters' do
let(:invalid_attributes) do
{
custom_tool: {
title: ''
}
}
end
it 'returns unprocessable entity status' do
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
params: invalid_attributes,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
let!(:custom_tool) { create(:captain_custom_tool, account: account) }
context 'when it is an un-authenticated user' do
it 'returns unauthorized status' do
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns unauthorized status' do
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
it 'deletes the custom tool and returns no content status' do
expect do
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
headers: admin.create_new_auth_token
end.to change(Captain::CustomTool, :count).by(-1)
expect(response).to have_http_status(:no_content)
end
context 'when custom tool does not exist' do
it 'returns not found status' do
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/999999",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:not_found)
end
end
end
end
end
@@ -0,0 +1,141 @@
require 'rails_helper'
RSpec.describe 'Companies API', type: :request do
let(:account) { create(:account) }
describe 'GET /api/v1/accounts/{account.id}/companies' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/companies"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:company1) { create(:company, name: 'Company 1', account: account) }
let!(:company2) { create(:company, account: account) }
it 'returns all companies' do
get "/api/v1/accounts/#{account.id}/companies",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].size).to eq(2)
expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
it 'returns the company' do
get "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq(company.name)
expect(response_body['payload']['id']).to eq(company.id)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/companies' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:valid_params) do
{
company: {
name: 'New Company',
domain: 'newcompany.com',
description: 'A new company'
}
}
end
it 'creates a new company' do
expect do
post "/api/v1/accounts/#{account.id}/companies",
params: valid_params,
headers: admin.create_new_auth_token,
as: :json
end.to change(Company, :count).by(1)
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq('New Company')
expect(response_body['payload']['domain']).to eq('newcompany.com')
end
it 'returns error for invalid params' do
invalid_params = { company: { name: '' } }
post "/api/v1/accounts/#{account.id}/companies",
params: invalid_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
let(:update_params) do
{
company: {
name: 'Updated Company Name',
domain: 'updated.com'
}
}
end
it 'updates the company' do
patch "/api/v1/accounts/#{account.id}/companies/#{company.id}",
params: update_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq('Updated Company Name')
expect(response_body['payload']['domain']).to eq('updated.com')
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated administrator' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
it 'deletes the company' do
company
expect do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
end.to change(Company, :count).by(-1)
expect(response).to have_http_status(:ok)
end
end
context 'when it is a regular agent' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:company) { create(:company, account: account) }
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end
@@ -30,5 +30,76 @@ RSpec.describe 'Conversations API', type: :request do
expect(response.parsed_body.keys).not_to include('applied_sla')
expect(response.parsed_body.keys).not_to include('sla_events')
end
context 'when agent has team access' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:team) { create(:team, account: account) }
let(:conversation) { create(:conversation, account: account, team: team) }
before do
create(:team_member, team: team, user: agent)
end
it 'allows accessing the conversation via team membership' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(conversation.display_id)
end
end
context 'when agent has a custom role' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:conversation) { create(:conversation, account: account) }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'returns unauthorized for unassigned conversation without permission' do
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
account.account_users.find_by(user_id: agent.id).update!(custom_role: custom_role)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
it 'returns the conversation when permission allows managing unassigned conversations, including when assigned to agent' do
custom_role = create(:custom_role, account: account, permissions: ['conversation_unassigned_manage'])
account_user = account.account_users.find_by(user_id: agent.id)
account_user.update!(custom_role: custom_role)
conversation.update!(assignee: agent)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(conversation.display_id)
end
it 'returns the conversation when permission allows managing assigned conversations' do
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
account_user = account.account_users.find_by(user_id: agent.id)
account_user.update!(custom_role: custom_role)
conversation.update!(assignee: agent)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(conversation.display_id)
end
it 'returns the conversation when permission allows managing participating conversations' do
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
account_user = account.account_users.find_by(user_id: agent.id)
account_user.update!(custom_role: custom_role)
create(:conversation_participant, conversation: conversation, account: account, user: agent)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(conversation.display_id)
end
end
end
end
@@ -36,6 +36,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
it 'redirects to mobile deep link with error when target is mobile' do
post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com', target: 'mobile' }
expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
end
end
context 'when user exists but has no SAML enabled accounts' do
@@ -48,6 +54,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
it 'redirects to mobile deep link with error when target is mobile' do
post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
end
end
context 'when user has account without SAML feature enabled' do
@@ -65,6 +77,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
it 'redirects to mobile deep link with error when target is mobile' do
post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
end
end
context 'when user has valid SAML configuration' do
@@ -82,6 +100,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to include("/auth/saml?account_id=#{account.id}")
end
it 'redirects to SAML initiation URL with mobile relay state' do
post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
expect(response.location).to include("/auth/saml?account_id=#{account.id}&RelayState=mobile")
end
end
context 'when user has multiple accounts with SAML' do
@@ -1,8 +1,6 @@
require 'rails_helper'
RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
include ActiveJob::TestHelper
let!(:inbox) { create(:inbox) }
let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) }
@@ -14,6 +12,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
before do
create(:captain_inbox, inbox: inbox, captain_assistant: captain_assistant)
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
inbox.reload
end
it 'queues the job' do
@@ -22,7 +21,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
end
it 'resolves only the eligible pending conversations' do
perform_enqueued_jobs { described_class.perform_later(inbox) }
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
expect(recent_pending_conversation.reload.status).to eq('pending')
@@ -34,7 +33,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
captain_assistant.update!(config: { 'resolution_message' => custom_message })
expect do
perform_enqueued_jobs { described_class.perform_later(inbox) }
described_class.perform_now(inbox)
end.to change { resolvable_pending_conversation.messages.outgoing.reload.count }.by(1)
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
@@ -44,7 +43,7 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
it 'creates an outgoing message with default auto resolution message if not configured' do
captain_assistant.update!(config: {})
perform_enqueued_jobs { described_class.perform_later(inbox) }
described_class.perform_now(inbox)
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
expect(outgoing_message.content).to eq(
I18n.t('conversations.activity.auto_resolution_message')
@@ -52,11 +51,17 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
end
it 'adds the correct activity message after resolution by Captain' do
perform_enqueued_jobs { described_class.perform_later(inbox) }
activity_message = resolvable_pending_conversation.messages.activity.last
expect(activity_message).not_to be_nil
expect(activity_message.content).to eq(
I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
)
described_class.perform_now(inbox)
expected_content = I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
expect(Conversations::ActivityMessageJob)
.to have_been_enqueued.with(
resolvable_pending_conversation,
{
account_id: resolvable_pending_conversation.account_id,
inbox_id: resolvable_pending_conversation.inbox_id,
message_type: :activity,
content: expected_content
}
)
end
end
@@ -0,0 +1,241 @@
require 'rails_helper'
RSpec.describe Captain::Tools::HttpTool, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_tool) { create(:captain_custom_tool, account: account) }
let(:tool) { described_class.new(assistant, custom_tool) }
let(:tool_context) { Struct.new(:state).new({}) }
describe '#active?' do
it 'returns true when custom tool is enabled' do
custom_tool.update!(enabled: true)
expect(tool.active?).to be true
end
it 'returns false when custom tool is disabled' do
custom_tool.update!(enabled: false)
expect(tool.active?).to be false
end
end
describe '#perform' do
context 'with GET request' do
before do
custom_tool.update!(
http_method: 'GET',
endpoint_url: 'https://example.com/orders/123',
response_template: nil
)
stub_request(:get, 'https://example.com/orders/123')
.to_return(status: 200, body: '{"status": "success"}')
end
it 'executes GET request and returns response body' do
result = tool.perform(tool_context)
expect(result).to eq('{"status": "success"}')
expect(WebMock).to have_requested(:get, 'https://example.com/orders/123')
end
end
context 'with POST request' do
before do
custom_tool.update!(
http_method: 'POST',
endpoint_url: 'https://example.com/orders',
request_template: '{"order_id": "{{ order_id }}"}',
response_template: nil
)
stub_request(:post, 'https://example.com/orders')
.with(body: '{"order_id": "123"}', headers: { 'Content-Type' => 'application/json' })
.to_return(status: 200, body: '{"created": true}')
end
it 'executes POST request with rendered body' do
result = tool.perform(tool_context, order_id: '123')
expect(result).to eq('{"created": true}')
expect(WebMock).to have_requested(:post, 'https://example.com/orders')
.with(body: '{"order_id": "123"}')
end
end
context 'with template variables in URL' do
before do
custom_tool.update!(
endpoint_url: 'https://example.com/orders/{{ order_id }}',
response_template: nil
)
stub_request(:get, 'https://example.com/orders/456')
.to_return(status: 200, body: '{"order_id": "456"}')
end
it 'renders URL template with params' do
result = tool.perform(tool_context, order_id: '456')
expect(result).to eq('{"order_id": "456"}')
expect(WebMock).to have_requested(:get, 'https://example.com/orders/456')
end
end
context 'with bearer token authentication' do
before do
custom_tool.update!(
auth_type: 'bearer',
auth_config: { 'token' => 'secret_bearer_token' },
endpoint_url: 'https://example.com/data',
response_template: nil
)
stub_request(:get, 'https://example.com/data')
.with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
.to_return(status: 200, body: '{"authenticated": true}')
end
it 'adds Authorization header with bearer token' do
result = tool.perform(tool_context)
expect(result).to eq('{"authenticated": true}')
expect(WebMock).to have_requested(:get, 'https://example.com/data')
.with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
end
end
context 'with basic authentication' do
before do
custom_tool.update!(
auth_type: 'basic',
auth_config: { 'username' => 'user123', 'password' => 'pass456' },
endpoint_url: 'https://example.com/data',
response_template: nil
)
stub_request(:get, 'https://example.com/data')
.with(basic_auth: %w[user123 pass456])
.to_return(status: 200, body: '{"authenticated": true}')
end
it 'adds basic auth credentials' do
result = tool.perform(tool_context)
expect(result).to eq('{"authenticated": true}')
expect(WebMock).to have_requested(:get, 'https://example.com/data')
.with(basic_auth: %w[user123 pass456])
end
end
context 'with API key authentication' do
before do
custom_tool.update!(
auth_type: 'api_key',
auth_config: { 'key' => 'api_key_123', 'location' => 'header', 'name' => 'X-API-Key' },
endpoint_url: 'https://example.com/data',
response_template: nil
)
stub_request(:get, 'https://example.com/data')
.with(headers: { 'X-API-Key' => 'api_key_123' })
.to_return(status: 200, body: '{"authenticated": true}')
end
it 'adds API key header' do
result = tool.perform(tool_context)
expect(result).to eq('{"authenticated": true}')
expect(WebMock).to have_requested(:get, 'https://example.com/data')
.with(headers: { 'X-API-Key' => 'api_key_123' })
end
end
context 'with response template' do
before do
custom_tool.update!(
endpoint_url: 'https://example.com/orders/123',
response_template: 'Order status: {{ response.status }}, ID: {{ response.order_id }}'
)
stub_request(:get, 'https://example.com/orders/123')
.to_return(status: 200, body: '{"status": "shipped", "order_id": "123"}')
end
it 'formats response using template' do
result = tool.perform(tool_context)
expect(result).to eq('Order status: shipped, ID: 123')
end
end
context 'when handling errors' do
it 'returns generic error message on network failure' do
custom_tool.update!(endpoint_url: 'https://example.com/data')
stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect'))
result = tool.perform(tool_context)
expect(result).to eq('An error occurred while executing the request')
end
it 'returns generic error message on timeout' do
custom_tool.update!(endpoint_url: 'https://example.com/data')
stub_request(:get, 'https://example.com/data').to_timeout
result = tool.perform(tool_context)
expect(result).to eq('An error occurred while executing the request')
end
it 'returns generic error message on HTTP 404' do
custom_tool.update!(endpoint_url: 'https://example.com/data')
stub_request(:get, 'https://example.com/data').to_return(status: 404, body: 'Not found')
result = tool.perform(tool_context)
expect(result).to eq('An error occurred while executing the request')
end
it 'returns generic error message on HTTP 500' do
custom_tool.update!(endpoint_url: 'https://example.com/data')
stub_request(:get, 'https://example.com/data').to_return(status: 500, body: 'Server error')
result = tool.perform(tool_context)
expect(result).to eq('An error occurred while executing the request')
end
it 'logs error details' do
custom_tool.update!(endpoint_url: 'https://example.com/data')
stub_request(:get, 'https://example.com/data').to_raise(StandardError.new('Test error'))
expect(Rails.logger).to receive(:error).with(/HttpTool execution error.*Test error/)
tool.perform(tool_context)
end
end
context 'when integrating with Toolable methods' do
it 'correctly integrates URL rendering, body rendering, auth, and response formatting' do
custom_tool.update!(
http_method: 'POST',
endpoint_url: 'https://example.com/users/{{ user_id }}/orders',
request_template: '{"product": "{{ product }}", "quantity": {{ quantity }}}',
auth_type: 'bearer',
auth_config: { 'token' => 'integration_token' },
response_template: 'Created order #{{ response.order_number }} for {{ response.product }}'
)
stub_request(:post, 'https://example.com/users/42/orders')
.with(
body: '{"product": "Widget", "quantity": 5}',
headers: {
'Authorization' => 'Bearer integration_token',
'Content-Type' => 'application/json'
}
)
.to_return(status: 200, body: '{"order_number": "ORD-789", "product": "Widget"}')
result = tool.perform(tool_context, user_id: '42', product: 'Widget', quantity: 5)
expect(result).to eq('Created order #ORD-789 for Widget')
end
end
end
end
@@ -0,0 +1,388 @@
require 'rails_helper'
RSpec.describe Captain::CustomTool, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:endpoint_url) }
it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) }
it {
expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic',
'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth)
}
describe 'slug uniqueness' do
let(:account) { create(:account) }
it 'validates uniqueness of slug scoped to account' do
create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test_tool')
expect(duplicate).not_to be_valid
expect(duplicate.errors[:slug]).to include('has already been taken')
end
it 'allows same slug across different accounts' do
account2 = create(:account)
create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test_tool')
expect(different_account_tool).to be_valid
end
end
describe 'param_schema validation' do
let(:account) { create(:account) }
it 'is valid with proper param_schema' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'required' => true }
])
expect(tool).to be_valid
end
it 'is valid with empty param_schema' do
tool = build(:captain_custom_tool, account: account, param_schema: [])
expect(tool).to be_valid
end
it 'is invalid when param_schema is missing name' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'type' => 'string', 'description' => 'Order ID' }
])
expect(tool).not_to be_valid
end
it 'is invalid when param_schema is missing type' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'description' => 'Order ID' }
])
expect(tool).not_to be_valid
end
it 'is invalid when param_schema is missing description' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string' }
])
expect(tool).not_to be_valid
end
it 'is invalid with additional properties in param_schema' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'extra_field' => 'value' }
])
expect(tool).not_to be_valid
end
it 'is valid when required field is omitted (defaults to optional param)' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID' }
])
expect(tool).to be_valid
end
end
end
describe 'scopes' do
let(:account) { create(:account) }
describe '.enabled' do
it 'returns only enabled custom tools' do
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
expect(described_class.enabled).to include(enabled_tool)
expect(described_class.enabled).not_to include(disabled_tool)
end
end
end
describe 'slug generation' do
let(:account) { create(:account) }
it 'generates slug from title on creation' do
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status')
expect(tool.slug).to eq('custom_fetch_order_status')
end
it 'adds custom_ prefix to generated slug' do
tool = create(:captain_custom_tool, account: account, title: 'My Tool')
expect(tool.slug).to start_with('custom_')
end
it 'does not override manually set slug' do
tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual_slug')
expect(tool.slug).to eq('custom_manual_slug')
end
it 'handles slug collisions by appending random suffix' do
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool')
expect(tool2.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
end
it 'handles multiple slug collisions' do
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool_abc123')
tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool')
expect(tool3.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
expect(tool3.slug).not_to eq('custom_test_tool')
expect(tool3.slug).not_to eq('custom_test_tool_abc123')
end
it 'does not generate slug when title is blank' do
tool = build(:captain_custom_tool, account: account, title: nil)
expect(tool).not_to be_valid
expect(tool.errors[:title]).to include("can't be blank")
end
it 'parameterizes title correctly' do
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!')
expect(tool.slug).to eq('custom_fetch_order_status_details')
end
end
describe 'factory' do
it 'creates a valid custom tool with default attributes' do
tool = create(:captain_custom_tool)
expect(tool).to be_valid
expect(tool.title).to be_present
expect(tool.slug).to be_present
expect(tool.endpoint_url).to be_present
expect(tool.http_method).to eq('GET')
expect(tool.auth_type).to eq('none')
expect(tool.enabled).to be true
end
it 'creates valid tool with POST trait' do
tool = create(:captain_custom_tool, :with_post)
expect(tool.http_method).to eq('POST')
expect(tool.request_template).to be_present
end
it 'creates valid tool with bearer auth trait' do
tool = create(:captain_custom_tool, :with_bearer_auth)
expect(tool.auth_type).to eq('bearer')
expect(tool.auth_config['token']).to eq('test_bearer_token_123')
end
it 'creates valid tool with basic auth trait' do
tool = create(:captain_custom_tool, :with_basic_auth)
expect(tool.auth_type).to eq('basic')
expect(tool.auth_config['username']).to eq('test_user')
expect(tool.auth_config['password']).to eq('test_pass')
end
it 'creates valid tool with api key trait' do
tool = create(:captain_custom_tool, :with_api_key)
expect(tool.auth_type).to eq('api_key')
expect(tool.auth_config['key']).to eq('test_api_key')
expect(tool.auth_config['location']).to eq('header')
end
end
describe 'Toolable concern' do
let(:account) { create(:account) }
describe '#build_request_url' do
it 'returns static URL when no template variables present' do
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders')
expect(tool.build_request_url({})).to eq('https://api.example.com/orders')
end
it 'renders URL template with params' do
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders/{{ order_id }}')
expect(tool.build_request_url({ order_id: '12345' })).to eq('https://api.example.com/orders/12345')
end
it 'handles multiple template variables' do
tool = create(:captain_custom_tool, account: account,
endpoint_url: 'https://api.example.com/{{ resource }}/{{ id }}?details={{ show_details }}')
result = tool.build_request_url({ resource: 'orders', id: '123', show_details: 'true' })
expect(result).to eq('https://api.example.com/orders/123?details=true')
end
end
describe '#build_request_body' do
it 'returns nil when request_template is blank' do
tool = create(:captain_custom_tool, account: account, request_template: nil)
expect(tool.build_request_body({})).to be_nil
end
it 'renders request body template with params' do
tool = create(:captain_custom_tool, account: account,
request_template: '{ "order_id": "{{ order_id }}", "source": "chatwoot" }')
result = tool.build_request_body({ order_id: '12345' })
expect(result).to eq('{ "order_id": "12345", "source": "chatwoot" }')
end
end
describe '#build_auth_headers' do
it 'returns empty hash for none auth type' do
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
expect(tool.build_auth_headers).to eq({})
end
it 'returns bearer token header' do
tool = create(:captain_custom_tool, :with_bearer_auth, account: account)
expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' })
end
it 'returns API key header when location is header' do
tool = create(:captain_custom_tool, :with_api_key, account: account)
expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' })
end
it 'returns empty hash for API key when location is not header' do
tool = create(:captain_custom_tool, account: account, auth_type: 'api_key',
auth_config: { key: 'test_key', location: 'query', name: 'api_key' })
expect(tool.build_auth_headers).to eq({})
end
it 'returns empty hash for basic auth' do
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
expect(tool.build_auth_headers).to eq({})
end
end
describe '#build_basic_auth_credentials' do
it 'returns nil for non-basic auth types' do
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
expect(tool.build_basic_auth_credentials).to be_nil
end
it 'returns username and password array for basic auth' do
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
expect(tool.build_basic_auth_credentials).to eq(%w[test_user test_pass])
end
end
describe '#format_response' do
it 'returns raw response when no response_template' do
tool = create(:captain_custom_tool, account: account, response_template: nil)
expect(tool.format_response('raw response')).to eq('raw response')
end
it 'renders response template with JSON response' do
tool = create(:captain_custom_tool, account: account,
response_template: 'Order status: {{ response.status }}')
raw_response = '{"status": "shipped", "tracking": "123ABC"}'
result = tool.format_response(raw_response)
expect(result).to eq('Order status: shipped')
end
it 'handles response template with multiple fields' do
tool = create(:captain_custom_tool, account: account,
response_template: 'Order {{ response.id }} is {{ response.status }}. Tracking: {{ response.tracking }}')
raw_response = '{"id": "12345", "status": "delivered", "tracking": "ABC123"}'
result = tool.format_response(raw_response)
expect(result).to eq('Order 12345 is delivered. Tracking: ABC123')
end
it 'handles non-JSON response' do
tool = create(:captain_custom_tool, account: account,
response_template: 'Response: {{ response }}')
raw_response = 'plain text response'
result = tool.format_response(raw_response)
expect(result).to eq('Response: plain text response')
end
end
describe '#to_tool_metadata' do
it 'returns tool metadata hash with custom flag' do
tool = create(:captain_custom_tool, account: account,
slug: 'custom_test-tool',
title: 'Test Tool',
description: 'A test tool')
metadata = tool.to_tool_metadata
expect(metadata).to eq({
id: 'custom_test-tool',
title: 'Test Tool',
description: 'A test tool',
custom: true
})
end
end
describe '#tool' do
let(:assistant) { create(:captain_assistant, account: account) }
it 'returns HttpTool instance' do
tool = create(:captain_custom_tool, account: account)
tool_instance = tool.tool(assistant)
expect(tool_instance).to be_a(Captain::Tools::HttpTool)
end
it 'sets description on the tool class' do
tool = create(:captain_custom_tool, account: account, description: 'Fetches order data')
tool_instance = tool.tool(assistant)
expect(tool_instance.description).to eq('Fetches order data')
end
it 'sets parameters on the tool class' do
tool = create(:captain_custom_tool, :with_params, account: account)
tool_instance = tool.tool(assistant)
params = tool_instance.parameters
expect(params.keys).to contain_exactly(:order_id, :include_details)
expect(params[:order_id].name).to eq(:order_id)
expect(params[:order_id].type).to eq('string')
expect(params[:order_id].description).to eq('The order ID')
expect(params[:order_id].required).to be true
expect(params[:include_details].name).to eq(:include_details)
expect(params[:include_details].required).to be false
end
it 'works with empty param_schema' do
tool = create(:captain_custom_tool, account: account, param_schema: [])
tool_instance = tool.tool(assistant)
expect(tool_instance.parameters).to be_empty
end
end
end
end
+180 -3
View File
@@ -48,9 +48,9 @@ RSpec.describe Captain::Scenario, type: :model do
before do
# Mock available tools
allow(described_class).to receive(:available_tool_ids).and_return(%w[
add_contact_note add_private_note update_priority
])
allow(described_class).to receive(:built_in_tool_ids).and_return(%w[
add_contact_note add_private_note update_priority
])
end
describe 'validate_instruction_tools' do
@@ -102,6 +102,49 @@ RSpec.describe Captain::Scenario, type: :model do
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/)
end
it 'is valid with custom tool references' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
expect(scenario).to be_valid
end
it 'is invalid with custom tool from different account' do
other_account = create(:account)
create(:captain_custom_tool, account: other_account, slug: 'custom_fetch-order')
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
end
it 'is invalid with disabled custom tool' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
end
it 'is valid with mixed static and custom tool references' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
expect(scenario).to be_valid
end
end
describe 'resolve_tool_references' do
@@ -146,6 +189,140 @@ RSpec.describe Captain::Scenario, type: :model do
end
end
describe 'custom tool integration' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
before do
allow(described_class).to receive(:built_in_tool_ids).and_return(%w[add_contact_note])
allow(described_class).to receive(:built_in_agent_tools).and_return([
{ id: 'add_contact_note', title: 'Add Contact Note',
description: 'Add a note' }
])
end
describe '#resolved_tools' do
it 'includes custom tool metadata' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order',
title: 'Fetch Order', description: 'Gets order details')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
resolved = scenario.send(:resolved_tools)
expect(resolved.length).to eq(1)
expect(resolved.first[:id]).to eq('custom_fetch-order')
expect(resolved.first[:title]).to eq('Fetch Order')
expect(resolved.first[:description]).to eq('Gets order details')
end
it 'includes both static and custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
resolved = scenario.send(:resolved_tools)
expect(resolved.length).to eq(2)
expect(resolved.map { |t| t[:id] }).to contain_exactly('add_contact_note', 'custom_fetch-order')
end
it 'excludes disabled custom tools' do
custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
custom_tool.update!(enabled: false)
resolved = scenario.send(:resolved_tools)
expect(resolved).to be_empty
end
end
describe '#resolve_tool_instance' do
it 'returns HttpTool instance for custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario, assistant: assistant, account: account)
tool_metadata = { id: 'custom_fetch-order', custom: true }
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
expect(tool_instance).to be_a(Captain::Tools::HttpTool)
end
it 'returns nil for disabled custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
scenario = create(:captain_scenario, assistant: assistant, account: account)
tool_metadata = { id: 'custom_fetch-order', custom: true }
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
expect(tool_instance).to be_nil
end
it 'returns static tool instance for non-custom tools' do
scenario = create(:captain_scenario, assistant: assistant, account: account)
allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
Class.new do
def initialize(_assistant); end
end
)
tool_metadata = { id: 'add_contact_note' }
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
expect(tool_instance).not_to be_nil
expect(tool_instance).not_to be_a(Captain::Tools::HttpTool)
end
end
describe '#agent_tools' do
it 'returns array of tool instances including custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
tools = scenario.send(:agent_tools)
expect(tools.length).to eq(1)
expect(tools.first).to be_a(Captain::Tools::HttpTool)
end
it 'excludes disabled custom tools from execution' do
custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
custom_tool.update!(enabled: false)
tools = scenario.send(:agent_tools)
expect(tools).to be_empty
end
it 'returns mixed static and custom tool instances' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
Class.new do
def initialize(_assistant); end
end
)
tools = scenario.send(:agent_tools)
expect(tools.length).to eq(2)
expect(tools.last).to be_a(Captain::Tools::HttpTool)
end
end
end
describe 'factory' do
it 'creates a valid scenario with associations' do
account = create(:account)
+38
View File
@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Company, type: :model do
context 'with validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(100) }
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
describe 'domain validation' do
it { is_expected.to allow_value('example.com').for(:domain) }
it { is_expected.to allow_value('sub.example.com').for(:domain) }
it { is_expected.to allow_value('').for(:domain) }
it { is_expected.to allow_value(nil).for(:domain) }
it { is_expected.not_to allow_value('invalid-domain').for(:domain) }
it { is_expected.not_to allow_value('.example.com').for(:domain) }
end
end
context 'with associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:contacts).dependent(:nullify) }
end
describe 'scopes' do
let(:account) { create(:account) }
let!(:company_b) { create(:company, name: 'B Company', account: account) }
let!(:company_a) { create(:company, name: 'A Company', account: account) }
let!(:company_c) { create(:company, name: 'C Company', account: account) }
describe '.ordered_by_name' do
it 'orders companies by name alphabetically' do
companies = described_class.where(account: account).ordered_by_name
expect(companies.map(&:name)).to eq([company_a.name, company_b.name, company_c.name])
end
end
end
end
@@ -42,58 +42,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
end
end
describe '.available_agent_tools' do
before do
# Mock the YAML file loading
allow(YAML).to receive(:load_file).and_return([
{
'id' => 'add_contact_note',
'title' => 'Add Contact Note',
'description' => 'Add a note to a contact',
'icon' => 'note-add'
},
{
'id' => 'invalid_tool',
'title' => 'Invalid Tool',
'description' => 'This tool does not exist',
'icon' => 'invalid'
}
])
# Mock class resolution - only add_contact_note exists
allow(test_class).to receive(:resolve_tool_class) do |tool_id|
case tool_id
when 'add_contact_note'
Captain::Tools::AddContactNoteTool
end
end
end
it 'returns only resolvable tools' do
tools = test_class.available_agent_tools
expect(tools.length).to eq(1)
expect(tools.first).to eq({
id: 'add_contact_note',
title: 'Add Contact Note',
description: 'Add a note to a contact',
icon: 'note-add'
})
end
it 'logs warnings for unresolvable tools' do
expect(Rails.logger).to receive(:warn).with('Tool class not found for ID: invalid_tool')
test_class.available_agent_tools
end
it 'memoizes the result' do
expect(YAML).to receive(:load_file).once.and_return([])
2.times { test_class.available_agent_tools }
end
end
describe '.resolve_tool_class' do
it 'resolves valid tool classes' do
# Mock the constantize to return a class
@@ -116,28 +64,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
end
end
describe '.available_tool_ids' do
before do
allow(test_class).to receive(:available_agent_tools).and_return([
{ id: 'add_contact_note', title: 'Add Contact Note', description: '...',
icon: 'note' },
{ id: 'update_priority', title: 'Update Priority', description: '...',
icon: 'priority' }
])
end
it 'returns array of tool IDs' do
ids = test_class.available_tool_ids
expect(ids).to eq(%w[add_contact_note update_priority])
end
it 'memoizes the result' do
expect(test_class).to receive(:available_agent_tools).once.and_return([])
2.times { test_class.available_tool_ids }
end
end
describe '#extract_tool_ids_from_text' do
it 'extracts tool IDs from text' do
text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)'
@@ -0,0 +1,33 @@
require 'rails_helper'
RSpec.describe CompanyPolicy, type: :policy do
subject(:company_policy) { described_class }
let(:account) { create(:account) }
let(:administrator) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account) }
let(:company) { create(:company, account: account) }
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :index?, :show?, :create?, :update? do
context 'when administrator' do
it { expect(company_policy).to permit(administrator_context, company) }
end
context 'when agent' do
it { expect(company_policy).to permit(agent_context, company) }
end
end
permissions :destroy? do
context 'when administrator' do
it { expect(company_policy).to permit(administrator_context, company) }
end
context 'when agent' do
it { expect(company_policy).not_to permit(agent_context, company) }
end
end
end
@@ -0,0 +1,65 @@
require 'rails_helper'
RSpec.describe ConversationPolicy, type: :policy do
subject { described_class }
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
let(:agent_account_user) { agent.account_users.find_by(account: account) }
let(:context) { { user: agent, account: account, account_user: agent_account_user } }
before do
create(:inbox_member, user: agent, inbox: inbox)
end
permissions :show? do
context 'when role grants conversation_unassigned_manage' do
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']) }
before do
agent_account_user.update!(role: :agent, custom_role: custom_role)
end
it 'allows access to conversations assigned to the agent' do
conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
expect(subject).to permit(context, conversation)
end
it 'denies access to conversations assigned to someone else' do
other_agent = create(:user, account: account, role: :agent)
conversation = create(:conversation, account: account, inbox: inbox, assignee: other_agent)
expect(subject).not_to permit(context, conversation)
end
end
context 'when role grants conversation_participating_manage' do
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_participating_manage']) }
before do
agent_account_user.update!(role: :agent, custom_role: custom_role)
end
it 'allows access to conversations assigned to the agent' do
conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
expect(subject).to permit(context, conversation)
end
it 'allows access to conversations where the agent is a participant' do
conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
create(:conversation_participant, conversation: conversation, account: account, user: agent)
expect(subject).to permit(context, conversation)
end
it 'denies access to unrelated conversations' do
conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
expect(subject).not_to permit(context, conversation)
end
end
end
end
@@ -13,7 +13,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
let(:mock_runner) { instance_double(Agents::Runner) }
let(:mock_agent) { instance_double(Agents::Agent) }
let(:mock_scenario_agent) { instance_double(Agents::Agent) }
let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }) }
let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil) }
let(:message_history) do
[
@@ -90,7 +90,8 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
expect(mock_runner).to receive(:run).with(
'I need help with my account',
context: expected_context
context: expected_context,
max_turns: 100
)
service.generate_response(message_history: message_history)
@@ -99,7 +100,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
it 'processes and formats agent result' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({ 'response' => 'Test response' })
expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil })
end
context 'when no scenarios are enabled' do
@@ -118,14 +119,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
context 'when agent result is a string' do
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response') }
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil) }
it 'formats string response correctly' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'Simple string response',
'reasoning' => 'Processed by agent'
'reasoning' => 'Processed by agent',
'agent_name' => nil
})
end
end
@@ -0,0 +1,99 @@
require 'rails_helper'
RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
let(:website_url) { 'https://example.com' }
let(:service) { described_class.new(website_url) }
let(:mock_crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
let(:mock_client) { instance_double(OpenAI::Client) }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(Captain::Tools::SimplePageCrawlService).to receive(:new).and_return(mock_crawler)
allow(service).to receive(:client).and_return(mock_client)
allow(service).to receive(:model).and_return('gpt-3.5-turbo')
end
describe '#analyze' do
context 'when website content is available and OpenAI call is successful' do
let(:openai_response) do
{
'choices' => [{
'message' => {
'content' => {
'business_name' => 'Example Corp',
'suggested_assistant_name' => 'Alex from Example Corp',
'description' => 'You specialize in helping customers with business solutions and support'
}.to_json
}
}]
}
end
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
allow(mock_client).to receive(:chat).and_return(openai_response)
end
it 'returns success' do
result = service.analyze
expect(result[:success]).to be true
expect(result[:data]).to include(
business_name: 'Example Corp',
suggested_assistant_name: 'Alex from Example Corp',
description: 'You specialize in helping customers with business solutions and support',
website_url: website_url,
favicon_url: 'https://example.com/favicon.ico'
)
end
end
context 'when website content is errored' do
before do
allow(mock_crawler).to receive(:body_text_content).and_raise(StandardError, 'Network error')
end
it 'returns error' do
result = service.analyze
expect(result[:success]).to be false
expect(result[:error]).to eq('Failed to fetch website content')
end
end
context 'when website content is unavailable' do
before do
allow(mock_crawler).to receive(:body_text_content).and_return('')
allow(mock_crawler).to receive(:page_title).and_return('')
allow(mock_crawler).to receive(:meta_description).and_return('')
end
it 'returns error' do
result = service.analyze
expect(result[:success]).to be false
expect(result[:error]).to eq('Failed to fetch website content')
end
end
context 'when OpenAI error' do
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
allow(mock_client).to receive(:chat).and_raise(StandardError, 'API error')
end
it 'returns error' do
result = service.analyze
expect(result[:success]).to be false
expect(result[:error]).to eq('API error')
end
end
end
end
@@ -125,4 +125,63 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
)
end
end
describe '#meta_description' do
context 'when meta description exists' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head><meta name="description" content="This is a test page description"></head></html>')
end
it 'returns the meta description content' do
expect(service.meta_description).to eq('This is a test page description')
end
end
context 'when meta description does not exist' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head><title>Test</title></head></html>')
end
it 'returns nil' do
expect(service.meta_description).to be_nil
end
end
end
describe '#favicon_url' do
context 'when favicon exists with relative URL' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head><link rel="icon" href="/favicon.ico"></head></html>')
end
it 'returns the resolved absolute favicon URL' do
expect(service.favicon_url).to eq('https://example.com/favicon.ico')
end
end
context 'when favicon exists with absolute URL' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head><link rel="icon" href="https://cdn.example.com/favicon.ico"></head></html>')
end
it 'returns the absolute favicon URL' do
expect(service.favicon_url).to eq('https://cdn.example.com/favicon.ico')
end
end
context 'when favicon does not exist' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head><title>Test</title></head></html>')
end
it 'returns nil' do
expect(service.favicon_url).to be_nil
end
end
end
end
+51
View File
@@ -0,0 +1,51 @@
FactoryBot.define do
factory :captain_custom_tool, class: 'Captain::CustomTool' do
sequence(:title) { |n| "Custom Tool #{n}" }
description { 'A custom HTTP tool for external API integration' }
endpoint_url { 'https://api.example.com/endpoint' }
http_method { 'GET' }
auth_type { 'none' }
auth_config { {} }
param_schema { [] }
enabled { true }
association :account
trait :with_post do
http_method { 'POST' }
request_template { '{ "key": "{{ value }}" }' }
end
trait :with_bearer_auth do
auth_type { 'bearer' }
auth_config { { token: 'test_bearer_token_123' } }
end
trait :with_basic_auth do
auth_type { 'basic' }
auth_config { { username: 'test_user', password: 'test_pass' } }
end
trait :with_api_key do
auth_type { 'api_key' }
auth_config { { key: 'test_api_key', location: 'header', name: 'X-API-Key' } }
end
trait :with_templates do
request_template { '{ "order_id": "{{ order_id }}", "source": "chatwoot" }' }
response_template { 'Order status: {{ response.status }}' }
end
trait :with_params do
param_schema do
[
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'The order ID', 'required' => true },
{ 'name' => 'include_details', 'type' => 'boolean', 'description' => 'Include order details', 'required' => false }
]
end
end
trait :disabled do
enabled { false }
end
end
end
+4
View File
@@ -13,5 +13,9 @@ FactoryBot.define do
sequence(:phone_number) { |n| "+123456789#{n}1" }
messaging_service_sid { nil }
end
trait :whatsapp do
medium { :whatsapp }
end
end
end
+20
View File
@@ -0,0 +1,20 @@
FactoryBot.define do
factory :company do
sequence(:name) { |n| "Company #{n}" }
sequence(:domain) { |n| "company#{n}.com" }
description { 'A sample company description' }
account
trait :without_domain do
domain { nil }
end
trait :with_avatar do
avatar { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
end
trait :with_long_description do
description { 'A' * 500 }
end
end
end
@@ -0,0 +1,33 @@
require 'rails_helper'
RSpec.describe ConversationReplyEmailJob, type: :job do
let(:conversation) { create(:conversation) }
let(:mailer) { double }
let(:mailer_action) { double }
before do
allow(Conversation).to receive(:find).and_return(conversation)
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:reply_with_summary).and_return(mailer_action)
allow(mailer).to receive(:reply_without_summary).and_return(mailer_action)
allow(mailer_action).to receive(:deliver_later).and_return(true)
end
it 'enqueues on mailers queue' do
ActiveJob::Base.queue_adapter = :test
expect do
described_class.perform_later(conversation.id, 123)
end.to have_enqueued_job(described_class).on_queue('mailers')
end
it 'calls reply_with_summary when last incoming message was not email' do
described_class.perform_now(conversation.id, 123)
expect(mailer).to have_received(:reply_with_summary)
end
it 'calls reply_without_summary when last incoming message was email' do
create(:message, conversation: conversation, message_type: :incoming, content_type: 'incoming_email')
described_class.perform_now(conversation.id, 123)
expect(mailer).to have_received(:reply_without_summary)
end
end
+27
View File
@@ -108,5 +108,32 @@ RSpec.describe SendReplyJob do
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Email::SendOnEmailService when its email message' do
email_channel = create(:channel_email)
message = create(:message, conversation: create(:conversation, inbox: email_channel.inbox))
allow(Email::SendOnEmailService).to receive(:new).with(message: message).and_return(process_service)
expect(Email::SendOnEmailService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Messages::SendEmailNotificationService when its webwidget message' do
webwidget_channel = create(:channel_widget)
message = create(:message, conversation: create(:conversation, inbox: webwidget_channel.inbox))
allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Messages::SendEmailNotificationService when its api channel message' do
api_channel = create(:channel_api)
message = create(:message, conversation: create(:conversation, inbox: api_channel.inbox))
allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
end
end
@@ -157,6 +157,19 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(conversation.messages.count).to eql(messages_count)
end
it 'handles different file types correctly' do
expect(hook).not_to be_nil
video_attachment_params = message_with_attachments.deep_dup
video_attachment_params[:event][:files][0][:filetype] = 'mp4'
video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4'
builder = described_class.new(video_attachment_params)
allow(builder).to receive(:sender).and_return(nil)
expect { builder.perform }.not_to raise_error
expect(conversation.messages.last.attachments).to be_any
end
end
context 'when link shared' do
+64 -1
View File
@@ -1,6 +1,8 @@
require 'rails_helper'
describe Webhooks::Trigger do
include ActiveJob::TestHelper
subject(:trigger) { described_class }
let!(:account) { create(:account) }
@@ -8,8 +10,18 @@ describe Webhooks::Trigger do
let!(:conversation) { create(:conversation, inbox: inbox) }
let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) }
let!(:webhook_type) { :api_inbox_webhook }
let(:webhook_type) { :api_inbox_webhook }
let!(:url) { 'https://test.com' }
let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') }
before do
ActiveJob::Base.queue_adapter = :test
end
after do
clear_enqueued_jobs
clear_performed_jobs
end
describe '#execute' do
it 'triggers webhook' do
@@ -54,6 +66,57 @@ describe Webhooks::Trigger do
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
end
context 'when webhook type is agent bot' do
let(:webhook_type) { :agent_bot_webhook }
it 'reopens conversation and enqueues activity message if pending' do
conversation.update(status: :pending)
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: 5
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect do
perform_enqueued_jobs do
trigger.execute(url, payload, webhook_type)
end
end.not_to(change { message.reload.status })
expect(conversation.reload.status).to eq('open')
activity_message = conversation.reload.messages.order(:created_at).last
expect(activity_message.message_type).to eq('activity')
expect(activity_message.content).to eq(agent_bot_error_content)
end
it 'does not change message status or enqueue activity when conversation is not pending' do
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: 5
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect do
trigger.execute(url, payload, webhook_type)
end.not_to(change { message.reload.status })
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
expect(conversation.reload.status).to eq('open')
end
end
end
it 'does not update message status if webhook fails for other events' do
+114 -2
View File
@@ -243,8 +243,8 @@ RSpec.describe ConversationReplyMailer do
expect(mail.decoded).to include message.content
end
it 'updates the source_id' do
expect(mail.message_id).to eq message.source_id
it 'builds messageID properly' do
expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
end
context 'when message is a CSAT survey' do
@@ -335,6 +335,118 @@ RSpec.describe ConversationReplyMailer do
expect(mail.body.encoded).not_to match(%r{<a [^>]*>avatar\.png</a>})
end
end
context 'with custom email content' do
it 'uses custom HTML content when available and creates multipart email' do
message_with_custom_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular message content',
content_attributes: {
email: {
html_content: {
reply: '<p>Custom <strong>HTML</strong> content for email</p>'
},
text_content: {
reply: 'Custom text content for email'
}
}
})
mail = described_class.email_reply(message_with_custom_content).deliver_now
# Check HTML part contains custom HTML content
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<p>Custom <strong>HTML</strong> content for email</p>')
expect(html_part.body.encoded).not_to include('Regular message content')
# Check text part contains custom text content
text_part = mail.text_part
if text_part
expect(text_part.body.encoded).to include('Custom text content for email')
expect(text_part.body.encoded).not_to include('Regular message content')
end
end
it 'falls back to markdown rendering when custom HTML content is not available' do
message_without_custom_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content')
mail = described_class.email_reply(message_without_custom_content).deliver_now
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<strong>markdown</strong>')
expect(html_part.body.encoded).to include('Regular')
end
it 'handles empty custom HTML content gracefully' do
message_with_empty_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content',
content_attributes: {
email: {
html_content: {
reply: ''
}
}
})
mail = described_class.email_reply(message_with_empty_content).deliver_now
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<strong>markdown</strong>')
expect(html_part.body.encoded).to include('Regular')
end
it 'handles nil custom HTML content gracefully' do
message_with_nil_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content',
content_attributes: {
email: {
html_content: {
reply: nil
}
}
})
mail = described_class.email_reply(message_with_nil_content).deliver_now
expect(mail.body.encoded).to include('<strong>markdown</strong>')
expect(mail.body.encoded).to include('Regular')
end
it 'uses custom text content in text part when only text is provided' do
message_with_text_only = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular message content',
content_attributes: {
email: {
text_content: {
reply: 'Custom text content only'
}
}
})
mail = described_class.email_reply(message_with_text_only).deliver_now
text_part = mail.text_part
if text_part
expect(text_part.body.encoded).to include('Custom text content only')
expect(text_part.body.encoded).not_to include('Regular message content')
end
end
end
end
context 'when smtp enabled for email channel' do
@@ -0,0 +1,113 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ApplicationRecord do
it_behaves_like 'encrypted external credential',
factory: :channel_email,
attribute: :smtp_password,
value: 'smtp-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_email,
attribute: :imap_password,
value: 'imap-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_twilio_sms,
attribute: :auth_token,
value: 'twilio-secret'
it_behaves_like 'encrypted external credential',
factory: :integrations_hook,
attribute: :access_token,
value: 'hook-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_facebook_page,
attribute: :page_access_token,
value: 'fb-page-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_facebook_page,
attribute: :user_access_token,
value: 'fb-user-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_instagram,
attribute: :access_token,
value: 'ig-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_line,
attribute: :line_channel_secret,
value: 'line-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_line,
attribute: :line_channel_token,
value: 'line-token-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_telegram,
attribute: :bot_token,
value: 'telegram-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_twitter_profile,
attribute: :twitter_access_token,
value: 'twitter-access-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_twitter_profile,
attribute: :twitter_access_token_secret,
value: 'twitter-secret-secret'
context 'when backfilling legacy plaintext' do
before do
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
end
it 'reads existing plaintext and encrypts on update' do
account = create(:account)
channel = create(:channel_email, account: account, smtp_password: nil)
# Simulate legacy plaintext by updating the DB directly
sql = ActiveRecord::Base.send(
:sanitize_sql_array,
['UPDATE channel_email SET smtp_password = ? WHERE id = ?', 'legacy-plain', channel.id]
)
ActiveRecord::Base.connection.execute(sql)
legacy_record = Channel::Email.find(channel.id)
expect(legacy_record.smtp_password).to eq('legacy-plain')
legacy_record.update!(smtp_password: 'encrypted-now')
stored_value = legacy_record.reload.read_attribute_before_type_cast(:smtp_password)
expect(stored_value).to be_present
expect(stored_value).not_to include('encrypted-now')
expect(legacy_record.smtp_password).to eq('encrypted-now')
end
end
context 'when looking up telegram legacy records' do
before do
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
end
it 'finds plaintext records via fallback lookup' do
channel = create(:channel_telegram, bot_token: 'legacy-token')
# Simulate legacy plaintext by updating the DB directly
sql = ActiveRecord::Base.send(
:sanitize_sql_array,
['UPDATE channel_telegram SET bot_token = ? WHERE id = ?', 'legacy-token', channel.id]
)
ActiveRecord::Base.connection.execute(sql)
found = Channel::Telegram.find_by(bot_token: 'legacy-token')
expect(found).to eq(channel)
end
end
end
+92 -27
View File
@@ -4,6 +4,12 @@ require 'rails_helper'
require Rails.root.join 'spec/models/concerns/liquidable_shared.rb'
RSpec.describe Message do
before do
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
# rubocop:enable RSpec/AnyInstance
end
context 'with validations' do
it { is_expected.to validate_presence_of(:inbox_id) }
it { is_expected.to validate_presence_of(:conversation_id) }
@@ -310,43 +316,52 @@ RSpec.describe Message do
end
context 'with conversation continuity' do
it 'calls notify email method on after save for outgoing messages in website channel' do
allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
message.message_type = 'outgoing'
message.save!
expect(ConversationReplyEmailWorker).to have_received(:perform_in)
let(:inbox_with_continuity) do
create(:inbox, account: message.account,
channel: build(:channel_widget, account: message.account, continuity_via_email: true))
end
it 'does not call notify email for website channel if continuity is disabled' do
message.inbox = create(:inbox, account: message.account,
channel: build(:channel_widget, account: message.account, continuity_via_email: false))
allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
it 'schedules email notification for outgoing messages in website channel' do
message.inbox = inbox_with_continuity
message.conversation.update!(inbox: inbox_with_continuity)
message.conversation.contact.update!(email: 'test@example.com')
message.message_type = 'outgoing'
message.save!
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
ActiveJob::Base.queue_adapter = :test
allow(Redis::Alfred).to receive(:set).and_return(true)
perform_enqueued_jobs(only: SendReplyJob) do
expect { message.save! }.to have_enqueued_job(ConversationReplyEmailJob).with(message.conversation.id, kind_of(Integer)).on_queue('mailers')
end
end
it 'wont call notify email method for private notes' do
it 'does not schedule email for website channel if continuity is disabled' do
inbox_without_continuity = create(:inbox, account: message.account,
channel: build(:channel_widget, account: message.account, continuity_via_email: false))
message.inbox = inbox_without_continuity
message.conversation.update!(inbox: inbox_without_continuity)
message.conversation.contact.update!(email: 'test@example.com')
message.message_type = 'outgoing'
ActiveJob::Base.queue_adapter = :test
expect { message.save! }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
it 'does not schedule email for private notes' do
message.inbox = inbox_with_continuity
message.conversation.update!(inbox: inbox_with_continuity)
message.conversation.contact.update!(email: 'test@example.com')
message.private = true
allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
message.save!
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
end
it 'calls EmailReply worker if the channel is email' do
message.inbox = create(:inbox, account: message.account, channel: build(:channel_email, account: message.account))
allow(EmailReplyWorker).to receive(:perform_in).and_return(true)
message.message_type = 'outgoing'
message.content_attributes = { email: { text_content: { quoted: 'quoted text' } } }
message.save!
expect(EmailReplyWorker).to have_received(:perform_in).with(1.second, message.id)
ActiveJob::Base.queue_adapter = :test
expect { message.save! }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
it 'wont call notify email method unless its website or email channel' do
message.inbox = create(:inbox, account: message.account, channel: build(:channel_api, account: message.account))
allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
it 'calls SendReplyJob for all channels' do
allow(SendReplyJob).to receive(:perform_later).and_return(true)
message.message_type = 'outgoing'
message.save!
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
expect(SendReplyJob).to have_received(:perform_later).with(message.id)
end
end
end
@@ -678,4 +693,54 @@ RSpec.describe Message do
end
end
end
describe '#reindex_for_search callback' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
account.enable_features('advanced_search_indexing')
end
context 'when message should be indexed' do
it 'calls reindex_for_search for incoming message on create' do
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).to receive(:reindex_for_search)
message.save!
end
it 'calls reindex_for_search for outgoing message on update' do
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
# rubocop:enable RSpec/AnyInstance
message = create(:message, conversation: conversation, account: account, message_type: :outgoing)
expect(message).to receive(:reindex_for_search).and_return(true)
message.update!(content: 'Updated content')
end
end
context 'when message should not be indexed' do
it 'does not call reindex_for_search for activity message' do
message = build(:message, conversation: conversation, account: account, message_type: :activity)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
it 'does not call reindex_for_search for unpaid account on cloud' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account.disable_features('advanced_search_indexing')
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
it 'does not call reindex_for_search when advanced search is not allowed' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
end
end
end
+42 -3
View File
@@ -4,11 +4,12 @@ RSpec.describe ConversationPolicy, type: :policy do
subject { described_class }
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator_context) { { user: administrator, account: account, account_user: administrator.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: agent.account_users.first } }
let(:administrator_context) { { user: administrator, account: account, account_user: administrator.account_users.find_by(account: account) } }
let(:agent_context) { { user: agent, account: account, account_user: agent.account_users.find_by(account: account) } }
let(:conversation) { create(:conversation, account: account) }
permissions :destroy? do
context 'when user is an administrator' do
@@ -31,4 +32,42 @@ RSpec.describe ConversationPolicy, type: :policy do
end
end
end
permissions :show? do
context 'when user is an administrator' do
it 'allows access' do
expect(subject).to permit(administrator_context, conversation)
end
end
context 'when agent has inbox access' do
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before { create(:inbox_member, user: agent, inbox: inbox) }
it 'allows access' do
expect(subject).to permit(agent_context, conversation)
end
end
context 'when agent has team access' do
let(:team) { create(:team, account: account) }
let(:conversation) { create(:conversation, :with_team, account: account, team: team) }
before { create(:team_member, team: team, user: agent) }
it 'allows access' do
expect(subject).to permit(agent_context, conversation)
end
end
context 'when agent lacks inbox and team access' do
let(:conversation) { create(:conversation, account: account) }
it 'denies access' do
expect(subject).not_to permit(agent_context, conversation)
end
end
end
end
@@ -0,0 +1,86 @@
require 'rails_helper'
describe Email::SendOnEmailService do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, account: account) }
let(:inbox) { create(:inbox, account: account, channel: email_channel) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
let(:service) { described_class.new(message: message) }
describe '#perform' do
let(:mailer_context) { instance_double(ConversationReplyMailer) }
let(:delivery) { instance_double(ActionMailer::MessageDelivery) }
let(:email_message) { instance_double(Mail::Message) }
before do
allow(ConversationReplyMailer).to receive(:with).with(account: message.account).and_return(mailer_context)
end
context 'when message is email notifiable' do
before do
allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
allow(delivery).to receive(:deliver_now).and_return(email_message)
allow(email_message).to receive(:message_id).and_return(
"conversation/#{conversation.uuid}/messages/" \
"#{message.id}@#{conversation.account.domain}"
)
end
it 'sends email via ConversationReplyMailer' do
service.perform
expect(ConversationReplyMailer).to have_received(:with).with(account: message.account)
expect(mailer_context).to have_received(:email_reply).with(message)
expect(delivery).to have_received(:deliver_now)
end
it 'updates message source id on success' do
service.perform
expect(message.reload.source_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
end
end
context 'when message is not email notifiable' do
let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
before do
allow(mailer_context).to receive(:email_reply)
end
it 'does not send email' do
service.perform
expect(ConversationReplyMailer).not_to have_received(:with)
expect(mailer_context).not_to have_received(:email_reply)
end
end
context 'when an error occurs' do
let(:error_message) { 'SMTP connection failed' }
let(:error) { StandardError.new(error_message) }
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
let(:status_service) { instance_double(Messages::StatusUpdateService, perform: true) }
before do
allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
allow(delivery).to receive(:deliver_now).and_raise(error)
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
end
it 'captures the exception' do
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: message.account)
service.perform
end
it 'updates message status to failed' do
service.perform
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq(error_message)
end
end
end
end
@@ -0,0 +1,178 @@
require 'rails_helper'
describe Messages::SendEmailNotificationService do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
let(:service) { described_class.new(message: message) }
describe '#perform' do
context 'when email notification should be sent' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
allow(Redis::Alfred).to receive(:set).and_return(true)
ActiveJob::Base.queue_adapter = :test
end
it 'enqueues ConversationReplyEmailJob' do
expect { service.perform }.to have_enqueued_job(ConversationReplyEmailJob).with(conversation.id, message.id).on_queue('mailers')
end
it 'atomically sets redis key to prevent duplicate emails' do
expected_key = format(Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
service.perform
expect(Redis::Alfred).to have_received(:set).with(expected_key, message.id, nx: true, ex: 1.hour.to_i)
end
context 'when redis key already exists' do
before do
allow(Redis::Alfred).to receive(:set).and_return(false)
end
it 'does not enqueue job' do
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
it 'attempts atomic set once' do
service.perform
expect(Redis::Alfred).to have_received(:set).once
end
end
end
context 'when handling concurrent requests' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
end
it 'prevents duplicate jobs under race conditions' do
# Create 5 threads that simultaneously try to enqueue workers for the same conversation
threads = Array.new(5) do
Thread.new do
msg = create(:message, conversation: conversation, message_type: 'outgoing')
described_class.new(message: msg).perform
end
end
threads.each(&:join)
# Only ONE job should be scheduled despite 5 concurrent attempts
jobs_for_conversation = ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job|
job[:job] == ConversationReplyEmailJob && job[:args].first == conversation.id
end
expect(jobs_for_conversation.size).to eq(1)
end
end
context 'when email notification should not be sent' do
before do
ActiveJob::Base.queue_adapter = :test
end
context 'when message is not email notifiable' do
let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
it 'does not enqueue job' do
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
end
context 'when contact has no email' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: nil)
end
it 'does not enqueue job' do
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
end
context 'when channel does not support email notifications' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_sms, account: account)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
end
it 'does not enqueue job' do
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
end
end
end
describe '#should_send_email_notification?' do
context 'with WebWidget channel' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
end
it 'returns true when continuity_via_email is enabled' do
expect(service.send(:should_send_email_notification?)).to be true
end
context 'when continuity_via_email is disabled' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: false)) }
it 'returns false' do
expect(service.send(:should_send_email_notification?)).to be false
end
end
end
context 'with API channel' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_api, account: account)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(true)
end
it 'returns true when email_continuity_on_api_channel feature is enabled' do
expect(service.send(:should_send_email_notification?)).to be true
end
context 'when email_continuity_on_api_channel feature is disabled' do
before do
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(false)
end
it 'returns false' do
expect(service.send(:should_send_email_notification?)).to be false
end
end
end
context 'with other channels' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_email, account: account)) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
conversation.contact.update!(email: 'test@example.com')
end
it 'returns false' do
expect(service.send(:should_send_email_notification?)).to be false
end
end
end
end
@@ -402,6 +402,230 @@ describe Twilio::IncomingMessageService do
existing_contact.reload
expect(existing_contact.name).to eq('Alice Johnson')
end
describe 'When the incoming number is a Brazilian number in new format with 9 included' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'creates appropriate conversations, message and contacts if contact does not exist' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5541988887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil',
ProfileName: 'João Silva'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('João Silva')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
end
it 'appends to existing contact if contact inbox exists' do
# Create existing contact with same format
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5541988887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Another message from Brazil',
ProfileName: 'João Silva'
}
described_class.new(params: params).perform
# No new conversation should be created
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the last conversation
expect(last_conversation.messages.last.content).to eq('Another message from Brazil')
end
end
describe 'When incoming number is a Brazilian number in old format without the 9 included' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'appends to existing contact when contact inbox exists in old format' do
# Create existing contact with old format (12 digits)
old_contact = create(:contact, account: account, phone_number: '+554188887777')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+554188887777', contact: old_contact, inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+554188887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil old format',
ProfileName: 'Maria Silva'
}
described_class.new(params: params).perform
# No new conversation should be created
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the last conversation
expect(last_conversation.messages.last.content).to eq('Test message from Brazil old format')
end
it 'appends to existing contact when contact inbox exists in new format' do
# Create existing contact with new format (13 digits)
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
# Incoming message with old format (12 digits)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+554188887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil',
ProfileName: 'João Silva'
}
described_class.new(params: params).perform
# Should find and use existing contact, not create duplicate
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the existing conversation
expect(last_conversation.messages.last.content).to eq('Test message from Brazil')
# Should use the existing contact's source_id (normalized format)
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
end
it 'creates contact inbox with incoming number when no existing contact' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+554188887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil',
ProfileName: 'Carlos Silva'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Silva')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+554188887777')
end
end
describe 'When the incoming number is an Argentine number with 9 after country code' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'creates appropriate conversations, message and contacts if contact does not exist' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5491123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Carlos Mendoza'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Mendoza')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5491123456789')
end
it 'appends to existing contact if contact inbox exists with normalized format' do
# Create existing contact with normalized format (without 9 after country code)
normalized_contact = create(:contact, account: account, phone_number: '+541123456789')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: normalized_contact,
inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
# Incoming message with 9 after country code
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5491123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Carlos Mendoza'
}
described_class.new(params: params).perform
# Should find and use existing contact, not create duplicate
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the existing conversation
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
# Should use the normalized source_id from existing contact
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
end
end
describe 'When incoming number is an Argentine number without 9 after country code' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'appends to existing contact when contact inbox exists with same format' do
# Create existing contact with same format (without 9)
contact = create(:contact, account: account, phone_number: '+541123456789')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: contact, inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+541123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Ana García'
}
described_class.new(params: params).perform
# No new conversation should be created
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the last conversation
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
end
it 'creates contact inbox with incoming number when no existing contact' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+541123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Diego López'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Diego López')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
end
end
end
end
end
@@ -341,6 +341,58 @@ describe Whatsapp::IncomingMessageService do
end
end
describe 'When the incoming waid is an Argentine number with 9 after country code' do
let(:wa_id) { '5491123456789' }
it 'creates appropriate conversations, message and contacts if contact does not exist' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
it 'appends to existing contact if contact inbox exists with normalized format' do
# Normalized format removes the 9 after country code
normalized_wa_id = '541123456789'
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
# should use the normalized wa_id from existing contact
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(normalized_wa_id)
end
end
describe 'When incoming waid is an Argentine number without 9 after country code' do
let(:wa_id) { '541123456789' }
context 'when a contact inbox exists with the same format' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
end
end
context 'when a contact inbox does not exist' do
it 'creates contact inbox with the incoming waid' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
end
end
describe 'when message processing is in progress' do
it 'ignores the current message creation request' do
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],
@@ -0,0 +1,70 @@
require 'rails_helper'
describe Whatsapp::PopulateTemplateParametersService do
let(:service) { described_class.new }
describe '#normalize_url' do
it 'normalizes URLs with spaces' do
url_with_spaces = 'https://example.com/path with spaces'
normalized = service.send(:normalize_url, url_with_spaces)
expect(normalized).to eq('https://example.com/path%20with%20spaces')
end
it 'handles URLs with special characters' do
url = 'https://example.com/path?query=test value'
normalized = service.send(:normalize_url, url)
expect(normalized).to include('https://example.com/path')
expect(normalized).not_to include(' ')
end
it 'returns valid URLs unchanged' do
url = 'https://example.com/valid-path'
normalized = service.send(:normalize_url, url)
expect(normalized).to eq(url)
end
end
describe '#build_media_parameter' do
context 'when URL contains spaces' do
it 'normalizes the URL before building media parameter' do
url_with_spaces = 'https://example.com/image with spaces.jpg'
result = service.build_media_parameter(url_with_spaces, 'IMAGE')
expect(result[:type]).to eq('image')
expect(result[:image][:link]).to eq('https://example.com/image%20with%20spaces.jpg')
end
end
context 'when URL contains special characters in query string' do
it 'normalizes the URL correctly' do
url = 'https://example.com/video.mp4?title=My Video'
result = service.build_media_parameter(url, 'VIDEO', 'test_video')
expect(result[:type]).to eq('video')
expect(result[:video][:link]).not_to include(' ')
end
end
context 'when URL is already valid' do
it 'builds media parameter without changing URL' do
url = 'https://example.com/document.pdf'
result = service.build_media_parameter(url, 'DOCUMENT', 'test.pdf')
expect(result[:type]).to eq('document')
expect(result[:document][:link]).to eq(url)
expect(result[:document][:filename]).to eq('test.pdf')
end
end
context 'when URL is blank' do
it 'returns nil' do
result = service.build_media_parameter('', 'IMAGE')
expect(result).to be_nil
end
end
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
RSpec.shared_examples 'encrypted external credential' do |factory:, attribute:, value: 'secret-token'|
before do
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
if defined?(Facebook::Messenger::Subscriptions)
allow(Facebook::Messenger::Subscriptions).to receive(:subscribe).and_return(true)
allow(Facebook::Messenger::Subscriptions).to receive(:unsubscribe).and_return(true)
end
end
it "encrypts #{attribute} at rest" do
record = create(factory, attribute => value)
raw_stored_value = record.reload.read_attribute_before_type_cast(attribute).to_s
expect(raw_stored_value).to be_present
expect(raw_stored_value).not_to include(value)
expect(record.public_send(attribute)).to eq(value)
expect(record.encrypted_attribute?(attribute)).to be(true)
end
end
@@ -1,46 +0,0 @@
require 'rails_helper'
Sidekiq::Testing.fake!
RSpec.describe ConversationReplyEmailWorker, type: :worker do
let(:conversation) { build(:conversation, display_id: nil) }
let(:message) { build(:message, conversation: conversation, content_type: 'incoming_email', inbox: conversation.inbox) }
let(:mailer) { double }
let(:mailer_action) { double }
describe 'testing ConversationSummaryEmailWorker' do
before do
conversation.save!
allow(Conversation).to receive(:find).and_return(conversation)
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:reply_with_summary).and_return(mailer_action)
allow(mailer).to receive(:reply_without_summary).and_return(mailer_action)
allow(mailer_action).to receive(:deliver_later).and_return(true)
end
it 'worker jobs are enqueued in the mailers queue' do
described_class.perform_async
expect(described_class.queue).to eq(:mailers)
end
it 'goes into the jobs array for testing environment' do
expect do
described_class.perform_async
end.to change(described_class.jobs, :size).by(1)
described_class.new.perform(1, message.id)
end
context 'with actions performed by the worker' do
it 'calls ConversationSummaryMailer#reply_with_summary when last incoming message was not email' do
described_class.new.perform(1, message.id)
expect(mailer).to have_received(:reply_with_summary)
end
it 'calls ConversationSummaryMailer#reply_without_summary when last incoming message was from email' do
message.save!
described_class.new.perform(1, message.id)
expect(mailer).to have_received(:reply_without_summary)
end
end
end
end
-58
View File
@@ -1,58 +0,0 @@
require 'rails_helper'
RSpec.describe EmailReplyWorker, type: :worker do
let(:account) { create(:account) }
let(:channel) { create(:channel_email, account: account) }
let(:message) { create(:message, message_type: :outgoing, inbox: channel.inbox, account: account) }
let(:private_message) { create(:message, private: true, message_type: :outgoing, inbox: channel.inbox, account: account) }
let(:incoming_message) { create(:message, message_type: :incoming, inbox: channel.inbox, account: account) }
let(:template_message) { create(:message, message_type: :template, content_type: :input_csat, inbox: channel.inbox, account: account) }
let(:mailer) { double }
let(:mailer_action) { double }
describe '#perform' do
context 'when emails are successfully sent' do
before do
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:email_reply).and_return(mailer_action)
allow(mailer_action).to receive(:deliver_now).and_return(true)
end
it 'calls mailer action with message' do
described_class.new.perform(message.id)
expect(mailer).to have_received(:email_reply).with(message)
expect(mailer_action).to have_received(:deliver_now)
end
it 'does not call mailer action with a private message' do
described_class.new.perform(private_message.id)
expect(mailer).not_to have_received(:email_reply)
expect(mailer_action).not_to have_received(:deliver_now)
end
it 'calls mailer action with a CSAT message' do
described_class.new.perform(template_message.id)
expect(mailer).to have_received(:email_reply).with(template_message)
expect(mailer_action).to have_received(:deliver_now)
end
it 'does not call mailer action with an incoming message' do
described_class.new.perform(incoming_message.id)
expect(mailer).not_to have_received(:email_reply)
expect(mailer_action).not_to have_received(:deliver_now)
end
end
context 'when emails are not sent' do
before do
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:email_reply).and_return(mailer_action)
allow(mailer_action).to receive(:deliver_now).and_raise(ArgumentError)
end
it 'mark message as failed' do
expect { described_class.new.perform(message.id) }.to change { message.reload.status }.from('sent').to('failed')
end
end
end
end