Merge branch 'develop' into fix/CW-7007

This commit is contained in:
Sivin Varghese
2026-05-20 10:21:12 +05:30
committed by GitHub
1139 changed files with 25484 additions and 1462 deletions
@@ -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',
+64 -1
View File
@@ -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
+2
View File
@@ -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
@@ -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
+11
View File
@@ -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
+1
View File
@@ -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
@@ -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
+29 -19
View File
@@ -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
+7
View File
@@ -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
+14
View File
@@ -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 = '![Sample](https://example.com/image.jpg?cw_image_width=400px)'
expect(render_markdown(markdown)).to include(
'style="width: 400px; max-width: 100%; height: auto;"'
)
end
it 'ignores a non-numeric width' do
markdown = '![Sample](https://example.com/image.jpg?cw_image_width=auto)'
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
View File
@@ -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
+7
View File
@@ -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
+39
View File
@@ -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
+2
View File
@@ -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' }
+66
View File
@@ -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
+1 -1
View File
@@ -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