Merge branch 'develop' into fix/CW-7007
This commit is contained in:
@@ -181,10 +181,6 @@ describe Messages::MessageBuilder do
|
||||
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',
|
||||
|
||||
@@ -6,9 +6,11 @@ describe NotificationBuilder do
|
||||
describe '#perform' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account: account) }
|
||||
let!(:primary_actor) { create(:conversation, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:primary_actor) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: user, inbox: inbox)
|
||||
notification_setting = user.notification_settings.find_by(account_id: account.id)
|
||||
notification_setting.selected_email_flags = [:email_conversation_creation]
|
||||
notification_setting.selected_push_flags = [:push_conversation_creation]
|
||||
@@ -97,5 +99,66 @@ describe NotificationBuilder do
|
||||
).perform
|
||||
end.to change { user.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
context 'when the user does not have access to the conversation' do
|
||||
let!(:outsider) { create(:user, account: account) }
|
||||
|
||||
it 'does not create a notification for an agent without inbox or team access' do
|
||||
expect do
|
||||
described_class.new(
|
||||
notification_type: 'conversation_creation',
|
||||
user: outsider,
|
||||
account: account,
|
||||
primary_actor: primary_actor
|
||||
).perform
|
||||
end.not_to(change { outsider.notifications.count })
|
||||
end
|
||||
|
||||
it 'still creates a notification for administrators regardless of inbox membership' do
|
||||
admin = create(:user, account: account, role: :administrator)
|
||||
admin_setting = admin.notification_settings.find_by(account_id: account.id)
|
||||
admin_setting.selected_email_flags = [:email_conversation_creation]
|
||||
admin_setting.selected_push_flags = [:push_conversation_creation]
|
||||
admin_setting.save!
|
||||
|
||||
expect do
|
||||
described_class.new(
|
||||
notification_type: 'conversation_creation',
|
||||
user: admin,
|
||||
account: account,
|
||||
primary_actor: primary_actor
|
||||
).perform
|
||||
end.to change { admin.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'does not create a notification when the user is not part of the account' do
|
||||
unrelated_user = create(:user)
|
||||
|
||||
expect do
|
||||
described_class.new(
|
||||
notification_type: 'conversation_creation',
|
||||
user: unrelated_user,
|
||||
account: account,
|
||||
primary_actor: primary_actor
|
||||
).perform
|
||||
end.not_to(change { unrelated_user.notifications.count })
|
||||
end
|
||||
|
||||
it 'derives the conversation from a message primary_actor' do
|
||||
outsider_inbox = create(:inbox, account: account)
|
||||
message = create(:message, account: account, inbox: outsider_inbox,
|
||||
conversation: create(:conversation, account: account, inbox: outsider_inbox))
|
||||
|
||||
expect do
|
||||
described_class.new(
|
||||
notification_type: 'conversation_mention',
|
||||
user: outsider,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
).perform
|
||||
end.not_to(change { outsider.notifications.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -120,6 +120,8 @@ describe V2::ReportBuilder do
|
||||
# Reopen 1 conversation
|
||||
conversations.first.open!
|
||||
end
|
||||
create(:reporting_event, account: account, inbox: account.inboxes.first, conversation: nil, conversation_id: nil,
|
||||
name: 'conversation_bot_handoff', created_at: Time.zone.today)
|
||||
|
||||
builder = described_class.new(account, params)
|
||||
metrics = builder.timeseries
|
||||
|
||||
@@ -4,35 +4,99 @@ RSpec.describe V2::Reports::BotMetricsBuilder do
|
||||
subject(:bot_metrics_builder) { described_class.new(inbox.account, params) }
|
||||
|
||||
let(:inbox) { create(:inbox) }
|
||||
let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
let!(:unresolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
let(:since) { 1.week.ago.to_i.to_s }
|
||||
let(:until_time) { Time.now.to_i.to_s }
|
||||
let(:params) { { since: since, until: until_time } }
|
||||
|
||||
before do
|
||||
create(:agent_bot_inbox, inbox: inbox)
|
||||
create(:message, account: inbox.account, conversation: resolved_conversation, created_at: 2.days.ago, message_type: 'outgoing')
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved', conversation_id: resolved_conversation.id,
|
||||
created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: resolved_conversation.id, created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: unresolved_conversation.id, created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
describe '#metrics' do
|
||||
context 'with valid params' do
|
||||
let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
let!(:handoff_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
|
||||
before do
|
||||
create(:message, account: inbox.account, conversation: resolved_conversation, created_at: 2.days.ago, message_type: 'outgoing')
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
|
||||
conversation_id: resolved_conversation.id, created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: handoff_conversation.id, created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
it 'returns correct metrics' do
|
||||
metrics = bot_metrics_builder.metrics
|
||||
|
||||
expect(metrics[:conversation_count]).to eq(2)
|
||||
expect(metrics[:message_count]).to eq(1)
|
||||
expect(metrics[:resolution_rate]).to eq(50)
|
||||
expect(metrics[:handoff_rate]).to eq(50)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a conversation has both bot_resolved and bot_handoff events in the same range' do
|
||||
let!(:double_counted_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
let!(:handoff_only_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
|
||||
before do
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
|
||||
conversation_id: double_counted_conversation.id, created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: double_counted_conversation.id, created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: handoff_only_conversation.id, created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
it 'excludes the conversation from resolution count — handoff wins' do
|
||||
metrics = bot_metrics_builder.metrics
|
||||
|
||||
expect(metrics[:conversation_count]).to eq(2)
|
||||
expect(metrics[:resolution_rate]).to eq(0)
|
||||
expect(metrics[:handoff_rate]).to eq(100)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when bot_resolved and bot_handoff are in different date ranges' do
|
||||
let!(:multi_cycle_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
|
||||
before do
|
||||
# Bot resolved in current range
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
|
||||
conversation_id: multi_cycle_conversation.id, created_at: 2.days.ago)
|
||||
# Handoff happened before the range (in a previous cycle)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: multi_cycle_conversation.id, created_at: 2.weeks.ago)
|
||||
end
|
||||
|
||||
it 'counts the resolution since the handoff is outside the range' do
|
||||
metrics = bot_metrics_builder.metrics
|
||||
|
||||
expect(metrics[:conversation_count]).to eq(1)
|
||||
expect(metrics[:resolution_rate]).to eq(100)
|
||||
expect(metrics[:handoff_rate]).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a bot_handoff event has no conversation' do
|
||||
let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
|
||||
before do
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved',
|
||||
conversation_id: resolved_conversation.id, created_at: 2.days.ago)
|
||||
create(:reporting_event, account: inbox.account, inbox: inbox, conversation: nil, conversation_id: nil,
|
||||
name: 'conversation_bot_handoff', created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
it 'does not exclude all bot resolutions' do
|
||||
metrics = bot_metrics_builder.metrics
|
||||
|
||||
expect(metrics[:conversation_count]).to eq(1)
|
||||
expect(metrics[:resolution_rate]).to eq(100)
|
||||
expect(metrics[:handoff_rate]).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing params' do
|
||||
let(:params) { {} }
|
||||
|
||||
|
||||
@@ -310,4 +310,43 @@ describe V2::Reports::Timeseries::ReportBuilder do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'bot resolution counts' do
|
||||
subject(:builder) { described_class.new(account, params) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:current_time) { Time.current }
|
||||
let(:params) do
|
||||
{
|
||||
type: 'account',
|
||||
metric: 'bot_resolutions_count',
|
||||
since: (current_time - 1.day).beginning_of_day.to_i.to_s,
|
||||
until: current_time.end_of_day.to_i.to_s,
|
||||
timezone_offset: nil,
|
||||
group_by: 'day'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
travel_to current_time
|
||||
|
||||
resolved_conversation = create(:conversation, account: account, inbox: inbox)
|
||||
double_counted_conversation = create(:conversation, account: account, inbox: inbox)
|
||||
|
||||
create(:reporting_event, name: 'conversation_bot_resolved', account: account, conversation: resolved_conversation,
|
||||
created_at: current_time)
|
||||
create(:reporting_event, name: 'conversation_bot_resolved', account: account, conversation: double_counted_conversation,
|
||||
created_at: current_time)
|
||||
create(:reporting_event, name: 'conversation_bot_handoff', account: account, conversation: double_counted_conversation,
|
||||
created_at: current_time)
|
||||
create(:reporting_event, name: 'conversation_bot_handoff', account: account, inbox: inbox, conversation: nil, conversation_id: nil,
|
||||
created_at: current_time)
|
||||
end
|
||||
|
||||
it 'excludes conversations that also had a bot handoff in the range' do
|
||||
expect(builder.aggregate_value).to eq(1)
|
||||
expect(builder.timeseries.sum { |row| row[:value] }).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,6 +34,10 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
||||
context 'when it is an authenticated user' do
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
Conversation.all.find_each { |conversation| create(:inbox_member, inbox: conversation.inbox, user: agent) }
|
||||
end
|
||||
|
||||
it 'Ignores bulk_actions for wrong type' do
|
||||
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
||||
headers: agent.create_new_auth_token,
|
||||
@@ -202,6 +206,10 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
||||
context 'when it is an authenticated user' do
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
Conversation.all.find_each { |conversation| create(:inbox_member, inbox: conversation.inbox, user: agent) }
|
||||
end
|
||||
|
||||
it 'Bulk delete conversation labels' do
|
||||
Conversation.first.add_labels(%w[support priority_customer])
|
||||
Conversation.second.add_labels(%w[support priority_customer])
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/attachments', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:inbox_1) { create(:inbox, account: account) }
|
||||
let(:inbox_2) { create(:inbox, account: account) }
|
||||
let(:contact_inbox_1) { create(:contact_inbox, contact: contact, inbox: inbox_1) }
|
||||
let(:contact_inbox_2) { create(:contact_inbox, contact: contact, inbox: inbox_2) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:unknown) { create(:user, account: account, role: nil) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox_1)
|
||||
|
||||
conversation_1 = create(:conversation, account: account, inbox: inbox_1, contact: contact, contact_inbox: contact_inbox_1)
|
||||
conversation_2 = create(:conversation, account: account, inbox: inbox_2, contact: contact, contact_inbox: contact_inbox_2)
|
||||
|
||||
create(:message, :with_attachment, conversation: conversation_1, account: account, inbox: inbox_1, message_type: 'incoming')
|
||||
create(:message, :with_attachment, conversation: conversation_2, account: account, inbox: inbox_2, message_type: 'incoming')
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/contacts/:id/attachments' do
|
||||
context 'when unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is logged in' do
|
||||
context 'with user as administrator' do
|
||||
it 'returns attachments from all the contact conversations' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload'].length).to eq 2
|
||||
expect(json_response['meta']['total_count']).to eq 2
|
||||
end
|
||||
|
||||
it 'serialises the conversation display id as conversation_id' do
|
||||
conversation = contact.conversations.first
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
payload = response.parsed_body['payload']
|
||||
attachment = payload.find { |a| a['conversation_id'] == conversation.display_id }
|
||||
expect(attachment).not_to be_nil
|
||||
expect(attachment).to include('id', 'message_id', 'data_url', 'file_type', 'created_at', 'sender')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with user as agent' do
|
||||
it 'returns attachments only from inboxes the agent has access to' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload'].length).to eq 1
|
||||
expect(json_response['meta']['total_count']).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'with user as unknown role' do
|
||||
it 'returns no attachments' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
|
||||
headers: unknown.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload']).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1008,6 +1008,19 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'does not allow binding an agent bot from another account' do
|
||||
other_account = create(:account)
|
||||
foreign_bot = create(:agent_bot, account: other_account)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/set_agent_bot",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { agent_bot: foreign_bot.id },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(inbox.reload.agent_bot).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ require 'rails_helper'
|
||||
RSpec.describe 'Integration Apps API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
|
||||
|
||||
describe 'GET /api/v1/integrations/apps' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
|
||||
@@ -101,6 +101,20 @@ RSpec.describe 'Notifications API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(notification.reload.read_at).not_to eq('')
|
||||
end
|
||||
|
||||
it 'does not update a notification reached via a different account that the user belongs to' do
|
||||
other_account = create(:account)
|
||||
create(:account_user, account: other_account, user: admin, role: :administrator)
|
||||
original_read_at = notification.read_at
|
||||
|
||||
patch "/api/v1/accounts/#{other_account.id}/notifications/#{notification.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { read_at: true },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(notification.reload.read_at).to eq(original_read_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -227,7 +241,7 @@ RSpec.describe 'Notifications API', type: :request do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'deletes all the read notifications' do
|
||||
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, type: :read)
|
||||
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, account, type: :read)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/notifications/destroy_all",
|
||||
headers: admin.create_new_auth_token,
|
||||
@@ -238,7 +252,7 @@ RSpec.describe 'Notifications API', type: :request do
|
||||
end
|
||||
|
||||
it 'deletes all the notifications' do
|
||||
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, type: :all)
|
||||
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, account, type: :all)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/notifications/destroy_all",
|
||||
headers: admin.create_new_auth_token,
|
||||
|
||||
@@ -100,6 +100,41 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
expect(json_response['name']).to eql('test_portal')
|
||||
expect(json_response['custom_domain']).to eql('support.chatwoot.dev')
|
||||
end
|
||||
|
||||
it 'creates portal when custom_domain is omitted from request body' do
|
||||
portal_params = {
|
||||
portal: {
|
||||
name: 'test_portal_no_domain',
|
||||
slug: 'test_kbase_no_domain'
|
||||
}
|
||||
}
|
||||
post "/api/v1/accounts/#{account.id}/portals",
|
||||
params: portal_params,
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['name']).to eql('test_portal_no_domain')
|
||||
expect(json_response['custom_domain']).to be_nil
|
||||
end
|
||||
|
||||
it 'creates portal when custom_domain is blank' do
|
||||
portal_params = {
|
||||
portal: {
|
||||
name: 'test_portal_blank_domain',
|
||||
slug: 'test_kbase_blank_domain',
|
||||
custom_domain: ''
|
||||
}
|
||||
}
|
||||
post "/api/v1/accounts/#{account.id}/portals",
|
||||
params: portal_params,
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['name']).to eql('test_portal_blank_domain')
|
||||
expect(json_response['custom_domain']).to be_blank
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -135,7 +170,10 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
'allowed_locales' => [
|
||||
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'en', 'draft' => false },
|
||||
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'es', 'draft' => true }
|
||||
]
|
||||
],
|
||||
'default_locale' => 'en',
|
||||
'layout' => 'classic',
|
||||
'social_profiles' => {}
|
||||
}
|
||||
)
|
||||
end
|
||||
@@ -192,6 +230,21 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
expect(portal.reload.logo).to be_attached
|
||||
end
|
||||
|
||||
it 'does not allow associating an inbox from another account' do
|
||||
other_account = create(:account)
|
||||
foreign_inbox = create(:inbox, account: other_account)
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
|
||||
params: {
|
||||
portal: { name: portal.name },
|
||||
inbox_id: foreign_inbox.id
|
||||
},
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(portal.reload.channel_web_widget_id).to be_nil
|
||||
end
|
||||
|
||||
it 'clears associated web widget when inbox selection is blank' do
|
||||
web_widget_inbox = create(:inbox, account: account)
|
||||
portal.update!(channel_web_widget: web_widget_inbox.channel)
|
||||
|
||||
@@ -106,6 +106,21 @@ RSpec.describe 'Notifications Subscriptions API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect { subscription.reload }.to raise_exception(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
it 'does not delete another user notification subscription with the same push token' do
|
||||
victim = create(:user, account: account, role: :agent)
|
||||
victim_subscription = create(:notification_subscription, subscription_type: 'fcm',
|
||||
subscription_attributes: { push_token: 'victimToken' },
|
||||
user: victim)
|
||||
|
||||
delete '/api/v1/notification_subscriptions',
|
||||
params: { push_token: 'victimToken' },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect { victim_subscription.reload }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -212,6 +212,26 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/widget/messages' do
|
||||
context 'when put request targets a message from another visitor in the same inbox' do
|
||||
it 'does not update the foreign message' do
|
||||
other_contact = create(:contact, account: account, email: nil)
|
||||
other_contact_inbox = create(:contact_inbox, contact: other_contact, inbox: web_widget.inbox)
|
||||
other_conversation = create(:conversation, contact: other_contact, account: account,
|
||||
inbox: web_widget.inbox, contact_inbox: other_contact_inbox)
|
||||
foreign_message = create(:message, content_type: 'input_email', account: account,
|
||||
inbox: web_widget.inbox, conversation: other_conversation)
|
||||
original_email = foreign_message.submitted_email
|
||||
|
||||
put api_v1_widget_message_url(foreign_message.id),
|
||||
params: { website_token: web_widget.website_token, contact: { email: Faker::Internet.email } },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(foreign_message.reload.submitted_email).to eq(original_email)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when put request is made with non existing email' do
|
||||
it 'updates message in conversation and creates a new contact' do
|
||||
message = create(:message, content_type: 'input_email', account: account, inbox: web_widget.inbox, conversation: conversation)
|
||||
|
||||
@@ -16,6 +16,19 @@ RSpec.describe 'Public Inbox Contact Conversation Messages API', type: :request
|
||||
data = response.parsed_body
|
||||
expect(data.length).to eq 2
|
||||
end
|
||||
|
||||
it 'does not return messages from a conversation in another inbox even when both share the same contact' do
|
||||
other_channel = create(:channel_api, account: conversation.account)
|
||||
other_contact_inbox = create(:contact_inbox, contact: contact, inbox: other_channel.inbox)
|
||||
foreign_conversation = create(:conversation, contact: contact, account: conversation.account,
|
||||
inbox: other_channel.inbox, contact_inbox: other_contact_inbox)
|
||||
create(:message, account: foreign_conversation.account, inbox: foreign_conversation.inbox, conversation: foreign_conversation)
|
||||
|
||||
get "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations/" \
|
||||
"#{foreign_conversation.display_id}/messages"
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /public/api/v1/inboxes/{identifier}/contact/{source_id}/conversations/{conversation_id}/messages' do
|
||||
|
||||
@@ -140,6 +140,38 @@ RSpec.describe 'Public Articles API', type: :request do
|
||||
get "/hc/#{portal.slug}/articles/#{article_in_locale.slug}"
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'resolves the locale from the article itself for an uncategorized article' do
|
||||
uncategorized_article = create(:article, category: nil, locale: 'es', portal: portal,
|
||||
account_id: account.id, author_id: agent.id)
|
||||
get "/hc/#{portal.slug}/articles/#{uncategorized_article.slug}"
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('lang="es"')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /public/api/v1/portals/:slug/articles/:slug.md (markdown)' do
|
||||
it 'serves the raw article markdown for a published article' do
|
||||
get "/hc/#{portal.slug}/articles/#{article.slug}.md"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.headers['Content-Type']).to include('text/markdown')
|
||||
expect(response.body).to eq(article.content)
|
||||
end
|
||||
|
||||
it 'returns 404 for a draft article' do
|
||||
draft_article = create(:article, category: category, status: :draft, portal: portal, account_id: account.id, author_id: agent.id)
|
||||
|
||||
get "/hc/#{portal.slug}/articles/#{draft_article.slug}.md"
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns 404 if the article does not exist' do
|
||||
get "/hc/#{portal.slug}/articles/non-existent-article.md"
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /public/api/v1/portals/:slug/articles/:slug.png (tracking pixel)' do
|
||||
|
||||
@@ -11,12 +11,13 @@ RSpec.describe 'Public Categories API', type: :request do
|
||||
end
|
||||
|
||||
describe 'GET /public/api/v1/portals/:portal_slug/categories' do
|
||||
it 'Fetch all categories in the portal' do
|
||||
it 'redirects to the locale home page' do
|
||||
category = portal.categories.first
|
||||
|
||||
get "/hc/#{portal.slug}/#{category.locale}/categories"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response).to have_http_status(:moved_permanently)
|
||||
expect(response).to redirect_to("/hc/#{portal.slug}/#{category.locale}")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -38,10 +38,24 @@ RSpec.describe 'Super Admin Application Config API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(response).to redirect_to(super_admin_settings_path)
|
||||
expect(flash[:notice]).to be_present
|
||||
expect(flash[:alert]).to be_blank
|
||||
expect(flash[:success]).to be_blank
|
||||
|
||||
config = GlobalConfig.get('FB_APP_ID')
|
||||
expect(config['FB_APP_ID']).to eq('FB_APP_ID')
|
||||
end
|
||||
|
||||
it 'asks admins to restart web and worker processes for runtime config changes' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
post '/super_admin/app_config?config=captain', params: { app_config: { CAPTAIN_OPEN_AI_ENDPOINT: 'https://api.openai.com' } }
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(response).to redirect_to(super_admin_settings_path)
|
||||
expect(flash[:success]).to be_present
|
||||
expect(flash[:alert]).to be_blank
|
||||
expect(flash[:notice]).to be_blank
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,4 +39,34 @@ RSpec.describe 'Super Admin Installation Config API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /super_admin/installation_configs/:id' do
|
||||
context 'when it is an authenticated super admin' do
|
||||
it 'shows a regular success notice for config that does not require restart' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
config = create(:installation_config, name: 'TESTCONFIG', value: 'TESTVALUE', locked: false)
|
||||
|
||||
patch "/super_admin/installation_configs/#{config.id}", params: {
|
||||
installation_config: { name: config.name, value: 'UPDATEDVALUE' }
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(flash[:notice]).to be_present
|
||||
expect(flash[:success]).to be_blank
|
||||
end
|
||||
|
||||
it 'shows a restart success notice for runtime config changes' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
config = create(:installation_config, name: 'OTEL_PROVIDER', value: 'langfuse', locked: false)
|
||||
|
||||
patch "/super_admin/installation_configs/#{config.id}", params: {
|
||||
installation_config: { name: config.name, value: 'langfuse' }
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(flash[:success]).to be_present
|
||||
expect(flash[:notice]).to be_blank
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,5 +8,10 @@ describe '/swagger', type: :request do
|
||||
expect(response.body).to include('redoc')
|
||||
expect(response.body).to include('/swagger.json')
|
||||
end
|
||||
|
||||
it 'does not render files outside the swagger directory' do
|
||||
get '/swagger/%2Fetc%2Fpasswd'
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe NotificationBuilder do
|
||||
describe '#perform with custom role permissions' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:account_user) { agent.account_users.find_by(account: account) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
notification_setting = agent.notification_settings.find_by(account_id: account.id)
|
||||
notification_setting.selected_email_flags = [:email_conversation_creation]
|
||||
notification_setting.selected_push_flags = [:push_conversation_creation]
|
||||
notification_setting.save!
|
||||
end
|
||||
|
||||
def build_notification(conversation, type: 'conversation_creation')
|
||||
described_class.new(
|
||||
notification_type: type,
|
||||
user: agent,
|
||||
account: account,
|
||||
primary_actor: conversation
|
||||
).perform
|
||||
end
|
||||
|
||||
context 'when the agent has conversation_manage permission' do
|
||||
before do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_manage'])
|
||||
account_user.update!(custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'creates a notification for any inbox conversation' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
|
||||
expect { build_notification(conversation) }.to change { agent.notifications.count }.by(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the agent has conversation_unassigned_manage permission' do
|
||||
before do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_unassigned_manage'])
|
||||
account_user.update!(custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'creates a notification for unassigned conversations' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
|
||||
|
||||
expect { build_notification(conversation) }.to change { agent.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'creates a notification for conversations assigned to the agent' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
|
||||
|
||||
expect { build_notification(conversation) }.to change { agent.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'does not create a notification for conversations assigned to someone else' do
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
create(:inbox_member, user: other_agent, inbox: inbox)
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: other_agent)
|
||||
|
||||
expect { build_notification(conversation) }.not_to(change { agent.notifications.count })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the agent has conversation_participating_manage permission' do
|
||||
before do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
|
||||
account_user.update!(custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'creates a notification for conversations assigned to the agent' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
|
||||
|
||||
expect { build_notification(conversation) }.to change { agent.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'creates a notification for conversations the agent participates in' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
|
||||
create(:conversation_participant, conversation: conversation, account: account, user: agent)
|
||||
|
||||
expect { build_notification(conversation) }.to change { agent.notifications.count }.by(1)
|
||||
end
|
||||
|
||||
it 'does not create a notification for unassigned conversations the agent does not participate in' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: nil)
|
||||
|
||||
expect { build_notification(conversation) }.not_to(change { agent.notifications.count })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the custom role grants no conversation permissions' do
|
||||
before do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['contact_manage'])
|
||||
account_user.update!(custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'does not create a notification' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
|
||||
|
||||
expect { build_notification(conversation) }.not_to(change { agent.notifications.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -46,7 +46,8 @@ RSpec.describe 'Company contacts API', type: :request do
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/companies/{company.id}/contacts' do
|
||||
it 'links an existing contact to the company' do
|
||||
contact = create(:contact, name: 'Jane Contact', account: account, additional_attributes: { 'city' => 'Berlin' })
|
||||
contact = create(:contact, name: 'Jane Contact', account: account, last_activity_at: 1.hour.ago,
|
||||
additional_attributes: { 'city' => 'Berlin' })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/companies/#{company.id}/contacts",
|
||||
params: { contact_id: contact.id },
|
||||
@@ -58,6 +59,7 @@ RSpec.describe 'Company contacts API', type: :request do
|
||||
expect(contact.additional_attributes).to eq('city' => 'Berlin')
|
||||
expect(response.parsed_body['payload']['company_id']).to eq(company.id)
|
||||
expect(response.parsed_body['payload']['linked_to_current_company']).to be true
|
||||
expect(company.reload.last_activity_at).to be_within(1.second).of(contact.last_activity_at)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'WhatsApp Calls API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:call) do
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: 'wacid_abc')
|
||||
end
|
||||
let(:provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
|
||||
|
||||
before do
|
||||
account.enable_features!('channel_voice')
|
||||
channel.provider_config = channel.provider_config.merge('source' => 'embedded_signup', 'calling_enabled' => true)
|
||||
channel.save!
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(provider_service)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/whatsapp_calls/:id' do
|
||||
it 'returns the call payload' do
|
||||
get "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}", headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = response.parsed_body
|
||||
expect(body['id']).to eq(call.id)
|
||||
expect(body['call_id']).to eq('wacid_abc')
|
||||
expect(body['provider']).to eq('whatsapp')
|
||||
end
|
||||
|
||||
it 'returns 401 when unauthenticated' do
|
||||
get "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/accept' do
|
||||
it 'forwards SDP and returns the updated call payload' do
|
||||
allow(provider_service).to receive(:pre_accept_call).and_return(true)
|
||||
allow(provider_service).to receive(:accept_call).and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/accept",
|
||||
params: { sdp_answer: 'sdp_answer' }, headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(call.reload.status).to eq('in_progress')
|
||||
end
|
||||
|
||||
it 'returns 422 when sdp_answer is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/accept",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/reject' do
|
||||
it 'rejects the call via Meta and returns its new status' do
|
||||
allow(provider_service).to receive(:reject_call).and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/reject",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(call.reload.status).to eq('failed')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/terminate' do
|
||||
it 'terminates the call via Meta and returns its new status' do
|
||||
call.update!(status: 'in_progress')
|
||||
allow(provider_service).to receive(:terminate_call).and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/terminate",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(call.reload.status).to eq('completed')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/initiate' do
|
||||
let(:contact) { create(:contact, account: account, phone_number: '+15551234567') }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '15551234567') }
|
||||
let(:initiate_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
|
||||
end
|
||||
|
||||
it 'creates an outbound Call and returns calling status' do
|
||||
allow(provider_service).to receive(:initiate_call).and_return({ 'calls' => [{ 'id' => 'wacid_outbound' }] })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body).to include('status' => 'calling', 'call_id' => 'wacid_outbound')
|
||||
expect(Call.find_by(provider_call_id: 'wacid_outbound')).to have_attributes(direction: 'outgoing', status: 'ringing')
|
||||
end
|
||||
|
||||
it 'sends a permission request and records the wamid when Meta returns NoCallPermission' do
|
||||
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
|
||||
allow(provider_service).to receive(:send_call_permission_request).and_return({ 'messages' => [{ 'id' => 'wamid.req_xyz' }] })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
# Controller deliberately returns 422 so clients can't mistake the permission-template path for a successful dial.
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['status']).to eq('permission_requested')
|
||||
attrs = initiate_conversation.reload.additional_attributes
|
||||
expect(attrs['call_permission_requested_at']).to be_present
|
||||
expect(attrs['call_permission_request_message_id']).to eq('wamid.req_xyz')
|
||||
end
|
||||
|
||||
it 'returns permission_request_failed when send_call_permission_request raises a transport error' do
|
||||
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
|
||||
allow(provider_service).to receive(:send_call_permission_request).and_raise(Faraday::TimeoutError)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.permission_request_failed'))
|
||||
end
|
||||
|
||||
it 'returns 422 when sdp_offer is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns 422 when Meta raises CallFailed for non-permission errors' do
|
||||
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::CallFailed, 'Meta error')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Meta error')
|
||||
end
|
||||
|
||||
it 'returns 422 when the conversation contact has no phone number' do
|
||||
contact.update!(phone_number: nil)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.contact_phone_required'))
|
||||
end
|
||||
|
||||
it 'returns 422 when the conversation belongs to a non-WhatsApp inbox' do
|
||||
twilio_channel = create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239998')
|
||||
create(:inbox_member, user: agent, inbox: twilio_channel.inbox)
|
||||
twilio_conversation = create(:conversation, account: account, inbox: twilio_channel.inbox)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: twilio_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.not_enabled'))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording' do
|
||||
before do
|
||||
message = create(:message, conversation: conversation, account: account, inbox: inbox,
|
||||
content_type: 'voice_call', message_type: 'incoming')
|
||||
call.update!(message_id: message.id)
|
||||
end
|
||||
|
||||
it 'attaches the recording to the call message' do
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg')
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/upload_recording",
|
||||
params: { recording: file }, headers: agent.create_new_auth_token
|
||||
end.to change { call.message.attachments.count }.by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['status']).to eq('uploaded')
|
||||
end
|
||||
|
||||
it 'is idempotent: returns already_uploaded if an audio attachment exists' do
|
||||
call.message.attachments.create!(account_id: account.id, file_type: :audio,
|
||||
file: fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg'))
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/upload_recording",
|
||||
params: { recording: fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg') },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response.parsed_body['status']).to eq('already_uploaded')
|
||||
end
|
||||
end
|
||||
end
|
||||
+11
-12
@@ -29,11 +29,6 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
# expect a 302 redirect to auth/saml/callback
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
# expect redirect to login with SSO token
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
|
||||
# verify user was created
|
||||
@@ -50,14 +45,21 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
# expect a 302 redirect to auth/saml/callback
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
end
|
||||
end
|
||||
|
||||
it 'redirects mobile SAML login to the mobile deep link' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
create(:user, email: 'mobile@example.com', account: account)
|
||||
set_saml_config('mobile@example.com')
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}&RelayState=mobile"
|
||||
|
||||
expect(response).to redirect_to(%r{\Achatwootapp://auth/saml\?email=.+&sso_auth_token=.+\z})
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects an existing user from another account' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
other_account = create(:account)
|
||||
@@ -66,9 +68,6 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/app/login?error=saml-authentication-failed')
|
||||
expect(existing_user.reload.provider).to eq('email')
|
||||
expect(existing_user.accounts).not_to include(account)
|
||||
|
||||
@@ -32,7 +32,6 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
|
||||
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
call_sid: call_sid
|
||||
|
||||
@@ -165,5 +165,37 @@ RSpec.describe Captain::BaseTaskService, type: :model do
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when subclass opts out via counts_toward_usage?' do
|
||||
let(:test_service_class) do
|
||||
result = perform_result
|
||||
klass = Class.new(described_class) do
|
||||
define_method(:perform) { result }
|
||||
define_method(:event_name) { 'test_event' }
|
||||
define_method(:counts_toward_usage?) { false }
|
||||
end
|
||||
klass.prepend(Enterprise::Captain::BaseTaskService)
|
||||
klass
|
||||
end
|
||||
|
||||
it 'does not increment usage even on a successful result' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
|
||||
context 'when the captain_responses quota is exhausted on Cloud' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
allow(account).to receive(:usage_limits).and_return({
|
||||
captain: { responses: { current_available: 0 } }
|
||||
})
|
||||
end
|
||||
|
||||
it 'bypasses the 429 gate and returns the underlying result' do
|
||||
result = service.perform
|
||||
expect(result).to eq(perform_result)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,7 @@ RSpec.describe Captain::ConversationCompletionService do
|
||||
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
|
||||
@@ -35,4 +35,15 @@ RSpec.describe Company, type: :model do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#record_activity_at!' do
|
||||
it 'does not move company activity backwards' do
|
||||
company = create(:company, last_activity_at: Time.zone.now)
|
||||
original_activity_at = company.last_activity_at
|
||||
|
||||
company.record_activity_at!(1.hour.ago)
|
||||
|
||||
expect(company.reload.last_activity_at).to be_within(1.second).of(original_activity_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,6 +43,15 @@ RSpec.describe Contact, type: :model do
|
||||
contact.reload
|
||||
expect(contact.company).to eq(existing_company)
|
||||
end
|
||||
|
||||
it 'updates company activity when contact activity changes' do
|
||||
company = create(:company, account: account)
|
||||
contact = create(:contact, account: account, company: company)
|
||||
|
||||
contact.update!(last_activity_at: Time.zone.now)
|
||||
|
||||
expect(company.reload.last_activity_at).to be_within(1.second).of(contact.last_activity_at)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple contacts share the same domain' do
|
||||
|
||||
@@ -55,6 +55,7 @@ RSpec.describe SlaEvent, type: :model do
|
||||
before do
|
||||
# to ensure notifications are not sent to other users
|
||||
create(:user, account: account)
|
||||
create(:inbox_member, inbox: inbox, user: assignee)
|
||||
create(:inbox_member, inbox: inbox, user: participant)
|
||||
create(:conversation_participant, conversation: conversation, user: participant)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# Simulate the prepend_mod_with overlay for testing.
|
||||
test_klass = Class.new(Onboarding::WebWidgetCreationService) do
|
||||
prepend Enterprise::Onboarding::WebWidgetCreationService
|
||||
end
|
||||
|
||||
RSpec.describe Enterprise::Onboarding::WebWidgetCreationService do
|
||||
let(:account) do
|
||||
create(:account, name: 'Acme Inc', custom_attributes: {
|
||||
'website' => 'acme.com',
|
||||
'brand_info' => { 'slogan' => 'Fallback slogan', 'description' => 'Fallback description' }
|
||||
})
|
||||
end
|
||||
let(:user) { create(:user) }
|
||||
let(:service) { test_klass.new(account, user) }
|
||||
|
||||
before { create(:account_user, account: account, user: user, role: :administrator) }
|
||||
|
||||
describe '#welcome_tagline_text via #perform' do
|
||||
let(:llm_double) { instance_double(Captain::Llm::WidgetTaglineService) }
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::WidgetTaglineService).to receive(:new).and_return(llm_double)
|
||||
end
|
||||
|
||||
context 'when the LLM returns a tagline' do
|
||||
before { allow(llm_double).to receive(:perform).and_return(message: ' LLM tagline ') }
|
||||
|
||||
it 'uses the (stripped) LLM-generated tagline' do
|
||||
expect(service.perform.channel.welcome_tagline).to eq('LLM tagline')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the LLM returns a blank message' do
|
||||
before { allow(llm_double).to receive(:perform).and_return(message: '') }
|
||||
|
||||
it 'falls back to brand_info text' do
|
||||
expect(service.perform.channel.welcome_tagline).to eq('Fallback slogan')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the LLM returns an error response' do
|
||||
before { allow(llm_double).to receive(:perform).and_return(error: 'LLM timeout', error_code: 500) }
|
||||
|
||||
it 'falls back to brand_info text' do
|
||||
expect(service.perform.channel.welcome_tagline).to eq('Fallback slogan')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the LLM raises an exception' do
|
||||
before { allow(llm_double).to receive(:perform).and_raise(StandardError, 'boom') }
|
||||
|
||||
it 'still creates the widget with brand_info fallback (no transaction rollback)' do
|
||||
expect { service.perform }.to change(Channel::WebWidget, :count).by(1)
|
||||
expect(service.perform.channel.welcome_tagline).to eq('Fallback slogan')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+5
-3
@@ -4,8 +4,10 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
|
||||
|
||||
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
|
||||
let(:calls_url) { 'https://graph.facebook.com/v13.0/123456789/calls' }
|
||||
let(:messages_url) { 'https://graph.facebook.com/v13.0/123456789/messages' }
|
||||
# Call-flow endpoints use the configured WHATSAPP_API_VERSION (fallback v22.0),
|
||||
# not the OSS v13.0 path locked for legacy /messages compatibility.
|
||||
let(:calls_url) { 'https://graph.facebook.com/v22.0/123456789/calls' }
|
||||
let(:messages_url) { 'https://graph.facebook.com/v22.0/123456789/messages' }
|
||||
let(:headers) { { 'Content-Type' => 'application/json' } }
|
||||
|
||||
before { stub_request(:get, /message_templates/) }
|
||||
@@ -40,7 +42,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
describe '#initiate_call' do
|
||||
it 'returns the parsed body on success' do
|
||||
stub_request(:post, calls_url)
|
||||
.with(body: { messaging_product: 'whatsapp', to: '15551234567', type: 'audio',
|
||||
.with(body: { messaging_product: 'whatsapp', to: '15551234567', action: 'connect',
|
||||
session: { sdp: 'sdp_offer', sdp_type: 'offer' } }.to_json)
|
||||
.to_return(status: 200, body: { messages: [{ id: 'wacall_1' }] }.to_json, headers: headers)
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ RSpec.describe Voice::InboundCallBuilder do
|
||||
|
||||
def perform_builder
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
call_sid: call_sid
|
||||
@@ -87,6 +86,47 @@ RSpec.describe Voice::InboundCallBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a ContactInbox already exists for the source_id (different contact)' do
|
||||
let!(:original_contact) { create(:contact, account: account, phone_number: '+15550009999') }
|
||||
let!(:original_contact_inbox) do
|
||||
create(:contact_inbox, contact: original_contact, inbox: inbox, source_id: from_number)
|
||||
end
|
||||
|
||||
it 'reuses the existing ContactInbox instead of raising RecordNotUnique' do
|
||||
call = perform_builder
|
||||
|
||||
expect(call.contact).to eq(original_contact)
|
||||
expect(call.conversation.contact_inbox).to eq(original_contact_inbox)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the WhatsApp wa_id needs Brazil normalization to match an existing ContactInbox' do
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
provider_config: { 'phone_number_id' => '123', 'source' => 'embedded_signup', 'calling_enabled' => true },
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:whatsapp_inbox) { whatsapp_channel.inbox }
|
||||
let!(:stored_contact) { create(:contact, account: account, phone_number: '+5541988887777') }
|
||||
let!(:stored_contact_inbox) do
|
||||
create(:contact_inbox, contact: stored_contact, inbox: whatsapp_inbox, source_id: '5541988887777')
|
||||
end
|
||||
|
||||
before { account.enable_features!('channel_voice') }
|
||||
|
||||
it 'reuses the contact via normalized wa_id rather than forking a new ContactInbox' do
|
||||
call = described_class.perform!(
|
||||
inbox: whatsapp_inbox,
|
||||
from_number: '+554188887777',
|
||||
call_sid: 'wacall_br_1',
|
||||
provider: :whatsapp
|
||||
)
|
||||
|
||||
expect(call.contact).to eq(stored_contact)
|
||||
expect(call.conversation.contact_inbox).to eq(stored_contact_inbox)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the inbox has lock_to_single_conversation enabled' do
|
||||
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::CallPermissionReplyService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '15550001111') }
|
||||
let(:request_wamid) { 'wamid.permission_request_abc' }
|
||||
let!(:conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open,
|
||||
additional_attributes: {
|
||||
'call_permission_requested_at' => Time.current.iso8601,
|
||||
'call_permission_request_message_id' => request_wamid
|
||||
})
|
||||
end
|
||||
|
||||
before do
|
||||
account.enable_features!('channel_voice')
|
||||
channel.provider_config = channel.provider_config.merge('source' => 'embedded_signup', 'calling_enabled' => true)
|
||||
channel.save!
|
||||
end
|
||||
|
||||
def reply_params(response:, context_id: request_wamid)
|
||||
interactive = { type: 'call_permission_reply',
|
||||
call_permission_reply: { response: response, is_permanent: false } }
|
||||
message = { from: '15550001111', type: 'interactive', interactive: interactive }
|
||||
message[:context] = { id: context_id } if context_id
|
||||
{ entry: [{ changes: [{ value: { messages: [message] } }] }] }
|
||||
end
|
||||
|
||||
it 'clears both permission flags and broadcasts voice_call.permission_granted on accept' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform
|
||||
|
||||
attrs = conversation.reload.additional_attributes
|
||||
expect(attrs).not_to include('call_permission_requested_at')
|
||||
expect(attrs).not_to include('call_permission_request_message_id')
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.permission_granted',
|
||||
data: hash_including(conversation_id: conversation.id))
|
||||
)
|
||||
end
|
||||
|
||||
it 'is a no-op when the contact rejected the request' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
described_class.new(inbox: inbox, params: reply_params(response: 'reject')).perform
|
||||
|
||||
expect(conversation.reload.additional_attributes).to include('call_permission_requested_at')
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
|
||||
it 'is a no-op when calling is disabled on the channel' do
|
||||
channel.provider_config = channel.provider_config.merge('calling_enabled' => false)
|
||||
channel.save!
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform
|
||||
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
|
||||
it 'matches the originating conversation by context.id when the contact has multiple pending requests' do
|
||||
other_request_wamid = 'wamid.permission_request_xyz'
|
||||
other_open = create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox,
|
||||
status: :open,
|
||||
additional_attributes: {
|
||||
'call_permission_requested_at' => Time.current.iso8601,
|
||||
'call_permission_request_message_id' => other_request_wamid
|
||||
})
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
described_class.new(inbox: inbox, params: reply_params(response: 'accept', context_id: other_request_wamid)).perform
|
||||
|
||||
# The reply pointed at other_open's request — it should be the cleared one, not `conversation`
|
||||
expect(other_open.reload.additional_attributes).not_to include('call_permission_request_message_id')
|
||||
expect(conversation.reload.additional_attributes).to include('call_permission_request_message_id')
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(data: hash_including(conversation_id: other_open.id))
|
||||
)
|
||||
end
|
||||
|
||||
it 'is a no-op when the reply has no context.id' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
described_class.new(inbox: inbox, params: reply_params(response: 'accept', context_id: nil)).perform
|
||||
|
||||
expect(conversation.reload.additional_attributes).to include('call_permission_request_message_id')
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,136 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::CallService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:call) do
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: 'wacid_abc')
|
||||
end
|
||||
let(:provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
|
||||
|
||||
before do
|
||||
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
|
||||
channel.save!
|
||||
allow(channel).to receive(:provider_service).and_return(provider_service)
|
||||
allow(inbox).to receive(:channel).and_return(channel)
|
||||
allow(call).to receive(:inbox).and_return(inbox)
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
end
|
||||
|
||||
describe '#accept' do
|
||||
let(:sdp_answer) { "v=0\r\n...sdp..." }
|
||||
|
||||
before do
|
||||
allow(provider_service).to receive(:pre_accept_call).and_return(true)
|
||||
allow(provider_service).to receive(:accept_call).and_return(true)
|
||||
end
|
||||
|
||||
it 'forwards the SDP answer to Meta and transitions the call to in_progress' do
|
||||
described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept
|
||||
|
||||
expect(provider_service).to have_received(:pre_accept_call).with('wacid_abc', sdp_answer)
|
||||
expect(provider_service).to have_received(:accept_call).with('wacid_abc', sdp_answer)
|
||||
expect(call.reload).to have_attributes(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: be_present)
|
||||
expect(call.meta['sdp_answer']).to eq(sdp_answer)
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}", hash_including(event: 'voice_call.accepted')
|
||||
)
|
||||
end
|
||||
|
||||
it 'claims the conversation when no assignee is set' do
|
||||
described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept
|
||||
|
||||
expect(conversation.reload.assignee_id).to eq(agent.id)
|
||||
end
|
||||
|
||||
it 'raises AlreadyAccepted when another agent has already accepted the call' do
|
||||
call.update!(status: 'in_progress')
|
||||
|
||||
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::AlreadyAccepted') }
|
||||
end
|
||||
|
||||
it 'raises NotRinging when the call has reached a terminal state' do
|
||||
call.update!(status: 'completed')
|
||||
|
||||
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::NotRinging') }
|
||||
end
|
||||
|
||||
it 'raises CallFailed when sdp_answer is missing' do
|
||||
expect { described_class.new(call: call, agent: agent, sdp_answer: nil).accept }
|
||||
.to raise_error(StandardError) do |error|
|
||||
expect(error.class.name).to eq('Voice::CallErrors::CallFailed')
|
||||
expect(error.message).to eq('sdp_answer is required')
|
||||
end
|
||||
end
|
||||
|
||||
it 'wraps Meta transport exceptions as CallFailed and leaves the call ringing' do
|
||||
allow(provider_service).to receive(:pre_accept_call).and_raise(Faraday::TimeoutError)
|
||||
|
||||
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::CallFailed') }
|
||||
expect(call.reload.status).to eq('ringing')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reject' do
|
||||
before { allow(provider_service).to receive(:reject_call).and_return(true) }
|
||||
|
||||
it 'tells Meta to reject and finalizes the call as failed' do
|
||||
described_class.new(call: call, agent: agent).reject
|
||||
|
||||
expect(provider_service).to have_received(:reject_call).with('wacid_abc')
|
||||
expect(call.reload.status).to eq('failed')
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'failed'))
|
||||
)
|
||||
end
|
||||
|
||||
it 'is a no-op for already-terminal calls' do
|
||||
call.update!(status: 'completed')
|
||||
|
||||
described_class.new(call: call, agent: agent).reject
|
||||
|
||||
expect(provider_service).not_to have_received(:reject_call)
|
||||
end
|
||||
|
||||
it 'raises CallFailed and leaves the call ringing when Meta rejects the request' do
|
||||
allow(provider_service).to receive(:reject_call).and_return(false)
|
||||
|
||||
expect { described_class.new(call: call, agent: agent).reject }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::CallFailed') }
|
||||
expect(call.reload.status).to eq('ringing')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#terminate' do
|
||||
before { allow(provider_service).to receive(:terminate_call).and_return(true) }
|
||||
|
||||
it 'finalizes an in-progress call as completed' do
|
||||
call.update!(status: 'in_progress')
|
||||
|
||||
described_class.new(call: call, agent: agent).terminate
|
||||
|
||||
expect(provider_service).to have_received(:terminate_call).with('wacid_abc')
|
||||
expect(call.reload.status).to eq('completed')
|
||||
expect(call.meta['ended_at']).to be_present
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}", hash_including(event: 'voice_call.ended')
|
||||
)
|
||||
end
|
||||
|
||||
it 'finalizes a still-ringing call as no_answer when the agent hangs up before the contact picks up' do
|
||||
described_class.new(call: call, agent: agent).terminate
|
||||
|
||||
expect(call.reload.status).to eq('no_answer')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,242 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::IncomingCallService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:from_number) { '15550001111' }
|
||||
let(:provider_call_id) { 'wacid_abc' }
|
||||
|
||||
before do
|
||||
account.enable_features!('channel_voice')
|
||||
channel.provider_config = channel.provider_config.merge('source' => 'embedded_signup', 'calling_enabled' => true)
|
||||
channel.save!
|
||||
end
|
||||
|
||||
def call_payload(event:, **extra)
|
||||
{ calls: [{ id: provider_call_id, from: from_number, event: event, **extra }] }
|
||||
end
|
||||
|
||||
context 'when calling is disabled on the channel' do
|
||||
it 'is a no-op' do
|
||||
channel.provider_config = channel.provider_config.merge('calling_enabled' => false)
|
||||
channel.save!
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: call_payload(event: 'connect')).perform }
|
||||
.not_to change(Call, :count)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'inbound connect' do
|
||||
let(:sdp_offer) { "v=0\r\n...sdp..." }
|
||||
|
||||
it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'connect', session: { sdp: sdp_offer, sdp_type: 'offer' })
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }
|
||||
.to change(Call, :count).by(1).and change(Conversation, :count).by(1)
|
||||
|
||||
call = Call.last
|
||||
expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing',
|
||||
provider_call_id: provider_call_id)
|
||||
expect(call.meta['sdp_offer']).to eq(sdp_offer)
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'outbound connect (existing call)' do
|
||||
let!(:call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :outgoing, status: 'ringing', provider_call_id: provider_call_id)
|
||||
end
|
||||
|
||||
it 'stores the SDP answer and broadcasts voice_call.outbound_connected without flipping to in_progress' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
sdp_answer = "v=0\r\na=setup:actpass\r\n"
|
||||
|
||||
params = call_payload(event: 'connect', session: { sdp: sdp_answer, sdp_type: 'answer' })
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
# connect only completes the SDP handshake; pickup is reported separately as status=ACCEPTED.
|
||||
expect(call.reload).to have_attributes(status: 'ringing', started_at: nil)
|
||||
expect(call.meta['sdp_answer']).to include('a=setup:active')
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.outbound_connected')
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'terminate' do
|
||||
let!(:call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :incoming, status: 'in_progress', provider_call_id: provider_call_id)
|
||||
end
|
||||
|
||||
it 'marks the call completed when the call had been answered' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'terminate', duration: 42, terminate_reason: 'completed_normally')
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload).to have_attributes(status: 'completed', duration_seconds: 42, end_reason: 'completed_normally')
|
||||
expect(call.ended_at).to be_present
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.ended', data: hash_including(status: 'completed'))
|
||||
)
|
||||
end
|
||||
|
||||
it 'marks unanswered ringing calls as no_answer' do
|
||||
call.update!(status: 'ringing')
|
||||
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
expect(call.reload.status).to eq('no_answer')
|
||||
end
|
||||
|
||||
it 'records the call as failed when Meta reports a failure reason for an in_progress call' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'terminate', duration: 12, terminate_reason: 'failed')
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload).to have_attributes(status: 'failed', duration_seconds: 12, end_reason: 'failed')
|
||||
end
|
||||
|
||||
it 'is a no-op when the call is already terminal so retries cannot flip a completed call to no_answer' do
|
||||
call.update!(status: 'completed', duration_seconds: 5, direction: :outgoing, accepted_by_agent: nil)
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'completed_normally')
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload).to have_attributes(status: 'completed', duration_seconds: 5)
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'terminate with no local row yet' do
|
||||
it 'logs and skips instead of materialising an inbound missed-call row' do
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }
|
||||
.not_to change(Call, :count)
|
||||
expect(Rails.logger).to have_received(:warn).with(/Terminate for unknown call/)
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'outbound connect with no local row yet' do
|
||||
it 'does not mint an inbound call when sdp_type is answer' do
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'connect', session: { sdp: 'sdp_answer', sdp_type: 'answer' })
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }
|
||||
.not_to change(Call, :count)
|
||||
expect(Rails.logger).to have_received(:warn).with(/Outbound connect for unknown call/)
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'duplicate inbound connect' do
|
||||
let!(:call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: provider_call_id)
|
||||
end
|
||||
|
||||
it 'logs and ignores rather than treating it as outbound' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
params = call_payload(event: 'connect', session: { sdp: 'sdp_x', sdp_type: 'offer' })
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload).to have_attributes(status: 'ringing')
|
||||
expect(Rails.logger).to have_received(:info).with(/Duplicate inbound connect/)
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'connect arriving after terminal status' do
|
||||
let!(:call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :outgoing, status: 'completed', provider_call_id: provider_call_id)
|
||||
end
|
||||
|
||||
it 'does not reopen a completed outbound call' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
params = call_payload(event: 'connect', session: { sdp: 'late_sdp', sdp_type: 'answer' })
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload.status).to eq('completed')
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'unanswered outbound call terminate' do
|
||||
let!(:agent) { create(:user, account: account) }
|
||||
let!(:call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||
provider: :whatsapp, direction: :outgoing, status: 'ringing',
|
||||
accepted_by_agent: agent, provider_call_id: provider_call_id)
|
||||
end
|
||||
|
||||
it 'marks the call as no_answer even though accepted_by_agent_id is set' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
|
||||
|
||||
described_class.new(inbox: inbox, params: params).perform
|
||||
|
||||
expect(call.reload.status).to eq('no_answer')
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.ended', data: hash_including(status: 'no-answer'))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'unknown event' do
|
||||
it 'logs a warning and does not raise' do
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
params = call_payload(event: 'mystery')
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }.not_to raise_error
|
||||
expect(Rails.logger).to have_received(:warn).with(/Unknown call event: mystery/)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'multiple calls in one webhook payload' do
|
||||
it 'processes every call in the array' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = {
|
||||
calls: [
|
||||
{ id: 'wacid_a', from: from_number, event: 'connect', session: { sdp: 'sdp_a', sdp_type: 'offer' } },
|
||||
{ id: 'wacid_b', from: '15550002222', event: 'connect', session: { sdp: 'sdp_b', sdp_type: 'offer' } }
|
||||
]
|
||||
}
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }.to change(Call, :count).by(2)
|
||||
expect(Call.where(provider_call_id: %w[wacid_a wacid_b]).pluck(:provider_call_id)).to contain_exactly('wacid_a', 'wacid_b')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,12 +1,6 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe BulkActionsJob do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed' },
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
}
|
||||
|
||||
subject(:job) { described_class.perform_later(account: account, params: params, user: agent) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
@@ -14,9 +8,11 @@ RSpec.describe BulkActionsJob do
|
||||
let!(:conversation_1) { create(:conversation, account_id: account.id, status: :open) }
|
||||
let!(:conversation_2) { create(:conversation, account_id: account.id, status: :open) }
|
||||
let!(:conversation_3) { create(:conversation, account_id: account.id, status: :open) }
|
||||
let(:conversation_ids) { [conversation_1.display_id, conversation_2.display_id, conversation_3.display_id] }
|
||||
let(:params) { { type: 'Conversation', fields: { status: 'snoozed' }, ids: conversation_ids } }
|
||||
|
||||
before do
|
||||
Conversation.all.find_each do |conversation|
|
||||
[conversation_1, conversation_2, conversation_3].each do |conversation|
|
||||
create(:inbox_member, inbox: conversation.inbox, user: agent)
|
||||
end
|
||||
end
|
||||
@@ -38,10 +34,10 @@ RSpec.describe BulkActionsJob do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed', assignee_id: agent.id },
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
ids: conversation_ids
|
||||
}
|
||||
|
||||
expect(Conversation.first.status).to eq('open')
|
||||
expect(conversation_1.status).to eq('open')
|
||||
|
||||
described_class.perform_now(account: account, params: params, user: agent)
|
||||
|
||||
@@ -54,32 +50,46 @@ RSpec.describe BulkActionsJob do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed', assignee_id: agent.id },
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
ids: conversation_ids
|
||||
}
|
||||
|
||||
expect(Conversation.first.assignee_id).to be_nil
|
||||
expect(conversation_1.assignee_id).to be_nil
|
||||
|
||||
described_class.perform_now(account: account, params: params, user: agent)
|
||||
|
||||
expect(Conversation.first.assignee_id).to eq(agent.id)
|
||||
expect(Conversation.second.assignee_id).to eq(agent.id)
|
||||
expect(Conversation.third.assignee_id).to eq(agent.id)
|
||||
expect(conversation_1.reload.assignee_id).to eq(agent.id)
|
||||
expect(conversation_2.reload.assignee_id).to eq(agent.id)
|
||||
expect(conversation_3.reload.assignee_id).to eq(agent.id)
|
||||
end
|
||||
|
||||
it 'bulk updates the snoozed_until' do
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'snoozed', snoozed_until: Time.zone.now },
|
||||
ids: Conversation.first(3).pluck(:display_id)
|
||||
ids: conversation_ids
|
||||
}
|
||||
|
||||
expect(Conversation.first.snoozed_until).to be_nil
|
||||
expect(conversation_1.snoozed_until).to be_nil
|
||||
|
||||
described_class.perform_now(account: account, params: params, user: agent)
|
||||
|
||||
expect(Conversation.first.snoozed_until).to be_present
|
||||
expect(Conversation.second.snoozed_until).to be_present
|
||||
expect(Conversation.third.snoozed_until).to be_present
|
||||
expect(conversation_1.reload.snoozed_until).to be_present
|
||||
expect(conversation_2.reload.snoozed_until).to be_present
|
||||
expect(conversation_3.reload.snoozed_until).to be_present
|
||||
end
|
||||
|
||||
it 'skips conversations whose inbox the agent does not belong to' do
|
||||
forbidden_conversation = create(:conversation, account_id: account.id, status: :open)
|
||||
params = {
|
||||
type: 'Conversation',
|
||||
fields: { status: 'resolved' },
|
||||
ids: [conversation_1.display_id, forbidden_conversation.display_id]
|
||||
}
|
||||
|
||||
described_class.perform_now(account: account, params: params, user: agent)
|
||||
|
||||
expect(conversation_1.reload.status).to eq('resolved')
|
||||
expect(forbidden_conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,6 +65,13 @@ RSpec.describe HookJob do
|
||||
expect(Integrations::GoogleTranslate::DetectLanguageService).to receive(:new).with(hook: hook, message: event_data[:message])
|
||||
described_class.perform_now(hook, event_name, event_data)
|
||||
end
|
||||
|
||||
it "calls Integrations::Linear::AutoLinkService when it's a linear hook" do
|
||||
hook = create(:integrations_hook, :linear, account: account)
|
||||
allow(Integrations::Linear::AutoLinkService).to receive(:new).and_return(process_service)
|
||||
expect(Integrations::Linear::AutoLinkService).to receive(:new).with(account: account, message: event_data[:message])
|
||||
described_class.perform_now(hook, event_name, event_data)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handleable events like message.updated for slack' do
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Migration::ValidateOpenaiHooksJob do
|
||||
let(:integrations_mailer) { instance_double(AdministratorNotifications::IntegrationsNotificationMailer) }
|
||||
let(:mailer_response) { instance_double(ActionMailer::MessageDelivery, deliver_later: true) }
|
||||
|
||||
before do
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
allow(AdministratorNotifications::IntegrationsNotificationMailer).to receive(:with).and_return(integrations_mailer)
|
||||
allow(integrations_mailer).to receive(:openai_disconnect).and_return(mailer_response)
|
||||
end
|
||||
|
||||
def create_openai_hook(account:, api_key: 'sk-good')
|
||||
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => api_key })
|
||||
end
|
||||
|
||||
it 'destroys invalid hooks, preserves valid ones, sends disconnect email, and reports stats' do
|
||||
account_a = create(:account)
|
||||
account_b = create(:account)
|
||||
valid_hook = create_openai_hook(account: account_a, api_key: 'sk-good')
|
||||
invalid_hook = create_openai_hook(account: account_b, api_key: 'sk-bad')
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false)
|
||||
|
||||
result = described_class.perform_now
|
||||
|
||||
expect(valid_hook.reload).to be_enabled
|
||||
expect { invalid_hook.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
expect(AdministratorNotifications::IntegrationsNotificationMailer).to have_received(:with).with(account: account_b)
|
||||
expect(result).to eq(checked: 2, destroyed: 1)
|
||||
end
|
||||
|
||||
it 'scopes to a specific account when provided' do
|
||||
account_a = create(:account)
|
||||
account_b = create(:account)
|
||||
hook_a = create_openai_hook(account: account_a, api_key: 'sk-bad')
|
||||
hook_b = create_openai_hook(account: account_b, api_key: 'sk-bad')
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false)
|
||||
|
||||
described_class.perform_now(account: account_a)
|
||||
|
||||
expect { hook_a.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
expect(hook_b.reload).to be_enabled
|
||||
end
|
||||
|
||||
it 'only checks enabled OpenAI hooks' do
|
||||
account = create(:account)
|
||||
slack_hook = create(:integrations_hook, account: account, app_id: 'slack')
|
||||
disabled_hook = create_openai_hook(account: account)
|
||||
disabled_hook.disable
|
||||
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(slack_hook.reload).to be_enabled
|
||||
expect(disabled_hook.reload).to be_disabled # still disabled, not re-checked
|
||||
end
|
||||
end
|
||||
@@ -2,37 +2,45 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Notification::DeleteNotificationJob do
|
||||
let(:user) { create(:user) }
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
context 'when enqueuing the job' do
|
||||
it 'enqueues the job to delete all notifications' do
|
||||
expect do
|
||||
described_class.perform_later(user.id, type: :all)
|
||||
described_class.perform_later(user, account, type: :all)
|
||||
end.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
|
||||
it 'enqueues the job to delete read notifications' do
|
||||
expect do
|
||||
described_class.perform_later(user.id, type: :read)
|
||||
described_class.perform_later(user, account, type: :read)
|
||||
end.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when performing the job' do
|
||||
let(:other_account) { create(:account) }
|
||||
|
||||
before do
|
||||
create(:notification, user: user, read_at: nil)
|
||||
create(:notification, user: user, read_at: Time.current)
|
||||
create(:notification, account: account, user: user, read_at: nil)
|
||||
create(:notification, account: account, user: user, read_at: Time.current)
|
||||
create(:notification, account: other_account, user: user, read_at: Time.current)
|
||||
end
|
||||
|
||||
it 'deletes all notifications' do
|
||||
described_class.perform_now(user, type: :all)
|
||||
expect(user.notifications.count).to eq(0)
|
||||
it 'deletes all notifications for the requested account' do
|
||||
described_class.perform_now(user, account, type: :all)
|
||||
|
||||
expect(user.notifications.where(account_id: account.id).count).to eq(0)
|
||||
expect(user.notifications.where(account_id: other_account.id).count).to eq(1)
|
||||
end
|
||||
|
||||
it 'deletes only read notifications' do
|
||||
described_class.perform_now(user, type: :read)
|
||||
expect(user.notifications.count).to eq(1)
|
||||
expect(user.notifications.where(read_at: nil).count).to eq(1)
|
||||
it 'deletes only read notifications for the requested account' do
|
||||
described_class.perform_now(user, account, type: :read)
|
||||
|
||||
expect(user.notifications.where(account_id: account.id).count).to eq(1)
|
||||
expect(user.notifications.where(account_id: account.id, read_at: nil).count).to eq(1)
|
||||
expect(user.notifications.where(account_id: other_account.id).count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,6 +26,7 @@ RSpec.describe Captain::BaseTaskService do
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
|
||||
@@ -257,4 +257,18 @@ describe CustomMarkdownRenderer do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#image' do
|
||||
it 'renders width in px with responsive cap and auto height' do
|
||||
markdown = ''
|
||||
expect(render_markdown(markdown)).to include(
|
||||
'style="width: 400px; max-width: 100%; height: auto;"'
|
||||
)
|
||||
end
|
||||
|
||||
it 'ignores a non-numeric width' do
|
||||
markdown = ''
|
||||
expect(render_markdown(markdown)).not_to include('style=')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Linear::AutoLinkService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:processor) { instance_double(Integrations::Linear::ProcessorService) }
|
||||
let(:activity_service) { instance_double(Linear::ActivityMessageService, perform: true) }
|
||||
|
||||
let(:linear_url) { 'https://linear.app/chatwoot/issue/CW-1234/some-slug' }
|
||||
let(:identifier) { 'CW-1234' }
|
||||
let(:node_id) { 'linear-node-id-1' }
|
||||
let(:search_response) do
|
||||
{ data: [{ 'id' => node_id, 'identifier' => identifier, 'title' => 'Issue title',
|
||||
'url' => 'https://linear.app/chatwoot/issue/CW-1234/issue-title' }] }
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Integrations::Linear::ProcessorService).to receive(:new).with(account: account).and_return(processor)
|
||||
allow(Linear::ActivityMessageService).to receive(:new).and_return(activity_service)
|
||||
allow(processor).to receive(:linked_issues).and_return({ data: [] })
|
||||
allow(processor).to receive(:search_issue).and_return(search_response)
|
||||
allow(processor).to receive(:link_issue).and_return({ data: { id: node_id, link_id: 'attachment-1' } })
|
||||
end
|
||||
|
||||
def build_private_note(content)
|
||||
create(:message,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
content: content)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when the message is not a private note' do
|
||||
it 'does no work' do
|
||||
message = create(:message, account: account, inbox: inbox, conversation: conversation,
|
||||
sender: user, message_type: :outgoing, private: false,
|
||||
content: "see #{linear_url}")
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:linked_issues)
|
||||
expect(processor).not_to have_received(:search_issue)
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the sender is not a User' do
|
||||
it 'does no work' do
|
||||
contact = create(:contact, account: account)
|
||||
message = create(:message, account: account, inbox: inbox, conversation: conversation,
|
||||
sender: contact, message_type: :incoming, private: true,
|
||||
content: "see #{linear_url}")
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the private note has no Linear URL' do
|
||||
it 'does no work' do
|
||||
message = build_private_note('just a regular note with no link')
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the issue identifier is already linked from this conversation' do
|
||||
it 'skips silently' do
|
||||
message = build_private_note("see #{linear_url}")
|
||||
allow(processor).to receive(:linked_issues).and_return(
|
||||
{ data: [{ 'id' => 'attachment-prev', 'issue' => { 'id' => node_id, 'identifier' => identifier } }] }
|
||||
)
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:search_issue)
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear search returns no exact match for the identifier' do
|
||||
it 'does not link' do
|
||||
message = build_private_note("see #{linear_url}")
|
||||
allow(processor).to receive(:search_issue).with(identifier).and_return(
|
||||
{ data: [{ 'id' => 'other', 'identifier' => 'OTHER-1', 'url' => 'https://linear.app/chatwoot/issue/OTHER-1' }] }
|
||||
)
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the matching issue belongs to a different Linear workspace' do
|
||||
it 'does not link' do
|
||||
message = build_private_note("see #{linear_url}")
|
||||
allow(processor).to receive(:search_issue).with(identifier).and_return(
|
||||
{ data: [{ 'id' => node_id, 'identifier' => identifier,
|
||||
'url' => 'https://linear.app/other-workspace/issue/CW-1234' }] }
|
||||
)
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear search returns an error' do
|
||||
it 'does not link' do
|
||||
message = build_private_note("see #{linear_url}")
|
||||
allow(processor).to receive(:search_issue).with(identifier).and_return({ error: 'boom' })
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).not_to have_received(:link_issue)
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link_issue returns an error' do
|
||||
it 'does not post the activity message' do
|
||||
message = build_private_note("see #{linear_url}")
|
||||
allow(processor).to receive(:link_issue).and_return({ error: 'nope' })
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(Linear::ActivityMessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the private note contains a Linear URL' do
|
||||
it 'links the issue and posts an activity message' do
|
||||
message = build_private_note("Found it: #{linear_url}")
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).to have_received(:link_issue).with(
|
||||
a_string_matching(%r{/conversations/#{conversation.display_id}\z}),
|
||||
node_id,
|
||||
anything,
|
||||
user
|
||||
)
|
||||
expect(Linear::ActivityMessageService).to have_received(:new).with(
|
||||
conversation: conversation,
|
||||
action_type: :issue_linked,
|
||||
user: user,
|
||||
issue_data: { id: identifier }
|
||||
)
|
||||
expect(activity_service).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'links only the first Linear URL when multiple are present' do
|
||||
second_url = 'https://linear.app/chatwoot/issue/CW-9999'
|
||||
message = build_private_note("see #{linear_url} and #{second_url}")
|
||||
|
||||
described_class.new(account: account, message: message).perform
|
||||
|
||||
expect(processor).to have_received(:search_issue).with(identifier).once
|
||||
expect(processor).not_to have_received(:search_issue).with('CW-9999')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,6 +10,8 @@ RSpec.describe Integrations::LlmBaseService do
|
||||
let(:error) { StandardError.new('API Error') }
|
||||
let(:body) { { model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }] }.to_json }
|
||||
|
||||
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
|
||||
|
||||
describe '#make_api_call' do
|
||||
before do
|
||||
allow(service).to receive(:instrument_llm_call).and_yield
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::Openai::KeyValidator do
|
||||
let(:api_key) { 'sk-test-valid-key-123456789' }
|
||||
let(:probe_url) { 'https://api.openai.com/v1/models' }
|
||||
|
||||
it 'accepts keys that OpenAI recognizes' do
|
||||
stub_request(:get, probe_url).to_return(status: 200)
|
||||
expect(described_class.valid?(api_key)).to be true
|
||||
end
|
||||
|
||||
it 'rejects keys that OpenAI does not recognize' do
|
||||
stub_request(:get, probe_url).to_return(status: 401)
|
||||
expect(described_class.valid?(api_key)).to be false
|
||||
end
|
||||
|
||||
it 'rejects blank keys without making a network call' do
|
||||
expect(described_class.valid?(nil)).to be false
|
||||
expect(described_class.valid?('')).to be false
|
||||
end
|
||||
|
||||
it 'treats transient failures as valid to avoid blocking saves' do
|
||||
stub_request(:get, probe_url).to_return(status: 500)
|
||||
expect(described_class.valid?(api_key)).to be true
|
||||
|
||||
stub_request(:get, probe_url).to_timeout
|
||||
expect(described_class.valid?(api_key)).to be true
|
||||
end
|
||||
|
||||
it 'routes the probe through the configured endpoint' do
|
||||
custom_url = 'https://proxy.example.com/v1/models'
|
||||
allow(InstallationConfig).to receive(:find_by).with(name: 'CAPTAIN_OPEN_AI_ENDPOINT')
|
||||
.and_return(instance_double(InstallationConfig, value: 'https://proxy.example.com/'))
|
||||
stub_request(:get, custom_url).to_return(status: 200)
|
||||
|
||||
described_class.valid?(api_key)
|
||||
|
||||
expect(WebMock).to have_requested(:get, custom_url)
|
||||
expect(WebMock).not_to have_requested(:get, probe_url)
|
||||
end
|
||||
end
|
||||
+62
-46
@@ -144,56 +144,66 @@ RSpec.describe SafeFetch do
|
||||
|
||||
context 'with URL validation' do
|
||||
it 'raises InvalidUrlError for javascript: URLs' do
|
||||
expect { described_class.fetch('javascript:alert(1)') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError)
|
||||
expect { described_class.fetch('javascript:alert(1)') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for mailto: URLs' do
|
||||
expect { described_class.fetch('mailto:test@example.com') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError)
|
||||
expect { described_class.fetch('mailto:test@example.com') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for data: URLs' do
|
||||
expect { described_class.fetch('data:text/html,<x>') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError)
|
||||
expect { described_class.fetch('data:text/html,<x>') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for ftp: URLs' do
|
||||
expect { described_class.fetch('ftp://example.com/file') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError)
|
||||
expect { described_class.fetch('ftp://example.com/file') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for malformed URLs' do
|
||||
expect { described_class.fetch('not_a_url') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError)
|
||||
expect { described_class.fetch('not_a_url') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError when host is missing' do
|
||||
expect { described_class.fetch('http:///path') { nil } }
|
||||
.to raise_error(described_class::InvalidUrlError, /missing host/)
|
||||
expect { described_class.fetch('http:///path') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with SSRF protection (integration with ssrf_filter)' do
|
||||
it 'raises UnsafeUrlError for private IP literals (10.x.x.x)' do
|
||||
expect { described_class.fetch('http://10.0.0.1/secret') { nil } }
|
||||
.to raise_error(described_class::UnsafeUrlError)
|
||||
expect { described_class.fetch('http://10.0.0.1/secret') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for loopback addresses' do
|
||||
expect { described_class.fetch('http://127.0.0.1/secret') { nil } }
|
||||
.to raise_error(described_class::UnsafeUrlError)
|
||||
expect { described_class.fetch('http://127.0.0.1/secret') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for AWS metadata IP (169.254.169.254)' do
|
||||
expect { described_class.fetch('http://169.254.169.254/latest/meta-data/') { nil } }
|
||||
.to raise_error(described_class::UnsafeUrlError)
|
||||
expect { described_class.fetch('http://169.254.169.254/latest/meta-data/') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError when hostname resolves to a private IP (DNS rebinding)' do
|
||||
allow(Resolv).to receive(:getaddresses).with('evil.example.com').and_return(['10.0.0.1'])
|
||||
expect { described_class.fetch('http://evil.example.com/secret') { nil } }
|
||||
.to raise_error(described_class::UnsafeUrlError)
|
||||
expect { described_class.fetch('http://evil.example.com/secret') { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -205,8 +215,9 @@ RSpec.describe SafeFetch do
|
||||
headers: { 'Content-Type' => 'text/html' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::UnsupportedContentTypeError)
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects application/octet-stream responses' do
|
||||
@@ -216,8 +227,9 @@ RSpec.describe SafeFetch do
|
||||
headers: { 'Content-Type' => 'application/octet-stream' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::UnsupportedContentTypeError)
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows video/mp4 responses' do
|
||||
@@ -266,20 +278,18 @@ RSpec.describe SafeFetch do
|
||||
headers: { 'Content-Type' => 'image/webp' }
|
||||
)
|
||||
|
||||
expect do
|
||||
described_class.fetch(
|
||||
url,
|
||||
allowed_content_type_prefixes: [],
|
||||
allowed_content_types: ['image/png']
|
||||
) { nil }
|
||||
end.to raise_error(described_class::UnsupportedContentTypeError)
|
||||
expect { described_class.fetch(url, allowed_content_type_prefixes: [], allowed_content_types: ['image/png']) { nil } }
|
||||
.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects when the content-type header is missing' do
|
||||
stub_request(:get, url).to_return(status: 200, body: 'x', headers: {})
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::UnsupportedContentTypeError)
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -347,8 +357,9 @@ RSpec.describe SafeFetch do
|
||||
end
|
||||
|
||||
it 'raises UnsupportedMethodError for unsupported HTTP methods' do
|
||||
expect { described_class.fetch(url, method: :options) { nil } }
|
||||
.to raise_error(described_class::UnsupportedMethodError)
|
||||
expect { described_class.fetch(url, method: :options) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsupportedMethodError')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -360,8 +371,9 @@ RSpec.describe SafeFetch do
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url, max_bytes: 2) { nil } }
|
||||
.to raise_error(described_class::FileTooLargeError)
|
||||
expect { described_class.fetch(url, max_bytes: 2) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::FileTooLargeError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'reads the default cap from GlobalConfigService MAXIMUM_FILE_UPLOAD_SIZE (matching Attachment#validate_file_size)' do
|
||||
@@ -375,8 +387,9 @@ RSpec.describe SafeFetch do
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::FileTooLargeError)
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::FileTooLargeError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'falls back to 40 MB when GlobalConfigService returns a non-positive value' do
|
||||
@@ -414,24 +427,27 @@ RSpec.describe SafeFetch do
|
||||
it 'maps Net::ReadTimeout to FetchError' do
|
||||
stub_request(:get, url).to_raise(Net::ReadTimeout)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::FetchError)
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::FetchError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'maps SocketError to FetchError' do
|
||||
stub_request(:get, url).to_raise(SocketError.new('connection refused'))
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::FetchError)
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::FetchError')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-2xx upstream responses' do
|
||||
it 'raises HttpError with the status code in the message' do
|
||||
it 'raises HttpError on non-2xx responses' do
|
||||
stub_request(:get, url).to_return(status: 404, body: '', headers: {})
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(described_class::HttpError, /404/)
|
||||
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::HttpError')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,6 +84,13 @@ describe HookListener do
|
||||
|
||||
listener.message_created(event)
|
||||
end
|
||||
|
||||
it 'enqueues the job for linear' do
|
||||
hook = create(:integrations_hook, :linear, account: account)
|
||||
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message, previous_changes: nil)
|
||||
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with disabled hook' do
|
||||
|
||||
@@ -39,4 +39,20 @@ RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do
|
||||
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'openai_disconnect' do
|
||||
let(:mail) { described_class.with(account: account).openai_disconnect.deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq('Your OpenAI integration was disconnected')
|
||||
end
|
||||
|
||||
it 'renders the content' do
|
||||
expect(mail.body.encoded).to include('the configured API key is invalid or revoked')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -209,4 +209,43 @@ RSpec.describe Channel::Whatsapp do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#voice_enabled?' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
before { account.enable_features!('channel_voice') }
|
||||
|
||||
it 'returns true for embedded-signup whatsapp_cloud channels with calling_enabled' do
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel.update!(provider_config: channel.provider_config.merge('source' => 'embedded_signup', 'calling_enabled' => true))
|
||||
|
||||
expect(channel.voice_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for whatsapp_cloud channels without embedded_signup source' do
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel.update!(provider_config: channel.provider_config.merge('source' => 'manual', 'calling_enabled' => true))
|
||||
|
||||
expect(channel.voice_enabled?).to be false
|
||||
end
|
||||
|
||||
it 'returns false for default-provider channels (360dialog) even with calling_enabled' do
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'default',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel.update!(provider_config: channel.provider_config.merge('source' => 'embedded_signup', 'calling_enabled' => true))
|
||||
|
||||
expect(channel.voice_enabled?).to be false
|
||||
end
|
||||
|
||||
it 'returns false when the channel_voice feature is disabled on the account' do
|
||||
account.disable_features!('channel_voice')
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel.update!(provider_config: channel.provider_config.merge('source' => 'embedded_signup', 'calling_enabled' => true))
|
||||
|
||||
expect(channel.voice_enabled?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,8 @@ RSpec.describe Integrations::App do
|
||||
let(:app) { apps.find(id: app_name) }
|
||||
let(:account) { create(:account) }
|
||||
|
||||
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
|
||||
|
||||
describe '#name' do
|
||||
let(:app_name) { 'slack' }
|
||||
|
||||
|
||||
@@ -111,4 +111,70 @@ RSpec.describe Integrations::Hook do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'openai api key validation' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'prevents saving an openai hook with an invalid key' do
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
|
||||
|
||||
hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-bad' })
|
||||
|
||||
expect(hook).not_to be_valid
|
||||
expect(hook.errors[:base]).to include(I18n.t('errors.openai.invalid_api_key'))
|
||||
end
|
||||
|
||||
it 'prevents saving an openai hook with a blank key' do
|
||||
hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => '' })
|
||||
|
||||
expect(hook).not_to be_valid
|
||||
end
|
||||
|
||||
it 'allows saving an openai hook with a valid key' do
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
|
||||
hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good' })
|
||||
|
||||
expect(hook).to be_valid
|
||||
end
|
||||
|
||||
it 'skips validation when an enabled openai hook is saved without changing the api key' do
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good', 'label_suggestion' => false })
|
||||
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
|
||||
hook.settings['label_suggestion'] = true
|
||||
|
||||
expect(hook.save).to be true
|
||||
end
|
||||
|
||||
it 'validates when a disabled openai hook is re-enabled' do
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-bad' })
|
||||
hook.update!(status: :disabled)
|
||||
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false)
|
||||
|
||||
expect(hook.update(status: :enabled)).to be false
|
||||
expect(hook.errors[:base]).to include(I18n.t('errors.openai.invalid_api_key'))
|
||||
end
|
||||
|
||||
it 'skips validation for disabled hooks' do
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good' })
|
||||
|
||||
# Even with validator returning false, disable succeeds because disabled hooks skip validation
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
|
||||
hook.disable
|
||||
expect(hook.reload).to be_disabled
|
||||
end
|
||||
|
||||
it 'does not validate keys for non-openai hooks' do
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
|
||||
|
||||
hook = build(:integrations_hook, account: account, app_id: 'slack')
|
||||
|
||||
expect(hook).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,7 +30,7 @@ RSpec.describe Portal do
|
||||
it 'Does not allow any other config than allowed_locales' do
|
||||
portal.update(config: { 'some_other_key': 'test_value' })
|
||||
expect(portal).not_to be_valid
|
||||
expect(portal.errors.full_messages[0]).to eq('Cofig in portal on some_other_key is not supported.')
|
||||
expect(portal.errors.full_messages[0]).to eq('Config in portal on some_other_key is not supported.')
|
||||
end
|
||||
|
||||
it 'falls back to no drafted locales for existing portals' do
|
||||
|
||||
@@ -157,7 +157,7 @@ RSpec.describe Crm::Leadsquared::ProcessorService do
|
||||
|
||||
it 'logs the error' do
|
||||
service.handle_conversation_created(conversation)
|
||||
expect(Rails.logger).to have_received(:error).with(/Error creating conversation activity/)
|
||||
expect(Rails.logger).to have_received(:error).with(/LeadSquared conversation activity failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -117,6 +117,26 @@ describe Messages::MentionService do
|
||||
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to include(first_agent.id)
|
||||
end
|
||||
|
||||
it 'adds the mentioned user as a participant before generating the notification' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
participant_user_ids_when_notified = nil
|
||||
allow(NotificationBuilder).to receive(:new) do |**_kwargs|
|
||||
participant_user_ids_when_notified = conversation.conversation_participants.reload.map(&:user_id)
|
||||
builder
|
||||
end
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(participant_user_ids_when_notified).to include(first_agent.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains multiple user mentions' do
|
||||
|
||||
@@ -30,6 +30,15 @@ RSpec.describe Reports::ReportMetricRegistry do
|
||||
expect(metric.raw_count_strategy).to eq(:distinct_conversation)
|
||||
end
|
||||
|
||||
it 'locks the handoff exclusion strategy for bot_resolutions_count' do
|
||||
metric = described_class.fetch(:bot_resolutions_count)
|
||||
|
||||
expect(metric.count?).to be(true)
|
||||
expect(metric.raw_event_name).to eq(:conversation_bot_resolved)
|
||||
expect(metric.rollup_metric).to eq(:bot_resolutions_count)
|
||||
expect(metric.raw_count_strategy).to eq(:exclude_bot_handoffs)
|
||||
end
|
||||
|
||||
it 'returns nil for unsupported metrics' do
|
||||
expect(described_class.fetch(:unknown_metric)).to be_nil
|
||||
end
|
||||
|
||||
@@ -41,7 +41,9 @@ RSpec.describe Telegram::SendAttachmentsService do
|
||||
end
|
||||
|
||||
context 'when this is business chat' do
|
||||
before { allow(channel).to receive(:business_connection_id).and_return('eooW3KF5WB5HxTD7T826') }
|
||||
before do
|
||||
message.conversation.update!(additional_attributes: { 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' })
|
||||
end
|
||||
|
||||
it 'sends all types of attachments in seperate groups and returns the last successful message ID from the batch' do
|
||||
attach_files(message)
|
||||
|
||||
@@ -177,7 +177,7 @@ describe Whatsapp::FacebookApiClient do
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes] }.to_json
|
||||
subscribed_fields: %w[messages smb_message_echoes calls] }.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
@@ -224,7 +224,7 @@ describe Whatsapp::FacebookApiClient do
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes] }.to_json
|
||||
subscribed_fields: %w[messages smb_message_echoes calls] }.to_json
|
||||
)
|
||||
.to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user