Merge branch 'develop' into feat/github-integration
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user