Merge branch 'develop' into codex/agent-bot-assignment-status
This commit is contained in:
@@ -15,7 +15,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an authenticated agent' do
|
||||
it 'returns all the agent_bots in account along with global agent bots' do
|
||||
global_bot = create(:agent_bot)
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots",
|
||||
@@ -25,7 +25,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.name)
|
||||
expect(response.body).to include(global_bot.name)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
end
|
||||
|
||||
@@ -54,6 +54,17 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(account_bot_response).to include('thumbnail')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'returns the account bot access token' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/agent_bots/:id' do
|
||||
@@ -65,7 +76,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an authenticated agent' do
|
||||
it 'shows the agent bot' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
@@ -73,7 +84,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.name)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(agent_bot.access_token.token)
|
||||
end
|
||||
|
||||
it 'will show a global agent bot' do
|
||||
@@ -91,6 +102,17 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response.parsed_body).not_to include('outgoing_url')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'returns the account bot access token' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agent_bots' do
|
||||
|
||||
@@ -62,6 +62,24 @@ RSpec.describe 'Assignable Agents API', type: :request do
|
||||
expect(response_data.size).to eq(2)
|
||||
expect(response_data.pluck(:role)).to include('agent', 'administrator')
|
||||
end
|
||||
|
||||
context 'with Agent Bots' do
|
||||
let!(:account_bot) { create(:agent_bot, account: account, name: 'Account bot') }
|
||||
let!(:global_bot) { create(:agent_bot, account: nil, name: 'Global bot') }
|
||||
|
||||
it 'returns assignable agents and accessible agent bots' do
|
||||
get "/api/v1/accounts/#{account.id}/assignable_agents",
|
||||
params: { inbox_ids: [inbox1.id, inbox2.id], include_agent_bots: true },
|
||||
headers: agent1.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
response_data = response.parsed_body['payload']
|
||||
expect(response_data.pluck('assignee_type')).to include('User', 'AgentBot')
|
||||
expect(response_data.pluck('name')).to include(agent1.name, admin.name, account_bot.name, global_bot.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -67,6 +67,50 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
source: 'default'
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the assistant YAML default for V1 accounts' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.dig(:features, :assistant)).to include(
|
||||
default: Llm::Models.default_model_for('assistant'),
|
||||
selected: Llm::Models.default_model_for('assistant'),
|
||||
source: 'default'
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns GPT-5.2 as the assistant default for V2 accounts' do
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.dig(:features, :assistant)).to include(
|
||||
default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
|
||||
selected: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
|
||||
source: 'default'
|
||||
)
|
||||
end
|
||||
|
||||
it 'keeps the V2 assistant default when an account override is selected' do
|
||||
account.enable_features!('captain_integration_v2')
|
||||
account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.dig(:features, :assistant)).to include(
|
||||
default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
|
||||
selected: 'gpt-5.1',
|
||||
source: 'account_override'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -154,6 +198,17 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
expect(account.reload.captain_models['document_faq_generation']).to eq('gpt-5.2')
|
||||
end
|
||||
|
||||
it 'updates captain_models for conversation FAQ generation' do
|
||||
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { captain_models: { conversation_faq_generation: 'gpt-4.1-mini' } },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.dig(:features, :conversation_faq_generation, :selected)).to eq('gpt-4.1-mini')
|
||||
expect(account.reload.captain_models['conversation_faq_generation']).to eq('gpt-4.1-mini')
|
||||
end
|
||||
|
||||
it 'updates captain_models for PDF FAQ generation' do
|
||||
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
|
||||
@@ -51,6 +51,22 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
expect(json_response['error']).to eq('Validation failed: Content is too long (maximum is 150000 characters)')
|
||||
end
|
||||
|
||||
it 'returns a customer-safe error when the database query is canceled' do
|
||||
message_builder = instance_double(Messages::MessageBuilder)
|
||||
allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder)
|
||||
allow(message_builder).to receive(:perform)
|
||||
.and_raise(ActiveRecord::QueryCanceled, 'PG::QueryCanceled: ERROR: canceling statement due to statement timeout')
|
||||
|
||||
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: { content: 'test-message', private: true },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.database.query_canceled'))
|
||||
expect(response.parsed_body['error']).not_to include('PG::QueryCanceled')
|
||||
end
|
||||
|
||||
it 'creates an outgoing text message with a specific bot sender' do
|
||||
agent_bot = create(:agent_bot)
|
||||
time_stamp = Time.now.utc.to_s
|
||||
@@ -103,7 +119,13 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to(have_been_enqueued.at_least(:once)
|
||||
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
|
||||
content: 'System reopened the conversation due to a new incoming message.' }))
|
||||
content: 'System reopened the conversation due to a new incoming message.',
|
||||
content_attributes: {
|
||||
activity: {
|
||||
type: 'conversation_status_changed',
|
||||
status: 'open'
|
||||
}
|
||||
} }))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -68,6 +68,23 @@ RSpec.describe 'Conversation Participants API', type: :request do
|
||||
expect(response.body).to include(participant.email)
|
||||
expect(conversation.conversation_participants.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'notifies unread counts when a participant is added' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
params = { user_ids: [participant.id] }
|
||||
|
||||
post api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(ActiveSupport::TimeWithZone),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -106,6 +123,25 @@ RSpec.describe 'Conversation Participants API', type: :request do
|
||||
expect(response.body).to include(participant_to_be_added.email)
|
||||
expect(conversation.conversation_participants.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'notifies unread counts when participant membership changes' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
params = { user_ids: [participant.id, participant_to_be_added.id] }
|
||||
create(:conversation_participant, conversation: conversation, user: participant)
|
||||
create(:conversation_participant, conversation: conversation, user: participant_to_be_removed)
|
||||
|
||||
put api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(ActiveSupport::TimeWithZone),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -137,6 +173,24 @@ RSpec.describe 'Conversation Participants API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(conversation.conversation_participants.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'notifies unread counts when a participant is removed' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
params = { user_ids: [participant.id] }
|
||||
create(:conversation_participant, conversation: conversation, user: participant)
|
||||
|
||||
delete api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(ActiveSupport::TimeWithZone),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -159,6 +159,28 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1)
|
||||
end
|
||||
|
||||
it 'returns filtered unread counts when the filtered count feature is enabled' do
|
||||
account.enable_features!(:unread_count_for_filters)
|
||||
allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:summarize_request) do |**_attributes, &block|
|
||||
block.call
|
||||
end
|
||||
mentioned = create_unread_conversation(account: account, inbox: visible_inbox)
|
||||
create(:mention, account: account, conversation: mentioned, user: agent)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']).to include(
|
||||
'mentions_count' => 1,
|
||||
'participating_count' => 0,
|
||||
'unattended_count' => 1,
|
||||
'folders' => {}
|
||||
)
|
||||
expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:summarize_request).with(account_id: account.id)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns forbidden when conversation unread counts feature is disabled' do
|
||||
@@ -865,6 +887,59 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
end
|
||||
|
||||
it 'refreshes unread count cache before invalidating filtered counts when conversation is marked read' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
conversation.update!(agent_last_seen_at: 1.hour.ago)
|
||||
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
|
||||
notifier = instance_double(Conversations::UnreadCounts::Notifier)
|
||||
invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
|
||||
|
||||
allow(Conversations::UnreadCounts::Notifier).to receive(:new).with(conversation).and_return(notifier)
|
||||
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
|
||||
expect(notifier).to receive(:perform).ordered.and_return(true)
|
||||
expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'invalidates filtered unread counts when conversation is marked read' do
|
||||
conversation.update!(agent_last_seen_at: 1.hour.ago)
|
||||
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
|
||||
account.enable_features!(:unread_count_for_filters)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change { Conversations::UnreadCounts::FilteredCountStore.conversation_version(account.id) }.by(1)
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'notifies clients when marking read only affects filtered counts' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
conversation.update!(agent_last_seen_at: 1.hour.ago)
|
||||
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
|
||||
allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(
|
||||
instance_double(Conversations::UnreadCounts::Refresher, perform: false)
|
||||
)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(Time),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
|
||||
it 'updates both if one timestamp is old even when the other is recent' do
|
||||
conversation.update!(assignee_id: agent.id, agent_last_seen_at: 2.hours.ago, assignee_last_seen_at: 30.minutes.ago)
|
||||
# Ensure all messages are older than assignee_last_seen_at (no unread messages)
|
||||
@@ -951,6 +1026,56 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
ensure
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
end
|
||||
|
||||
it 'refreshes unread count cache before invalidating filtered counts when conversation is marked unread' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
|
||||
notifier = instance_double(Conversations::UnreadCounts::Notifier)
|
||||
invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
|
||||
|
||||
allow(Conversations::UnreadCounts::Notifier).to receive(:new).with(conversation).and_return(notifier)
|
||||
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
|
||||
expect(notifier).to receive(:perform).ordered.and_return(true)
|
||||
expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'invalidates filtered unread counts when conversation is marked unread' do
|
||||
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
|
||||
account.enable_features!(:unread_count_for_filters)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change { Conversations::UnreadCounts::FilteredCountStore.conversation_version(account.id) }.by(1)
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'notifies clients when marking unread only affects filtered counts' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
|
||||
allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(
|
||||
instance_double(Conversations::UnreadCounts::Refresher, perform: false)
|
||||
)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(Time),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -70,8 +70,8 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
context 'when it is an authenticated administrator' do
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'creates the dashboard app' do
|
||||
expect do
|
||||
@@ -130,11 +130,26 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'does not create account-wide dashboard apps' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/dashboard_apps",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: payload,
|
||||
as: :json
|
||||
end.not_to change(DashboardApp, :count)
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/dashboard_apps/:id' do
|
||||
let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
@@ -160,10 +175,24 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'does not update account-wide dashboard apps' do
|
||||
patch "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: payload,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(dashboard_app.reload.title).not_to eq('CRM Dashboard')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/dashboard_apps/:id' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
@@ -182,5 +211,18 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
expect(user.dashboard_apps.count).to be 0
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'does not delete account-wide dashboard apps' do
|
||||
delete "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(DashboardApp.exists?(dashboard_app.id)).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,7 +43,7 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
|
||||
]
|
||||
expect(params['scope']).to eq(expected_scope)
|
||||
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
|
||||
expect(url).not_to match(/(?:\?|&)prompt=/)
|
||||
expect(params['prompt']).to eq(['select_account'])
|
||||
|
||||
# Validate state parameter exists and can be decoded back to the account
|
||||
expect(params['state']).to be_present
|
||||
|
||||
@@ -116,6 +116,78 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/widget/contact with HMAC enforcement' do
|
||||
let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
|
||||
let!(:victim) { create(:contact, account: account, identifier: 'victim-identifier', name: 'Victim') }
|
||||
let(:correct_identifier_hash) { OpenSSL::HMAC.hexdigest('sha256', web_widget.hmac_token, 'victim-identifier') }
|
||||
|
||||
context 'when an identifier is supplied on a mandatory-hmac inbox' do
|
||||
it 'rejects when identifier_hash is omitted' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', name: 'Attacker' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(victim.reload.name).to eq('Victim')
|
||||
end
|
||||
|
||||
it 'rejects when identifier_hash is blank' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: '', name: 'Attacker' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(victim.reload.name).to eq('Victim')
|
||||
end
|
||||
|
||||
it 'rejects when identifier_hash is null' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: nil, name: 'Attacker' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(victim.reload.name).to eq('Victim')
|
||||
end
|
||||
|
||||
it 'rejects when identifier_hash is invalid' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
|
||||
identifier_hash: 'DEFINITELY_INVALID_AAAAA_NOT_A_REAL_HMAC', name: 'Attacker' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(victim.reload.name).to eq('Victim')
|
||||
end
|
||||
|
||||
it 'succeeds when a valid identifier_hash is provided' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
|
||||
identifier_hash: correct_identifier_hash, name: 'Legit' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no identifier is supplied (anonymous prechat update)' do
|
||||
it 'allows updating name/email without an identifier_hash' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, email: 'prechat@test.com', name: 'Prechat User' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(victim.reload.email).to be_nil
|
||||
expect(Contact.from_email('prechat@test.com')).to be_present
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/widget/contact/set_user' do
|
||||
let(:params) { { website_token: web_widget.website_token, identifier: 'test' } }
|
||||
let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
|
||||
|
||||
@@ -140,6 +140,51 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
|
||||
expect(json_response['messages'][0]['content']).to eq 'This is a test message'
|
||||
end
|
||||
|
||||
it 'saves contact custom attributes on the widget contact' do
|
||||
post '/api/v1/widget/conversations',
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
params: {
|
||||
website_token: web_widget.website_token,
|
||||
contact: {
|
||||
name: 'contact-name',
|
||||
email: 'contact-email@chatwoot.com',
|
||||
custom_attributes: { cpf: '123.456.789-09' }
|
||||
},
|
||||
message: {
|
||||
content: 'This is a test message'
|
||||
}
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(contact.reload.custom_attributes['cpf']).to eq('123.456.789-09')
|
||||
end
|
||||
|
||||
it 'saves contact custom attributes on the surviving contact when merged into an existing contact' do
|
||||
existing_contact = create(:contact, account: account, email: 'contact-email@chatwoot.com', custom_attributes: { 'cpf' => 'old-value' })
|
||||
|
||||
post '/api/v1/widget/conversations',
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
params: {
|
||||
website_token: web_widget.website_token,
|
||||
contact: {
|
||||
name: 'contact-name',
|
||||
email: existing_contact.email,
|
||||
custom_attributes: { cpf: '123.456.789-09' }
|
||||
},
|
||||
message: {
|
||||
content: 'This is a test message'
|
||||
}
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
# the widget contact is merged into the existing contact; the freshly
|
||||
# submitted value must land on the surviving contact and win over stale data
|
||||
expect(Contact.exists?(contact.id)).to be(false)
|
||||
expect(existing_contact.reload.custom_attributes['cpf']).to eq('123.456.789-09')
|
||||
end
|
||||
|
||||
it 'doesnt not add phone number if the invalid phone number is provided' do
|
||||
existing_contact = create(:contact, account: account)
|
||||
|
||||
@@ -240,7 +285,8 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: "Conversation was resolved by #{contact.name}"
|
||||
content: "Conversation was resolved by #{contact.name}",
|
||||
content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } }
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -202,7 +202,8 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: "Conversation was resolved by #{contact.name}"
|
||||
content: "Conversation was resolved by #{contact.name}",
|
||||
content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } }
|
||||
}
|
||||
)
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
Reference in New Issue
Block a user