Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Account::SignUpEmailValidationService, type: :service do
|
||||
let(:service) { described_class.new(email) }
|
||||
let(:blocked_domains) { "gmail.com\noutlook.com" }
|
||||
let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable?: false) }
|
||||
let(:disposable_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable?: true) }
|
||||
let(:invalid_email_address) { instance_double(ValidEmail2::Address, valid?: false) }
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('BLOCKED_EMAIL_DOMAINS', '').and_return(blocked_domains)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when email is invalid format' do
|
||||
let(:email) { 'invalid-email' }
|
||||
|
||||
it 'raises InvalidEmail with invalid message' do
|
||||
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address)
|
||||
expect { service.perform }.to raise_error do |error|
|
||||
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
|
||||
expect(error.message).to eq(I18n.t('errors.signup.invalid_email'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when domain is blocked' do
|
||||
let(:email) { 'test@gmail.com' }
|
||||
|
||||
it 'raises InvalidEmail with blocked domain message' do
|
||||
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
|
||||
expect { service.perform }.to raise_error do |error|
|
||||
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
|
||||
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when domain is blocked (case insensitive)' do
|
||||
let(:email) { 'test@GMAIL.COM' }
|
||||
|
||||
it 'raises InvalidEmail with blocked domain message' do
|
||||
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
|
||||
expect { service.perform }.to raise_error do |error|
|
||||
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
|
||||
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when email is from disposable provider' do
|
||||
let(:email) { 'test@mailinator.com' }
|
||||
|
||||
it 'raises InvalidEmail with disposable message' do
|
||||
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address)
|
||||
expect { service.perform }.to raise_error do |error|
|
||||
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
|
||||
expect(error.message).to eq(I18n.t('errors.signup.disposable_email'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when email is valid business email' do
|
||||
let(:email) { 'test@example.com' }
|
||||
|
||||
it 'returns true' do
|
||||
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
|
||||
expect(service.perform).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AccountDeletionService do
|
||||
let(:account) { create(:account) }
|
||||
let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_later: nil) }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(DeleteObjectJob).to receive(:perform_later)
|
||||
allow(AdministratorNotifications::AccountComplianceMailer).to receive(:with).and_return(
|
||||
instance_double(AdministratorNotifications::AccountComplianceMailer, account_deleted: mailer)
|
||||
)
|
||||
end
|
||||
|
||||
it 'enqueues DeleteObjectJob with the account' do
|
||||
described_class.new(account: account).perform
|
||||
|
||||
expect(DeleteObjectJob).to have_received(:perform_later).with(account)
|
||||
end
|
||||
|
||||
it 'sends a compliance notification email' do
|
||||
described_class.new(account: account).perform
|
||||
|
||||
expect(AdministratorNotifications::AccountComplianceMailer).to have_received(:with) do |args|
|
||||
expect(args[:account]).to eq(account)
|
||||
expect(args).to include(:soft_deleted_users)
|
||||
end
|
||||
expect(mailer).to have_received(:deliver_later)
|
||||
end
|
||||
|
||||
context 'when handling users' do
|
||||
let(:user_with_one_account) { create(:user) }
|
||||
let(:user_with_multiple_accounts) { create(:user) }
|
||||
let(:second_account) { create(:account) }
|
||||
|
||||
before do
|
||||
create(:account_user, user: user_with_one_account, account: account)
|
||||
create(:account_user, user: user_with_multiple_accounts, account: account)
|
||||
create(:account_user, user: user_with_multiple_accounts, account: second_account)
|
||||
end
|
||||
|
||||
it 'soft deletes users who only belong to the deleted account' do
|
||||
original_email = user_with_one_account.email
|
||||
|
||||
described_class.new(account: account).perform
|
||||
|
||||
# Reload the user to get the updated email
|
||||
user_with_one_account.reload
|
||||
expect(user_with_one_account.email).to eq("#{user_with_one_account.id}@chatwoot-deleted.invalid")
|
||||
expect(user_with_one_account.email).not_to eq(original_email)
|
||||
end
|
||||
|
||||
it 'does not modify emails for users belonging to multiple accounts' do
|
||||
original_email = user_with_multiple_accounts.email
|
||||
|
||||
described_class.new(account: account).perform
|
||||
|
||||
# Reload the user to get the updated email
|
||||
user_with_multiple_accounts.reload
|
||||
expect(user_with_multiple_accounts.email).to eq(original_email)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
95
research/chatwoot/spec/services/action_service_spec.rb
Normal file
95
research/chatwoot/spec/services/action_service_spec.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe ActionService do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe '#resolve_conversation' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:action_service) { described_class.new(conversation) }
|
||||
|
||||
it 'resolves the conversation' do
|
||||
expect(conversation.status).to eq('open')
|
||||
action_service.resolve_conversation(nil)
|
||||
expect(conversation.reload.status).to eq('resolved')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#open_conversation' do
|
||||
let(:conversation) { create(:conversation, status: :resolved) }
|
||||
let(:action_service) { described_class.new(conversation) }
|
||||
|
||||
it 'opens the conversation' do
|
||||
expect(conversation.status).to eq('resolved')
|
||||
action_service.open_conversation(nil)
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#change_priority' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:action_service) { described_class.new(conversation) }
|
||||
|
||||
it 'changes the priority of the conversation to medium' do
|
||||
action_service.change_priority(['medium'])
|
||||
expect(conversation.reload.priority).to eq('medium')
|
||||
end
|
||||
|
||||
it 'changes the priority of the conversation to nil' do
|
||||
action_service.change_priority(['nil'])
|
||||
expect(conversation.reload.priority).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#assign_agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: agent) }
|
||||
let(:conversation) { create(:conversation, :with_assignee, account: account) }
|
||||
let(:action_service) { described_class.new(conversation) }
|
||||
|
||||
it 'unassigns the conversation if agent id is nil' do
|
||||
action_service.assign_agent(['nil'])
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#assign_team' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: agent) }
|
||||
let(:team) { create(:team, name: 'ConversationTeam', account: account) }
|
||||
let(:conversation) { create(:conversation, :with_team, account: account) }
|
||||
let(:action_service) { described_class.new(conversation) }
|
||||
|
||||
context 'when team_id is not present' do
|
||||
it 'unassign the if team_id is "nil"' do
|
||||
expect do
|
||||
action_service.assign_team(['nil'])
|
||||
end.not_to raise_error
|
||||
expect(conversation.reload.team).to be_nil
|
||||
end
|
||||
|
||||
it 'unassign the if team_id is 0' do
|
||||
expect do
|
||||
action_service.assign_team([0])
|
||||
end.not_to raise_error
|
||||
expect(conversation.reload.team).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team_id is present' do
|
||||
it 'assign the team if the team is part of the account' do
|
||||
original_team = conversation.team
|
||||
expect do
|
||||
action_service.assign_team([team.id])
|
||||
end.to change { conversation.reload.team }.from(original_team)
|
||||
end
|
||||
|
||||
it 'does not assign the team if the team is part of the account' do
|
||||
original_team = conversation.team
|
||||
invalid_team_id = 999_999_999
|
||||
expect do
|
||||
action_service.assign_team([invalid_team_id])
|
||||
end.not_to change { conversation.reload.team }.from(original_team)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::AgentAssignmentService do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
|
||||
let!(:inbox_members) { create_list(:inbox_member, 5, inbox: inbox) }
|
||||
let!(:conversation) { create(:conversation, inbox: inbox, account: account) }
|
||||
let!(:online_users) do
|
||||
{
|
||||
inbox_members[0].user_id.to_s => 'busy',
|
||||
inbox_members[1].user_id.to_s => 'busy',
|
||||
inbox_members[2].user_id.to_s => 'busy',
|
||||
inbox_members[3].user_id.to_s => 'online',
|
||||
inbox_members[4].user_id.to_s => 'online'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
inbox_members.each { |inbox_member| create(:account_user, account: account, user: inbox_member.user) }
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return(online_users)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'will assign an online agent to the conversation' do
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
described_class.new(conversation: conversation, allowed_agent_ids: inbox_members.map(&:user_id).map(&:to_s)).perform
|
||||
expect(conversation.reload.assignee).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#find_assignee' do
|
||||
it 'will return an online agent from the allowed agent ids in roud robin' do
|
||||
expect(described_class.new(conversation: conversation,
|
||||
allowed_agent_ids: inbox_members.map(&:user_id).map(&:to_s)).find_assignee).to eq(inbox_members[3].user)
|
||||
expect(described_class.new(conversation: conversation,
|
||||
allowed_agent_ids: inbox_members.map(&:user_id).map(&:to_s)).find_assignee).to eq(inbox_members[4].user)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,358 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::AssignmentService do
|
||||
let(:account) { create(:account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
|
||||
let(:service) { described_class.new(inbox: inbox) }
|
||||
let(:agent) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
# Enable assignment_v2 feature for the account (basic assignment features)
|
||||
account.enable_features('assignment_v2')
|
||||
account.save!
|
||||
# Link inbox to assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
end
|
||||
|
||||
describe '#perform_bulk_assignment' do
|
||||
context 'when auto assignment is enabled' do
|
||||
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
|
||||
|
||||
before do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
|
||||
|
||||
# Mock RoundRobinSelector to return the agent
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
|
||||
|
||||
# Mock RateLimiter to allow all assignments by default
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
|
||||
allow(rate_limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(rate_limiter).to receive(:track_assignment)
|
||||
end
|
||||
|
||||
it 'assigns conversations to available agents' do
|
||||
# Create conversation and ensure it's unassigned
|
||||
conv = create(:conversation, inbox: inbox, status: 'open')
|
||||
conv.update!(assignee_id: nil)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conv.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
it 'returns 0 when no agents are online' do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(assigned_count).to eq(0)
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'respects the limit parameter' do
|
||||
3.times do
|
||||
conv = create(:conversation, inbox: inbox, status: 'open')
|
||||
conv.update!(assignee_id: nil)
|
||||
end
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 2)
|
||||
|
||||
expect(assigned_count).to eq(2)
|
||||
expect(inbox.conversations.unassigned.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'only assigns open conversations' do
|
||||
conversation # ensure it exists
|
||||
conversation.update!(assignee_id: nil)
|
||||
resolved_conversation = create(:conversation, inbox: inbox, status: 'resolved')
|
||||
resolved_conversation.update!(assignee_id: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(conversation.reload.assignee).to eq(agent)
|
||||
expect(resolved_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'does not reassign already assigned conversations' do
|
||||
conversation # ensure it exists
|
||||
conversation.update!(assignee_id: nil)
|
||||
assigned_conversation = create(:conversation, inbox: inbox, assignee: agent)
|
||||
unassigned_conversation = create(:conversation, inbox: inbox, status: 'open')
|
||||
unassigned_conversation.update!(assignee_id: nil)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(2) # conversation + unassigned_conversation
|
||||
expect(assigned_conversation.reload.assignee).to eq(agent)
|
||||
expect(unassigned_conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
it 'dispatches assignee changed event' do
|
||||
conversation # ensure it exists
|
||||
conversation.update!(assignee_id: nil)
|
||||
|
||||
# The conversation model also dispatches a conversation.updated event
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
|
||||
Events::Types::ASSIGNEE_CHANGED,
|
||||
anything,
|
||||
hash_including(conversation: conversation, user: agent)
|
||||
)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when auto assignment is disabled' do
|
||||
before { assignment_policy.update!(enabled: false) }
|
||||
|
||||
it 'returns 0 without processing' do
|
||||
assigned_count = service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(0)
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation priority' do
|
||||
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
|
||||
|
||||
before do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
|
||||
|
||||
# Mock RoundRobinSelector to return the agent
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
|
||||
|
||||
# Mock RateLimiter to allow all assignments by default
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
|
||||
allow(rate_limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(rate_limiter).to receive(:track_assignment)
|
||||
end
|
||||
|
||||
context 'when priority is longest_waiting' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_config).and_return({ 'conversation_priority' => 'longest_waiting' })
|
||||
end
|
||||
|
||||
it 'assigns conversations with oldest last_activity_at first' do
|
||||
old_conversation = create(:conversation,
|
||||
inbox: inbox,
|
||||
status: 'open',
|
||||
created_at: 2.hours.ago,
|
||||
last_activity_at: 2.hours.ago)
|
||||
old_conversation.update!(assignee_id: nil)
|
||||
new_conversation = create(:conversation,
|
||||
inbox: inbox,
|
||||
status: 'open',
|
||||
created_at: 1.hour.ago,
|
||||
last_activity_at: 1.hour.ago)
|
||||
new_conversation.update!(assignee_id: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(old_conversation.reload.assignee).to eq(agent)
|
||||
expect(new_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when priority is default' do
|
||||
it 'assigns conversations by created_at' do
|
||||
old_conversation = create(:conversation, inbox: inbox, status: 'open', created_at: 2.hours.ago)
|
||||
old_conversation.update!(assignee_id: nil)
|
||||
new_conversation = create(:conversation, inbox: inbox, status: 'open', created_at: 1.hour.ago)
|
||||
new_conversation.update!(assignee_id: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(old_conversation.reload.assignee).to eq(agent)
|
||||
expect(new_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with fair distribution' do
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
|
||||
agent.id.to_s => 'online',
|
||||
agent2.id.to_s => 'online'
|
||||
})
|
||||
end
|
||||
|
||||
context 'when fair distribution is enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_config).and_return({
|
||||
'fair_distribution_limit' => 2,
|
||||
'fair_distribution_window' => 3600
|
||||
})
|
||||
end
|
||||
|
||||
it 'respects the assignment limit per agent' do
|
||||
# Mock RoundRobinSelector to select agent2
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent2)
|
||||
|
||||
# Mock agent1 at limit, agent2 not at limit
|
||||
agent1_limiter = instance_double(AutoAssignment::RateLimiter)
|
||||
agent2_limiter = instance_double(AutoAssignment::RateLimiter)
|
||||
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).with(inbox: inbox, agent: agent).and_return(agent1_limiter)
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).with(inbox: inbox, agent: agent2).and_return(agent2_limiter)
|
||||
|
||||
allow(agent1_limiter).to receive(:within_limit?).and_return(false)
|
||||
allow(agent2_limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(agent2_limiter).to receive(:track_assignment)
|
||||
|
||||
unassigned_conversation = create(:conversation, inbox: inbox, status: 'open')
|
||||
unassigned_conversation.update!(assignee_id: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(unassigned_conversation.reload.assignee).to eq(agent2)
|
||||
end
|
||||
|
||||
it 'tracks assignments in Redis' do
|
||||
conversation # ensure it exists
|
||||
conversation.update!(assignee_id: nil)
|
||||
|
||||
# Mock RoundRobinSelector
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
|
||||
|
||||
limiter = instance_double(AutoAssignment::RateLimiter)
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
|
||||
allow(limiter).to receive(:within_limit?).and_return(true)
|
||||
expect(limiter).to receive(:track_assignment)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
end
|
||||
|
||||
it 'allows assignments after window expires' do
|
||||
# Mock RoundRobinSelector
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent, agent2)
|
||||
|
||||
# Mock RateLimiter to allow all
|
||||
limiter = instance_double(AutoAssignment::RateLimiter)
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
|
||||
allow(limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(limiter).to receive(:track_assignment)
|
||||
|
||||
# Simulate time passing for rate limit window
|
||||
freeze_time do
|
||||
2.times do
|
||||
conversation_new = create(:conversation, inbox: inbox, status: 'open')
|
||||
conversation_new.update!(assignee_id: nil)
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
expect(conversation_new.reload.assignee).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
# Move forward past the window
|
||||
travel_to(2.hours.from_now) do
|
||||
new_conversation = create(:conversation, inbox: inbox, status: 'open')
|
||||
new_conversation.update!(assignee_id: nil)
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
expect(new_conversation.reload.assignee).not_to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when fair distribution is disabled' do
|
||||
it 'assigns without rate limiting' do
|
||||
5.times do
|
||||
conv = create(:conversation, inbox: inbox, status: 'open')
|
||||
conv.update!(assignee_id: nil)
|
||||
end
|
||||
|
||||
# Mock RoundRobinSelector
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
|
||||
|
||||
# Mock RateLimiter to allow all
|
||||
limiter = instance_double(AutoAssignment::RateLimiter)
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
|
||||
allow(limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(limiter).to receive(:track_assignment)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 5)
|
||||
expect(assigned_count).to eq(5)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with round robin assignment' do
|
||||
it 'distributes conversations evenly among agents' do
|
||||
conversations = Array.new(4) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
service.perform_bulk_assignment(limit: 4)
|
||||
|
||||
agent1_count = conversations.count { |c| c.reload.assignee == agent }
|
||||
agent2_count = conversations.count { |c| c.reload.assignee == agent2 }
|
||||
|
||||
# Should be distributed evenly (2 each) or close to even (3 and 1)
|
||||
expect([agent1_count, agent2_count].sort).to eq([2, 2]).or(eq([1, 3]))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with team assignments' do
|
||||
let(:team) { create(:team, account: account, allow_auto_assign: true) }
|
||||
let(:team_member) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
|
||||
|
||||
before do
|
||||
create(:team_member, team: team, user: team_member)
|
||||
create(:inbox_member, inbox: inbox, user: team_member)
|
||||
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ team_member.id.to_s => 'online' })
|
||||
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
|
||||
allow(rate_limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(rate_limiter).to receive(:track_assignment)
|
||||
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(team_member)
|
||||
end
|
||||
|
||||
it 'assigns conversation with team to team member' do
|
||||
conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(conversation_with_team.reload.assignee).to eq(team_member)
|
||||
end
|
||||
|
||||
it 'skips assignment when team has allow_auto_assign false' do
|
||||
team.update!(allow_auto_assign: false)
|
||||
conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(conversation_with_team.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'skips assignment when no team members are available' do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
|
||||
conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(conversation_with_team.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe AutoAssignment::InboxRoundRobinService do
|
||||
subject(:inbox_round_robin_service) { described_class.new(inbox: inbox) }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:inbox_members) { create_list(:inbox_member, 5, inbox: inbox) }
|
||||
|
||||
describe '#available_agent' do
|
||||
it 'returns nil if allowed_agent_ids is not passed or empty' do
|
||||
expect(described_class.new(inbox: inbox).available_agent).to be_nil
|
||||
end
|
||||
|
||||
it 'gets the first available agent in allowed_agent_ids and move agent to end of the list' do
|
||||
expected_queue = [inbox_members[0].user_id, inbox_members[4].user_id, inbox_members[3].user_id, inbox_members[2].user_id,
|
||||
inbox_members[1].user_id].map(&:to_s)
|
||||
described_class.new(inbox: inbox).available_agent(allowed_agent_ids: [inbox_members[0].user_id, inbox_members[4].user_id].map(&:to_s))
|
||||
expect(inbox_round_robin_service.send(:queue)).to eq(expected_queue)
|
||||
end
|
||||
|
||||
it 'constructs round_robin_queue if queue is not present' do
|
||||
inbox_round_robin_service.clear_queue
|
||||
expect(inbox_round_robin_service.send(:queue)).to eq([])
|
||||
inbox_round_robin_service.available_agent
|
||||
# the service constructed the redis queue before performing
|
||||
expect(inbox_round_robin_service.send(:queue).map(&:to_i)).to match_array(inbox_members.map(&:user_id))
|
||||
end
|
||||
|
||||
it 'validates the queue and correct it before performing round robin' do
|
||||
# adding some invalid ids to queue
|
||||
inbox_round_robin_service.add_agent_to_queue([2, 3, 5, 9])
|
||||
expect(inbox_round_robin_service.send(:queue).map(&:to_i)).not_to match_array(inbox_members.map(&:user_id))
|
||||
inbox_round_robin_service.available_agent
|
||||
# the service have refreshed the redis queue before performing
|
||||
expect(inbox_round_robin_service.send(:queue).map(&:to_i)).to match_array(inbox_members.map(&:user_id))
|
||||
end
|
||||
|
||||
context 'when allowed_agent_ids is passed' do
|
||||
it 'will get the first allowed member and move it to the end of the queue' do
|
||||
expected_queue = [inbox_members[3].user_id, inbox_members[2].user_id, inbox_members[4].user_id, inbox_members[1].user_id,
|
||||
inbox_members[0].user_id].map(&:to_s)
|
||||
expect(described_class.new(inbox: inbox).available_agent(
|
||||
allowed_agent_ids: [
|
||||
inbox_members[3].user_id,
|
||||
inbox_members[2].user_id
|
||||
].map(&:to_s)
|
||||
)).to eq inbox_members[2].user
|
||||
expect(described_class.new(inbox: inbox).available_agent(
|
||||
allowed_agent_ids: [
|
||||
inbox_members[3].user_id,
|
||||
inbox_members[2].user_id
|
||||
].map(&:to_s)
|
||||
)).to eq inbox_members[3].user
|
||||
expect(inbox_round_robin_service.send(:queue)).to eq(expected_queue)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,168 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::RateLimiter do
|
||||
# Stub Math methods for testing when assignment_policy is nil
|
||||
# rubocop:disable RSpec/BeforeAfterAll, RSpec/InstanceVariable
|
||||
before(:all) do
|
||||
@math_had_positive = Math.respond_to?(:positive?)
|
||||
Math.define_singleton_method(:positive?) { false } unless @math_had_positive
|
||||
end
|
||||
|
||||
after(:all) do
|
||||
Math.singleton_class.send(:remove_method, :positive?) unless @math_had_positive
|
||||
end
|
||||
# rubocop:enable RSpec/BeforeAfterAll, RSpec/InstanceVariable
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox) }
|
||||
let(:rate_limiter) { described_class.new(inbox: inbox, agent: agent) }
|
||||
|
||||
describe '#within_limit?' do
|
||||
context 'when rate limiting is not enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(nil)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(rate_limiter.within_limit?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rate limiting is enabled' do
|
||||
let(:assignment_policy) do
|
||||
instance_double(AssignmentPolicy,
|
||||
fair_distribution_limit: 5,
|
||||
fair_distribution_window: 3600)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
|
||||
end
|
||||
|
||||
it 'returns true when under the limit' do
|
||||
allow(rate_limiter).to receive(:current_count).and_return(3)
|
||||
expect(rate_limiter.within_limit?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when at or over the limit' do
|
||||
allow(rate_limiter).to receive(:current_count).and_return(5)
|
||||
expect(rate_limiter.within_limit?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#track_assignment' do
|
||||
context 'when rate limiting is not enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(nil)
|
||||
end
|
||||
|
||||
it 'still tracks the assignment with default window' do
|
||||
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
|
||||
expect(Redis::Alfred).to receive(:set).with(expected_key, conversation.id.to_s, ex: 24.hours.to_i)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rate limiting is enabled' do
|
||||
let(:assignment_policy) do
|
||||
instance_double(AssignmentPolicy,
|
||||
fair_distribution_limit: 5,
|
||||
fair_distribution_window: 3600)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
|
||||
end
|
||||
|
||||
it 'creates a Redis key with correct expiry' do
|
||||
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
|
||||
expect(Redis::Alfred).to receive(:set).with(
|
||||
expected_key,
|
||||
conversation.id.to_s,
|
||||
ex: 3600
|
||||
)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#current_count' do
|
||||
context 'when rate limiting is not enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(nil)
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
expect(rate_limiter.current_count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rate limiting is enabled' do
|
||||
let(:assignment_policy) do
|
||||
instance_double(AssignmentPolicy,
|
||||
fair_distribution_limit: 5,
|
||||
fair_distribution_window: 3600)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
|
||||
end
|
||||
|
||||
it 'counts matching Redis keys' do
|
||||
pattern = format(Redis::RedisKeys::ASSIGNMENT_KEY_PATTERN, inbox_id: inbox.id, agent_id: agent.id)
|
||||
allow(Redis::Alfred).to receive(:keys_count).with(pattern).and_return(3)
|
||||
|
||||
expect(rate_limiter.current_count).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'configuration' do
|
||||
context 'with custom window' do
|
||||
let(:assignment_policy) do
|
||||
instance_double(AssignmentPolicy,
|
||||
fair_distribution_limit: 10,
|
||||
fair_distribution_window: 7200)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
|
||||
end
|
||||
|
||||
it 'uses the custom window value' do
|
||||
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
|
||||
expect(Redis::Alfred).to receive(:set).with(
|
||||
expected_key,
|
||||
conversation.id.to_s,
|
||||
ex: 7200
|
||||
)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without custom window' do
|
||||
let(:assignment_policy) do
|
||||
instance_double(AssignmentPolicy,
|
||||
fair_distribution_limit: 10,
|
||||
fair_distribution_window: nil)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
|
||||
end
|
||||
|
||||
it 'uses the default window value of 24 hours' do
|
||||
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
|
||||
expect(Redis::Alfred).to receive(:set).with(
|
||||
expected_key,
|
||||
conversation.id.to_s,
|
||||
ex: 86_400
|
||||
)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::RoundRobinSelector do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:selector) { described_class.new(inbox: inbox) }
|
||||
let(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent3) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
|
||||
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
|
||||
|
||||
let(:member1) do
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
|
||||
allow(round_robin_service).to receive(:add_agent_to_queue)
|
||||
create(:inbox_member, inbox: inbox, user: agent1)
|
||||
end
|
||||
|
||||
let(:member2) do
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
|
||||
allow(round_robin_service).to receive(:add_agent_to_queue)
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
end
|
||||
|
||||
let(:member3) do
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
|
||||
allow(round_robin_service).to receive(:add_agent_to_queue)
|
||||
create(:inbox_member, inbox: inbox, user: agent3)
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock the round robin service to avoid Redis calls
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
|
||||
allow(round_robin_service).to receive(:add_agent_to_queue)
|
||||
allow(round_robin_service).to receive(:reset_queue)
|
||||
allow(round_robin_service).to receive(:validate_queue?).and_return(true)
|
||||
end
|
||||
|
||||
describe '#select_agent' do
|
||||
context 'when agents are available' do
|
||||
let(:available_agents) { [member1, member2, member3] }
|
||||
|
||||
it 'returns an agent from the available list' do
|
||||
allow(round_robin_service).to receive(:available_agent).and_return(agent1)
|
||||
|
||||
selected_agent = selector.select_agent(available_agents)
|
||||
|
||||
expect(selected_agent).not_to be_nil
|
||||
expect([agent1, agent2, agent3]).to include(selected_agent)
|
||||
end
|
||||
|
||||
it 'uses round robin service for selection' do
|
||||
expect(round_robin_service).to receive(:available_agent).with(
|
||||
allowed_agent_ids: [agent1.id.to_s, agent2.id.to_s, agent3.id.to_s]
|
||||
).and_return(agent1)
|
||||
|
||||
selected_agent = selector.select_agent(available_agents)
|
||||
expect(selected_agent).to eq(agent1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no agents are available' do
|
||||
it 'returns nil' do
|
||||
selected_agent = selector.select_agent([])
|
||||
expect(selected_agent).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when one agent is available' do
|
||||
it 'returns that agent' do
|
||||
allow(round_robin_service).to receive(:available_agent).and_return(agent1)
|
||||
|
||||
selected_agent = selector.select_agent([member1])
|
||||
expect(selected_agent).to eq(agent1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,182 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutomationRules::ActionService do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let!(:rule) do
|
||||
create(:automation_rule, account: account,
|
||||
actions: [
|
||||
{ action_name: 'send_webhook_event', action_params: ['https://example.com'] },
|
||||
{ action_name: 'send_message', action_params: { message: 'Hello' } }
|
||||
])
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when actions are defined in the rule' do
|
||||
it 'will call the actions' do
|
||||
expect(Messages::MessageBuilder).to receive(:new)
|
||||
expect(WebhookJob).to receive(:perform_later)
|
||||
described_class.new(rule, account, conversation).perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with send_attachment action' do
|
||||
let(:message_builder) { double }
|
||||
|
||||
before do
|
||||
allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder)
|
||||
rule.actions.delete_if { |a| a['action_name'] == 'send_message' }
|
||||
rule.files.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
rule.save!
|
||||
rule.actions << { action_name: 'send_attachment', action_params: [rule.files.first.blob_id] }
|
||||
end
|
||||
|
||||
it 'will send attachment' do
|
||||
expect(message_builder).to receive(:perform)
|
||||
described_class.new(rule, account, conversation).perform
|
||||
end
|
||||
|
||||
it 'will not send attachment is conversation is a tweet' do
|
||||
twitter_inbox = create(:inbox, channel: create(:channel_twitter_profile, account: account))
|
||||
conversation = create(:conversation, inbox: twitter_inbox, additional_attributes: { type: 'tweet' })
|
||||
expect(message_builder).not_to receive(:perform)
|
||||
described_class.new(rule, account, conversation).perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with send_webhook_event action' do
|
||||
it 'will send webhook event' do
|
||||
expect(rule.actions.pluck('action_name')).to include('send_webhook_event')
|
||||
expect(WebhookJob).to receive(:perform_later)
|
||||
described_class.new(rule, account, conversation).perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with send_message action' do
|
||||
let(:message_builder) { double }
|
||||
|
||||
before do
|
||||
allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder)
|
||||
end
|
||||
|
||||
it 'will send message' do
|
||||
expect(rule.actions.pluck('action_name')).to include('send_message')
|
||||
expect(message_builder).to receive(:perform)
|
||||
described_class.new(rule, account, conversation).perform
|
||||
end
|
||||
|
||||
it 'will not send message if conversation is a tweet' do
|
||||
expect(rule.actions.pluck('action_name')).to include('send_message')
|
||||
twitter_inbox = create(:inbox, channel: create(:channel_twitter_profile, account: account))
|
||||
conversation = create(:conversation, inbox: twitter_inbox, additional_attributes: { type: 'tweet' })
|
||||
expect(message_builder).not_to receive(:perform)
|
||||
described_class.new(rule, account, conversation).perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with send_email_to_team action' do
|
||||
let!(:team) { create(:team, account: account) }
|
||||
|
||||
before do
|
||||
rule.actions << { action_name: 'send_email_to_team', action_params: [{ team_ids: [team.id], message: 'Hello' }] }
|
||||
end
|
||||
|
||||
it 'will send email to team' do
|
||||
expect(TeamNotifications::AutomationNotificationMailer).to receive(:conversation_creation).with(conversation, team, 'Hello').and_call_original
|
||||
described_class.new(rule, account, conversation).perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with send_email_transcript action' do
|
||||
before do
|
||||
rule.actions << { action_name: 'send_email_transcript', action_params: ['contact@example.com, agent@example.com,agent1@example.com'] }
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will send email to transcript to action params emails' do
|
||||
mailer = double
|
||||
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
|
||||
allow(mailer).to receive(:conversation_transcript).with(conversation, 'contact@example.com')
|
||||
allow(mailer).to receive(:conversation_transcript).with(conversation, 'agent@example.com')
|
||||
allow(mailer).to receive(:conversation_transcript).with(conversation, 'agent1@example.com')
|
||||
|
||||
described_class.new(rule, account, conversation).perform
|
||||
expect(mailer).to have_received(:conversation_transcript).exactly(3).times
|
||||
end
|
||||
|
||||
it 'will send email to transcript to contacts' do
|
||||
rule.actions = [{ action_name: 'send_email_transcript', action_params: ['{{contact.email}}'] }]
|
||||
rule.save
|
||||
|
||||
mailer = double
|
||||
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
|
||||
allow(mailer).to receive(:conversation_transcript).with(conversation, conversation.contact.email)
|
||||
|
||||
described_class.new(rule.reload, account, conversation).perform
|
||||
expect(mailer).to have_received(:conversation_transcript).exactly(1).times
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with add_label action' do
|
||||
before do
|
||||
rule.actions << { action_name: 'add_label', action_params: %w[bug feature] }
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will add labels to conversation' do
|
||||
described_class.new(rule, account, conversation).perform
|
||||
expect(conversation.reload.label_list).to include('bug', 'feature')
|
||||
end
|
||||
|
||||
it 'will not duplicate existing labels' do
|
||||
conversation.add_labels(['bug'])
|
||||
described_class.new(rule, account, conversation).perform
|
||||
expect(conversation.reload.label_list.count('bug')).to eq(1)
|
||||
expect(conversation.reload.label_list).to include('feature')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with remove_label action' do
|
||||
before do
|
||||
conversation.add_labels(%w[bug feature support])
|
||||
rule.actions << { action_name: 'remove_label', action_params: %w[bug feature] }
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will remove specified labels from conversation' do
|
||||
described_class.new(rule, account, conversation).perform
|
||||
expect(conversation.reload.label_list).not_to include('bug', 'feature')
|
||||
expect(conversation.reload.label_list).to include('support')
|
||||
end
|
||||
|
||||
it 'will not fail if labels do not exist on conversation' do
|
||||
conversation.update_labels(['support']) # Remove bug and feature first
|
||||
expect { described_class.new(rule, account, conversation).perform }.not_to raise_error
|
||||
expect(conversation.reload.label_list).to include('support')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with add_private_note action' do
|
||||
let(:message_builder) { double }
|
||||
|
||||
before do
|
||||
allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder)
|
||||
rule.actions.delete_if { |a| a['action_name'] == 'send_message' }
|
||||
rule.actions << { action_name: 'add_private_note', action_params: ['Note'] }
|
||||
end
|
||||
|
||||
it 'will add private note' do
|
||||
expect(message_builder).to receive(:perform)
|
||||
described_class.new(rule, account, conversation).perform
|
||||
end
|
||||
|
||||
it 'will not add note if conversation is a tweet' do
|
||||
twitter_inbox = create(:inbox, channel: create(:channel_twitter_profile, account: account))
|
||||
conversation = create(:conversation, inbox: twitter_inbox, additional_attributes: { type: 'tweet' })
|
||||
expect(message_builder).not_to receive(:perform)
|
||||
described_class.new(rule, account, conversation).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,116 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutomationRules::ConditionValidationService do
|
||||
let(:account) { create(:account) }
|
||||
let(:rule) { create(:automation_rule, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'with standard attributes' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' },
|
||||
{ 'values': ['+918484'], 'attribute_key': 'phone_number', 'query_operator': 'OR', 'filter_operator': 'contains' },
|
||||
{ 'values': ['test'], 'attribute_key': 'email', 'query_operator': nil, 'filter_operator': 'contains' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(described_class.new(rule).perform).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with wrong attribute' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['open'], 'attribute_key': 'not-a-standard-attribute-for-sure', 'query_operator': nil, 'filter_operator': 'equal_to' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(described_class.new(rule).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with wrong filter operator' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'not-a-filter-operator' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(described_class.new(rule).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with wrong query operator' do
|
||||
before do
|
||||
rule.conditions = [{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': 'invalid', 'filter_operator': 'attribute_changed' }]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(described_class.new(rule).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with "attribute_changed" filter operator' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'attribute_changed' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(described_class.new(rule).perform).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with correct custom attribute' do
|
||||
before do
|
||||
create(:custom_attribute_definition,
|
||||
attribute_key: 'custom_attr_priority',
|
||||
account: account,
|
||||
attribute_model: 'conversation_attribute',
|
||||
attribute_display_type: 'list',
|
||||
attribute_values: %w[P0 P1 P2])
|
||||
|
||||
rule.conditions = [
|
||||
{
|
||||
'values': ['true'],
|
||||
'attribute_key': 'custom_attr_priority',
|
||||
'filter_operator': 'equal_to',
|
||||
'custom_attribute_type': 'conversation_attribute'
|
||||
}
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(described_class.new(rule).perform).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing custom attribute' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{
|
||||
'values': ['true'],
|
||||
'attribute_key': 'attribute_is_not_present', # the attribute is not present
|
||||
'filter_operator': 'equal_to',
|
||||
'custom_attribute_type': 'conversation_attribute'
|
||||
}
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'returns false for missing custom attribute' do
|
||||
expect(described_class.new(rule).perform).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,239 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutomationRules::ConditionsFilterService do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:email_channel) { create(:channel_email, account: account) }
|
||||
let(:email_inbox) { create(:inbox, channel: email_channel, account: account) }
|
||||
let(:message) do
|
||||
create(:message, account: account, conversation: conversation, content: 'test text', inbox: conversation.inbox, message_type: :incoming)
|
||||
end
|
||||
let(:rule) { create(:automation_rule, account: account) }
|
||||
|
||||
before do
|
||||
conversation = create(:conversation, account: account)
|
||||
conversation.contact.update(phone_number: '+918484828282', email: 'test@test.com')
|
||||
create(:conversation, account: account)
|
||||
create(:conversation, account: account)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conditions based on filter_operator equal_to' do
|
||||
before do
|
||||
rule.conditions = [{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' }]
|
||||
rule.save
|
||||
end
|
||||
|
||||
context 'when conditions in rule matches with object' do
|
||||
it 'will return true' do
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: { status: [nil, 'open'] } }).perform).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conditions in rule does not match with object' do
|
||||
it 'will return false' do
|
||||
conversation.update(status: 'resolved')
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: { status: %w[open resolved] } }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conditions based on filter_operator start_with' do
|
||||
before do
|
||||
contact = conversation.contact
|
||||
contact.update(phone_number: '+918484848484')
|
||||
rule.conditions = [
|
||||
{ 'values': ['+918484'], 'attribute_key': 'phone_number', 'query_operator': 'OR', 'filter_operator': 'starts_with' },
|
||||
{ 'values': ['test'], 'attribute_key': 'email', 'query_operator': nil, 'filter_operator': 'contains' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
context 'when conditions in rule matches with object' do
|
||||
it 'will return true' do
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conditions in rule does not match with object' do
|
||||
it 'will return false' do
|
||||
conversation.contact.update(phone_number: '+918585858585')
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conditions based on messages attributes' do
|
||||
context 'when filter_operator is equal_to' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['test text'], 'attribute_key': 'content', 'query_operator': 'AND', 'filter_operator': 'equal_to' },
|
||||
{ 'values': ['incoming'], 'attribute_key': 'message_type', 'query_operator': nil, 'filter_operator': 'equal_to' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will return true when conditions matches' do
|
||||
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
|
||||
it 'will return false when conditions in rule does not match' do
|
||||
message.update!(message_type: :outgoing)
|
||||
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filter_operator is on processed_message_content' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['help'], 'attribute_key': 'content', 'query_operator': 'AND', 'filter_operator': 'contains' },
|
||||
{ 'values': ['incoming'], 'attribute_key': 'message_type', 'query_operator': nil, 'filter_operator': 'equal_to' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
let(:conversation) { create(:conversation, account: account, inbox: email_inbox) }
|
||||
let(:message) do
|
||||
create(:message, account: account, conversation: conversation, content: "We will help you\n\n\n test",
|
||||
inbox: conversation.inbox, message_type: :incoming,
|
||||
content_attributes: { email: { text_content: { quoted: 'We will help you' } } })
|
||||
end
|
||||
|
||||
it 'will return true for processed_message_content matches' do
|
||||
message
|
||||
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
|
||||
it 'will return false when processed_message_content does no match' do
|
||||
rule.update(conditions: [{ 'values': ['text'], 'attribute_key': 'content', 'query_operator': nil, 'filter_operator': 'contains' }])
|
||||
|
||||
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering messages based on conversation attributes' do
|
||||
let(:conversation) { create(:conversation, account: account, status: :open, priority: :high) }
|
||||
let(:message) do
|
||||
create(:message, account: account, conversation: conversation, content: 'Test message',
|
||||
inbox: conversation.inbox, message_type: :incoming)
|
||||
end
|
||||
|
||||
it 'will return true when conversation status matches' do
|
||||
rule.update(conditions: [{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' }])
|
||||
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
|
||||
it 'will return false when conversation status does not match' do
|
||||
rule.update(conditions: [{ 'values': ['resolved'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' }])
|
||||
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
|
||||
it 'will return true when conversation priority matches' do
|
||||
rule.update(conditions: [{ 'values': ['high'], 'attribute_key': 'priority', 'query_operator': nil, 'filter_operator': 'equal_to' }])
|
||||
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conditions based on labels' do
|
||||
before do
|
||||
conversation.add_labels(['bug'])
|
||||
end
|
||||
|
||||
context 'when filter_operator is equal_to' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['bug'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'equal_to' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will return true when conversation has the label' do
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
|
||||
it 'will return false when conversation does not have the label' do
|
||||
rule.conditions = [
|
||||
{ 'values': ['feature'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'equal_to' }
|
||||
]
|
||||
rule.save
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filter_operator is not_equal_to' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['feature'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'not_equal_to' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will return true when conversation does not have the label' do
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
|
||||
it 'will return false when conversation has the label' do
|
||||
conversation.add_labels(['feature'])
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filter_operator is is_present' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': [], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'is_present' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will return true when conversation has any labels' do
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
|
||||
it 'will return false when conversation has no labels' do
|
||||
conversation.update_labels([])
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filter_operator is is_not_present' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': [], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'is_not_present' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will return false when conversation has any labels' do
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
|
||||
it 'will return true when conversation has no labels' do
|
||||
conversation.update_labels([])
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conditions based on contact country_code' do
|
||||
before do
|
||||
conversation.update(additional_attributes: { country_code: 'US' })
|
||||
conversation.contact.update(additional_attributes: { country_code: 'IN' })
|
||||
rule.conditions = [
|
||||
{ 'values': ['IN'], 'attribute_key': 'country_code', 'query_operator': nil, 'filter_operator': 'equal_to' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'matches against the contact additional_attributes' do
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
|
||||
it 'returns false when the contact country_code does not match' do
|
||||
conversation.contact.update(additional_attributes: { country_code: 'GB' })
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
42
research/chatwoot/spec/services/base_token_service_spec.rb
Normal file
42
research/chatwoot/spec/services/base_token_service_spec.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe BaseTokenService do
|
||||
let(:payload) { { user_id: 1, exp: 5.minutes.from_now.to_i } }
|
||||
let(:token_service) { described_class.new(payload: payload) }
|
||||
|
||||
describe '#generate_token' do
|
||||
it 'generates a JWT token with the provided payload' do
|
||||
token = token_service.generate_token
|
||||
expect(token).to be_present
|
||||
expect(token).to be_a(String)
|
||||
end
|
||||
|
||||
it 'encodes the payload correctly' do
|
||||
token = token_service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expect(decoded['user_id']).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#decode_token' do
|
||||
let(:token) { token_service.generate_token }
|
||||
let(:decoder_service) { described_class.new(token: token) }
|
||||
|
||||
it 'decodes a valid JWT token' do
|
||||
decoded = decoder_service.decode_token
|
||||
expect(decoded[:user_id]).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns empty hash for invalid token' do
|
||||
invalid_service = described_class.new(token: 'invalid_token')
|
||||
expect(invalid_service.decode_token).to eq({})
|
||||
end
|
||||
|
||||
it 'returns empty hash for expired token' do
|
||||
expired_payload = { user_id: 1, exp: 1.minute.ago.to_i }
|
||||
expired_token = JWT.encode(expired_payload, Rails.application.secret_key_base, 'HS256')
|
||||
expired_service = described_class.new(token: expired_token)
|
||||
expect(expired_service.decode_token).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkActionService do
|
||||
subject(:service) { described_class.new(account: account, user: user, params: params) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when delete action is requested via action_name' do
|
||||
let(:params) { { ids: [1, 2], action_name: 'delete' } }
|
||||
|
||||
it 'delegates to the bulk delete service' do
|
||||
bulk_delete_service = instance_double(Contacts::BulkDeleteService, perform: true)
|
||||
|
||||
expect(Contacts::BulkDeleteService).to receive(:new)
|
||||
.with(account: account, contact_ids: [1, 2])
|
||||
.and_return(bulk_delete_service)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when labels are provided' do
|
||||
let(:params) { { ids: [10, 20], labels: { add: %w[vip support] }, extra: 'ignored' } }
|
||||
|
||||
it 'delegates to the bulk assign labels service with permitted params' do
|
||||
bulk_assign_service = instance_double(Contacts::BulkAssignLabelsService, perform: true)
|
||||
|
||||
expect(Contacts::BulkAssignLabelsService).to receive(:new)
|
||||
.with(account: account, contact_ids: [10, 20], labels: %w[vip support])
|
||||
.and_return(bulk_assign_service)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkAssignLabelsService do
|
||||
subject(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
contact_ids: [contact_one.id, contact_two.id, other_contact.id],
|
||||
labels: labels
|
||||
)
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:contact_one) { create(:contact, account: account) }
|
||||
let!(:contact_two) { create(:contact, account: account) }
|
||||
let!(:other_contact) { create(:contact) }
|
||||
let(:labels) { %w[vip support] }
|
||||
|
||||
it 'assigns labels to the contacts that belong to the account' do
|
||||
service.perform
|
||||
|
||||
expect(contact_one.reload.label_list).to include(*labels)
|
||||
expect(contact_two.reload.label_list).to include(*labels)
|
||||
end
|
||||
|
||||
it 'does not assign labels to contacts outside the account' do
|
||||
service.perform
|
||||
|
||||
expect(other_contact.reload.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'returns ids of contacts that were updated' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(result[:updated_contact_ids]).to contain_exactly(contact_one.id, contact_two.id)
|
||||
end
|
||||
|
||||
it 'returns success with no updates when labels are blank' do
|
||||
result = described_class.new(
|
||||
account: account,
|
||||
contact_ids: [contact_one.id],
|
||||
labels: []
|
||||
).perform
|
||||
|
||||
expect(result).to eq(success: true, updated_contact_ids: [])
|
||||
expect(contact_one.reload.label_list).to be_empty
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkDeleteService do
|
||||
subject(:service) { described_class.new(account: account, contact_ids: contact_ids) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:contact_one) { create(:contact, account: account) }
|
||||
let!(:contact_two) { create(:contact, account: account) }
|
||||
let(:contact_ids) { [contact_one.id, contact_two.id] }
|
||||
|
||||
describe '#perform' do
|
||||
it 'deletes the provided contacts' do
|
||||
expect { service.perform }
|
||||
.to change { account.contacts.exists?(contact_one.id) }.from(true).to(false)
|
||||
.and change { account.contacts.exists?(contact_two.id) }.from(true).to(false)
|
||||
end
|
||||
|
||||
it 'returns when no contact ids are provided' do
|
||||
empty_service = described_class.new(account: account, contact_ids: [])
|
||||
|
||||
expect { empty_service.perform }.not_to change(Contact, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Contacts::ContactableInboxesService do
|
||||
before do
|
||||
stub_request(:post, /graph.facebook.com/)
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account, email: 'contact@example.com', phone_number: '+2320000') }
|
||||
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
|
||||
let!(:twilio_sms_inbox) { create(:inbox, channel: twilio_sms, account: account) }
|
||||
let!(:twilio_whatsapp) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
|
||||
let!(:twilio_whatsapp_inbox) { create(:inbox, channel: twilio_whatsapp, account: account) }
|
||||
let!(:email_channel) { create(:channel_email, account: account) }
|
||||
let!(:email_inbox) { create(:inbox, channel: email_channel, account: account) }
|
||||
let!(:api_channel) { create(:channel_api, account: account) }
|
||||
let!(:api_inbox) { create(:inbox, channel: api_channel, account: account) }
|
||||
let!(:website_inbox) { create(:inbox, channel: create(:channel_widget, account: account), account: account) }
|
||||
let!(:sms_inbox) { create(:inbox, channel: create(:channel_sms, account: account), account: account) }
|
||||
|
||||
describe '#get' do
|
||||
it 'returns the contactable inboxes for the contact' do
|
||||
contactable_inboxes = described_class.new(contact: contact).get
|
||||
|
||||
expect(contactable_inboxes).to include({ source_id: contact.phone_number, inbox: twilio_sms_inbox })
|
||||
expect(contactable_inboxes).to include({ source_id: "whatsapp:#{contact.phone_number}", inbox: twilio_whatsapp_inbox })
|
||||
expect(contactable_inboxes).to include({ source_id: contact.email, inbox: email_inbox })
|
||||
expect(contactable_inboxes).to include({ source_id: contact.phone_number, inbox: sms_inbox })
|
||||
end
|
||||
|
||||
it 'doest not return the non contactable inboxes for the contact' do
|
||||
facebook_channel = create(:channel_facebook_page, account: account)
|
||||
facebook_inbox = create(:inbox, channel: facebook_channel, account: account)
|
||||
twitter_channel = create(:channel_twitter_profile, account: account)
|
||||
twitter_inbox = create(:inbox, channel: twitter_channel, account: account)
|
||||
|
||||
contactable_inboxes = described_class.new(contact: contact).get
|
||||
|
||||
expect(contactable_inboxes.pluck(:inbox)).not_to include(website_inbox)
|
||||
expect(contactable_inboxes.pluck(:inbox)).not_to include(facebook_inbox)
|
||||
expect(contactable_inboxes.pluck(:inbox)).not_to include(twitter_inbox)
|
||||
end
|
||||
|
||||
context 'when api inbox is available' do
|
||||
it 'returns existing source id if contact inbox exists' do
|
||||
contact_inbox = create(:contact_inbox, inbox: api_inbox, contact: contact)
|
||||
|
||||
contactable_inboxes = described_class.new(contact: contact).get
|
||||
expect(contactable_inboxes).to include({ source_id: contact_inbox.source_id, inbox: api_inbox })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when website inbox is available' do
|
||||
it 'returns existing source id if contact inbox exists without any conversations' do
|
||||
contact_inbox = create(:contact_inbox, inbox: website_inbox, contact: contact)
|
||||
|
||||
contactable_inboxes = described_class.new(contact: contact).get
|
||||
expect(contactable_inboxes).to include({ source_id: contact_inbox.source_id, inbox: website_inbox })
|
||||
end
|
||||
|
||||
it 'does not return existing source id if contact inbox exists with conversations' do
|
||||
contact_inbox = create(:contact_inbox, inbox: website_inbox, contact: contact)
|
||||
create(:conversation, contact: contact, inbox: website_inbox, contact_inbox: contact_inbox)
|
||||
|
||||
contactable_inboxes = described_class.new(contact: contact).get
|
||||
expect(contactable_inboxes.pluck(:inbox)).not_to include(website_inbox)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
374
research/chatwoot/spec/services/contacts/filter_service_spec.rb
Normal file
374
research/chatwoot/spec/services/contacts/filter_service_spec.rb
Normal file
@@ -0,0 +1,374 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Contacts::FilterService do
|
||||
subject(:filter_service) { described_class }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:first_user) { create(:user, account: account) }
|
||||
let!(:second_user) { create(:user, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
|
||||
let!(:en_contact) do
|
||||
create(:contact,
|
||||
account: account,
|
||||
email: Faker::Internet.unique.email,
|
||||
additional_attributes: { 'country_code': 'uk' })
|
||||
end
|
||||
let!(:el_contact) do
|
||||
create(:contact,
|
||||
account: account,
|
||||
email: Faker::Internet.unique.email,
|
||||
additional_attributes: { 'country_code': 'gr' })
|
||||
end
|
||||
let!(:cs_contact) do
|
||||
create(:contact,
|
||||
:with_phone_number,
|
||||
account: account,
|
||||
email: Faker::Internet.unique.email,
|
||||
additional_attributes: { 'country_code': 'cz' })
|
||||
end
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: first_user, inbox: inbox)
|
||||
create(:inbox_member, user: second_user, inbox: inbox)
|
||||
create(:conversation, account: account, inbox: inbox, assignee: first_user, contact: en_contact)
|
||||
create(:conversation, account: account, inbox: inbox, contact: el_contact)
|
||||
|
||||
create(:custom_attribute_definition,
|
||||
attribute_key: 'contact_additional_information',
|
||||
account: account,
|
||||
attribute_model: 'contact_attribute',
|
||||
attribute_display_type: 'text')
|
||||
create(:custom_attribute_definition,
|
||||
attribute_key: 'customer_type',
|
||||
account: account,
|
||||
attribute_model: 'contact_attribute',
|
||||
attribute_display_type: 'list',
|
||||
attribute_values: %w[regular platinum gold])
|
||||
create(:custom_attribute_definition,
|
||||
attribute_key: 'signed_in_at',
|
||||
account: account,
|
||||
attribute_model: 'contact_attribute',
|
||||
attribute_display_type: 'date')
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let!(:params) { { payload: [], page: 1 } }
|
||||
|
||||
before do
|
||||
en_contact.update_labels(%w[random_label support])
|
||||
cs_contact.update_labels('support')
|
||||
|
||||
en_contact.update!(custom_attributes: { contact_additional_information: 'test custom data' })
|
||||
el_contact.update!(custom_attributes: { contact_additional_information: 'test custom data', customer_type: 'platinum' })
|
||||
cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' })
|
||||
end
|
||||
|
||||
context 'with standard attributes - name' do
|
||||
it 'filter contacts by name' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'name',
|
||||
filter_operator: 'equal_to',
|
||||
values: [en_contact.name],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:count]).to be 1
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.name).to eq(en_contact.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with standard attributes - phone' do
|
||||
it 'filter contacts by name' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'equal_to',
|
||||
values: [cs_contact.phone_number],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:count]).to be 1
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.name).to eq(cs_contact.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with standard attributes - phone (without +)' do
|
||||
it 'filter contacts by name' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'equal_to',
|
||||
values: [cs_contact.phone_number[1..]],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:count]).to be 1
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.name).to eq(cs_contact.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with standard attributes - blocked' do
|
||||
it 'filter contacts by blocked' do
|
||||
blocked_contact = create(
|
||||
:contact,
|
||||
account: account,
|
||||
blocked: true,
|
||||
email: Faker::Internet.unique.email
|
||||
)
|
||||
params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: ['true'],
|
||||
query_operator: nil }.with_indifferent_access] }
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:count]).to be 1
|
||||
expect(result[:contacts].first.id).to eq(blocked_contact.id)
|
||||
end
|
||||
|
||||
it 'filter contacts by not_blocked' do
|
||||
params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: [false],
|
||||
query_operator: nil }.with_indifferent_access] }
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
# existing contacts are not blocked
|
||||
expect(result[:count]).to be 3
|
||||
end
|
||||
end
|
||||
|
||||
context 'with standard attributes - label' do
|
||||
it 'returns equal_to filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['support'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:contacts].length).to be 2
|
||||
expect(result[:contacts].first.label_list).to include('support')
|
||||
expect(result[:contacts].last.label_list).to include('support')
|
||||
end
|
||||
|
||||
it 'returns not_equal_to filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: ['support'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.id).to eq el_contact.id
|
||||
end
|
||||
|
||||
it 'returns is_present filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'is_present',
|
||||
values: [],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:contacts].length).to be 2
|
||||
expect(result[:contacts].first.label_list).to include('support')
|
||||
expect(result[:contacts].last.label_list).to include('support')
|
||||
end
|
||||
|
||||
it 'returns is_not_present filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'is_not_present',
|
||||
values: [],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.id).to eq el_contact.id
|
||||
end
|
||||
|
||||
it 'handles invalid query conditions' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'is_not_present',
|
||||
values: [],
|
||||
query_operator: 'INVALID'
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with standard attributes - last_activity_at' do
|
||||
before do
|
||||
Time.zone = 'UTC'
|
||||
el_contact.update(last_activity_at: (Time.zone.today - 4.days))
|
||||
cs_contact.update(last_activity_at: (Time.zone.today - 5.days))
|
||||
en_contact.update(last_activity_at: (Time.zone.today - 2.days))
|
||||
end
|
||||
|
||||
it 'filter by last_activity_at 3_days_before and custom_attributes' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'last_activity_at',
|
||||
filter_operator: 'days_before',
|
||||
values: [3],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'contact_additional_information',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['test custom data'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expected_count = Contact.where(
|
||||
"last_activity_at < ? AND
|
||||
custom_attributes->>'contact_additional_information' = ?",
|
||||
(Time.zone.today - 3.days),
|
||||
'test custom data'
|
||||
).count
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:contacts].length).to be expected_count
|
||||
expect(result[:contacts].first.id).to eq(el_contact.id)
|
||||
end
|
||||
|
||||
it 'filter by last_activity_at 2_days_before and custom_attributes' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'last_activity_at',
|
||||
filter_operator: 'days_before',
|
||||
values: [2],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expected_count = Contact.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:contacts].length).to be expected_count
|
||||
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
|
||||
expect(result[:contacts].pluck(:id)).to include(cs_contact.id)
|
||||
expect(result[:contacts].pluck(:id)).not_to include(en_contact.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with additional attributes' do
|
||||
let(:payload) do
|
||||
[
|
||||
{
|
||||
attribute_key: 'country_code',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['uk'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
end
|
||||
|
||||
it 'filter contacts by additional_attributes' do
|
||||
params[:payload] = payload
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:count]).to be 1
|
||||
expect(result[:contacts].first.id).to eq(en_contact.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with custom attributes' do
|
||||
it 'filter by custom_attributes and labels' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'customer_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['platinum'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['support'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'signed_in_at',
|
||||
filter_operator: 'is_less_than',
|
||||
values: ['2022-01-20'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.id).to eq(cs_contact.id)
|
||||
end
|
||||
|
||||
it 'filter by custom_attributes and additional_attributes' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'customer_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['platinum'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'country_code',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['GR'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'contact_additional_information',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['test custom data'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.id).to eq(el_contact.id)
|
||||
end
|
||||
|
||||
it 'filter by created_at and custom_attributes' do
|
||||
tomorrow = Date.tomorrow.strftime
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'customer_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['platinum'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'created_at',
|
||||
filter_operator: 'is_less_than',
|
||||
values: [tomorrow.to_s],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expected_count = Contact.where("created_at < ? AND custom_attributes->>'customer_type' = ?", Date.tomorrow, 'platinum').count
|
||||
|
||||
expect(result[:contacts].length).to be expected_count
|
||||
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
# spec/services/contacts/sync_attributes_spec.rb
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::SyncAttributes do
|
||||
describe '#perform' do
|
||||
let(:contact) { create(:contact, additional_attributes: { 'city' => 'New York', 'country' => 'US' }) }
|
||||
|
||||
context 'when contact has neither email/phone number nor social details' do
|
||||
it 'does not change contact type' do
|
||||
described_class.new(contact).perform
|
||||
expect(contact.reload.contact_type).to eq('visitor')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has email or phone number' do
|
||||
it 'sets contact type to lead' do
|
||||
contact.email = 'test@test.com'
|
||||
contact.save
|
||||
described_class.new(contact).perform
|
||||
|
||||
expect(contact.reload.contact_type).to eq('lead')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has social details' do
|
||||
it 'sets contact type to lead' do
|
||||
contact.additional_attributes['social_facebook_user_id'] = '123456789'
|
||||
contact.save
|
||||
described_class.new(contact).perform
|
||||
|
||||
expect(contact.reload.contact_type).to eq('lead')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when location and country code are updated from additional attributes' do
|
||||
it 'updates location and country code' do
|
||||
described_class.new(contact).perform
|
||||
|
||||
# Expect location and country code to be updated
|
||||
expect(contact.reload.location).to eq('New York')
|
||||
expect(contact.reload.country_code).to eq('US')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Conversations::AssignmentService do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:agent_bot) { create(:agent_bot, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when assignee_id is blank' do
|
||||
before do
|
||||
conversation.update!(assignee: agent, assignee_agent_bot: agent_bot)
|
||||
end
|
||||
|
||||
it 'clears both human and bot assignees' do
|
||||
described_class.new(conversation: conversation, assignee_id: nil).perform
|
||||
|
||||
conversation.reload
|
||||
expect(conversation.assignee_id).to be_nil
|
||||
expect(conversation.assignee_agent_bot_id).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assigning a user' do
|
||||
before do
|
||||
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil)
|
||||
end
|
||||
|
||||
it 'sets the agent and clears agent bot' do
|
||||
result = described_class.new(conversation: conversation, assignee_id: agent.id).perform
|
||||
|
||||
conversation.reload
|
||||
expect(result).to eq(agent)
|
||||
expect(conversation.assignee_id).to eq(agent.id)
|
||||
expect(conversation.assignee_agent_bot_id).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assigning an agent bot' do
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
conversation: conversation,
|
||||
assignee_id: agent_bot.id,
|
||||
assignee_type: 'AgentBot'
|
||||
)
|
||||
end
|
||||
|
||||
it 'sets the agent bot and clears human assignee' do
|
||||
conversation.update!(assignee: agent, assignee_agent_bot: nil)
|
||||
|
||||
result = service.perform
|
||||
|
||||
conversation.reload
|
||||
expect(result).to eq(agent_bot)
|
||||
expect(conversation.assignee_agent_bot_id).to eq(agent_bot.id)
|
||||
expect(conversation.assignee_id).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,254 @@
|
||||
## This spec is to ensure alignment between frontend and backend filters
|
||||
# ref: https://github.com/chatwoot/chatwoot/pull/11111
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Conversations::FilterService do
|
||||
describe 'Frontend alignment tests' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user_1) { create(:user, account: account, role: :administrator) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:params) { { payload: [], page: 1 } }
|
||||
|
||||
before do
|
||||
account.conversations.destroy_all
|
||||
|
||||
# Create inbox membership
|
||||
create(:inbox_member, user: user_1, inbox: inbox)
|
||||
|
||||
# Create custom attribute definition for conversation_type
|
||||
create(:custom_attribute_definition,
|
||||
attribute_key: 'conversation_type',
|
||||
account: account,
|
||||
attribute_model: 'conversation_attribute',
|
||||
attribute_display_type: 'list',
|
||||
attribute_values: %w[platinum silver gold regular])
|
||||
end
|
||||
|
||||
context 'with A AND B OR C filter chain' do
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
|
||||
let(:filter_payload) do
|
||||
[
|
||||
{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['open'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'priority',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['urgent'],
|
||||
query_operator: 'OR'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['12345'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
conversation.update!(
|
||||
status: 'open',
|
||||
priority: 'urgent',
|
||||
display_id: '12345',
|
||||
additional_attributes: { 'browser_language': 'en' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'matches when all conditions are true' do
|
||||
params[:payload] = filter_payload
|
||||
result = described_class.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
|
||||
it 'matches when first condition is false but third is true' do
|
||||
conversation.update!(status: 'resolved', priority: 'urgent', display_id: '12345')
|
||||
params[:payload] = filter_payload
|
||||
result = described_class.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
|
||||
it 'matches when first and second condition is false but third is true' do
|
||||
conversation.update!(status: 'resolved', priority: 'low', display_id: '12345')
|
||||
params[:payload] = filter_payload
|
||||
result = described_class.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
|
||||
it 'does not match when all conditions are false' do
|
||||
conversation.update!(status: 'resolved', priority: 'low', display_id: '67890')
|
||||
params[:payload] = filter_payload
|
||||
result = described_class.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 0
|
||||
end
|
||||
end
|
||||
|
||||
context 'with A OR B AND C filter chain' do
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
|
||||
let(:filter_payload) do
|
||||
[
|
||||
{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['open'],
|
||||
query_operator: 'OR'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'priority',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['low'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['67890'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
conversation.update!(
|
||||
status: 'open',
|
||||
priority: 'urgent',
|
||||
display_id: '12345',
|
||||
additional_attributes: { 'browser_language': 'en' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'matches when first condition is true' do
|
||||
params[:payload] = filter_payload
|
||||
result = described_class.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
|
||||
it 'matches when second and third conditions are true' do
|
||||
conversation.update!(status: 'resolved', priority: 'low', display_id: '67890')
|
||||
params[:payload] = filter_payload
|
||||
result = described_class.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'with complex filter chain A AND B OR C AND D' do
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
|
||||
let(:filter_payload) do
|
||||
[
|
||||
{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['open'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'priority',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['urgent'],
|
||||
query_operator: 'OR'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['67890'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['tr'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
conversation.update!(
|
||||
status: 'open',
|
||||
priority: 'urgent',
|
||||
display_id: '12345',
|
||||
additional_attributes: { 'browser_language': 'en' },
|
||||
custom_attributes: { conversation_type: 'platinum' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'matches when first two conditions are true' do
|
||||
params[:payload] = filter_payload
|
||||
result = described_class.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
|
||||
it 'matches when last two conditions are true' do
|
||||
conversation.update!(
|
||||
status: 'resolved',
|
||||
priority: 'low',
|
||||
display_id: '67890',
|
||||
additional_attributes: { 'browser_language': 'tr' }
|
||||
)
|
||||
params[:payload] = filter_payload
|
||||
result = described_class.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mixed operators filter chain' do
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
|
||||
let(:filter_payload) do
|
||||
[
|
||||
{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['open'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'priority',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['urgent'],
|
||||
query_operator: 'OR'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'display_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['67890'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'conversation_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['platinum'],
|
||||
custom_attribute_type: '',
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
conversation.update!(
|
||||
status: 'open',
|
||||
priority: 'urgent',
|
||||
display_id: '12345',
|
||||
additional_attributes: { 'browser_language': 'en' },
|
||||
custom_attributes: { conversation_type: 'platinum' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'matches when all conditions in the chain are true' do
|
||||
params[:payload] = filter_payload
|
||||
result = described_class.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
|
||||
it 'does not match when the last condition is false' do
|
||||
conversation.update!(custom_attributes: { conversation_type: 'silver' })
|
||||
params[:payload] = filter_payload
|
||||
result = described_class.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,555 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Conversations::FilterService do
|
||||
subject(:filter_service) { described_class }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user_1) { create(:user, account: account) }
|
||||
let!(:user_2) { create(:user, account: account) }
|
||||
let!(:campaign_1) { create(:campaign, title: 'Test Campaign', account: account) }
|
||||
let!(:campaign_2) { create(:campaign, title: 'Campaign', account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
|
||||
|
||||
let!(:user_2_assigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_2) }
|
||||
let!(:en_conversation_1) do
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, campaign_id: campaign_1.id,
|
||||
status: 'pending', additional_attributes: { 'browser_language': 'en' })
|
||||
end
|
||||
let!(:en_conversation_2) do
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, campaign_id: campaign_2.id,
|
||||
status: 'pending', additional_attributes: { 'browser_language': 'en' })
|
||||
end
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: user_1, inbox: inbox)
|
||||
create(:inbox_member, user: user_2, inbox: inbox)
|
||||
|
||||
en_conversation_1.update!(custom_attributes: { conversation_additional_information: 'test custom data' })
|
||||
en_conversation_2.update!(custom_attributes: { conversation_additional_information: 'test custom data', conversation_type: 'platinum' })
|
||||
user_2_assigned_conversation.update!(custom_attributes: { conversation_type: 'platinum', conversation_created: '2022-01-19' })
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1)
|
||||
|
||||
create(:custom_attribute_definition,
|
||||
attribute_key: 'conversation_type',
|
||||
account: account,
|
||||
attribute_model: 'conversation_attribute',
|
||||
attribute_display_type: 'list',
|
||||
attribute_values: %w[regular platinum gold])
|
||||
create(:custom_attribute_definition,
|
||||
attribute_key: 'conversation_created',
|
||||
account: account,
|
||||
attribute_model: 'conversation_attribute',
|
||||
attribute_display_type: 'date')
|
||||
create(:custom_attribute_definition,
|
||||
attribute_key: 'conversation_additional_information',
|
||||
account: account,
|
||||
attribute_model: 'conversation_attribute',
|
||||
attribute_display_type: 'text')
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'with query present' do
|
||||
let!(:params) { { payload: [], page: 1 } }
|
||||
let(:payload) do
|
||||
[
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'equal_to',
|
||||
values: 'en',
|
||||
query_operator: 'AND',
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: %w[resolved],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
end
|
||||
|
||||
it 'filter conversations by additional_attributes and status' do
|
||||
params[:payload] = payload
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
|
||||
expect(result[:count][:all_count]).to be conversations.count
|
||||
end
|
||||
|
||||
it 'filter conversations by priority' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'priority',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['high'],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to eq 1
|
||||
expect(result[:conversations][0][:id]).to eq conversation.id
|
||||
end
|
||||
|
||||
it 'filter conversations by multiple priority values' do
|
||||
high_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
|
||||
urgent_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :urgent)
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :low)
|
||||
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'priority',
|
||||
filter_operator: 'equal_to',
|
||||
values: %w[high urgent],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to eq 2
|
||||
expect(result[:conversations].pluck(:id)).to include(high_priority.id, urgent_priority.id)
|
||||
end
|
||||
|
||||
it 'filter conversations with not_equal_to priority operator' do
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :high)
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :urgent)
|
||||
low_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :low)
|
||||
medium_priority = create(:conversation, account: account, inbox: inbox, assignee: user_1, priority: :medium)
|
||||
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'priority',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: %w[high urgent],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
|
||||
# Only include conversations with medium and low priority, excluding high and urgent
|
||||
expect(result[:conversations].length).to eq 2
|
||||
expect(result[:conversations].pluck(:id)).to include(low_priority.id, medium_priority.id)
|
||||
end
|
||||
|
||||
it 'filter conversations by additional_attributes and status with pagination' do
|
||||
params[:payload] = payload
|
||||
params[:page] = 2
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
|
||||
expect(result[:count][:all_count]).to be conversations.count
|
||||
end
|
||||
|
||||
it 'filters items with contains filter_operator with values being an array' do
|
||||
params[:payload] = [{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'equal_to',
|
||||
values: %w[tr fr],
|
||||
query_operator: '',
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access]
|
||||
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, campaign_id: campaign_1.id,
|
||||
status: 'pending', additional_attributes: { 'browser_language': 'fr' })
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, campaign_id: campaign_1.id,
|
||||
status: 'pending', additional_attributes: { 'browser_language': 'tr' })
|
||||
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:count][:all_count]).to be 2
|
||||
end
|
||||
|
||||
it 'filters items with does not contain filter operator with values being an array' do
|
||||
params[:payload] = [{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: %w[tr en],
|
||||
query_operator: '',
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access]
|
||||
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, campaign_id: campaign_1.id,
|
||||
status: 'pending', additional_attributes: { 'browser_language': 'fr' })
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, campaign_id: campaign_1.id,
|
||||
status: 'pending', additional_attributes: { 'browser_language': 'tr' })
|
||||
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
|
||||
expect(result[:count][:all_count]).to be 1
|
||||
expect(result[:conversations].first.additional_attributes['browser_language']).to eq 'fr'
|
||||
end
|
||||
|
||||
it 'filter conversations by additional_attributes with NOT_IN filter' do
|
||||
payload = [{ attribute_key: 'conversation_type', filter_operator: 'not_equal_to', values: 'platinum', query_operator: nil,
|
||||
custom_attribute_type: 'conversation_attribute' }.with_indifferent_access]
|
||||
params[:payload] = payload
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
conversations = Conversation.where(
|
||||
"custom_attributes ->> 'conversation_type' NOT IN (?) OR custom_attributes ->> 'conversation_type' IS NULL", ['platinum']
|
||||
)
|
||||
expect(result[:count][:all_count]).to be conversations.count
|
||||
end
|
||||
|
||||
it 'filter conversations by tags' do
|
||||
user_2_assigned_conversation.update_labels('support')
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'assignee_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [user_1.id, user_2.id],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['support'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: ['random-label'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:count][:all_count]).to be 1
|
||||
end
|
||||
|
||||
it 'filter conversations by is_present filter_operator' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'assignee_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
user_1.id,
|
||||
user_2.id
|
||||
],
|
||||
query_operator: 'AND',
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'campaign_id',
|
||||
filter_operator: 'is_present',
|
||||
values: [],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
|
||||
expect(result[:count][:all_count]).to be 2
|
||||
expect(result[:conversations].pluck(:campaign_id).sort).to eq [campaign_2.id, campaign_1.id].sort
|
||||
end
|
||||
|
||||
it 'handles invalid query conditions' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'assignee_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
user_1.id,
|
||||
user_2.id
|
||||
],
|
||||
query_operator: 'INVALID',
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'campaign_id',
|
||||
filter_operator: 'is_present',
|
||||
values: [],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expect { filter_service.new(params, user_1, account).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform on custom attribute' do
|
||||
context 'with query present' do
|
||||
let!(:params) { { payload: [], page: 1 } }
|
||||
|
||||
it 'filter by custom_attributes and labels' do
|
||||
user_2_assigned_conversation.update_labels('support')
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'conversation_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['platinum'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'conversation_created',
|
||||
filter_operator: 'is_less_than',
|
||||
values: ['2022-01-20'],
|
||||
query_operator: 'OR',
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['support'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
expect(result[:conversations][0][:id]).to be user_2_assigned_conversation.id
|
||||
end
|
||||
|
||||
it 'filter by custom_attributes and labels with custom_attribute_type nil' do
|
||||
user_2_assigned_conversation.update_labels('support')
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'conversation_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['platinum'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'conversation_created',
|
||||
filter_operator: 'is_less_than',
|
||||
values: ['2022-01-20'],
|
||||
query_operator: 'OR',
|
||||
custom_attribute_type: nil
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['support'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
expect(result[:conversations][0][:id]).to be user_2_assigned_conversation.id
|
||||
end
|
||||
|
||||
it 'filter by custom_attributes' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'conversation_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['platinum'],
|
||||
query_operator: 'AND',
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'conversation_created',
|
||||
filter_operator: 'is_less_than',
|
||||
values: ['2022-01-20'],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
|
||||
it 'filter by custom_attributes with custom_attribute_type nil' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'conversation_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['platinum'],
|
||||
query_operator: 'AND',
|
||||
custom_attribute_type: nil
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'conversation_created',
|
||||
filter_operator: 'is_less_than',
|
||||
values: ['2022-01-20'],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
|
||||
it 'filter by custom_attributes and additional_attributes' do
|
||||
conversations = user_1.conversations
|
||||
conversations[0].update!(additional_attributes: { 'browser_language': 'en' }, custom_attributes: { conversation_type: 'silver' })
|
||||
conversations[1].update!(additional_attributes: { 'browser_language': 'en' }, custom_attributes: { conversation_type: 'platinum' })
|
||||
conversations[2].update!(additional_attributes: { 'browser_language': 'tr' }, custom_attributes: { conversation_type: 'platinum' })
|
||||
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'conversation_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['platinum'],
|
||||
query_operator: 'AND',
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: 'en',
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform on date filter' do
|
||||
context 'with query present' do
|
||||
let!(:params) { { payload: [], page: 1 } }
|
||||
|
||||
it 'filter by created_at' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'created_at',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: ['2022-01-20'],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count
|
||||
expect(result[:conversations].length).to be expected_count
|
||||
end
|
||||
|
||||
it 'filter by created_at and conversation_type' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'conversation_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['platinum'],
|
||||
query_operator: 'AND',
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'created_at',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: ['2022-01-20'],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expected_count = Conversation.where("created_at > ? AND custom_attributes->>'conversation_type' = ?", DateTime.parse('2022-01-20'),
|
||||
'platinum').count
|
||||
|
||||
expect(result[:conversations].length).to be expected_count
|
||||
end
|
||||
|
||||
context 'with x_days_before filter' do
|
||||
before do
|
||||
Time.zone = 'UTC'
|
||||
en_conversation_1.update!(last_activity_at: (Time.zone.today - 4.days))
|
||||
en_conversation_2.update!(last_activity_at: (Time.zone.today - 5.days))
|
||||
user_2_assigned_conversation.update!(last_activity_at: (Time.zone.today - 2.days))
|
||||
end
|
||||
|
||||
it 'filter by last_activity_at 3_days_before and custom_attributes' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'last_activity_at',
|
||||
filter_operator: 'days_before',
|
||||
values: [3],
|
||||
query_operator: 'AND',
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'conversation_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['platinum'],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expected_count = Conversation.where("last_activity_at < ? AND custom_attributes->>'conversation_type' = ?", (Time.zone.today - 3.days),
|
||||
'platinum').count
|
||||
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be expected_count
|
||||
end
|
||||
|
||||
it 'filter by last_activity_at 2_days_before' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'last_activity_at',
|
||||
filter_operator: 'days_before',
|
||||
values: [3],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expected_count = Conversation.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
|
||||
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be expected_count
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform on date filter with no current account' do
|
||||
before do
|
||||
Current.account = nil
|
||||
end
|
||||
|
||||
context 'with query present' do
|
||||
let!(:params) { { payload: [], page: 1 } }
|
||||
|
||||
it 'filter by created_at' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'created_at',
|
||||
filter_operator: 'is_greater_than',
|
||||
values: ['2022-01-20'],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count
|
||||
|
||||
expect(Current.account).to be_nil
|
||||
expect(result[:conversations].length).to be expected_count
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#base_relation' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user_1) { create(:user, account: account, role: :agent) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:inbox_1) { create(:inbox, account: account) }
|
||||
let!(:inbox_2) { create(:inbox, account: account) }
|
||||
let!(:params) { { payload: [], page: 1 } }
|
||||
|
||||
before do
|
||||
account.conversations.destroy_all
|
||||
|
||||
# Make user_1 a regular agent with access to inbox_1 only
|
||||
create(:inbox_member, user: user_1, inbox: inbox_1)
|
||||
|
||||
# Create conversations in both inboxes
|
||||
create(:conversation, account: account, inbox: inbox_1)
|
||||
create(:conversation, account: account, inbox: inbox_2)
|
||||
end
|
||||
|
||||
it 'returns all conversations for administrators, even for inboxes they are not members of' do
|
||||
service = filter_service.new(params, admin, account)
|
||||
result = service.perform
|
||||
expect(result[:conversations].count).to eq 2
|
||||
end
|
||||
|
||||
it 'filters conversations by inbox membership for non-administrators' do
|
||||
service = filter_service.new(params, user_1, account)
|
||||
result = service.perform
|
||||
expect(result[:conversations].count).to eq 1
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,628 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Conversations::MessageWindowService do
|
||||
describe 'on API channels' do
|
||||
let!(:api_channel) { create(:channel_api, additional_attributes: {}) }
|
||||
let!(:api_channel_with_limit) { create(:channel_api, additional_attributes: { agent_reply_time_window: '12' }) }
|
||||
|
||||
context 'when agent_reply_time_window is not configured' do
|
||||
it 'return true irrespective of the last message time' do
|
||||
conversation = create(:conversation, inbox: api_channel.inbox)
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: api_channel.inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
|
||||
expect(api_channel.additional_attributes['agent_reply_time_window']).to be_nil
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent_reply_time_window is configured' do
|
||||
it 'return false if it is outside of agent_reply_time_window' do
|
||||
conversation = create(:conversation, inbox: api_channel_with_limit.inbox)
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: api_channel_with_limit.inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
|
||||
expect(api_channel_with_limit.additional_attributes['agent_reply_time_window']).to eq '12'
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if it is inside of agent_reply_time_window' do
|
||||
conversation = create(:conversation, inbox: api_channel_with_limit.inbox)
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: api_channel_with_limit.inbox,
|
||||
conversation: conversation
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Facebook channels' do
|
||||
before do
|
||||
stub_request(:post, /graph.facebook.com/)
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
let!(:facebook_channel) { create(:channel_facebook_page) }
|
||||
let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: facebook_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: facebook_inbox, account: facebook_channel.account) }
|
||||
|
||||
context 'when the HUMAN_AGENT is enabled' do
|
||||
it 'return false if the last message is outgoing' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 7 days)' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 5.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window (8 days ago )' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 8.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if last message is outgoing but previous incoming message is within window' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
created_at: 1.hour.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'considers only the last incoming message for determining time window' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
# Old message outside window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 10.days.ago
|
||||
)
|
||||
|
||||
# Recent message within window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the HUMAN_AGENT is disabled' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'false' do
|
||||
it 'return false if the last message is outgoing' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window ( 8 days ago )' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 4.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (24 hours limit)' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: facebook_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Instagram channels' do
|
||||
let!(:instagram_channel) { create(:channel_instagram) }
|
||||
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: instagram_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: instagram_inbox, account: instagram_channel.account) }
|
||||
|
||||
context 'when the HUMAN_AGENT is enabled' do
|
||||
it 'return false if the last message is outgoing' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 7 days)' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 6.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window (8 days ago)' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 8.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if last message is outgoing but previous incoming message is within window' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
created_at: 1.hour.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'considers only the last incoming message for determining time window' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
# Old message outside window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 10.days.ago
|
||||
)
|
||||
|
||||
# Recent message within window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and exactly at the edge of 24-hour window with HUMAN_AGENT disabled' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 24.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the HUMAN_AGENT is disabled' do
|
||||
it 'return false if the last message is outgoing' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'false' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window (8 days ago)' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'false' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 9.days.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (24 hours limit)' do
|
||||
with_modified_env ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT: 'false' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: instagram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on WhatsApp Cloud channels' do
|
||||
let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
|
||||
let!(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: whatsapp_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: whatsapp_inbox, account: whatsapp_channel.account) }
|
||||
|
||||
it 'return false if the last message is outgoing' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window (24 hours limit)' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 25.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if last message is outgoing but previous incoming message is within window' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
created_at: 1.hour.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'considers only the last incoming message for determining time window' do
|
||||
# Old message outside window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 10.days.ago
|
||||
)
|
||||
|
||||
# Recent message within window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Web widget channels' do
|
||||
let!(:widget_channel) { create(:channel_widget) }
|
||||
let!(:widget_inbox) { create(:inbox, channel: widget_channel, account: widget_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: widget_inbox, account: widget_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: widget_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on SMS channels' do
|
||||
let!(:sms_channel) { create(:channel_sms) }
|
||||
let!(:sms_inbox) { create(:inbox, channel: sms_channel, account: sms_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: sms_inbox, account: sms_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: sms_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Telegram channels' do
|
||||
let!(:telegram_channel) { create(:channel_telegram) }
|
||||
let!(:telegram_inbox) { create(:inbox, channel: telegram_channel, account: telegram_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: telegram_inbox, account: telegram_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: telegram_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Email channels' do
|
||||
let!(:email_channel) { create(:channel_email) }
|
||||
let!(:email_inbox) { create(:inbox, channel: email_channel, account: email_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: email_inbox, account: email_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: email_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Line channels' do
|
||||
let!(:line_channel) { create(:channel_line) }
|
||||
let!(:line_inbox) { create(:inbox, channel: line_channel, account: line_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: line_inbox, account: line_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: line_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Twilio SMS channels' do
|
||||
let!(:twilio_sms_channel) { create(:channel_twilio_sms) }
|
||||
let!(:twilio_sms_inbox) { create(:inbox, channel: twilio_sms_channel, account: twilio_sms_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: twilio_sms_inbox, account: twilio_sms_channel.account) }
|
||||
|
||||
it 'return true irrespective of the last message time' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: twilio_sms_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on WhatsApp Twilio channels' do
|
||||
let!(:whatsapp_channel) { create(:channel_twilio_sms, medium: :whatsapp) }
|
||||
let!(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: whatsapp_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: whatsapp_inbox, account: whatsapp_channel.account) }
|
||||
|
||||
it 'return false if the last message is outgoing' do
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if the last message is incoming and within the messaging window (with in 24 hours)' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 13.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'return false if the last message is incoming and outside the messaging window (24 hours limit)' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 25.hours.ago
|
||||
)
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be false
|
||||
end
|
||||
|
||||
it 'return true if last message is outgoing but previous incoming message is within window' do
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
created_at: 1.hour.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'considers only the last incoming message for determining time window' do
|
||||
# Old message outside window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 10.days.ago
|
||||
)
|
||||
|
||||
# Recent message within window
|
||||
create(
|
||||
:message,
|
||||
account: conversation.account,
|
||||
inbox: whatsapp_inbox,
|
||||
conversation: conversation,
|
||||
created_at: 6.hours.ago
|
||||
)
|
||||
|
||||
service = described_class.new(conversation)
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Conversations::PermissionFilterService do
|
||||
let(:account) { create(:account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let!(:another_conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
# This inbox_member is used to establish the agent's access to the inbox
|
||||
before { create(:inbox_member, user: agent, inbox: inbox) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when user is an administrator' do
|
||||
it 'returns all conversations' do
|
||||
result = described_class.new(
|
||||
account.conversations,
|
||||
admin,
|
||||
account
|
||||
).perform
|
||||
|
||||
expect(result).to include(conversation)
|
||||
expect(result).to include(another_conversation)
|
||||
expect(result.count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is an agent' do
|
||||
it 'returns all conversations with no further filtering' do
|
||||
inbox_ids = agent.inboxes.where(account_id: account.id).pluck(:id)
|
||||
|
||||
# The base implementation returns all conversations
|
||||
# expecting the caller to filter by assigned inboxes
|
||||
result = described_class.new(
|
||||
account.conversations.where(inbox_id: inbox_ids),
|
||||
agent,
|
||||
account
|
||||
).perform
|
||||
|
||||
expect(result).to include(conversation)
|
||||
expect(result).to include(another_conversation)
|
||||
expect(result.count).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,223 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Crm::Leadsquared::Api::ActivityClient do
|
||||
let(:credentials) do
|
||||
{
|
||||
access_key: SecureRandom.hex,
|
||||
secret_key: SecureRandom.hex,
|
||||
endpoint_url: 'https://api.leadsquared.com/'
|
||||
}
|
||||
end
|
||||
|
||||
let(:headers) do
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'x-LSQ-AccessKey': credentials[:access_key],
|
||||
'x-LSQ-SecretKey': credentials[:secret_key]
|
||||
}
|
||||
end
|
||||
let(:client) { described_class.new(credentials[:access_key], credentials[:secret_key], credentials[:endpoint_url]) }
|
||||
let(:prospect_id) { SecureRandom.uuid }
|
||||
let(:activity_event) { 1001 } # Example activity event code
|
||||
let(:activity_note) { 'Test activity note' }
|
||||
let(:activity_date_time) { '2025-04-11 14:15:00' }
|
||||
|
||||
describe '#post_activity' do
|
||||
let(:path) { '/ProspectActivity.svc/Create' }
|
||||
let(:full_url) { URI.join(credentials[:endpoint_url], path).to_s }
|
||||
|
||||
context 'with missing required parameters' do
|
||||
it 'raises ArgumentError when prospect_id is missing' do
|
||||
expect { client.post_activity(nil, activity_event, activity_note) }
|
||||
.to raise_error(ArgumentError, 'Prospect ID is required')
|
||||
end
|
||||
|
||||
it 'raises ArgumentError when activity_event is missing' do
|
||||
expect { client.post_activity(prospect_id, nil, activity_note) }
|
||||
.to raise_error(ArgumentError, 'Activity event code is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request is successful' do
|
||||
let(:activity_id) { SecureRandom.uuid }
|
||||
let(:success_response) do
|
||||
{
|
||||
'Status' => 'Success',
|
||||
'Message' => {
|
||||
'Id' => activity_id,
|
||||
'Message' => 'Activity created successfully'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
body: {
|
||||
'RelatedProspectId' => prospect_id,
|
||||
'ActivityEvent' => activity_event,
|
||||
'ActivityNote' => activity_note
|
||||
}.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: success_response.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns activity ID directly' do
|
||||
response = client.post_activity(prospect_id, activity_event, activity_note)
|
||||
expect(response).to eq(activity_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response indicates failure' do
|
||||
let(:error_response) do
|
||||
{
|
||||
'Status' => 'Error',
|
||||
'ExceptionType' => 'NullReferenceException',
|
||||
'ExceptionMessage' => 'There was an error processing the request.'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
body: {
|
||||
'RelatedProspectId' => prospect_id,
|
||||
'ActivityEvent' => activity_event,
|
||||
'ActivityNote' => activity_note
|
||||
}.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: error_response.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError when activity creation fails' do
|
||||
expect { client.post_activity(prospect_id, activity_event, activity_note) }
|
||||
.to raise_error do |error|
|
||||
expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_activity_type' do
|
||||
let(:path) { 'ProspectActivity.svc/CreateType' }
|
||||
let(:full_url) { URI.join(credentials[:endpoint_url], path).to_s }
|
||||
let(:activity_params) do
|
||||
{
|
||||
name: 'Test Activity Type',
|
||||
score: 10,
|
||||
direction: 0
|
||||
}
|
||||
end
|
||||
|
||||
context 'with missing required parameters' do
|
||||
it 'raises ArgumentError when name is missing' do
|
||||
expect { client.create_activity_type(name: nil, score: 10) }
|
||||
.to raise_error(ArgumentError, 'Activity name is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request is successful' do
|
||||
let(:activity_event_id) { 1001 }
|
||||
let(:success_response) do
|
||||
{
|
||||
'Status' => 'Success',
|
||||
'Message' => {
|
||||
'Id' => activity_event_id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
body: {
|
||||
'ActivityEventName' => activity_params[:name],
|
||||
'Score' => activity_params[:score],
|
||||
'Direction' => activity_params[:direction]
|
||||
}.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: success_response.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns activity ID directly' do
|
||||
response = client.create_activity_type(**activity_params)
|
||||
expect(response).to eq(activity_event_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response indicates failure' do
|
||||
let(:error_response) do
|
||||
{
|
||||
'Status' => 'Error',
|
||||
'ExceptionType' => 'MXInvalidInputException',
|
||||
'ExceptionMessage' => 'Invalid Input! Parameter Name: activity'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
body: {
|
||||
'ActivityEventName' => activity_params[:name],
|
||||
'Score' => activity_params[:score],
|
||||
'Direction' => activity_params[:direction]
|
||||
}.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: error_response.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError when activity type creation fails' do
|
||||
expect { client.create_activity_type(**activity_params) }
|
||||
.to raise_error do |error|
|
||||
expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API request fails' do
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
body: {
|
||||
'ActivityEventName' => activity_params[:name],
|
||||
'Score' => activity_params[:score],
|
||||
'Direction' => activity_params[:direction]
|
||||
}.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 500,
|
||||
body: 'Internal Server Error',
|
||||
headers: { 'Content-Type' => 'text/plain' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError when the request fails' do
|
||||
expect { client.create_activity_type(**activity_params) }
|
||||
.to raise_error do |error|
|
||||
expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,187 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Crm::Leadsquared::Api::BaseClient do
|
||||
let(:access_key) { SecureRandom.hex }
|
||||
let(:secret_key) { SecureRandom.hex }
|
||||
let(:headers) do
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'x-LSQ-AccessKey': access_key,
|
||||
'x-LSQ-SecretKey': secret_key
|
||||
}
|
||||
end
|
||||
|
||||
let(:endpoint_url) { 'https://api.leadsquared.com/v2' }
|
||||
let(:client) { described_class.new(access_key, secret_key, endpoint_url) }
|
||||
|
||||
describe '#initialize' do
|
||||
it 'creates a client with valid credentials' do
|
||||
expect(client.instance_variable_get(:@access_key)).to eq(access_key)
|
||||
expect(client.instance_variable_get(:@secret_key)).to eq(secret_key)
|
||||
expect(client.instance_variable_get(:@base_uri)).to eq(endpoint_url)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get' do
|
||||
let(:path) { 'LeadManagement.svc/Leads.Get' }
|
||||
let(:params) { { leadId: '123' } }
|
||||
let(:full_url) { URI.join(endpoint_url, path).to_s }
|
||||
|
||||
context 'when request is successful' do
|
||||
before do
|
||||
stub_request(:get, full_url)
|
||||
.with(
|
||||
query: params,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { Message: 'Success', Status: 'Success' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns parsed response data directly' do
|
||||
response = client.get(path, params)
|
||||
expect(response).to include('Message' => 'Success')
|
||||
expect(response).to include('Status' => 'Success')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request returns error status' do
|
||||
before do
|
||||
stub_request(:get, full_url)
|
||||
.with(
|
||||
query: params,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { Status: 'Error', ExceptionMessage: 'Invalid lead ID' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError with error message' do
|
||||
expect { client.get(path, params) }.to raise_error do |error|
|
||||
expect(error.class.name).to eq(described_class::ApiError.name)
|
||||
expect(error.message).to eq('Invalid lead ID')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request fails with non-200 status' do
|
||||
before do
|
||||
stub_request(:get, full_url)
|
||||
.with(
|
||||
query: params,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(status: 404, body: 'Not Found')
|
||||
end
|
||||
|
||||
it 'raises ApiError with status code' do
|
||||
expect { client.get(path, params) }.to raise_error do |error|
|
||||
expect(error.class.name).to eq(described_class::ApiError.name)
|
||||
expect(error.message).to include('Not Found')
|
||||
expect(error.code).to eq(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#post' do
|
||||
let(:path) { 'LeadManagement.svc/Lead.Create' }
|
||||
let(:params) { {} }
|
||||
let(:body) { { FirstName: 'John', LastName: 'Doe' } }
|
||||
let(:full_url) { URI.join(endpoint_url, path).to_s }
|
||||
|
||||
context 'when request is successful' do
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
query: params,
|
||||
body: body.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { Message: 'Lead created', Status: 'Success' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns parsed response data directly' do
|
||||
response = client.post(path, params, body)
|
||||
expect(response).to include('Message' => 'Lead created')
|
||||
expect(response).to include('Status' => 'Success')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request returns error status' do
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
query: params,
|
||||
body: body.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { Status: 'Error', ExceptionMessage: 'Invalid data' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError with error message' do
|
||||
expect { client.post(path, params, body) }.to raise_error do |error|
|
||||
expect(error.class.name).to eq(described_class::ApiError.name)
|
||||
expect(error.message).to eq('Invalid data')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response cannot be parsed' do
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
query: params,
|
||||
body: body.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: 'Invalid JSON',
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError for invalid JSON' do
|
||||
expect { client.post(path, params, body) }.to raise_error do |error|
|
||||
expect(error.class.name).to eq(described_class::ApiError.name)
|
||||
expect(error.message).to include('Failed to parse')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request fails with server error' do
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
query: params,
|
||||
body: body.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(status: 500, body: 'Internal Server Error')
|
||||
end
|
||||
|
||||
it 'raises ApiError with status code' do
|
||||
expect { client.post(path, params, body) }.to raise_error do |error|
|
||||
expect(error.class.name).to eq(described_class::ApiError.name)
|
||||
expect(error.message).to include('Internal Server Error')
|
||||
expect(error.code).to eq(500)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,231 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Crm::Leadsquared::Api::LeadClient do
|
||||
let(:access_key) { SecureRandom.hex }
|
||||
let(:secret_key) { SecureRandom.hex }
|
||||
let(:headers) do
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'x-LSQ-AccessKey': access_key,
|
||||
'x-LSQ-SecretKey': secret_key
|
||||
}
|
||||
end
|
||||
|
||||
let(:endpoint_url) { 'https://api.leadsquared.com/v2' }
|
||||
let(:client) { described_class.new(access_key, secret_key, endpoint_url) }
|
||||
|
||||
describe '#search_lead' do
|
||||
let(:path) { 'LeadManagement.svc/Leads.GetByQuickSearch' }
|
||||
let(:search_key) { 'test@example.com' }
|
||||
let(:full_url) { URI.join(endpoint_url, path).to_s }
|
||||
|
||||
context 'when search key is missing' do
|
||||
it 'raises ArgumentError' do
|
||||
expect { client.search_lead(nil) }
|
||||
.to raise_error(ArgumentError, 'Search key is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no leads are found' do
|
||||
before do
|
||||
stub_request(:get, full_url)
|
||||
.with(query: { key: search_key }, headers: headers)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: [].to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns empty array directly' do
|
||||
response = client.search_lead(search_key)
|
||||
expect(response).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when leads are found' do
|
||||
let(:lead_data) do
|
||||
[{
|
||||
'ProspectID' => SecureRandom.uuid,
|
||||
'FirstName' => 'John',
|
||||
'LastName' => 'Doe',
|
||||
'EmailAddress' => search_key
|
||||
}]
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, full_url)
|
||||
.with(query: { key: search_key }, headers: headers, body: anything)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: lead_data.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns lead data array directly' do
|
||||
response = client.search_lead(search_key)
|
||||
expect(response).to eq(lead_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_or_update_lead' do
|
||||
let(:path) { 'LeadManagement.svc/Lead.CreateOrUpdate' }
|
||||
let(:full_url) { URI.join(endpoint_url, path).to_s }
|
||||
let(:lead_data) do
|
||||
{
|
||||
'FirstName' => 'John',
|
||||
'LastName' => 'Doe',
|
||||
'EmailAddress' => 'john.doe@example.com'
|
||||
}
|
||||
end
|
||||
let(:formatted_lead_data) do
|
||||
lead_data.map do |key, value|
|
||||
{
|
||||
'Attribute' => key,
|
||||
'Value' => value
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lead data is missing' do
|
||||
it 'raises ArgumentError' do
|
||||
expect { client.create_or_update_lead(nil) }
|
||||
.to raise_error(ArgumentError, 'Lead data is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lead is successfully created' do
|
||||
let(:lead_id) { '8e0f69ae-e2ac-40fc-a0cf-827326181c8a' }
|
||||
let(:success_response) do
|
||||
{
|
||||
'Status' => 'Success',
|
||||
'Message' => {
|
||||
'Id' => lead_id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
body: formatted_lead_data.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: success_response.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns lead ID directly' do
|
||||
response = client.create_or_update_lead(lead_data)
|
||||
expect(response).to eq(lead_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request fails' do
|
||||
let(:error_response) do
|
||||
{
|
||||
'Status' => 'Error',
|
||||
'ExceptionMessage' => 'Error message'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
body: formatted_lead_data.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: error_response.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError' do
|
||||
expect { client.create_or_update_lead(lead_data) }
|
||||
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
|
||||
end
|
||||
end
|
||||
|
||||
# Add test for update_lead method
|
||||
describe '#update_lead' do
|
||||
let(:path) { 'LeadManagement.svc/Lead.Update' }
|
||||
let(:lead_id) { '8e0f69ae-e2ac-40fc-a0cf-827326181c8a' }
|
||||
let(:full_url) { URI.join(endpoint_url, "#{path}?leadId=#{lead_id}").to_s }
|
||||
|
||||
context 'with missing parameters' do
|
||||
it 'raises ArgumentError when lead_id is missing' do
|
||||
expect { client.update_lead(lead_data, nil) }
|
||||
.to raise_error(ArgumentError, 'Lead ID is required')
|
||||
end
|
||||
|
||||
it 'raises ArgumentError when lead_data is missing' do
|
||||
expect { client.update_lead(nil, lead_id) }
|
||||
.to raise_error(ArgumentError, 'Lead data is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when update is successful' do
|
||||
let(:success_response) do
|
||||
{
|
||||
'Status' => 'Success',
|
||||
'Message' => {
|
||||
'AffectedRows' => 1
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
body: formatted_lead_data.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: success_response.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns affected rows directly' do
|
||||
response = client.update_lead(lead_data, lead_id)
|
||||
expect(response).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when update fails' do
|
||||
let(:error_response) do
|
||||
{
|
||||
'Status' => 'Error',
|
||||
'ExceptionMessage' => 'Invalid lead ID'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
body: formatted_lead_data.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: error_response.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError' do
|
||||
expect { client.update_lead(lead_data, lead_id) }
|
||||
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Crm::Leadsquared::LeadFinderService do
|
||||
let(:lead_client) { instance_double(Crm::Leadsquared::Api::LeadClient) }
|
||||
let(:service) { described_class.new(lead_client) }
|
||||
let(:contact) { create(:contact, email: 'test@example.com', phone_number: '+1234567890') }
|
||||
|
||||
describe '#find_or_create' do
|
||||
context 'when contact has stored lead ID' do
|
||||
before do
|
||||
contact.additional_attributes = { 'external' => { 'leadsquared_id' => '123' } }
|
||||
contact.save!
|
||||
end
|
||||
|
||||
it 'returns the stored lead ID' do
|
||||
result = service.find_or_create(contact)
|
||||
expect(result).to eq('123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has no stored lead ID' do
|
||||
context 'when lead is found by email' do
|
||||
before do
|
||||
allow(lead_client).to receive(:search_lead)
|
||||
.with(contact.email)
|
||||
.and_return([{ 'ProspectID' => '456' }])
|
||||
end
|
||||
|
||||
it 'returns the found lead ID' do
|
||||
result = service.find_or_create(contact)
|
||||
expect(result).to eq('456')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lead is found by phone' do
|
||||
before do
|
||||
allow(lead_client).to receive(:search_lead)
|
||||
.with(contact.email)
|
||||
.and_return([])
|
||||
|
||||
allow(lead_client).to receive(:search_lead)
|
||||
.with(contact.phone_number)
|
||||
.and_return([{ 'ProspectID' => '789' }])
|
||||
end
|
||||
|
||||
it 'returns the found lead ID' do
|
||||
result = service.find_or_create(contact)
|
||||
expect(result).to eq('789')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lead is not found and needs to be created' do
|
||||
before do
|
||||
allow(lead_client).to receive(:search_lead)
|
||||
.with(contact.email)
|
||||
.and_return([])
|
||||
|
||||
allow(lead_client).to receive(:search_lead)
|
||||
.with(contact.phone_number)
|
||||
.and_return([])
|
||||
|
||||
allow(lead_client).to receive(:create_or_update_lead)
|
||||
.with(Crm::Leadsquared::Mappers::ContactMapper.map(contact))
|
||||
.and_return('999')
|
||||
end
|
||||
|
||||
it 'creates a new lead and returns its ID' do
|
||||
result = service.find_or_create(contact)
|
||||
expect(result).to eq('999')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lead creation fails' do
|
||||
before do
|
||||
allow(lead_client).to receive(:search_lead)
|
||||
.with(contact.email)
|
||||
.and_return([])
|
||||
|
||||
allow(lead_client).to receive(:search_lead)
|
||||
.with(contact.phone_number)
|
||||
.and_return([])
|
||||
|
||||
allow(Crm::Leadsquared::Mappers::ContactMapper).to receive(:map)
|
||||
.with(contact)
|
||||
.and_return({})
|
||||
|
||||
allow(lead_client).to receive(:create_or_update_lead)
|
||||
.with({})
|
||||
.and_raise(StandardError, 'Failed to create lead')
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.find_or_create(contact) }.to raise_error(StandardError, 'Failed to create lead')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Crm::Leadsquared::Mappers::ContactMapper do
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account, name: '', last_name: '', country_code: '') }
|
||||
let(:brand_name) { 'Test Brand' }
|
||||
|
||||
before do
|
||||
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => brand_name })
|
||||
end
|
||||
|
||||
describe '.map' do
|
||||
context 'with basic attributes' do
|
||||
it 'maps basic contact attributes correctly' do
|
||||
contact.update!(
|
||||
name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john@example.com',
|
||||
# the phone number is intentionally wrong
|
||||
phone_number: '+1234567890'
|
||||
)
|
||||
|
||||
mapped_data = described_class.map(contact)
|
||||
|
||||
expect(mapped_data).to include(
|
||||
'FirstName' => 'John',
|
||||
'LastName' => 'Doe',
|
||||
'EmailAddress' => 'john@example.com',
|
||||
'Mobile' => '+1234567890',
|
||||
'Source' => 'Test Brand'
|
||||
)
|
||||
end
|
||||
|
||||
it 'represents the phone number correctly' do
|
||||
contact.update!(
|
||||
name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john@example.com',
|
||||
phone_number: '+917507684392'
|
||||
)
|
||||
|
||||
mapped_data = described_class.map(contact)
|
||||
|
||||
expect(mapped_data).to include('Mobile' => '+91-7507684392')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,265 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account, name: 'Test Inbox', channel_type: 'Channel') }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:user) { create(:user, name: 'John Doe') }
|
||||
let(:contact) { create(:contact, name: 'Jane Smith') }
|
||||
let(:hook) do
|
||||
create(:integrations_hook, :leadsquared, account: account, settings: {
|
||||
'access_key' => 'test_access_key',
|
||||
'secret_key' => 'test_secret_key',
|
||||
'endpoint_url' => 'https://api.leadsquared.com/v2',
|
||||
'timezone' => 'UTC'
|
||||
})
|
||||
end
|
||||
let(:hook_with_pst) do
|
||||
create(:integrations_hook, :leadsquared, account: account, settings: {
|
||||
'access_key' => 'test_access_key',
|
||||
'secret_key' => 'test_secret_key',
|
||||
'endpoint_url' => 'https://api.leadsquared.com/v2',
|
||||
'timezone' => 'America/Los_Angeles'
|
||||
})
|
||||
end
|
||||
let(:hook_without_timezone) do
|
||||
create(:integrations_hook, :leadsquared, account: account, settings: {
|
||||
'access_key' => 'test_access_key',
|
||||
'secret_key' => 'test_secret_key',
|
||||
'endpoint_url' => 'https://api.leadsquared.com/v2'
|
||||
})
|
||||
end
|
||||
|
||||
before do
|
||||
account.enable_features('crm_integration')
|
||||
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' })
|
||||
end
|
||||
|
||||
describe '.map_conversation_activity' do
|
||||
it 'generates conversation activity note with UTC timezone' do
|
||||
travel_to(Time.zone.parse('2024-01-01 10:00:00 UTC')) do
|
||||
result = described_class.map_conversation_activity(hook, conversation)
|
||||
|
||||
expect(result).to include('New conversation started on TestBrand')
|
||||
expect(result).to include('Channel: Test Inbox')
|
||||
expect(result).to include('Created: 2024-01-01 10:00:00')
|
||||
expect(result).to include("Conversation ID: #{conversation.display_id}")
|
||||
expect(result).to include('View in TestBrand: http://')
|
||||
end
|
||||
end
|
||||
|
||||
it 'formats time according to hook timezone setting' do
|
||||
travel_to(Time.zone.parse('2024-01-01 18:00:00 UTC')) do
|
||||
result = described_class.map_conversation_activity(hook_with_pst, conversation)
|
||||
|
||||
# PST is UTC-8, so 18:00 UTC becomes 10:00:00 PST
|
||||
expect(result).to include('Created: 2024-01-01 10:00:00')
|
||||
end
|
||||
end
|
||||
|
||||
it 'falls back to system timezone when hook has no timezone setting' do
|
||||
travel_to(Time.zone.parse('2024-01-01 10:00:00')) do
|
||||
result = described_class.map_conversation_activity(hook_without_timezone, conversation)
|
||||
|
||||
expect(result).to include('Created: 2024-01-01 10:00:00')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.map_transcript_activity' do
|
||||
context 'when conversation has no messages' do
|
||||
it 'returns no messages message' do
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
expect(result).to eq('No messages in conversation')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has messages' do
|
||||
let(:message1) do
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
content: 'Hello',
|
||||
message_type: :outgoing,
|
||||
created_at: Time.zone.parse('2024-01-01 10:00:00'))
|
||||
end
|
||||
|
||||
let(:message2) do
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: contact,
|
||||
content: 'Hi there',
|
||||
message_type: :incoming,
|
||||
created_at: Time.zone.parse('2024-01-01 10:01:00'))
|
||||
end
|
||||
|
||||
let(:system_message) do
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: nil,
|
||||
content: 'System Message',
|
||||
message_type: :activity,
|
||||
created_at: Time.zone.parse('2024-01-01 10:02:00'))
|
||||
end
|
||||
|
||||
before do
|
||||
message1
|
||||
message2
|
||||
system_message
|
||||
end
|
||||
|
||||
def formatted_line_for(msg, hook_for_tz)
|
||||
tz = Time.find_zone(hook_for_tz.settings['timezone']) || Time.zone
|
||||
ts = msg.created_at.in_time_zone(tz).strftime('%Y-%m-%d %H:%M')
|
||||
sender = msg.sender&.name.presence || (msg.sender.present? ? "#{msg.sender_type} #{msg.sender_id}" : 'System')
|
||||
"[#{ts}] #{sender}: #{msg.content.presence || I18n.t('crm.no_content')}"
|
||||
end
|
||||
|
||||
it 'generates transcript with messages in reverse chronological order' do
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
|
||||
expect(result).to include('Conversation Transcript from TestBrand')
|
||||
expect(result).to include('Channel: Test Inbox')
|
||||
|
||||
# Check that messages appear in reverse order (newest first)
|
||||
newer = formatted_line_for(message2, hook)
|
||||
older = formatted_line_for(message1, hook)
|
||||
message_positions = {
|
||||
newer => result.index(newer),
|
||||
older => result.index(older)
|
||||
}
|
||||
|
||||
# Latest message (10:01) should come before older message (10:00)
|
||||
expect(message_positions[newer]).to be < message_positions[older]
|
||||
end
|
||||
|
||||
it 'formats message times according to hook timezone setting' do
|
||||
travel_to(Time.zone.parse('2024-01-01 18:00:00 UTC')) do
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
content: 'Test message',
|
||||
message_type: :outgoing,
|
||||
created_at: Time.zone.parse('2024-01-01 18:00:00 UTC'))
|
||||
|
||||
result = described_class.map_transcript_activity(hook_with_pst, conversation)
|
||||
|
||||
# PST is UTC-8, so 18:00 UTC becomes 10:00 PST
|
||||
expect(result).to include('[2024-01-01 10:00] John Doe: Test message')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has attachments' do
|
||||
let(:message_with_attachment) do
|
||||
create(:message, :with_attachment,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
content: 'See attachment',
|
||||
message_type: :outgoing,
|
||||
created_at: Time.zone.parse('2024-01-01 10:03:00'))
|
||||
end
|
||||
|
||||
before { message_with_attachment }
|
||||
|
||||
it 'includes attachment information' do
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
|
||||
expect(result).to include('See attachment')
|
||||
expect(result).to include('[Attachment: image]')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has empty content' do
|
||||
let(:empty_message) do
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
content: '',
|
||||
message_type: :outgoing,
|
||||
created_at: Time.zone.parse('2024-01-01 10:04'))
|
||||
end
|
||||
|
||||
before { empty_message }
|
||||
|
||||
it 'shows no content placeholder' do
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
expect(result).to include('[No content]')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when sender has no name' do
|
||||
let(:unnamed_sender_message) do
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: create(:user, name: ''),
|
||||
content: 'Message',
|
||||
message_type: :outgoing,
|
||||
created_at: Time.zone.parse('2024-01-01 10:05'))
|
||||
end
|
||||
|
||||
before { unnamed_sender_message }
|
||||
|
||||
it 'uses sender type and id' do
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
expect(result).to include("User #{unnamed_sender_message.sender_id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when messages exceed the ACTIVITY_NOTE_MAX_SIZE' do
|
||||
it 'truncates messages to stay within the character limit' do
|
||||
# Create a large number of messages with reasonably sized content
|
||||
long_message_content = 'A' * 200
|
||||
messages = []
|
||||
|
||||
# Create 15 messages (which should exceed the 1800 character limit)
|
||||
15.times do |i|
|
||||
messages << create(:message,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
content: "#{long_message_content} #{i}",
|
||||
message_type: :outgoing,
|
||||
created_at: Time.zone.parse('2024-01-01 10:00:00') + i.hours)
|
||||
end
|
||||
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
|
||||
# Verify latest message is included (message 14)
|
||||
tz = Time.find_zone(hook.settings['timezone']) || Time.zone
|
||||
latest_label = "[#{messages.last.created_at.in_time_zone(tz).strftime('%Y-%m-%d %H:%M')}] John Doe: #{long_message_content} 14"
|
||||
expect(result).to include(latest_label)
|
||||
|
||||
# Calculate the expected character count of the formatted messages
|
||||
messages.map do |msg|
|
||||
"[#{msg.created_at.strftime('%Y-%m-%d %H:%M')}] John Doe: #{msg.content}"
|
||||
end
|
||||
|
||||
# Verify the result is within the character limit
|
||||
expect(result.length).to be <= described_class::ACTIVITY_NOTE_MAX_SIZE + 100
|
||||
|
||||
# Verify that not all messages are included (some were truncated)
|
||||
expect(messages.count).to be > result.scan('John Doe:').count
|
||||
end
|
||||
|
||||
it 'respects the ACTIVITY_NOTE_MAX_SIZE constant' do
|
||||
# Create a single message that would exceed the limit by itself
|
||||
giant_content = 'A' * 2000
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
content: giant_content,
|
||||
message_type: :outgoing)
|
||||
|
||||
result = described_class.map_transcript_activity(hook, conversation)
|
||||
|
||||
# Extract just the formatted messages part
|
||||
id = conversation.display_id
|
||||
prefix = "Conversation Transcript from TestBrand\nChannel: Test Inbox\nConversation ID: #{id}\nView in TestBrand: "
|
||||
formatted_messages = result.sub(prefix, '').sub(%r{http://.*}, '')
|
||||
|
||||
# Check that it's under the limit (with some tolerance for the message format)
|
||||
expect(formatted_messages.length).to be <= described_class::ACTIVITY_NOTE_MAX_SIZE + 100
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,233 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Crm::Leadsquared::ProcessorService do
|
||||
let(:account) { create(:account) }
|
||||
let(:hook) do
|
||||
create(:integrations_hook, :leadsquared, account: account, settings: {
|
||||
'access_key' => 'test_access_key',
|
||||
'secret_key' => 'test_secret_key',
|
||||
'endpoint_url' => 'https://api.leadsquared.com/v2',
|
||||
'enable_transcript_activity' => true,
|
||||
'enable_conversation_activity' => true,
|
||||
'conversation_activity_code' => 1001,
|
||||
'transcript_activity_code' => 1002
|
||||
})
|
||||
end
|
||||
let(:contact) { create(:contact, account: account, email: 'test@example.com', phone_number: '+1234567890') }
|
||||
let(:contact_with_social_profile) do
|
||||
create(:contact, account: account, additional_attributes: { 'social_profiles' => { 'facebook' => 'chatwootapp' } })
|
||||
end
|
||||
let(:blank_contact) { create(:contact, account: account, email: '', phone_number: '') }
|
||||
let(:conversation) { create(:conversation, account: account, contact: contact) }
|
||||
let(:service) { described_class.new(hook) }
|
||||
let(:lead_client) { instance_double(Crm::Leadsquared::Api::LeadClient) }
|
||||
let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) }
|
||||
let(:lead_finder) { instance_double(Crm::Leadsquared::LeadFinderService) }
|
||||
|
||||
before do
|
||||
account.enable_features('crm_integration')
|
||||
allow(Crm::Leadsquared::Api::LeadClient).to receive(:new)
|
||||
.with('test_access_key', 'test_secret_key', 'https://api.leadsquared.com/v2')
|
||||
.and_return(lead_client)
|
||||
allow(Crm::Leadsquared::Api::ActivityClient).to receive(:new)
|
||||
.with('test_access_key', 'test_secret_key', 'https://api.leadsquared.com/v2')
|
||||
.and_return(activity_client)
|
||||
allow(Crm::Leadsquared::LeadFinderService).to receive(:new)
|
||||
.with(lead_client)
|
||||
.and_return(lead_finder)
|
||||
end
|
||||
|
||||
describe '.crm_name' do
|
||||
it 'returns leadsquared' do
|
||||
expect(described_class.crm_name).to eq('leadsquared')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#handle_contact' do
|
||||
context 'when contact is valid' do
|
||||
before do
|
||||
allow(service).to receive(:identifiable_contact?).and_return(true)
|
||||
end
|
||||
|
||||
context 'when contact has no stored lead ID' do
|
||||
before do
|
||||
contact.update(additional_attributes: { 'external' => nil })
|
||||
contact.reload
|
||||
|
||||
allow(lead_client).to receive(:create_or_update_lead)
|
||||
.with(any_args)
|
||||
.and_return('new_lead_id')
|
||||
end
|
||||
|
||||
it 'creates a new lead and stores the ID' do
|
||||
service.handle_contact(contact)
|
||||
expect(lead_client).to have_received(:create_or_update_lead).with(any_args)
|
||||
expect(contact.reload.additional_attributes['external']['leadsquared_id']).to eq('new_lead_id')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has existing lead ID' do
|
||||
before do
|
||||
contact.additional_attributes = { 'external' => { 'leadsquared_id' => 'existing_lead_id' } }
|
||||
contact.save!
|
||||
|
||||
allow(lead_client).to receive(:update_lead)
|
||||
.with(any_args)
|
||||
.and_return(nil) # The update method doesn't need to return anything
|
||||
end
|
||||
|
||||
it 'updates the lead using existing ID' do
|
||||
service.handle_contact(contact)
|
||||
expect(lead_client).to have_received(:update_lead).with(any_args)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API call raises an error' do
|
||||
before do
|
||||
allow(lead_client).to receive(:create_or_update_lead)
|
||||
.with(any_args)
|
||||
.and_raise(Crm::Leadsquared::Api::BaseClient::ApiError.new('API Error'))
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'catches and logs the error' do
|
||||
service.handle_contact(contact)
|
||||
expect(Rails.logger).to have_received(:error).with(/LeadSquared API error/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact is invalid' do
|
||||
before do
|
||||
allow(service).to receive(:identifiable_contact?).and_return(false)
|
||||
allow(lead_client).to receive(:create_or_update_lead)
|
||||
end
|
||||
|
||||
it 'returns without making API calls' do
|
||||
service.handle_contact(blank_contact)
|
||||
expect(lead_client).not_to have_received(:create_or_update_lead)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#handle_conversation_created' do
|
||||
let(:activity_note) { 'New conversation started' }
|
||||
|
||||
before do
|
||||
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_conversation_activity)
|
||||
.with(hook, conversation)
|
||||
.and_return(activity_note)
|
||||
end
|
||||
|
||||
context 'when conversation activities are enabled' do
|
||||
before do
|
||||
service.instance_variable_set(:@allow_conversation, true)
|
||||
end
|
||||
|
||||
context 'when lead_id is found' do
|
||||
before do
|
||||
allow(lead_finder).to receive(:find_or_create)
|
||||
.with(contact)
|
||||
.and_return('test_lead_id')
|
||||
|
||||
allow(activity_client).to receive(:post_activity)
|
||||
.with('test_lead_id', 1001, activity_note)
|
||||
.and_return('test_activity_id')
|
||||
end
|
||||
|
||||
it 'creates the activity and stores metadata' do
|
||||
service.handle_conversation_created(conversation)
|
||||
expect(conversation.reload.additional_attributes['leadsquared']['created_activity_id']).to eq('test_activity_id')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when post_activity raises an error' do
|
||||
before do
|
||||
allow(lead_finder).to receive(:find_or_create)
|
||||
.with(contact)
|
||||
.and_return('test_lead_id')
|
||||
|
||||
allow(activity_client).to receive(:post_activity)
|
||||
.with('test_lead_id', 1001, activity_note)
|
||||
.and_raise(StandardError.new('Activity error'))
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
service.handle_conversation_created(conversation)
|
||||
expect(Rails.logger).to have_received(:error).with(/Error creating conversation activity/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation activities are disabled' do
|
||||
before do
|
||||
service.instance_variable_set(:@allow_conversation, false)
|
||||
allow(activity_client).to receive(:post_activity)
|
||||
end
|
||||
|
||||
it 'does not create an activity' do
|
||||
service.handle_conversation_created(conversation)
|
||||
expect(activity_client).not_to have_received(:post_activity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#handle_conversation_resolved' do
|
||||
let(:activity_note) { 'Conversation transcript' }
|
||||
|
||||
before do
|
||||
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_transcript_activity)
|
||||
.with(hook, conversation)
|
||||
.and_return(activity_note)
|
||||
end
|
||||
|
||||
context 'when transcript activities are enabled and conversation is resolved' do
|
||||
before do
|
||||
service.instance_variable_set(:@allow_transcript, true)
|
||||
conversation.update!(status: 'resolved')
|
||||
|
||||
allow(lead_finder).to receive(:find_or_create)
|
||||
.with(contact)
|
||||
.and_return('test_lead_id')
|
||||
|
||||
allow(activity_client).to receive(:post_activity)
|
||||
.with('test_lead_id', 1002, activity_note)
|
||||
.and_return('test_activity_id')
|
||||
end
|
||||
|
||||
it 'creates the transcript activity and stores metadata' do
|
||||
service.handle_conversation_resolved(conversation)
|
||||
expect(conversation.reload.additional_attributes['leadsquared']['transcript_activity_id']).to eq('test_activity_id')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is not resolved' do
|
||||
before do
|
||||
service.instance_variable_set(:@allow_transcript, true)
|
||||
conversation.update!(status: 'open')
|
||||
allow(activity_client).to receive(:post_activity)
|
||||
end
|
||||
|
||||
it 'does not create an activity' do
|
||||
service.handle_conversation_resolved(conversation)
|
||||
expect(activity_client).not_to have_received(:post_activity)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when transcript activities are disabled' do
|
||||
before do
|
||||
service.instance_variable_set(:@allow_transcript, false)
|
||||
conversation.update!(status: 'resolved')
|
||||
allow(activity_client).to receive(:post_activity)
|
||||
end
|
||||
|
||||
it 'does not create an activity' do
|
||||
service.handle_conversation_resolved(conversation)
|
||||
expect(activity_client).not_to have_received(:post_activity)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Crm::Leadsquared::SetupService do
|
||||
let(:account) { create(:account) }
|
||||
let(:hook) { create(:integrations_hook, :leadsquared, account: account) }
|
||||
let(:service) { described_class.new(hook) }
|
||||
let(:base_client) { instance_double(Crm::Leadsquared::Api::BaseClient) }
|
||||
let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) }
|
||||
let(:endpoint_response) do
|
||||
{
|
||||
'TimeZone' => 'Asia/Kolkata',
|
||||
'LSQCommonServiceURLs' => {
|
||||
'api' => 'api-in.leadsquared.com',
|
||||
'app' => 'app.leadsquared.com'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
account.enable_features('crm_integration')
|
||||
allow(Crm::Leadsquared::Api::BaseClient).to receive(:new).and_return(base_client)
|
||||
allow(Crm::Leadsquared::Api::ActivityClient).to receive(:new).and_return(activity_client)
|
||||
allow(base_client).to receive(:get).with('Authentication.svc/UserByAccessKey.Get').and_return(endpoint_response)
|
||||
end
|
||||
|
||||
describe '#setup' do
|
||||
context 'when fetching activity types succeeds' do
|
||||
let(:started_type) do
|
||||
{ 'ActivityEventName' => 'Chatwoot Conversation Started', 'ActivityEvent' => 1001 }
|
||||
end
|
||||
|
||||
let(:transcript_type) do
|
||||
{ 'ActivityEventName' => 'Chatwoot Conversation Transcript', 'ActivityEvent' => 1002 }
|
||||
end
|
||||
|
||||
context 'when all required types exist' do
|
||||
before do
|
||||
allow(activity_client).to receive(:fetch_activity_types)
|
||||
.and_return([started_type, transcript_type])
|
||||
end
|
||||
|
||||
it 'uses existing activity types and updates hook settings' do
|
||||
service.setup
|
||||
|
||||
# Verify hook settings were merged with existing settings
|
||||
updated_settings = hook.reload.settings
|
||||
expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/')
|
||||
expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/')
|
||||
expect(updated_settings['timezone']).to eq('Asia/Kolkata')
|
||||
expect(updated_settings['conversation_activity_code']).to eq(1001)
|
||||
expect(updated_settings['transcript_activity_code']).to eq(1002)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when some activity types need to be created' do
|
||||
before do
|
||||
allow(activity_client).to receive(:fetch_activity_types)
|
||||
.and_return([started_type])
|
||||
|
||||
allow(activity_client).to receive(:create_activity_type)
|
||||
.with(
|
||||
name: 'Chatwoot Conversation Transcript',
|
||||
score: 0,
|
||||
direction: 0
|
||||
)
|
||||
.and_return(1002)
|
||||
end
|
||||
|
||||
it 'creates missing types and updates hook settings' do
|
||||
service.setup
|
||||
|
||||
# Verify hook settings were merged with existing settings
|
||||
updated_settings = hook.reload.settings
|
||||
expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/')
|
||||
expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/')
|
||||
expect(updated_settings['timezone']).to eq('Asia/Kolkata')
|
||||
expect(updated_settings['conversation_activity_code']).to eq(1001)
|
||||
expect(updated_settings['transcript_activity_code']).to eq(1002)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when activity type creation fails' do
|
||||
before do
|
||||
allow(activity_client).to receive(:fetch_activity_types)
|
||||
.and_return([started_type])
|
||||
|
||||
allow(activity_client).to receive(:create_activity_type)
|
||||
.with(anything)
|
||||
.and_raise(StandardError.new('Failed to create activity type'))
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'logs the error and returns nil' do
|
||||
expect(service.setup).to be_nil
|
||||
expect(Rails.logger).to have_received(:error).with(/Error during LeadSquared setup/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#activity_types' do
|
||||
it 'defines conversation started activity type' do
|
||||
required_types = service.send(:activity_types)
|
||||
conversation_type = required_types.find { |t| t[:setting_key] == 'conversation_activity_code' }
|
||||
expect(conversation_type).to include(
|
||||
name: 'Chatwoot Conversation Started',
|
||||
score: 0,
|
||||
direction: 0,
|
||||
setting_key: 'conversation_activity_code'
|
||||
)
|
||||
end
|
||||
|
||||
it 'defines transcript activity type' do
|
||||
required_types = service.send(:activity_types)
|
||||
transcript_type = required_types.find { |t| t[:setting_key] == 'transcript_activity_code' }
|
||||
expect(transcript_type).to include(
|
||||
name: 'Chatwoot Conversation Transcript',
|
||||
score: 0,
|
||||
direction: 0,
|
||||
setting_key: 'transcript_activity_code'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
399
research/chatwoot/spec/services/csat_survey_service_spec.rb
Normal file
399
research/chatwoot/spec/services/csat_survey_service_spec.rb
Normal file
@@ -0,0 +1,399 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe CsatSurveyService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account, csat_survey_enabled: true) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '+1234567890') }
|
||||
let(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: inbox, account: account, status: :resolved) }
|
||||
let(:service) { described_class.new(conversation: conversation) }
|
||||
|
||||
describe '#perform' do
|
||||
let(:csat_template) { instance_double(MessageTemplates::Template::CsatSurvey) }
|
||||
|
||||
before do
|
||||
allow(MessageTemplates::Template::CsatSurvey).to receive(:new).and_return(csat_template)
|
||||
allow(csat_template).to receive(:perform)
|
||||
allow(Conversations::ActivityMessageJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
context 'when CSAT survey should be sent' do
|
||||
before do
|
||||
allow(conversation).to receive(:can_reply?).and_return(true)
|
||||
end
|
||||
|
||||
it 'sends CSAT survey when within messaging window' do
|
||||
service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when outside messaging window' do
|
||||
before do
|
||||
allow(conversation).to receive(:can_reply?).and_return(false)
|
||||
end
|
||||
|
||||
it 'creates activity message instead of sending survey' do
|
||||
service.perform
|
||||
|
||||
expect(Conversations::ActivityMessageJob).to have_received(:perform_later).with(
|
||||
conversation,
|
||||
hash_including(content: I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window'))
|
||||
)
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when CSAT survey should not be sent' do
|
||||
it 'does nothing when conversation is not resolved' do
|
||||
conversation.update(status: :open)
|
||||
|
||||
service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
|
||||
end
|
||||
|
||||
it 'does nothing when CSAT survey is not enabled' do
|
||||
inbox.update(csat_survey_enabled: false)
|
||||
|
||||
service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
|
||||
end
|
||||
|
||||
it 'does nothing when CSAT already sent' do
|
||||
create(:message, conversation: conversation, content_type: :input_csat)
|
||||
|
||||
service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
|
||||
end
|
||||
|
||||
it 'does nothing for Twitter conversations' do
|
||||
twitter_channel = create(:channel_twitter_profile)
|
||||
twitter_inbox = create(:inbox, channel: twitter_channel, csat_survey_enabled: true)
|
||||
twitter_conversation = create(:conversation,
|
||||
inbox: twitter_inbox,
|
||||
status: :resolved,
|
||||
additional_attributes: { type: 'tweet' })
|
||||
twitter_service = described_class.new(conversation: twitter_conversation)
|
||||
|
||||
twitter_service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
|
||||
end
|
||||
|
||||
context 'when survey rules block sending' do
|
||||
before do
|
||||
inbox.update(csat_config: {
|
||||
'survey_rules' => {
|
||||
'operator' => 'does_not_contain',
|
||||
'values' => ['bot-detectado']
|
||||
}
|
||||
})
|
||||
conversation.update(label_list: ['bot-detectado'])
|
||||
end
|
||||
|
||||
it 'does not send CSAT' do
|
||||
service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
expect(conversation.messages.where(content_type: :input_csat)).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is a WhatsApp channel' do
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
end
|
||||
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account, csat_survey_enabled: true) }
|
||||
let(:whatsapp_contact) { create(:contact, account: account) }
|
||||
let(:whatsapp_contact_inbox) { create(:contact_inbox, contact: whatsapp_contact, inbox: whatsapp_inbox, source_id: '1234567890') }
|
||||
let(:whatsapp_conversation) do
|
||||
create(:conversation, contact_inbox: whatsapp_contact_inbox, inbox: whatsapp_inbox, account: account, status: :resolved)
|
||||
end
|
||||
let(:whatsapp_service) { described_class.new(conversation: whatsapp_conversation) }
|
||||
let(:mock_provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_provider_service)
|
||||
allow(whatsapp_conversation).to receive(:can_reply?).and_return(true)
|
||||
end
|
||||
|
||||
context 'when template is available and approved' do
|
||||
before do
|
||||
setup_approved_template('customer_survey_template')
|
||||
end
|
||||
|
||||
it 'sends WhatsApp template survey instead of regular survey' do
|
||||
mock_successful_template_send('template_message_id_123')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(mock_provider_service).to have_received(:send_template).with(
|
||||
'1234567890',
|
||||
hash_including(
|
||||
name: 'customer_survey_template',
|
||||
lang_code: 'en',
|
||||
parameters: array_including(
|
||||
hash_including(
|
||||
type: 'button',
|
||||
sub_type: 'url',
|
||||
index: '0',
|
||||
parameters: array_including(
|
||||
hash_including(type: 'text', text: whatsapp_conversation.uuid)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
instance_of(Message)
|
||||
)
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'updates message with returned message ID' do
|
||||
mock_successful_template_send('template_message_id_123')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||
expect(csat_message).to be_present
|
||||
expect(csat_message.source_id).to eq('template_message_id_123')
|
||||
end
|
||||
|
||||
it 'builds correct template info with default template name' do
|
||||
expected_template_name = "customer_satisfaction_survey_#{whatsapp_inbox.id}"
|
||||
whatsapp_inbox.update(csat_config: { 'template' => {}, 'message' => 'Rate us' })
|
||||
allow(mock_provider_service).to receive(:get_template_status)
|
||||
.with(expected_template_name)
|
||||
.and_return({ success: true, template: { status: 'APPROVED' } })
|
||||
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||
message.save!
|
||||
'msg_id'
|
||||
end
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(mock_provider_service).to have_received(:send_template).with(
|
||||
'1234567890',
|
||||
hash_including(
|
||||
name: expected_template_name,
|
||||
lang_code: 'en'
|
||||
),
|
||||
anything
|
||||
)
|
||||
end
|
||||
|
||||
it 'builds CSAT message with correct attributes' do
|
||||
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||
message.save!
|
||||
'msg_id'
|
||||
end
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||
expect(csat_message.account).to eq(account)
|
||||
expect(csat_message.inbox).to eq(whatsapp_inbox)
|
||||
expect(csat_message.message_type).to eq('outgoing')
|
||||
expect(csat_message.content).to eq('Please rate your experience')
|
||||
expect(csat_message.content_type).to eq('input_csat')
|
||||
end
|
||||
|
||||
it 'uses default message when not configured' do
|
||||
setup_approved_template('test', { 'template' => { 'name' => 'test' } })
|
||||
mock_successful_template_send('msg_id')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||
expect(csat_message.content).to eq('Please rate this conversation')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template is not available or not approved' do
|
||||
it 'falls back to regular survey when template is pending' do
|
||||
setup_template_with_status('pending_template', 'PENDING')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'falls back to regular survey when template is rejected' do
|
||||
setup_template_with_status('pending_template', 'REJECTED')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'falls back to regular survey when template API call fails' do
|
||||
allow(mock_provider_service).to receive(:get_template_status)
|
||||
.with('pending_template')
|
||||
.and_return({ success: false, error: 'Template not found' })
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'falls back to regular survey when template status check raises error' do
|
||||
allow(mock_provider_service).to receive(:get_template_status)
|
||||
.and_raise(StandardError, 'API connection failed')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no template is configured' do
|
||||
it 'falls back to regular survey' do
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||
expect(csat_template).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template sending fails' do
|
||||
before do
|
||||
setup_approved_template('working_template', {
|
||||
'template' => { 'name' => 'working_template' },
|
||||
'message' => 'Rate us'
|
||||
})
|
||||
end
|
||||
|
||||
it 'handles template sending errors gracefully' do
|
||||
mock_template_send_failure('Template send failed')
|
||||
|
||||
expect { whatsapp_service.perform }.not_to raise_error
|
||||
|
||||
# Should still create the CSAT message even if sending fails
|
||||
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||
expect(csat_message).to be_present
|
||||
expect(csat_message.source_id).to be_nil
|
||||
end
|
||||
|
||||
it 'does not update message when send_template returns nil' do
|
||||
mock_template_send_with_no_id
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||
expect(csat_message).to be_present
|
||||
expect(csat_message.source_id).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when outside messaging window' do
|
||||
before do
|
||||
allow(whatsapp_conversation).to receive(:can_reply?).and_return(false)
|
||||
end
|
||||
|
||||
it 'sends template survey even when outside messaging window if template is approved' do
|
||||
setup_approved_template('approved_template', { 'template' => { 'name' => 'approved_template' } })
|
||||
mock_successful_template_send('msg_id')
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(mock_provider_service).to have_received(:send_template)
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
# No activity message should be created when template is successfully sent
|
||||
end
|
||||
|
||||
it 'creates activity message when template is not available and outside window' do
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(Conversations::ActivityMessageJob).to have_received(:perform_later).with(
|
||||
whatsapp_conversation,
|
||||
hash_including(content: I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window'))
|
||||
)
|
||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when survey rules block sending' do
|
||||
before do
|
||||
whatsapp_inbox.update(csat_config: {
|
||||
'template' => { 'name' => 'customer_survey_template', 'language' => 'en' },
|
||||
'message' => 'Please rate your experience',
|
||||
'survey_rules' => {
|
||||
'operator' => 'does_not_contain',
|
||||
'values' => ['bot-detectado']
|
||||
}
|
||||
})
|
||||
whatsapp_conversation.update(label_list: ['bot-detectado'])
|
||||
end
|
||||
|
||||
it 'does not call WhatsApp template or create a CSAT message' do
|
||||
expect(mock_provider_service).not_to receive(:get_template_status)
|
||||
expect(mock_provider_service).not_to receive(:send_template)
|
||||
|
||||
whatsapp_service.perform
|
||||
|
||||
expect(whatsapp_conversation.messages.where(content_type: :input_csat)).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_approved_template(template_name, config = nil)
|
||||
template_config = config || {
|
||||
'template' => {
|
||||
'name' => template_name,
|
||||
'language' => 'en'
|
||||
},
|
||||
'message' => 'Please rate your experience'
|
||||
}
|
||||
whatsapp_inbox.update(csat_config: template_config)
|
||||
allow(mock_provider_service).to receive(:get_template_status)
|
||||
.with(template_name)
|
||||
.and_return({ success: true, template: { status: 'APPROVED' } })
|
||||
end
|
||||
|
||||
def setup_template_with_status(template_name, status)
|
||||
whatsapp_inbox.update(csat_config: {
|
||||
'template' => { 'name' => template_name }
|
||||
})
|
||||
allow(mock_provider_service).to receive(:get_template_status)
|
||||
.with(template_name)
|
||||
.and_return({ success: true, template: { status: status } })
|
||||
end
|
||||
|
||||
def mock_successful_template_send(message_id)
|
||||
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||
message.save!
|
||||
message_id
|
||||
end
|
||||
end
|
||||
|
||||
def mock_template_send_failure(error_message = 'Template send failed')
|
||||
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||
message.save!
|
||||
raise StandardError, error_message
|
||||
end
|
||||
end
|
||||
|
||||
def mock_template_send_with_no_id
|
||||
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||
message.save!
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Email::SendOnEmailService do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account, channel: email_channel) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
|
||||
let(:service) { described_class.new(message: message) }
|
||||
|
||||
describe '#perform' do
|
||||
let(:mailer_context) { instance_double(ConversationReplyMailer) }
|
||||
let(:delivery) { instance_double(ActionMailer::MessageDelivery) }
|
||||
let(:email_message) { instance_double(Mail::Message) }
|
||||
|
||||
before do
|
||||
allow(ConversationReplyMailer).to receive(:with).with(account: message.account).and_return(mailer_context)
|
||||
end
|
||||
|
||||
context 'when message is email notifiable' do
|
||||
before do
|
||||
allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
|
||||
allow(delivery).to receive(:deliver_now).and_return(email_message)
|
||||
allow(email_message).to receive(:message_id).and_return(
|
||||
"conversation/#{conversation.uuid}/messages/" \
|
||||
"#{message.id}@#{conversation.account.domain}"
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends email via ConversationReplyMailer' do
|
||||
service.perform
|
||||
|
||||
expect(ConversationReplyMailer).to have_received(:with).with(account: message.account)
|
||||
expect(mailer_context).to have_received(:email_reply).with(message)
|
||||
expect(delivery).to have_received(:deliver_now)
|
||||
end
|
||||
|
||||
it 'updates message source id on success' do
|
||||
service.perform
|
||||
|
||||
expect(message.reload.source_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is not email notifiable' do
|
||||
let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
|
||||
|
||||
before do
|
||||
allow(mailer_context).to receive(:email_reply)
|
||||
end
|
||||
|
||||
it 'does not send email' do
|
||||
service.perform
|
||||
|
||||
expect(ConversationReplyMailer).not_to have_received(:with)
|
||||
expect(mailer_context).not_to have_received(:email_reply)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an error occurs' do
|
||||
let(:error_message) { 'SMTP connection failed' }
|
||||
let(:error) { StandardError.new(error_message) }
|
||||
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
|
||||
let(:status_service) { instance_double(Messages::StatusUpdateService, perform: true) }
|
||||
|
||||
before do
|
||||
allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
|
||||
allow(delivery).to receive(:deliver_now).and_raise(error)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
end
|
||||
|
||||
it 'captures the exception' do
|
||||
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: message.account)
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'updates message status to failed' do
|
||||
service.perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq(error_message)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,198 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Facebook::SendOnFacebookService do
|
||||
subject(:send_reply_service) { described_class.new(message: message) }
|
||||
|
||||
before do
|
||||
allow(Facebook::Messenger::Subscriptions).to receive(:subscribe).and_return(true)
|
||||
allow(bot).to receive(:deliver).and_return({ recipient_id: '1008372609250235', message_id: 'mid.1456970487936:c34767dfe57ee6e339' }.to_json)
|
||||
create(:message, message_type: :incoming, inbox: facebook_inbox, account: account, conversation: conversation)
|
||||
end
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let(:bot) { class_double(Facebook::Messenger::Bot).as_stubbed_const }
|
||||
let!(:widget_inbox) { create(:inbox, account: account) }
|
||||
let!(:facebook_channel) { create(:channel_facebook_page, account: account) }
|
||||
let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) }
|
||||
let!(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: facebook_inbox) }
|
||||
let(:conversation) { create(:conversation, contact: contact, inbox: facebook_inbox, contact_inbox: contact_inbox) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'without reply' do
|
||||
it 'if message is private' do
|
||||
message = create(:message, message_type: 'outgoing', private: true, inbox: facebook_inbox, account: account)
|
||||
described_class.new(message: message).perform
|
||||
expect(bot).not_to have_received(:deliver)
|
||||
end
|
||||
|
||||
it 'if inbox channel is not facebook page' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: widget_inbox, account: account)
|
||||
expect { described_class.new(message: message).perform }.to raise_error 'Invalid channel service was called'
|
||||
expect(bot).not_to have_received(:deliver)
|
||||
end
|
||||
|
||||
it 'if message is not outgoing' do
|
||||
message = create(:message, message_type: 'incoming', inbox: facebook_inbox, account: account)
|
||||
described_class.new(message: message).perform
|
||||
expect(bot).not_to have_received(:deliver)
|
||||
end
|
||||
|
||||
it 'if message has an FB ID' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, source_id: SecureRandom.uuid)
|
||||
described_class.new(message: message).perform
|
||||
expect(bot).not_to have_received(:deliver)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with reply' do
|
||||
it 'if message is sent from chatwoot and is outgoing' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
|
||||
described_class.new(message: message).perform
|
||||
expect(bot).to have_received(:deliver)
|
||||
end
|
||||
|
||||
it 'raise and exception to validate access token' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
|
||||
allow(bot).to receive(:deliver).and_raise(Facebook::Messenger::FacebookError.new('message' => 'Error validating access token'))
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(facebook_channel.authorization_error_count).to eq(1)
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq('Error validating access token')
|
||||
end
|
||||
|
||||
it 'if message with attachment is sent from chatwoot and is outgoing' do
|
||||
message = build(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
allow(attachment).to receive(:download_url).and_return('url1')
|
||||
described_class.new(message: message).perform
|
||||
expect(bot).to have_received(:deliver).with({
|
||||
recipient: { id: contact_inbox.source_id },
|
||||
message: { text: message.content },
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: 'ACCOUNT_UPDATE'
|
||||
}, { page_id: facebook_channel.page_id })
|
||||
expect(bot).to have_received(:deliver).with({
|
||||
recipient: { id: contact_inbox.source_id },
|
||||
message: {
|
||||
attachment: {
|
||||
type: 'image',
|
||||
payload: {
|
||||
url: 'url1'
|
||||
}
|
||||
}
|
||||
},
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: 'ACCOUNT_UPDATE'
|
||||
}, { page_id: facebook_channel.page_id })
|
||||
end
|
||||
|
||||
it 'if message is sent with multiple attachments' do
|
||||
message = build(:message, content: nil, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
|
||||
avatar = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
avatar.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
sample = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
sample.file.attach(io: Rails.root.join('spec/assets/sample.png').open, filename: 'sample.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
service = described_class.new(message: message)
|
||||
|
||||
# Stub the send_to_facebook_page method on the service instance
|
||||
allow(service).to receive(:send_message_to_facebook)
|
||||
service.perform
|
||||
|
||||
# Now you can set expectations on the stubbed method for each attachment
|
||||
expect(service).to have_received(:send_message_to_facebook).exactly(:twice)
|
||||
end
|
||||
|
||||
it 'if message sent from chatwoot is failed' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
|
||||
allow(bot).to receive(:deliver).and_return({ error: { message: 'Invalid OAuth access token.', type: 'OAuthException', code: 190,
|
||||
fbtrace_id: 'BLBz/WZt8dN' } }.to_json)
|
||||
described_class.new(message: message).perform
|
||||
expect(bot).to have_received(:deliver)
|
||||
expect(message.reload.status).to eq('failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when deliver_message fails' do
|
||||
let(:message) { create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation) }
|
||||
|
||||
it 'handles JSON parse errors' do
|
||||
allow(bot).to receive(:deliver).and_return('invalid_json')
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq('Facebook was unable to process this request')
|
||||
end
|
||||
|
||||
it 'handles timeout errors' do
|
||||
allow(bot).to receive(:deliver).and_raise(Net::OpenTimeout)
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq('Request timed out, please try again later')
|
||||
end
|
||||
|
||||
it 'handles facebook error with code' do
|
||||
error_response = {
|
||||
error: {
|
||||
message: 'Invalid OAuth access token.',
|
||||
type: 'OAuthException',
|
||||
code: 190,
|
||||
fbtrace_id: 'BLBz/WZt8dN'
|
||||
}
|
||||
}.to_json
|
||||
allow(bot).to receive(:deliver).and_return(error_response)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq('190 - Invalid OAuth access token.')
|
||||
end
|
||||
|
||||
it 'handles successful delivery with message_id' do
|
||||
success_response = {
|
||||
message_id: 'mid.1456970487936:c34767dfe57ee6e339'
|
||||
}.to_json
|
||||
allow(bot).to receive(:deliver).and_return(success_response)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.source_id).to eq('mid.1456970487936:c34767dfe57ee6e339')
|
||||
expect(message.status).not_to eq('failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with input_select' do
|
||||
it 'if message with input_select is sent from chatwoot and is outgoing' do
|
||||
message = build(
|
||||
:message,
|
||||
message_type: 'outgoing',
|
||||
inbox: facebook_inbox,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
content_type: 'input_select',
|
||||
content_attributes: { 'items' => [{ 'title' => 'text 1', 'value' => 'value 1' }, { 'title' => 'text 2', 'value' => 'value 2' }] }
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
expect(bot).to have_received(:deliver).with({
|
||||
recipient: { id: contact_inbox.source_id },
|
||||
message: {
|
||||
text: message.content,
|
||||
quick_replies: [
|
||||
{ content_type: 'text', payload: 'text 1', title: 'text 1' },
|
||||
{ content_type: 'text', payload: 'text 2', title: 'text 2' }
|
||||
]
|
||||
},
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: 'ACCOUNT_UPDATE'
|
||||
}, { page_id: facebook_channel.page_id })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,89 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Google::RefreshOauthTokenService do
|
||||
let!(:google_channel) { create(:channel_email, :microsoft_email) }
|
||||
let!(:google_channel_with_expired_token) do
|
||||
create(
|
||||
:channel_email, :microsoft_email, provider_config: {
|
||||
expires_on: Time.zone.now - 3600,
|
||||
access_token: SecureRandom.hex,
|
||||
refresh_token: SecureRandom.hex
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:new_tokens) do
|
||||
{
|
||||
access_token: SecureRandom.hex,
|
||||
refresh_token: SecureRandom.hex,
|
||||
expires_at: (Time.zone.now + 3600).to_i,
|
||||
token_type: 'bearer'
|
||||
}
|
||||
end
|
||||
|
||||
context 'when token is not expired' do
|
||||
it 'returns the existing access token' do
|
||||
service = described_class.new(channel: google_channel)
|
||||
|
||||
expect(service.access_token).to eq(google_channel.provider_config['access_token'])
|
||||
expect(google_channel.reload.provider_config['refresh_token']).to eq(google_channel.provider_config['refresh_token'])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on expired token or invalid expiry' do
|
||||
before do
|
||||
stub_request(:post, 'https://oauth2.googleapis.com/token').with(
|
||||
body: { 'grant_type' => 'refresh_token', 'refresh_token' => google_channel_with_expired_token.provider_config['refresh_token'] }
|
||||
).to_return(status: 200, body: new_tokens.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
context 'when token is invalid' do
|
||||
it 'fetches new access token and refresh tokens' do
|
||||
with_modified_env GOOGLE_OAUTH_CLIENT_ID: SecureRandom.uuid, GOOGLE_OAUTH_CLIENT_SECRET: SecureRandom.hex do
|
||||
provider_config = google_channel_with_expired_token.provider_config
|
||||
service = described_class.new(channel: google_channel_with_expired_token)
|
||||
expect(service.access_token).not_to eq(provider_config['access_token'])
|
||||
|
||||
new_provider_config = google_channel_with_expired_token.reload.provider_config
|
||||
expect(new_provider_config['access_token']).to eq(new_tokens[:access_token])
|
||||
expect(new_provider_config['refresh_token']).to eq(new_tokens[:refresh_token])
|
||||
expect(new_provider_config['expires_on']).to eq(Time.at(new_tokens[:expires_at]).utc.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when expiry time is missing' do
|
||||
it 'fetches new access token and refresh tokens' do
|
||||
with_modified_env GOOGLE_OAUTH_CLIENT_ID: SecureRandom.uuid, GOOGLE_OAUTH_CLIENT_SECRET: SecureRandom.hex do
|
||||
google_channel_with_expired_token.provider_config['expires_on'] = nil
|
||||
google_channel_with_expired_token.save!
|
||||
provider_config = google_channel_with_expired_token.provider_config
|
||||
service = described_class.new(channel: google_channel_with_expired_token)
|
||||
expect(service.access_token).not_to eq(provider_config['access_token'])
|
||||
|
||||
new_provider_config = google_channel_with_expired_token.reload.provider_config
|
||||
expect(new_provider_config['access_token']).to eq(new_tokens[:access_token])
|
||||
expect(new_provider_config['refresh_token']).to eq(new_tokens[:refresh_token])
|
||||
expect(new_provider_config['expires_on']).to eq(Time.at(new_tokens[:expires_at]).utc.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when refresh token is not present in provider config and access token is expired' do
|
||||
it 'throws an error' do
|
||||
with_modified_env GOOGLE_OAUTH_CLIENT_ID: SecureRandom.uuid, GOOGLE_OAUTH_CLIENT_SECRET: SecureRandom.hex do
|
||||
google_channel.update(
|
||||
provider_config: {
|
||||
access_token: SecureRandom.hex,
|
||||
expires_on: Time.zone.now - 3600
|
||||
}
|
||||
)
|
||||
|
||||
expect do
|
||||
described_class.new(channel: google_channel).access_token
|
||||
end.to raise_error(RuntimeError, 'A refresh_token is not available')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Imap::FetchEmailService do
|
||||
include ActionMailbox::TestHelper
|
||||
let(:logger) { instance_double(ActiveSupport::Logger, info: true, error: true) }
|
||||
let(:account) { create(:account) }
|
||||
let(:imap_email_channel) { create(:channel_email, :imap_email, account: account) }
|
||||
let(:imap) { instance_double(Net::IMAP) }
|
||||
let(:eml_content_with_message_id) { Rails.root.join('spec/fixtures/files/only_text.eml').read }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(Rails).to receive(:logger).and_return(logger)
|
||||
allow(Net::IMAP).to receive(:new).with(
|
||||
imap_email_channel.imap_address, port: imap_email_channel.imap_port, ssl: true
|
||||
).and_return(imap)
|
||||
allow(imap).to receive(:authenticate).with(
|
||||
'PLAIN', imap_email_channel.imap_login, imap_email_channel.imap_password
|
||||
)
|
||||
allow(imap).to receive(:select).with('INBOX')
|
||||
end
|
||||
|
||||
context 'when new emails are available in the mailbox' do
|
||||
it 'fetches the emails and returns the emails that are not present in the db' do
|
||||
travel_to '26.10.2020 10:00'.to_datetime do
|
||||
email_object = create_inbound_email_from_fixture('only_text.eml')
|
||||
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
|
||||
imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id)
|
||||
|
||||
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1])
|
||||
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
|
||||
allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail])
|
||||
allow(imap).to receive(:logout)
|
||||
|
||||
result = described_class.new(channel: imap_email_channel).perform
|
||||
|
||||
expect(result.length).to eq 1
|
||||
expect(result[0].message_id).to eq email_object.message_id
|
||||
expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020])
|
||||
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
|
||||
expect(imap).to have_received(:fetch).with(1, 'RFC822')
|
||||
expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{imap_email_channel.email}, found 1.")
|
||||
expect(imap).to have_received(:logout)
|
||||
end
|
||||
end
|
||||
|
||||
it 'fetches the emails and returns the mail objects that are not present in the db' do
|
||||
travel_to '26.10.2020 10:00'.to_datetime do
|
||||
email_object = create_inbound_email_from_fixture('only_text.eml')
|
||||
create(:message, source_id: email_object.message_id, account: account, inbox: imap_email_channel.inbox)
|
||||
|
||||
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
|
||||
|
||||
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1])
|
||||
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
|
||||
allow(imap).to receive(:logout)
|
||||
|
||||
result = described_class.new(channel: imap_email_channel).perform
|
||||
|
||||
expect(result.length).to eq 0
|
||||
expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020])
|
||||
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
|
||||
expect(imap).not_to have_received(:fetch).with(1, 'RFC822')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Imap::MicrosoftFetchEmailService do
|
||||
include ActionMailbox::TestHelper
|
||||
let(:logger) { instance_double(ActiveSupport::Logger, info: true, error: true) }
|
||||
let(:account) { create(:account) }
|
||||
let(:microsoft_channel) { create(:channel_email, :microsoft_email, account: account) }
|
||||
let(:imap) { instance_double(Net::IMAP) }
|
||||
let(:refresh_token_service) { double }
|
||||
let(:eml_content_with_message_id) { Rails.root.join('spec/fixtures/files/only_text.eml').read }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(Rails).to receive(:logger).and_return(logger)
|
||||
|
||||
allow(Net::IMAP).to receive(:new).with(
|
||||
microsoft_channel.imap_address, port: microsoft_channel.imap_port, ssl: true
|
||||
).and_return(imap)
|
||||
allow(imap).to receive(:authenticate).with(
|
||||
'XOAUTH2', microsoft_channel.imap_login, microsoft_channel.provider_config['access_token']
|
||||
)
|
||||
allow(imap).to receive(:select).with('INBOX')
|
||||
|
||||
allow(Microsoft::RefreshOauthTokenService).to receive(:new).and_return(refresh_token_service)
|
||||
allow(refresh_token_service).to receive(:access_token).and_return(microsoft_channel.provider_config['access_token'])
|
||||
end
|
||||
|
||||
context 'when new emails are available in the mailbox' do
|
||||
it 'fetches the emails and returns the emails that are not present in the db' do
|
||||
travel_to '26.10.2020 10:00'.to_datetime do
|
||||
email_object = create_inbound_email_from_fixture('only_text.eml')
|
||||
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
|
||||
imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id)
|
||||
|
||||
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1])
|
||||
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
|
||||
allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail])
|
||||
allow(imap).to receive(:logout)
|
||||
|
||||
result = described_class.new(channel: microsoft_channel).perform
|
||||
|
||||
expect(refresh_token_service).to have_received(:access_token)
|
||||
|
||||
expect(result.length).to eq 1
|
||||
expect(result[0].message_id).to eq email_object.message_id
|
||||
expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020])
|
||||
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
|
||||
expect(imap).to have_received(:fetch).with(1, 'RFC822')
|
||||
expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the interval is passed during an IMAP Sync' do
|
||||
it 'fetches the emails based on the interval specified in the job' do
|
||||
travel_to '26.10.2020 10:00'.to_datetime do
|
||||
email_object = create_inbound_email_from_fixture('only_text.eml')
|
||||
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
|
||||
imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id)
|
||||
|
||||
allow(imap).to receive(:search).with(%w[SINCE 18-Oct-2020]).and_return([1])
|
||||
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
|
||||
allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail])
|
||||
allow(imap).to receive(:logout)
|
||||
|
||||
result = described_class.new(channel: microsoft_channel, interval: 8).perform
|
||||
|
||||
expect(refresh_token_service).to have_received(:access_token)
|
||||
|
||||
expect(result.length).to eq 1
|
||||
expect(result[0].message_id).to eq email_object.message_id
|
||||
expect(imap).to have_received(:search).with(%w[SINCE 18-Oct-2020])
|
||||
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
|
||||
expect(imap).to have_received(:fetch).with(1, 'RFC822')
|
||||
expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,184 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Instagram::Messenger::SendOnInstagramService do
|
||||
subject(:send_reply_service) { described_class.new(message: message) }
|
||||
|
||||
before do
|
||||
stub_request(:post, /graph\.facebook\.com/)
|
||||
create(:message, message_type: :incoming, inbox: instagram_messenger_inbox, account: account, conversation: conversation)
|
||||
end
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
|
||||
let!(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_messenger_inbox) }
|
||||
let(:conversation) { create(:conversation, contact: contact, inbox: instagram_messenger_inbox, contact_inbox: contact_inbox) }
|
||||
let(:response) { double }
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
HTTParty::Response,
|
||||
:success? => true,
|
||||
:body => { message_id: 'anyrandommessageid1234567890' }.to_json,
|
||||
:parsed_response => { 'message_id' => 'anyrandommessageid1234567890' }
|
||||
)
|
||||
end
|
||||
|
||||
let(:error_body) do
|
||||
{
|
||||
'error' => {
|
||||
'message' => 'The Instagram account is restricted.',
|
||||
'type' => 'OAuthException',
|
||||
'code' => 400,
|
||||
'fbtrace_id' => 'anyrandomfbtraceid1234567890'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
let(:error_response) do
|
||||
instance_double(
|
||||
HTTParty::Response,
|
||||
:success? => false,
|
||||
:body => error_body.to_json,
|
||||
:parsed_response => error_body
|
||||
)
|
||||
end
|
||||
|
||||
let(:response_with_error) do
|
||||
instance_double(
|
||||
HTTParty::Response,
|
||||
:success? => true,
|
||||
:body => error_body.to_json,
|
||||
:parsed_response => error_body
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'with reply' do
|
||||
before do
|
||||
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
|
||||
allow(HTTParty).to receive(:post).and_return(mock_response)
|
||||
end
|
||||
|
||||
context 'without message_tag HUMAN_AGENT' do
|
||||
before do
|
||||
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: false)
|
||||
end
|
||||
|
||||
it 'if message is sent from chatwoot and is outgoing' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
|
||||
|
||||
response = described_class.new(message: message).perform
|
||||
expect(response['message_id']).to eq('anyrandommessageid1234567890')
|
||||
end
|
||||
|
||||
it 'if message is sent from chatwoot and is outgoing with multiple attachments' do
|
||||
message = build(
|
||||
:message,
|
||||
content: nil,
|
||||
message_type: 'outgoing',
|
||||
inbox: instagram_messenger_inbox,
|
||||
account: account,
|
||||
conversation: conversation
|
||||
)
|
||||
avatar = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
avatar.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
sample = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
sample.file.attach(io: Rails.root.join('spec/assets/sample.png').open, filename: 'sample.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
service = described_class.new(message: message)
|
||||
|
||||
# Stub the send_message method on the service instance
|
||||
allow(service).to receive(:send_message)
|
||||
service.perform
|
||||
|
||||
# Now you can set expectations on the stubbed method for each attachment
|
||||
expect(service).to have_received(:send_message).exactly(:twice)
|
||||
end
|
||||
|
||||
it 'if message with attachment is sent from chatwoot and is outgoing' do
|
||||
message = build(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
response = described_class.new(message: message).perform
|
||||
|
||||
expect(response['message_id']).to eq('anyrandommessageid1234567890')
|
||||
end
|
||||
|
||||
it 'if message sent from chatwoot is failed' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
|
||||
|
||||
allow(HTTParty).to receive(:post).and_return(response_with_error)
|
||||
described_class.new(message: message).perform
|
||||
expect(HTTParty).to have_received(:post)
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with message_tag HUMAN_AGENT' do
|
||||
before do
|
||||
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: true)
|
||||
end
|
||||
|
||||
it 'if message is sent from chatwoot and is outgoing' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
|
||||
|
||||
allow(HTTParty).to receive(:post).with(
|
||||
{
|
||||
recipient: { id: contact.get_source_id(instagram_messenger_inbox.id) },
|
||||
message: {
|
||||
text: message.content
|
||||
},
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: 'HUMAN_AGENT'
|
||||
}
|
||||
).and_return(
|
||||
{
|
||||
'message_id': 'anyrandommessageid1234567890'
|
||||
}
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
expect(HTTParty).to have_received(:post)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling errors' do
|
||||
before do
|
||||
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
|
||||
end
|
||||
|
||||
it 'handles HTTP errors' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
|
||||
allow(HTTParty).to receive(:post).and_return(error_response)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
|
||||
end
|
||||
|
||||
it 'handles response errors' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
|
||||
|
||||
error_response = instance_double(
|
||||
HTTParty::Response,
|
||||
success?: true,
|
||||
body: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }.to_json,
|
||||
parsed_response: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }
|
||||
)
|
||||
|
||||
allow(HTTParty).to receive(:post).and_return(error_response)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq('100 - Invalid message format')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Instagram::ReadStatusService do
|
||||
before do
|
||||
create(:message, message_type: :incoming, inbox: instagram_inbox, account: account, conversation: conversation,
|
||||
source_id: 'chatwoot-app-user-id-1')
|
||||
end
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
|
||||
let!(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_inbox) }
|
||||
let(:conversation) { create(:conversation, contact: contact, inbox: instagram_inbox, contact_inbox: contact_inbox) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when messaging_seen callback is fired' do
|
||||
let(:message) { conversation.messages.last }
|
||||
|
||||
before do
|
||||
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
it 'enqueues the UpdateMessageStatusJob with correct parameters if the message is found' do
|
||||
params = {
|
||||
recipient: {
|
||||
id: 'chatwoot-app-user-id-1'
|
||||
},
|
||||
read: {
|
||||
mid: message.source_id
|
||||
}
|
||||
}
|
||||
described_class.new(params: params, channel: instagram_channel).perform
|
||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, message.created_at)
|
||||
end
|
||||
|
||||
it 'does not enqueue the UpdateMessageStatusJob if the message is not found' do
|
||||
params = {
|
||||
recipient: {
|
||||
id: 'chatwoot-app-user-id-1'
|
||||
},
|
||||
read: {
|
||||
mid: 'random-message-id'
|
||||
}
|
||||
}
|
||||
described_class.new(params: params, channel: instagram_channel).perform
|
||||
expect(Conversations::UpdateMessageStatusJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Instagram::RefreshOauthTokenService do
|
||||
let(:account) { create(:account) }
|
||||
let(:refresh_response) do
|
||||
{
|
||||
'access_token' => 'new_refreshed_token',
|
||||
'expires_in' => 5_184_000 # 60 days in seconds
|
||||
}
|
||||
end
|
||||
let(:fixed_token) { 'c061d0c51973a8fcab2ecec86f6aa41718414a10070967a5e9a58f49bf8a798e' }
|
||||
let(:instagram_channel) do
|
||||
create(:channel_instagram,
|
||||
account: account,
|
||||
access_token: fixed_token,
|
||||
expires_at: 20.days.from_now) # Set default expiry
|
||||
end
|
||||
let(:service) { described_class.new(channel: instagram_channel) }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://graph.instagram.com/refresh_access_token')
|
||||
.with(
|
||||
query: {
|
||||
'access_token' => fixed_token,
|
||||
'grant_type' => 'ig_refresh_token'
|
||||
},
|
||||
headers: {
|
||||
'Accept' => 'application/json',
|
||||
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
|
||||
'User-Agent' => 'Ruby'
|
||||
}
|
||||
)
|
||||
.to_return(status: 200, body: refresh_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
describe '#access_token' do
|
||||
context 'when token is valid and not eligible for refresh' do
|
||||
before do
|
||||
instagram_channel.update!(
|
||||
updated_at: 12.hours.ago # Less than 24 hours old
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns existing token without refresh' do
|
||||
expect(service).not_to receive(:refresh_long_lived_token)
|
||||
expect(service.access_token).to eq(fixed_token)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token is eligible for refresh' do
|
||||
before do
|
||||
instagram_channel.update!(
|
||||
expires_at: 5.days.from_now, # Within 10 days window
|
||||
updated_at: 25.hours.ago # More than 24 hours old
|
||||
)
|
||||
end
|
||||
|
||||
it 'refreshes the token and updates channel' do
|
||||
expect(service.access_token).to eq('new_refreshed_token')
|
||||
instagram_channel.reload
|
||||
expect(instagram_channel.access_token).to eq('new_refreshed_token')
|
||||
expect(instagram_channel.expires_at).to be_within(1.second).of(5_184_000.seconds.from_now)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#token_valid?' do
|
||||
# For the expires_at null test, we need to modify the validation or use a different approach
|
||||
context 'when expires_at is blank' do
|
||||
it 'returns false' do
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(nil)
|
||||
expect(service.send(:token_valid?)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token is expired' do
|
||||
it 'returns false' do
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(1.hour.ago)
|
||||
expect(service.send(:token_valid?)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token is valid' do
|
||||
it 'returns true' do
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(1.day.from_now)
|
||||
expect(service.send(:token_valid?)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#token_eligible_for_refresh?' do
|
||||
context 'when token is too new' do
|
||||
before do
|
||||
allow(instagram_channel).to receive(:updated_at).and_return(12.hours.ago)
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(5.days.from_now)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:token_eligible_for_refresh?)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token is not approaching expiry' do
|
||||
before do
|
||||
allow(instagram_channel).to receive(:updated_at).and_return(25.hours.ago)
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(20.days.from_now)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:token_eligible_for_refresh?)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token is expired' do
|
||||
before do
|
||||
allow(instagram_channel).to receive(:updated_at).and_return(25.hours.ago)
|
||||
allow(instagram_channel).to receive(:expires_at).and_return(1.hour.ago)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:token_eligible_for_refresh?)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,188 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Instagram::SendOnInstagramService do
|
||||
subject(:send_reply_service) { described_class.new(message: message) }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'instagram-message-id-123') }
|
||||
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
|
||||
|
||||
let!(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_inbox) }
|
||||
let(:conversation) { create(:conversation, contact: contact, inbox: instagram_inbox, contact_inbox: contact_inbox) }
|
||||
let(:response) { double }
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
HTTParty::Response,
|
||||
:success? => true,
|
||||
:body => { message_id: 'random_message_id' }.to_json,
|
||||
:parsed_response => { 'message_id' => 'random_message_id' }
|
||||
)
|
||||
end
|
||||
|
||||
let(:error_body) do
|
||||
{
|
||||
'error' => {
|
||||
'message' => 'The Instagram account is restricted.',
|
||||
'type' => 'OAuthException',
|
||||
'code' => 400,
|
||||
'fbtrace_id' => 'anyrandomfbtraceid1234567890'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
let(:error_response) do
|
||||
instance_double(
|
||||
HTTParty::Response,
|
||||
:success? => false,
|
||||
:body => error_body.to_json,
|
||||
:parsed_response => error_body
|
||||
)
|
||||
end
|
||||
|
||||
let(:response_with_error) do
|
||||
instance_double(
|
||||
HTTParty::Response,
|
||||
:success? => true,
|
||||
:body => error_body.to_json,
|
||||
:parsed_response => error_body
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'with reply' do
|
||||
before do
|
||||
allow(HTTParty).to receive(:post).and_return(mock_response)
|
||||
end
|
||||
|
||||
context 'without message_tag HUMAN_AGENT' do
|
||||
before do
|
||||
InstallationConfig.where(name: 'ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT').first_or_create(value: false)
|
||||
end
|
||||
|
||||
it 'if message is sent from chatwoot and is outgoing' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
||||
|
||||
response = described_class.new(message: message).perform
|
||||
expect(response['message_id']).to eq('random_message_id')
|
||||
end
|
||||
|
||||
it 'if message is sent from chatwoot and is outgoing with multiple attachments' do
|
||||
message = build(:message, content: nil, message_type: 'outgoing', inbox: instagram_inbox, account: account,
|
||||
conversation: conversation)
|
||||
avatar = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
avatar.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
sample = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
sample.file.attach(io: Rails.root.join('spec/assets/sample.png').open, filename: 'sample.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
service = described_class.new(message: message)
|
||||
|
||||
# Stub the send_message method on the service instance
|
||||
allow(service).to receive(:send_message)
|
||||
service.perform
|
||||
|
||||
# Now you can set expectations on the stubbed method for each attachment
|
||||
expect(service).to have_received(:send_message).exactly(:twice)
|
||||
end
|
||||
|
||||
it 'if message with attachment is sent from chatwoot and is outgoing' do
|
||||
message = build(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
response = described_class.new(message: message).perform
|
||||
|
||||
expect(response['message_id']).to eq('random_message_id')
|
||||
end
|
||||
|
||||
it 'if message sent from chatwoot is failed' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
||||
|
||||
allow(HTTParty).to receive(:post).and_return(response_with_error)
|
||||
described_class.new(message: message).perform
|
||||
expect(HTTParty).to have_received(:post)
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with message_tag HUMAN_AGENT' do
|
||||
before do
|
||||
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: true)
|
||||
end
|
||||
|
||||
it 'if message is sent from chatwoot and is outgoing' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
||||
|
||||
allow(HTTParty).to receive(:post).with(
|
||||
{
|
||||
recipient: { id: contact.get_source_id(instagram_inbox.id) },
|
||||
message: {
|
||||
text: message.content
|
||||
},
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: 'HUMAN_AGENT'
|
||||
}
|
||||
).and_return(
|
||||
{
|
||||
'message_id': 'random_message_id'
|
||||
}
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
expect(HTTParty).to have_received(:post)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling errors' do
|
||||
it 'handles HTTP errors' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
||||
allow(HTTParty).to receive(:post).and_return(error_response)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
|
||||
end
|
||||
|
||||
it 'handles response errors' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
||||
|
||||
error_response = instance_double(
|
||||
HTTParty::Response,
|
||||
success?: true,
|
||||
body: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }.to_json,
|
||||
parsed_response: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }
|
||||
)
|
||||
|
||||
allow(HTTParty).to receive(:post).and_return(error_response)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq('100 - Invalid message format')
|
||||
end
|
||||
|
||||
it 'handles reauthorization errors if access token is expired' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
|
||||
|
||||
error_response = instance_double(
|
||||
HTTParty::Response,
|
||||
success?: false,
|
||||
body: { 'error' => { 'message' => 'Access token has expired', 'code' => 190 } }.to_json,
|
||||
parsed_response: { 'error' => { 'message' => 'Access token has expired', 'code' => 190 } }
|
||||
)
|
||||
|
||||
allow(HTTParty).to receive(:post).and_return(error_response)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq('190 - Access token has expired')
|
||||
expect(instagram_channel.reload).to be_reauthorization_required
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Instagram::TestEventService do
|
||||
let(:account) { create(:account) }
|
||||
let(:instagram_channel) { create(:channel_instagram, account: account) }
|
||||
let(:inbox) { create(:inbox, channel: instagram_channel, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when validating test webhook event' do
|
||||
let(:test_messaging) do
|
||||
{
|
||||
'sender': {
|
||||
'id': '12334'
|
||||
},
|
||||
'recipient': {
|
||||
'id': '23245'
|
||||
},
|
||||
'timestamp': '1527459824',
|
||||
'message': {
|
||||
'mid': 'random_mid',
|
||||
'text': 'random_text'
|
||||
}
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'creates test message for valid test webhook event' do
|
||||
# Ensure inbox exists before test
|
||||
inbox
|
||||
|
||||
service = described_class.new(test_messaging)
|
||||
|
||||
expect { service.perform }.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to eq('random_text')
|
||||
expect(message.source_id).to eq('random_mid')
|
||||
expect(message.message_type).to eq('incoming')
|
||||
end
|
||||
|
||||
it 'creates a contact with sender_username' do
|
||||
# Ensure inbox exists before test
|
||||
inbox
|
||||
|
||||
service = described_class.new(test_messaging)
|
||||
service.perform
|
||||
|
||||
contact = Contact.last
|
||||
expect(contact.name).to eq('sender_username')
|
||||
end
|
||||
|
||||
it 'returns false for non-test webhook events' do
|
||||
invalid_messaging = test_messaging.deep_dup
|
||||
invalid_messaging[:sender][:id] = 'different_id'
|
||||
|
||||
service = described_class.new(invalid_messaging)
|
||||
|
||||
expect(service.perform).to be(false)
|
||||
end
|
||||
|
||||
it 'returns nil when no Instagram channel exists' do
|
||||
# Delete all inboxes and channels
|
||||
Inbox.destroy_all
|
||||
Channel::Instagram.destroy_all
|
||||
|
||||
service = described_class.new(test_messaging)
|
||||
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
# spec/services/remove_stale_contact_inboxes_service_spec.rb
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Internal::RemoveStaleContactInboxesService do
|
||||
describe '#perform' do
|
||||
it 'does not delete stale contact inboxes if REMOVE_STALE_CONTACT_INBOX_JOB_STATUS is false' do
|
||||
# default value of REMOVE_STALE_CONTACT_INBOX_JOB_STATUS is false
|
||||
create(:contact_inbox, created_at: 3.days.ago)
|
||||
create(:contact_inbox, created_at: 91.days.ago)
|
||||
create(:contact_inbox, created_at: 92.days.ago)
|
||||
create(:contact_inbox, created_at: 93.days.ago)
|
||||
create(:contact_inbox, created_at: 94.days.ago)
|
||||
|
||||
service = described_class.new
|
||||
expect { service.perform }.not_to change(ContactInbox, :count)
|
||||
end
|
||||
|
||||
it 'deletes stale contact inboxes' do
|
||||
with_modified_env REMOVE_STALE_CONTACT_INBOX_JOB_STATUS: 'true' do
|
||||
create(:contact_inbox, created_at: 3.days.ago)
|
||||
create(:contact_inbox, created_at: 91.days.ago)
|
||||
create(:contact_inbox, created_at: 92.days.ago)
|
||||
create(:contact_inbox, created_at: 93.days.ago)
|
||||
create(:contact_inbox, created_at: 94.days.ago)
|
||||
|
||||
service = described_class.new
|
||||
expect { service.perform }.to change(ContactInbox, :count).by(-4)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Internal::RemoveStaleContactsService do
|
||||
describe '#perform' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'does not delete contacts with conversations' do
|
||||
# Contact with NULL values and conversation
|
||||
contact1 = create(:contact, account: account, email: nil, phone_number: nil, identifier: nil, created_at: 31.days.ago)
|
||||
create(:conversation, contact: contact1)
|
||||
|
||||
# Contact with empty strings and conversation
|
||||
contact2 = create(:contact, account: account, email: '', phone_number: '', identifier: '', created_at: 31.days.ago)
|
||||
create(:conversation, contact: contact2)
|
||||
|
||||
service = described_class.new(account: account)
|
||||
expect { service.perform }.not_to change(Contact, :count)
|
||||
end
|
||||
|
||||
it 'does not delete contacts with identification' do
|
||||
create(:contact, :with_email, account: account, phone_number: '', identifier: nil, created_at: 31.days.ago)
|
||||
create(:contact, :with_phone_number, account: account, email: nil, identifier: '', created_at: 31.days.ago)
|
||||
create(:contact, account: account, identifier: 'test123', created_at: 31.days.ago)
|
||||
|
||||
create(:contact, :with_email, account: account, phone_number: '', identifier: nil, created_at: 31.days.ago)
|
||||
create(:contact, :with_phone_number, account: account, email: nil, identifier: nil, created_at: 31.days.ago)
|
||||
create(:contact, account: account, email: '', phone_number: nil, identifier: 'test1234', created_at: 31.days.ago)
|
||||
|
||||
service = described_class.new(account: account)
|
||||
expect { service.perform }.not_to change(Contact, :count)
|
||||
end
|
||||
|
||||
it 'deletes stale contacts' do
|
||||
create(:contact, account: account, created_at: 31.days.ago)
|
||||
create(:contact, account: account, created_at: 1.day.ago)
|
||||
|
||||
service = described_class.new(account: account)
|
||||
expect { service.perform }.to change(Contact, :count).by(-1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Internal::RemoveStaleRedisKeysService, type: :service do
|
||||
let(:account_id) { 1 }
|
||||
let(:service) { described_class.new(account_id: account_id) }
|
||||
|
||||
describe '#perform' do
|
||||
it 'removes stale Redis keys for the specified account' do
|
||||
presence_key = OnlineStatusTracker.presence_key(account_id, 'Contact')
|
||||
|
||||
# Mock Redis calls
|
||||
expect(Redis::Alfred).to receive(:zremrangebyscore)
|
||||
.with(presence_key, '-inf', anything)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Labels::UpdateService do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:label) { create(:label, account: account) }
|
||||
let(:contact) { conversation.contact }
|
||||
|
||||
before do
|
||||
conversation.label_list.add(label.title)
|
||||
conversation.save!
|
||||
|
||||
contact.label_list.add(label.title)
|
||||
contact.save!
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'updates associated conversations/contacts labels' do
|
||||
expect(conversation.label_list).to eq([label.title])
|
||||
expect(contact.label_list).to eq([label.title])
|
||||
|
||||
described_class.new(
|
||||
new_label_title: 'updated-label-title',
|
||||
old_label_title: label.title,
|
||||
account_id: account.id
|
||||
).perform
|
||||
|
||||
expect(conversation.reload.label_list).to eq(['updated-label-title'])
|
||||
expect(contact.reload.label_list).to eq(['updated-label-title'])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,409 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Line::IncomingMessageService do
|
||||
let!(:line_channel) { create(:channel_line) }
|
||||
let(:params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
'events': [
|
||||
{
|
||||
'replyToken': '0f3779fba3b349968c5d07db31eab56f',
|
||||
'type': 'message',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
},
|
||||
'message': {
|
||||
'id': '325708',
|
||||
'type': 'text',
|
||||
'text': 'Hello, world'
|
||||
}
|
||||
},
|
||||
{
|
||||
'replyToken': '8cf9239d56244f4197887e939187e19e',
|
||||
'type': 'follow',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:follow_params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
'events': [
|
||||
{
|
||||
'replyToken': '8cf9239d56244f4197887e939187e19e',
|
||||
'type': 'follow',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:multi_user_params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
'events': [
|
||||
{
|
||||
'replyToken': '0f3779fba3b349968c5d07db31eab56f1',
|
||||
'type': 'message',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
},
|
||||
'message': {
|
||||
'id': '3257081',
|
||||
'type': 'text',
|
||||
'text': 'Hello, world 1'
|
||||
}
|
||||
},
|
||||
{
|
||||
'replyToken': '0f3779fba3b349968c5d07db31eab56f2',
|
||||
'type': 'message',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af49806292'
|
||||
},
|
||||
'message': {
|
||||
'id': '3257082',
|
||||
'type': 'text',
|
||||
'text': 'Hello, world 2'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:image_params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
'events': [
|
||||
{
|
||||
'replyToken': '0f3779fba3b349968c5d07db31eab56f',
|
||||
'type': 'message',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
},
|
||||
'message': {
|
||||
'type': 'image',
|
||||
'id': '354718',
|
||||
'contentProvider': {
|
||||
'type': 'line'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
'replyToken': '8cf9239d56244f4197887e939187e19e',
|
||||
'type': 'follow',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:video_params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
'events': [
|
||||
{
|
||||
'replyToken': '0f3779fba3b349968c5d07db31eab56f',
|
||||
'type': 'message',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
},
|
||||
'message': {
|
||||
'type': 'video',
|
||||
'id': '354718',
|
||||
'contentProvider': {
|
||||
'type': 'line'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
'replyToken': '8cf9239d56244f4197887e939187e19e',
|
||||
'type': 'follow',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:file_params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
'events': [
|
||||
{
|
||||
'replyToken': '0f3779fba3b349968c5d07db31eab56f',
|
||||
'type': 'message',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
},
|
||||
'message': {
|
||||
'type': 'file',
|
||||
'id': '354718',
|
||||
'fileName': 'contacts.csv',
|
||||
'fileSize': 2978
|
||||
}
|
||||
},
|
||||
{
|
||||
'replyToken': '8cf9239d56244f4197887e939187e19e',
|
||||
'type': 'follow',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:sticker_params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
'events': [
|
||||
{
|
||||
'replyToken': '0f3779fba3b349968c5d07db31eab56f',
|
||||
'type': 'message',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
},
|
||||
'message': {
|
||||
'type': 'sticker',
|
||||
'id': '1501597916',
|
||||
'quoteToken': 'q3Plxr4AgKd...',
|
||||
'stickerId': '52002738',
|
||||
'packageId': '11537'
|
||||
}
|
||||
},
|
||||
{
|
||||
'replyToken': '8cf9239d56244f4197887e939187e19e',
|
||||
'type': 'follow',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when non-text message params' do
|
||||
it 'does not create conversations, messages and contacts' do
|
||||
line_bot = double
|
||||
line_user_profile = double
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
|
||||
allow(line_user_profile).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test',
|
||||
'userId': 'U4af4980629',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
described_class.new(inbox: line_channel.inbox, params: follow_params).perform
|
||||
expect(line_channel.inbox.conversations.size).to eq(0)
|
||||
expect(Contact.all.size).to eq(0)
|
||||
expect(line_channel.inbox.messages.size).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid text message params' do
|
||||
let(:line_bot) { double }
|
||||
let(:line_user_profile) { double }
|
||||
|
||||
before do
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
allow(line_bot).to receive(:get_profile).with('U4af4980629').and_return(line_user_profile)
|
||||
allow(line_user_profile).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test',
|
||||
'userId': 'U4af4980629',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
described_class.new(inbox: line_channel.inbox, params: params).perform
|
||||
expect(line_channel.inbox.conversations).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('LINE Test')
|
||||
expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629')
|
||||
expect(line_channel.inbox.messages.first.content).to eq('Hello, world')
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts for multi user' do
|
||||
line_user_profile2 = double
|
||||
allow(line_bot).to receive(:get_profile).with('U4af49806292').and_return(line_user_profile2)
|
||||
allow(line_user_profile2).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test 2',
|
||||
'userId': 'U4af49806292',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
described_class.new(inbox: line_channel.inbox, params: multi_user_params).perform
|
||||
expect(line_channel.inbox.conversations.size).to eq(2)
|
||||
expect(Contact.all.first.name).to eq('LINE Test')
|
||||
expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629')
|
||||
expect(Contact.all.last.name).to eq('LINE Test 2')
|
||||
expect(Contact.all.last.additional_attributes['social_line_user_id']).to eq('U4af49806292')
|
||||
expect(line_channel.inbox.messages.first.content).to eq('Hello, world 1')
|
||||
expect(line_channel.inbox.messages.last.content).to eq('Hello, world 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid sticker message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
line_bot = double
|
||||
line_user_profile = double
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
|
||||
allow(line_user_profile).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test',
|
||||
'userId': 'U4af4980629',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
described_class.new(inbox: line_channel.inbox, params: sticker_params).perform
|
||||
expect(line_channel.inbox.conversations).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('LINE Test')
|
||||
expect(line_channel.inbox.messages.first.content).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid image message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
line_bot = double
|
||||
line_user_profile = double
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||
allow(line_bot).to receive(:get_message_content).and_return(
|
||||
OpenStruct.new({
|
||||
body: Base64.encode64(file.read),
|
||||
content_type: 'image/png'
|
||||
})
|
||||
)
|
||||
allow(line_user_profile).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test',
|
||||
'userId': 'U4af4980629',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
described_class.new(inbox: line_channel.inbox, params: image_params).perform
|
||||
expect(line_channel.inbox.conversations).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('LINE Test')
|
||||
expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629')
|
||||
expect(line_channel.inbox.messages.first.content).to be_nil
|
||||
expect(line_channel.inbox.messages.first.attachments.first.file_type).to eq('image')
|
||||
expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('media-354718.png')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid video message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
line_bot = double
|
||||
line_user_profile = double
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/sample.mp4'), 'video/mp4')
|
||||
allow(line_bot).to receive(:get_message_content).and_return(
|
||||
OpenStruct.new({
|
||||
body: Base64.encode64(file.read),
|
||||
content_type: 'video/mp4'
|
||||
})
|
||||
)
|
||||
allow(line_user_profile).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test',
|
||||
'userId': 'U4af4980629',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
described_class.new(inbox: line_channel.inbox, params: video_params).perform
|
||||
expect(line_channel.inbox.conversations).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('LINE Test')
|
||||
expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629')
|
||||
expect(line_channel.inbox.messages.first.content).to be_nil
|
||||
expect(line_channel.inbox.messages.first.attachments.first.file_type).to eq('video')
|
||||
expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('media-354718.mp4')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid file message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
line_bot = double
|
||||
line_user_profile = double
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/contacts.csv'), 'text/csv')
|
||||
allow(line_bot).to receive(:get_message_content).and_return(
|
||||
OpenStruct.new({
|
||||
body: Base64.encode64(file.read),
|
||||
content_type: 'text/csv'
|
||||
})
|
||||
)
|
||||
allow(line_user_profile).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test',
|
||||
'userId': 'U4af4980629',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
described_class.new(inbox: line_channel.inbox, params: file_params).perform
|
||||
expect(line_channel.inbox.conversations).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('LINE Test')
|
||||
expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629')
|
||||
expect(line_channel.inbox.messages.first.content).to be_nil
|
||||
expect(line_channel.inbox.messages.first.attachments.first.file_type).to eq('file')
|
||||
expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('contacts.csv')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,212 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Line::SendOnLineService do
|
||||
describe '#perform' do
|
||||
let(:line_client) { double }
|
||||
let(:line_channel) { create(:channel_line) }
|
||||
let(:message) do
|
||||
create(:message, message_type: :outgoing, content: 'test',
|
||||
conversation: create(:conversation, inbox: line_channel.inbox))
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_client)
|
||||
end
|
||||
|
||||
context 'when message send' do
|
||||
it 'calls @channel.client.push_message' do
|
||||
allow(line_client).to receive(:push_message)
|
||||
expect(line_client).to receive(:push_message)
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message send fails without details' do
|
||||
let(:error_response) do
|
||||
{
|
||||
'message' => 'The request was invalid'
|
||||
}.to_json
|
||||
end
|
||||
|
||||
before do
|
||||
allow(line_client).to receive(:push_message).and_return(OpenStruct.new(code: '400', body: error_response))
|
||||
end
|
||||
|
||||
it 'updates the message status to failed' do
|
||||
described_class.new(message: message).perform
|
||||
message.reload
|
||||
expect(message.status).to eq('failed')
|
||||
end
|
||||
|
||||
it 'updates the external error without details' do
|
||||
described_class.new(message: message).perform
|
||||
message.reload
|
||||
expect(message.external_error).to eq('The request was invalid')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message send fails with details' do
|
||||
let(:error_response) do
|
||||
{
|
||||
'message' => 'The request was invalid',
|
||||
'details' => [
|
||||
{
|
||||
'property' => 'messages[0].text',
|
||||
'message' => 'May not be empty'
|
||||
}
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
before do
|
||||
allow(line_client).to receive(:push_message).and_return(OpenStruct.new(code: '400', body: error_response))
|
||||
end
|
||||
|
||||
it 'updates the message status to failed' do
|
||||
described_class.new(message: message).perform
|
||||
message.reload
|
||||
expect(message.status).to eq('failed')
|
||||
end
|
||||
|
||||
it 'updates the external error with details' do
|
||||
described_class.new(message: message).perform
|
||||
message.reload
|
||||
expect(message.external_error).to eq('The request was invalid, messages[0].text: May not be empty')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message send succeeds' do
|
||||
let(:success_response) do
|
||||
{
|
||||
'message' => 'ok'
|
||||
}.to_json
|
||||
end
|
||||
|
||||
before do
|
||||
allow(line_client).to receive(:push_message).and_return(OpenStruct.new(code: '200', body: success_response))
|
||||
end
|
||||
|
||||
it 'updates the message status to delivered' do
|
||||
described_class.new(message: message).perform
|
||||
message.reload
|
||||
expect(message.status).to eq('delivered')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with message input_select' do
|
||||
let(:success_response) do
|
||||
{
|
||||
'message' => 'ok'
|
||||
}.to_json
|
||||
end
|
||||
|
||||
let(:expect_message) do
|
||||
{
|
||||
type: 'flex',
|
||||
altText: 'test',
|
||||
contents: {
|
||||
type: 'bubble',
|
||||
body: {
|
||||
type: 'box',
|
||||
layout: 'vertical',
|
||||
contents: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'test',
|
||||
wrap: true
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
style: 'link',
|
||||
height: 'sm',
|
||||
action: {
|
||||
type: 'message',
|
||||
label: 'text 1',
|
||||
text: 'value 1'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
style: 'link',
|
||||
height: 'sm',
|
||||
action: {
|
||||
type: 'message',
|
||||
label: 'text 2',
|
||||
text: 'value 2'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'sends the message with input_select' do
|
||||
message = create(
|
||||
:message, message_type: :outgoing, content: 'test', content_type: 'input_select',
|
||||
content_attributes: { 'items' => [{ 'title' => 'text 1', 'value' => 'value 1' }, { 'title' => 'text 2', 'value' => 'value 2' }] },
|
||||
conversation: create(:conversation, inbox: line_channel.inbox)
|
||||
)
|
||||
|
||||
expect(line_client).to receive(:push_message).with(
|
||||
message.conversation.contact_inbox.source_id,
|
||||
expect_message
|
||||
).and_return(OpenStruct.new(code: '200', body: success_response))
|
||||
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'with message attachments' do
|
||||
it 'sends the message with text and attachments' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png}
|
||||
|
||||
expect(line_client).to receive(:push_message).with(
|
||||
message.conversation.contact_inbox.source_id,
|
||||
[
|
||||
{ type: 'text', text: message.content },
|
||||
{
|
||||
type: 'image',
|
||||
originalContentUrl: match(expected_url_regex),
|
||||
previewImageUrl: match(expected_url_regex)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
|
||||
it 'sends the message with attachments only' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.update!(content: nil)
|
||||
expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png}
|
||||
|
||||
expect(line_client).to receive(:push_message).with(
|
||||
message.conversation.contact_inbox.source_id,
|
||||
[
|
||||
{
|
||||
type: 'image',
|
||||
originalContentUrl: match(expected_url_regex),
|
||||
previewImageUrl: match(expected_url_regex)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
|
||||
it 'sends the message with text only' do
|
||||
message.attachments.destroy_all
|
||||
expect(line_client).to receive(:push_message).with(
|
||||
message.conversation.contact_inbox.source_id,
|
||||
{ type: 'text', text: message.content }
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,174 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Linear::ActivityMessageService, type: :service do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when action_type is issue_created' do
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
conversation: conversation,
|
||||
action_type: :issue_created,
|
||||
issue_data: { id: 'ENG-123' },
|
||||
user: user
|
||||
)
|
||||
end
|
||||
|
||||
it 'enqueues an activity message job' do
|
||||
expect do
|
||||
service.perform
|
||||
end.to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
.with(conversation, {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: "Linear issue ENG-123 was created by #{user.name}"
|
||||
})
|
||||
end
|
||||
|
||||
it 'does not enqueue job when issue data lacks id' do
|
||||
service = described_class.new(
|
||||
conversation: conversation,
|
||||
action_type: :issue_created,
|
||||
issue_data: { title: 'Some issue' },
|
||||
user: user
|
||||
)
|
||||
|
||||
expect do
|
||||
service.perform
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue job when issue_data is empty' do
|
||||
service = described_class.new(
|
||||
conversation: conversation,
|
||||
action_type: :issue_created,
|
||||
issue_data: {},
|
||||
user: user
|
||||
)
|
||||
|
||||
expect do
|
||||
service.perform
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue job when conversation is nil' do
|
||||
service = described_class.new(
|
||||
conversation: nil,
|
||||
action_type: :issue_created,
|
||||
issue_data: { id: 'ENG-123' },
|
||||
user: user
|
||||
)
|
||||
|
||||
expect do
|
||||
service.perform
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue job when user is nil' do
|
||||
service = described_class.new(
|
||||
conversation: conversation,
|
||||
action_type: :issue_created,
|
||||
issue_data: { id: 'ENG-123' },
|
||||
user: nil
|
||||
)
|
||||
|
||||
expect do
|
||||
service.perform
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when action_type is issue_linked' do
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
conversation: conversation,
|
||||
action_type: :issue_linked,
|
||||
issue_data: { id: 'ENG-456' },
|
||||
user: user
|
||||
)
|
||||
end
|
||||
|
||||
it 'enqueues an activity message job' do
|
||||
expect do
|
||||
service.perform
|
||||
end.to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
.with(conversation, {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: "Linear issue ENG-456 was linked by #{user.name}"
|
||||
})
|
||||
end
|
||||
|
||||
it 'does not enqueue job when issue data lacks id' do
|
||||
service = described_class.new(
|
||||
conversation: conversation,
|
||||
action_type: :issue_linked,
|
||||
issue_data: { title: 'Some issue' },
|
||||
user: user
|
||||
)
|
||||
|
||||
expect do
|
||||
service.perform
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when action_type is issue_unlinked' do
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
conversation: conversation,
|
||||
action_type: :issue_unlinked,
|
||||
issue_data: { id: 'ENG-789' },
|
||||
user: user
|
||||
)
|
||||
end
|
||||
|
||||
it 'enqueues an activity message job' do
|
||||
expect do
|
||||
service.perform
|
||||
end.to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
.with(conversation, {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: "Linear issue ENG-789 was unlinked by #{user.name}"
|
||||
})
|
||||
end
|
||||
|
||||
it 'does not enqueue job when issue data lacks id' do
|
||||
service = described_class.new(
|
||||
conversation: conversation,
|
||||
action_type: :issue_unlinked,
|
||||
issue_data: { title: 'Some issue' },
|
||||
user: user
|
||||
)
|
||||
|
||||
expect do
|
||||
service.perform
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when action_type is unknown' do
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
conversation: conversation,
|
||||
action_type: :unknown_action,
|
||||
issue_data: { id: 'ENG-999' },
|
||||
user: user
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not enqueue job for unknown action types' do
|
||||
expect do
|
||||
service.perform
|
||||
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Liquid::CampaignTemplateService do
|
||||
subject(:template_service) { described_class.new(campaign: campaign, contact: contact) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account, name: 'John Doe', phone_number: '+1234567890') }
|
||||
let(:campaign) { create(:campaign, account: account, inbox: inbox, sender: agent, message: message_content) }
|
||||
|
||||
describe '#call' do
|
||||
context 'with liquid template variables' do
|
||||
let(:message_content) { 'Hello {{contact.name}}, this is {{agent.name}} from {{account.name}}' }
|
||||
|
||||
it 'processes liquid template correctly' do
|
||||
result = template_service.call(message_content)
|
||||
agent_drop_name = UserDrop.new(agent).name
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result).to eq("Hello #{contact_drop_name}, this is #{agent_drop_name} from #{account.name}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with code blocks' do
|
||||
let(:message_content) { 'Check this code: `const x = {{contact.name}}`' }
|
||||
|
||||
it 'preserves code blocks without processing liquid' do
|
||||
result = template_service.call(message_content)
|
||||
|
||||
expect(result).to include('`const x = {{contact.name}}`')
|
||||
expect(result).not_to include(contact.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiline code blocks' do
|
||||
let(:message_content) do
|
||||
<<~MESSAGE
|
||||
Here's some code:
|
||||
```
|
||||
function greet() {
|
||||
return "Hello {{contact.name}}";
|
||||
}
|
||||
```
|
||||
MESSAGE
|
||||
end
|
||||
|
||||
it 'preserves multiline code blocks without processing liquid' do
|
||||
result = template_service.call(message_content)
|
||||
|
||||
expect(result).to include('{{contact.name}}')
|
||||
expect(result).not_to include(contact.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with malformed liquid syntax' do
|
||||
let(:message_content) { 'Hello {{contact.name missing closing braces' }
|
||||
|
||||
it 'returns original message when liquid parsing fails' do
|
||||
result = template_service.call(message_content)
|
||||
|
||||
expect(result).to eq(message_content)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid liquid tags' do
|
||||
let(:message_content) { 'Hello {% invalid_tag %} world' }
|
||||
|
||||
it 'returns original message when liquid parsing fails' do
|
||||
result = template_service.call(message_content)
|
||||
|
||||
expect(result).to eq(message_content)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mixed content' do
|
||||
let(:message_content) { 'Hi {{contact.name}}, use this code: `{{agent.name}}` to contact {{agent.name}}' }
|
||||
|
||||
it 'processes liquid outside code blocks but preserves code blocks' do
|
||||
result = template_service.call(message_content)
|
||||
agent_drop_name = UserDrop.new(agent).name
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result).to include("Hi #{contact_drop_name}")
|
||||
expect(result).to include("contact #{agent_drop_name}")
|
||||
expect(result).to include('`{{agent.name}}`')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with all drop types' do
|
||||
let(:message_content) do
|
||||
'Contact: {{contact.name}}, Agent: {{agent.name}}, Inbox: {{inbox.name}}, Account: {{account.name}}'
|
||||
end
|
||||
|
||||
it 'processes all available drops' do
|
||||
result = template_service.call(message_content)
|
||||
agent_drop_name = UserDrop.new(agent).name
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result).to include("Contact: #{contact_drop_name}")
|
||||
expect(result).to include("Agent: #{agent_drop_name}")
|
||||
expect(result).to include("Inbox: #{inbox.name}")
|
||||
expect(result).to include("Account: #{account.name}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe LlmFormatter::ArticleLlmFormatter do
|
||||
let(:account) { create(:account) }
|
||||
let(:portal) { create(:portal, account: account) }
|
||||
let(:category) { create(:category, slug: 'test_category', portal: portal, account: account) }
|
||||
let(:author) { create(:user, account: account) }
|
||||
let(:formatter) { described_class.new(article) }
|
||||
|
||||
describe '#format' do
|
||||
context 'when article has all details' do
|
||||
let(:article) do
|
||||
create(:article,
|
||||
slug: 'test_article',
|
||||
portal: portal, category: category, author: author, views: 100, account: account)
|
||||
end
|
||||
|
||||
it 'formats article details correctly' do
|
||||
expected_output = <<~TEXT
|
||||
Title: #{article.title}
|
||||
ID: #{article.id}
|
||||
Status: #{article.status}
|
||||
Category: #{category.name}
|
||||
Author: #{author.name}
|
||||
Views: #{article.views}
|
||||
Created At: #{article.created_at}
|
||||
Updated At: #{article.updated_at}
|
||||
Content:
|
||||
#{article.content}
|
||||
TEXT
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when article has no category' do
|
||||
let(:article) { create(:article, portal: portal, category: nil, author: author, account: account) }
|
||||
|
||||
it 'shows Uncategorized for category' do
|
||||
expect(formatter.format).to include('Category: Uncategorized')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe LlmFormatter::ContactLlmFormatter do
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account, name: 'John Doe', email: 'john@example.com', phone_number: '+1234567890') }
|
||||
let(:formatter) { described_class.new(contact) }
|
||||
|
||||
describe '#format' do
|
||||
context 'when contact has no notes' do
|
||||
it 'formats contact details correctly' do
|
||||
expected_output = [
|
||||
"Contact ID: ##{contact.id}",
|
||||
'Contact Attributes:',
|
||||
'Name: John Doe',
|
||||
'Email: john@example.com',
|
||||
'Phone: +1234567890',
|
||||
'Location: ',
|
||||
'Country Code: ',
|
||||
'Contact Notes:',
|
||||
'No notes for this contact'
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has notes' do
|
||||
before do
|
||||
create(:note, account: account, contact: contact, content: 'First interaction')
|
||||
create(:note, account: account, contact: contact, content: 'Follow up needed')
|
||||
end
|
||||
|
||||
it 'includes notes in the output' do
|
||||
expected_output = [
|
||||
"Contact ID: ##{contact.id}",
|
||||
'Contact Attributes:',
|
||||
'Name: John Doe',
|
||||
'Email: john@example.com',
|
||||
'Phone: +1234567890',
|
||||
'Location: ',
|
||||
'Country Code: ',
|
||||
'Contact Notes:',
|
||||
' - First interaction',
|
||||
' - Follow up needed'
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has custom attributes' do
|
||||
let!(:custom_attribute) do
|
||||
create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute', attribute_display_name: 'Company')
|
||||
end
|
||||
|
||||
before do
|
||||
contact.update(custom_attributes: { custom_attribute.attribute_key => 'Acme Inc' })
|
||||
end
|
||||
|
||||
it 'includes custom attributes in the output' do
|
||||
expected_output = [
|
||||
"Contact ID: ##{contact.id}",
|
||||
'Contact Attributes:',
|
||||
'Name: John Doe',
|
||||
'Email: john@example.com',
|
||||
'Phone: +1234567890',
|
||||
'Location: ',
|
||||
'Country Code: ',
|
||||
'Company: Acme Inc',
|
||||
'Contact Notes:',
|
||||
'No notes for this contact'
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe LlmFormatter::ConversationLlmFormatter do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:formatter) { described_class.new(conversation) }
|
||||
|
||||
describe '#format' do
|
||||
context 'when conversation has no messages' do
|
||||
it 'returns basic conversation info with no messages' do
|
||||
expected_output = [
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
'No messages in this conversation'
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has messages' do
|
||||
it 'formats messages in chronological order with sender labels' do
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: 'incoming',
|
||||
content: 'Hello, I need help'
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
:bot_message,
|
||||
conversation: conversation,
|
||||
message_type: 'outgoing',
|
||||
content: 'Thanks for reaching out, an agent will reach out to you soon'
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: 'outgoing',
|
||||
content: 'How can I assist you today?'
|
||||
)
|
||||
|
||||
expected_output = [
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
'User: Hello, I need help',
|
||||
'Bot: Thanks for reaching out, an agent will reach out to you soon',
|
||||
'Support Agent: How can I assist you today?',
|
||||
''
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when include_contact_details is true' do
|
||||
it 'includes contact details' do
|
||||
expected_output = [
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
'No messages in this conversation',
|
||||
"Contact Details: #{conversation.contact.to_llm_text}"
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format(include_contact_details: true)).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has custom attributes' do
|
||||
it 'includes formatted custom attributes in the output' do
|
||||
create(
|
||||
:custom_attribute_definition,
|
||||
account: account,
|
||||
attribute_display_name: 'Order ID',
|
||||
attribute_key: 'order_id',
|
||||
attribute_model: :conversation_attribute
|
||||
)
|
||||
|
||||
conversation.update(custom_attributes: { 'order_id' => '12345' })
|
||||
|
||||
expected_output = [
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
'No messages in this conversation',
|
||||
'Conversation Attributes:',
|
||||
'Order ID: 12345'
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
158
research/chatwoot/spec/services/macros/execution_service_spec.rb
Normal file
158
research/chatwoot/spec/services/macros/execution_service_spec.rb
Normal file
@@ -0,0 +1,158 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Macros::ExecutionService, type: :service do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:macro) { create(:macro, account: account) }
|
||||
let(:service) { described_class.new(macro, conversation, user) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: user, inbox: conversation.inbox)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when actions are present' do
|
||||
before do
|
||||
allow(macro).to receive(:actions).and_return([
|
||||
{ action_name: 'assign_agent', action_params: ['self'] },
|
||||
{ action_name: 'add_private_note', action_params: ['Test note'] },
|
||||
{ action_name: 'send_message', action_params: ['Test message'] },
|
||||
{ action_name: 'send_attachment', action_params: [1, 2] },
|
||||
{ action_name: 'send_webhook_event', action_params: ['https://example.com/webhook'] }
|
||||
])
|
||||
end
|
||||
|
||||
it 'executes the actions' do
|
||||
expect(service).to receive(:assign_agent).with(['self']).and_call_original
|
||||
expect(service).to receive(:add_private_note).with(['Test note']).and_call_original
|
||||
expect(service).to receive(:send_message).with(['Test message']).and_call_original
|
||||
expect(service).to receive(:send_attachment).with([1, 2]).and_call_original
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
context 'when an action raises an error' do
|
||||
let(:exception_tracker) { instance_spy(ChatwootExceptionTracker) }
|
||||
|
||||
before do
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
end
|
||||
|
||||
it 'captures the exception' do
|
||||
allow(service).to receive(:assign_agent).and_raise(StandardError.new('Random error'))
|
||||
expect(exception_tracker).to receive(:capture_exception)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#assign_agent' do
|
||||
context 'when agent_ids contains self' do
|
||||
it 'updates the conversation assignee to the current user' do
|
||||
service.send(:assign_agent, ['self'])
|
||||
expect(conversation.reload.assignee).to eq(user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent_ids does not contain self' do
|
||||
let(:other_user) { create(:user, account: account) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: other_user, inbox: conversation.inbox)
|
||||
end
|
||||
|
||||
it 'calls the super method' do
|
||||
service.send(:assign_agent, [other_user.id])
|
||||
expect(conversation.reload.assignee).to eq(other_user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#add_private_note' do
|
||||
context 'when conversation is not a tweet' do
|
||||
it 'creates a new private message' do
|
||||
expect do
|
||||
service.send(:add_private_note, ['Test private note'])
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to eq('Test private note')
|
||||
expect(message.private).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is a tweet' do
|
||||
before { allow(service).to receive(:conversation_a_tweet?).and_return(true) }
|
||||
|
||||
it 'does not create a new message' do
|
||||
expect do
|
||||
service.send(:add_private_note, ['Test private note'])
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#send_message' do
|
||||
context 'when conversation is not a tweet' do
|
||||
it 'creates a new public message' do
|
||||
expect do
|
||||
service.send(:send_message, ['Test message'])
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to eq('Test message')
|
||||
expect(message.private).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is a tweet' do
|
||||
before { allow(service).to receive(:conversation_a_tweet?).and_return(true) }
|
||||
|
||||
it 'does not create a new message' do
|
||||
expect do
|
||||
service.send(:send_message, ['Test message'])
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#send_attachment' do
|
||||
before do
|
||||
macro.files.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
macro.save!
|
||||
end
|
||||
|
||||
context 'when conversation is not a tweet and macro has files attached' do
|
||||
before { allow(service).to receive(:conversation_a_tweet?).and_return(false) }
|
||||
|
||||
it 'creates a new message with attachments' do
|
||||
expect do
|
||||
service.send(:send_attachment, [macro.files.first.blob_id])
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.attachments).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is a tweet or macro has no files attached' do
|
||||
before { allow(service).to receive(:conversation_a_tweet?).and_return(true) }
|
||||
|
||||
it 'does not create a new message' do
|
||||
expect do
|
||||
service.send(:send_attachment, [macro.files.first.blob_id])
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#send_webhook_event' do
|
||||
it 'sends a webhook event' do
|
||||
expect(WebhookJob).to receive(:perform_later)
|
||||
service.send(:send_webhook_event, ['https://example.com/webhook'])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,133 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinder do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when receiver uuid strategy finds conversation' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
finder = described_class.new(mail)
|
||||
expect(finder.find).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'logs which strategy succeeded' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with('Conversation found via receiver_uuid_strategy strategy')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to strategy finds conversation' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
finder = described_class.new(mail)
|
||||
expect(finder.find).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'logs which strategy succeeded' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with('Conversation found via in_reply_to_strategy strategy')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references strategy finds conversation' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
finder = described_class.new(mail)
|
||||
expect(finder.find).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'logs which strategy succeeded' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with('Conversation found via references_strategy strategy')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no strategy finds conversation' do
|
||||
# With NewConversationStrategy in default strategies, this scenario only happens
|
||||
# when using custom strategies that exclude NewConversationStrategy
|
||||
let(:finding_strategies) do
|
||||
[
|
||||
Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy,
|
||||
Mailbox::ConversationFinderStrategies::InReplyToStrategy,
|
||||
Mailbox::ConversationFinderStrategies::ReferencesStrategy
|
||||
]
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
finder = described_class.new(mail, strategies: finding_strategies)
|
||||
expect(finder.find).to be_nil
|
||||
end
|
||||
|
||||
it 'logs that no conversation was found' do
|
||||
allow(Rails.logger).to receive(:error)
|
||||
finder = described_class.new(mail, strategies: finding_strategies)
|
||||
finder.find
|
||||
|
||||
expect(Rails.logger).to have_received(:error).with('No conversation found via any strategy (NewConversationStrategy missing?)')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with custom strategies' do
|
||||
let(:custom_strategy_class) do
|
||||
Class.new(Mailbox::ConversationFinderStrategies::BaseStrategy) do
|
||||
def find
|
||||
# Always return nil for testing
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses provided strategies instead of defaults' do
|
||||
finder = described_class.new(mail, strategies: [custom_strategy_class])
|
||||
expect(finder.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with strategy execution order' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
|
||||
# Set up mail so all strategies could match
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
|
||||
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/456@example.com'
|
||||
end
|
||||
|
||||
it 'returns conversation from first matching strategy' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
finder = described_class.new(mail)
|
||||
result = finder.find
|
||||
|
||||
expect(result).to eq(conversation)
|
||||
# Should only log the first strategy that succeeded (ReceiverUuidStrategy)
|
||||
expect(Rails.logger).to have_received(:info).once.with('Conversation found via receiver_uuid_strategy strategy')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::InReplyToStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when in_reply_to has message-specific pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to has conversation fallback pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
mail.in_reply_to = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to matches message source_id' do
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
source_id: 'original-message-id@example.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Original message'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
message # Create the message
|
||||
mail.in_reply_to = 'original-message-id@example.com'
|
||||
end
|
||||
|
||||
it 'finds conversation from message source_id' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to has multiple values' do
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
source_id: 'message-123@example.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Test message'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
message # Create the message
|
||||
mail.in_reply_to = ['some-other-id@example.com', 'message-123@example.com']
|
||||
end
|
||||
|
||||
it 'finds conversation from any in_reply_to value' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to is blank' do
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to does not match any pattern or source_id' do
|
||||
before do
|
||||
mail.in_reply_to = 'random-message-id@gmail.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when UUID exists but conversation does not' do
|
||||
before do
|
||||
mail.in_reply_to = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with malformed in_reply_to pattern' do
|
||||
before do
|
||||
mail.in_reply_to = 'conversation/not-a-uuid/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,161 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::NewConversationStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
before do
|
||||
mail.to = [email_channel.email]
|
||||
mail.from = 'sender@example.com'
|
||||
mail.subject = 'Test Subject'
|
||||
mail.message_id = '<test@example.com>'
|
||||
end
|
||||
|
||||
describe '#find' do
|
||||
context 'when channel is found' do
|
||||
context 'with new contact' do
|
||||
it 'builds a new conversation with new contact' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
conversation = strategy.find
|
||||
expect(conversation).to be_a(Conversation)
|
||||
expect(conversation.new_record?).to be(true) # Not persisted yet
|
||||
expect(conversation.inbox).to eq(email_channel.inbox)
|
||||
expect(conversation.account).to eq(account)
|
||||
end.to not_change(Conversation, :count) # No conversation created yet
|
||||
.and change(Contact, :count).by(1) # Contact is created
|
||||
.and change(ContactInbox, :count).by(1)
|
||||
end
|
||||
|
||||
it 'sets conversation attributes correctly' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.additional_attributes['source']).to eq('email')
|
||||
expect(conversation.additional_attributes['mail_subject']).to eq('Test Subject')
|
||||
expect(conversation.additional_attributes['initiated_at']).to have_key('timestamp')
|
||||
end
|
||||
|
||||
it 'sets contact attributes correctly' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.contact.email).to eq('sender@example.com')
|
||||
expect(conversation.contact.name).to eq('sender')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with existing contact' do
|
||||
let!(:existing_contact) { create(:contact, email: 'sender@example.com', account: account) }
|
||||
|
||||
before do
|
||||
create(:contact_inbox, contact: existing_contact, inbox: email_channel.inbox)
|
||||
end
|
||||
|
||||
it 'builds conversation with existing contact' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
conversation = strategy.find
|
||||
expect(conversation).to be_a(Conversation)
|
||||
expect(conversation.new_record?).to be(true) # Not persisted yet
|
||||
expect(conversation.contact).to eq(existing_contact)
|
||||
end.to not_change(Conversation, :count) # No conversation created yet
|
||||
.and not_change(Contact, :count)
|
||||
.and not_change(ContactInbox, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has In-Reply-To header' do
|
||||
before do
|
||||
mail['In-Reply-To'] = '<previous-message@example.com>'
|
||||
end
|
||||
|
||||
it 'stores in_reply_to in additional_attributes' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.additional_attributes['in_reply_to']).to eq('<previous-message@example.com>')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail is auto reply' do
|
||||
before do
|
||||
mail['X-Autoreply'] = 'yes'
|
||||
end
|
||||
|
||||
it 'marks conversation as auto_reply' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.additional_attributes['auto_reply']).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when sender has name in From header' do
|
||||
before do
|
||||
mail.from = 'John Doe <john@example.com>'
|
||||
end
|
||||
|
||||
it 'uses sender name from mail' do
|
||||
strategy = described_class.new(mail)
|
||||
conversation = strategy.find
|
||||
|
||||
expect(conversation.contact.name).to eq('John Doe')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is not found' do
|
||||
before do
|
||||
mail.to = ['nonexistent@example.com']
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
result = strategy.find
|
||||
expect(result).to be_nil
|
||||
end.not_to change(Conversation, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact creation fails' do
|
||||
before do
|
||||
builder = instance_double(ContactInboxWithContactBuilder)
|
||||
allow(ContactInboxWithContactBuilder).to receive(:new).and_return(builder)
|
||||
allow(builder).to receive(:perform).and_raise(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
|
||||
it 'rolls back the transaction' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
expect do
|
||||
strategy.find
|
||||
end.to raise_error(ActiveRecord::RecordInvalid)
|
||||
.and not_change(Conversation, :count)
|
||||
.and not_change(Contact, :count)
|
||||
.and not_change(ContactInbox, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation creation fails' do
|
||||
before do
|
||||
# Make conversation build fail with invalid attributes
|
||||
allow(Conversation).to receive(:new).and_return(Conversation.new)
|
||||
end
|
||||
|
||||
it 'returns invalid conversation object' do
|
||||
strategy = described_class.new(mail)
|
||||
|
||||
conversation = strategy.find
|
||||
expect(conversation).to be_a(Conversation)
|
||||
expect(conversation.new_record?).to be(true)
|
||||
expect(conversation.valid?).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when mail has valid reply+uuid format' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has uppercase UUID' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012@EXAMPLE.COM'
|
||||
end
|
||||
|
||||
it 'returns the conversation (case-insensitive matching)' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has multiple recipients with valid UUID' do
|
||||
before do
|
||||
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
mail.to = ['other@example.com', 'reply+aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com']
|
||||
end
|
||||
|
||||
it 'extracts UUID from any recipient' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when UUID does not exist in database' do
|
||||
before do
|
||||
mail.to = 'reply+99999999-9999-9999-9999-999999999999@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has no recipients' do
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail recipient has malformed UUID' do
|
||||
before do
|
||||
mail.to = 'reply+not-a-valid-uuid@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail recipient has no reply+ prefix' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test+12345678-1234-1234-1234-123456789012@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail recipient has additional text after UUID' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'reply+12345678-1234-1234-1234-123456789012-extra@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil (UUID must be exact)' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,208 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Mailbox::ConversationFinderStrategies::ReferencesStrategy do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
|
||||
let(:mail) { Mail.new }
|
||||
|
||||
describe '#find' do
|
||||
context 'when references has message-specific pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references has conversation fallback pattern' do
|
||||
before do
|
||||
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
|
||||
end
|
||||
|
||||
it 'extracts UUID and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references matches message source_id' do
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
source_id: 'original-message-id@example.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Original message'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
message # Create the message
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'original-message-id@example.com'
|
||||
end
|
||||
|
||||
it 'finds conversation from message source_id' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references has multiple values' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = [
|
||||
'some-random-message@gmail.com',
|
||||
'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com',
|
||||
'another-message@outlook.com'
|
||||
]
|
||||
end
|
||||
|
||||
it 'finds conversation from any reference' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references is blank' do
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references does not match any pattern or source_id' do
|
||||
before do
|
||||
mail.references = 'random-message-id@gmail.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with channel validation' do
|
||||
context 'when conversation belongs to the correct channel' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns the conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation belongs to a different channel' do
|
||||
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
|
||||
let(:other_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
inbox: other_email_channel.inbox,
|
||||
account: account
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
# Mail is addressed to test@example.com but references conversation from other@example.com
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil (prevents cross-channel hijacking)' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel cannot be determined from mail' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = 'unknown@example.com' # Email not associated with any channel
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has multiple recipients including correct channel' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
mail.to = ['other@example.com', 'test@example.com']
|
||||
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'finds the correct channel and returns conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when UUID exists but conversation does not' do
|
||||
before do
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with malformed references pattern' do
|
||||
before do
|
||||
mail.references = 'conversation/not-a-uuid/messages/123@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when first reference fails channel validation but second succeeds' do
|
||||
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
|
||||
let(:other_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
inbox: other_email_channel.inbox,
|
||||
account: account
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = [
|
||||
'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com', # Wrong channel
|
||||
'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com' # Correct channel
|
||||
]
|
||||
end
|
||||
|
||||
it 'skips invalid reference and returns conversation from valid reference' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to eq(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,273 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe MessageTemplates::HookExecutionService do
|
||||
context 'when there is no incoming message in conversation' do
|
||||
it 'will not call any hooks' do
|
||||
contact = create(:contact, email: nil)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
# ensure greeting hook is enabled
|
||||
conversation.inbox.update(greeting_enabled: true, enable_email_collect: true)
|
||||
|
||||
email_collect_service = double
|
||||
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
|
||||
allow(email_collect_service).to receive(:perform).and_return(true)
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new)
|
||||
|
||||
# described class gets called in message after commit
|
||||
create(:message, conversation: conversation, message_type: 'activity', content: 'Conversation marked resolved!!')
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Greeting Message' do
|
||||
it 'doesnot calls ::MessageTemplates::Template::Greeting if greeting_message is empty' do
|
||||
contact = create(:contact, email: nil)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
# ensure greeting hook is enabled
|
||||
conversation.inbox.update(greeting_enabled: true, enable_email_collect: true)
|
||||
|
||||
email_collect_service = double
|
||||
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
|
||||
allow(email_collect_service).to receive(:perform).and_return(true)
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
expect(MessageTemplates::Template::EmailCollect).to have_received(:new).with(conversation: message.conversation)
|
||||
expect(email_collect_service).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'will not call ::MessageTemplates::Template::Greeting if its a tweet conversation' do
|
||||
twitter_channel = create(:channel_twitter_profile)
|
||||
twitter_inbox = create(:inbox, channel: twitter_channel)
|
||||
# ensure greeting hook is enabled and greeting_message is present
|
||||
twitter_inbox.update(greeting_enabled: true, greeting_message: 'Hi, this is a greeting message')
|
||||
|
||||
conversation = create(:conversation, inbox: twitter_inbox, additional_attributes: { type: 'tweet' })
|
||||
greeting_service = double
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
|
||||
message = create(:message, conversation: conversation)
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new).with(conversation: message.conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is a first message from web widget' do
|
||||
it 'calls ::MessageTemplates::Template::EmailCollect' do
|
||||
contact = create(:contact, email: nil)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
|
||||
# ensure greeting hook is enabled and greeting_message is present
|
||||
conversation.inbox.update(greeting_enabled: true, enable_email_collect: true, greeting_message: 'Hi, this is a greeting message')
|
||||
|
||||
email_collect_service = double
|
||||
greeting_service = double
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
|
||||
allow(email_collect_service).to receive(:perform).and_return(true)
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).to have_received(:new).with(conversation: message.conversation)
|
||||
expect(greeting_service).to have_received(:perform)
|
||||
expect(MessageTemplates::Template::EmailCollect).to have_received(:new).with(conversation: message.conversation)
|
||||
expect(email_collect_service).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'doesnot calls ::MessageTemplates::Template::EmailCollect on campaign conversations' do
|
||||
contact = create(:contact, email: nil)
|
||||
conversation = create(:conversation, contact: contact, campaign: create(:campaign))
|
||||
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation)
|
||||
end
|
||||
|
||||
it 'doesnot calls ::MessageTemplates::Template::EmailCollect when enable_email_collect form is disabled' do
|
||||
contact = create(:contact, email: nil)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
|
||||
conversation.inbox.update(enable_email_collect: false)
|
||||
# ensure prechat form is enabled
|
||||
conversation.inbox.channel.update(pre_chat_form_enabled: true)
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has a campaign' do
|
||||
let(:campaign) { create(:campaign) }
|
||||
|
||||
it 'does not call ::MessageTemplates::Template::Greeting on campaign conversations' do
|
||||
contact = create(:contact, email: nil)
|
||||
conversation = create(:conversation, contact: contact, campaign: campaign)
|
||||
conversation.inbox.update(greeting_enabled: true, greeting_message: 'Hi, this is a greeting message', enable_email_collect: false)
|
||||
|
||||
greeting_service = double
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'does not call ::MessageTemplates::Template::OutOfOffice on campaign conversations' do
|
||||
contact = create(:contact)
|
||||
conversation = create(:conversation, contact: contact, campaign: campaign)
|
||||
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
|
||||
out_of_office_service = double
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is an auto reply email' do
|
||||
it 'does not call any template hooks' do
|
||||
contact = create(:contact)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
conversation.inbox.update(greeting_enabled: true, enable_email_collect: true, greeting_message: 'Hi, this is a greeting message')
|
||||
|
||||
message = create(:message, conversation: conversation, content_type: :incoming_email)
|
||||
message.content_attributes = { email: { auto_reply: true } }
|
||||
message.save!
|
||||
|
||||
greeting_service = double
|
||||
email_collect_service = double
|
||||
out_of_office_service = double
|
||||
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
|
||||
allow(email_collect_service).to receive(:perform).and_return(true)
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is after working hours' do
|
||||
it 'calls ::MessageTemplates::Template::OutOfOffice' do
|
||||
contact = create(:contact)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
|
||||
out_of_office_service = double
|
||||
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: message.conversation)
|
||||
expect(out_of_office_service).to have_received(:perform)
|
||||
end
|
||||
|
||||
context 'with recent outgoing messages' do
|
||||
it 'does not call ::MessageTemplates::Template::OutOfOffice when there are recent outgoing messages' do
|
||||
contact = create(:contact)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
|
||||
out_of_office_service = double
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
expect(out_of_office_service).not_to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'ignores private note and calls ::MessageTemplates::Template::OutOfOffice' do
|
||||
contact = create(:contact)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
|
||||
create(:message, conversation: conversation, private: true, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
|
||||
out_of_office_service = double
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: conversation)
|
||||
expect(out_of_office_service).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
|
||||
it 'will not calls ::MessageTemplates::Template::OutOfOffice when outgoing message' do
|
||||
contact = create(:contact)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
|
||||
out_of_office_service = double
|
||||
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
# described class gets called in message after commit
|
||||
message = create(:message, conversation: conversation, message_type: 'outgoing')
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation)
|
||||
expect(out_of_office_service).not_to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'will not call ::MessageTemplates::Template::OutOfOffice if its a tweet conversation' do
|
||||
twitter_channel = create(:channel_twitter_profile)
|
||||
twitter_inbox = create(:inbox, channel: twitter_channel)
|
||||
twitter_inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
|
||||
conversation = create(:conversation, inbox: twitter_inbox, additional_attributes: { type: 'tweet' })
|
||||
|
||||
out_of_office_service = double
|
||||
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(false)
|
||||
|
||||
message = create(:message, conversation: conversation)
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation)
|
||||
expect(out_of_office_service).not_to receive(:perform)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe MessageTemplates::Template::CsatSurvey do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:service) { described_class.new(conversation: conversation) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when no survey rules are configured' do
|
||||
it 'creates a CSAT survey message' do
|
||||
inbox.update(csat_config: {})
|
||||
|
||||
service.perform
|
||||
|
||||
expect(conversation.messages.template.count).to eq(1)
|
||||
expect(conversation.messages.template.first.content_type).to eq('input_csat')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when csat config is provided' do
|
||||
let(:csat_config) do
|
||||
{
|
||||
'display_type' => 'star',
|
||||
'message' => 'Please rate your experience'
|
||||
}
|
||||
end
|
||||
|
||||
before { inbox.update(csat_config: csat_config) }
|
||||
|
||||
it 'creates a CSAT message with configured attributes' do
|
||||
service.perform
|
||||
|
||||
message = conversation.messages.template.last
|
||||
expect(message.content_type).to eq('input_csat')
|
||||
expect(message.content).to eq('Please rate your experience')
|
||||
expect(message.content_attributes['display_type']).to eq('star')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe MessageTemplates::Template::EmailCollect do
|
||||
context 'when this hook is called' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
it 'creates the email collect messages' do
|
||||
described_class.new(conversation: conversation).perform
|
||||
expect(conversation.messages.count).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe MessageTemplates::Template::Greeting do
|
||||
context 'when this hook is called' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
it 'creates the email collect messages' do
|
||||
described_class.new(conversation: conversation).perform
|
||||
expect(conversation.messages.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'creates the greeting messages with template variable' do
|
||||
conversation.inbox.update!(greeting_message: 'Hey, {{contact.name}} welcome to our board.')
|
||||
described_class.new(conversation: conversation).perform
|
||||
expect(conversation.messages.count).to eq(1)
|
||||
expect(conversation.messages.last.content).to eq("Hey, #{conversation.contact.name} welcome to our board.")
|
||||
end
|
||||
|
||||
it 'creates the greeting messages with more than one variable strings' do
|
||||
conversation.inbox.update!(greeting_message: 'Hey, {{contact.name}} welcome to our board. - from {{account.name}}')
|
||||
described_class.new(conversation: conversation).perform
|
||||
expect(conversation.messages.count).to eq(1)
|
||||
expect(conversation.messages.last.content).to eq("Hey, #{conversation.contact.name} welcome to our board. - from #{conversation.account.name}")
|
||||
end
|
||||
|
||||
it 'creates the greeting messages' do
|
||||
conversation.inbox.update!(greeting_message: 'Hello welcome to our board.')
|
||||
described_class.new(conversation: conversation).perform
|
||||
expect(conversation.messages.count).to eq(1)
|
||||
expect(conversation.messages.last.content).to eq('Hello welcome to our board.')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe MessageTemplates::Template::OutOfOffice do
|
||||
context 'when this hook is called' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
it 'creates the out of office messages' do
|
||||
described_class.new(conversation: conversation).perform
|
||||
expect(conversation.messages.template.count).to eq(1)
|
||||
expect(conversation.messages.template.first.content).to eq(conversation.inbox.out_of_office_message)
|
||||
end
|
||||
|
||||
it 'creates the out of office messages with template variable' do
|
||||
conversation.inbox.update!(out_of_office_message: 'Hey, {{contact.name}} we are unavailable at the moment.')
|
||||
described_class.new(conversation: conversation).perform
|
||||
expect(conversation.messages.count).to eq(1)
|
||||
expect(conversation.messages.last.content).to eq("Hey, #{conversation.contact.name} we are unavailable at the moment.")
|
||||
end
|
||||
|
||||
it 'creates the out of office messages with more than one variable strings' do
|
||||
conversation.inbox.update!(out_of_office_message:
|
||||
'Hey, {{contact.name}} we are unavailable at the moment. - from {{account.name}}')
|
||||
described_class.new(conversation: conversation).perform
|
||||
expect(conversation.messages.count).to eq(1)
|
||||
expect(conversation.messages.last.content).to eq(
|
||||
"Hey, #{conversation.contact.name} we are unavailable at the moment. - from #{conversation.account.name}"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,574 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Messages::MarkdownRendererService, type: :service do
|
||||
describe '#render' do
|
||||
context 'when content is blank' do
|
||||
it 'returns the content as-is for nil' do
|
||||
result = described_class.new(nil, 'Channel::Whatsapp').render
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns the content as-is for empty string' do
|
||||
result = described_class.new('', 'Channel::Whatsapp').render
|
||||
expect(result).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Whatsapp' do
|
||||
let(:channel_type) { 'Channel::Whatsapp' }
|
||||
|
||||
it 'converts bold from double to single asterisk' do
|
||||
content = '**bold text**'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('*bold text*')
|
||||
end
|
||||
|
||||
it 'keeps italic with underscore' do
|
||||
content = '_italic text_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('_italic text_')
|
||||
end
|
||||
|
||||
it 'keeps code with backticks' do
|
||||
content = '`code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('`code`')
|
||||
end
|
||||
|
||||
it 'converts links to URLs only' do
|
||||
content = '[link text](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('https://example.com')
|
||||
end
|
||||
|
||||
it 'handles combined formatting' do
|
||||
content = '**bold** _italic_ `code` [link](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('*bold* _italic_ `code` https://example.com')
|
||||
end
|
||||
|
||||
it 'handles nested formatting' do
|
||||
content = '**bold _italic_**'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('*bold _italic_*')
|
||||
end
|
||||
|
||||
it 'preserves unordered list with dash markers' do
|
||||
content = "- item 1\n- item 2\n- item 3"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('- item 1')
|
||||
expect(result).to include('- item 2')
|
||||
expect(result).to include('- item 3')
|
||||
end
|
||||
|
||||
it 'converts asterisk unordered lists to dash markers' do
|
||||
content = "* item 1\n* item 2\n* item 3"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('- item 1')
|
||||
expect(result).to include('- item 2')
|
||||
expect(result).to include('- item 3')
|
||||
end
|
||||
|
||||
it 'preserves ordered list markers with numbering' do
|
||||
content = "1. first step\n2. second step\n3. third step"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('1. first step')
|
||||
expect(result).to include('2. second step')
|
||||
expect(result).to include('3. third step')
|
||||
end
|
||||
|
||||
it 'preserves newlines in plain text without list markers' do
|
||||
content = "Line 1\nLine 2\nLine 3"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include("Line 1\nLine 2\nLine 3")
|
||||
expect(result).not_to include('Line 1 Line 2')
|
||||
end
|
||||
|
||||
it 'preserves multiple consecutive newlines for spacing' do
|
||||
content = "Para 1\n\n\n\nPara 2"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.scan("\n").count).to eq(4)
|
||||
expect(result).to include("Para 1\n\n\n\nPara 2")
|
||||
end
|
||||
|
||||
it 'renders code blocks as plain text' do
|
||||
content = "```\ncode here\n```"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('code here')
|
||||
end
|
||||
|
||||
it 'renders indented code blocks as plain text preserving exact content' do
|
||||
content = ' indented code line'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('indented code line')
|
||||
end
|
||||
|
||||
it 'handles code blocks with emojis and special characters without stack overflow' do
|
||||
content = " first line\n 🌐 second line\n"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq("first line\n🌐 second line")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Instagram' do
|
||||
let(:channel_type) { 'Channel::Instagram' }
|
||||
|
||||
it 'converts bold from double to single asterisk' do
|
||||
content = '**bold text**'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('*bold text*')
|
||||
end
|
||||
|
||||
it 'keeps italic with underscore' do
|
||||
content = '_italic text_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('_italic text_')
|
||||
end
|
||||
|
||||
it 'strips code backticks' do
|
||||
content = '`code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('code')
|
||||
end
|
||||
|
||||
it 'converts links to URLs only' do
|
||||
content = '[link text](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('https://example.com')
|
||||
end
|
||||
|
||||
it 'preserves bullet list markers' do
|
||||
content = "- first item\n- second item"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('- first item')
|
||||
expect(result).to include('- second item')
|
||||
end
|
||||
|
||||
it 'preserves ordered list markers with numbering' do
|
||||
content = "1. first step\n2. second step"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('1. first step')
|
||||
expect(result).to include('2. second step')
|
||||
end
|
||||
|
||||
it 'preserves newlines in plain text without list markers' do
|
||||
content = "Line 1\nLine 2\nLine 3"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include("Line 1\nLine 2\nLine 3")
|
||||
expect(result).not_to include('Line 1 Line 2')
|
||||
end
|
||||
|
||||
it 'preserves multiple consecutive newlines for spacing' do
|
||||
content = "Para 1\n\n\n\nPara 2"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.scan("\n").count).to eq(4)
|
||||
expect(result).to include("Para 1\n\n\n\nPara 2")
|
||||
end
|
||||
|
||||
it 'renders code blocks as plain text' do
|
||||
content = "```\ncode here\n```"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('code here')
|
||||
end
|
||||
|
||||
it 'renders indented code blocks as plain text preserving exact content' do
|
||||
content = ' indented code line'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('indented code line')
|
||||
end
|
||||
|
||||
it 'handles code blocks with emojis and special characters without stack overflow' do
|
||||
content = " first line\n 🌐 second line\n"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq("first line\n🌐 second line")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Line' do
|
||||
let(:channel_type) { 'Channel::Line' }
|
||||
|
||||
it 'adds spaces around bold markers' do
|
||||
content = '**bold**'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include(' *bold* ')
|
||||
end
|
||||
|
||||
it 'adds spaces around italic markers' do
|
||||
content = '_italic_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include(' _italic_ ')
|
||||
end
|
||||
|
||||
it 'adds spaces around code markers' do
|
||||
content = '`code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include(' `code` ')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Sms' do
|
||||
let(:channel_type) { 'Channel::Sms' }
|
||||
|
||||
it 'strips all markdown formatting' do
|
||||
content = '**bold** _italic_ `code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('bold italic code')
|
||||
end
|
||||
|
||||
it 'preserves URLs from links in plain text format' do
|
||||
content = '[link text](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('link text https://example.com')
|
||||
end
|
||||
|
||||
it 'preserves URLs in messages with multiple links' do
|
||||
content = 'Visit [our site](https://example.com) or [help center](https://help.example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('Visit our site https://example.com or help center https://help.example.com')
|
||||
end
|
||||
|
||||
it 'preserves link text and URL when both are present' do
|
||||
content = '[Reset password](https://example.com/reset)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('Reset password https://example.com/reset')
|
||||
end
|
||||
|
||||
it 'handles complex markdown' do
|
||||
content = "# Heading\n\n**bold** _italic_ [link](https://example.com)"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('Heading')
|
||||
expect(result).to include('bold')
|
||||
expect(result).to include('italic')
|
||||
expect(result).to include('link https://example.com')
|
||||
expect(result).not_to include('**')
|
||||
expect(result).not_to include('_')
|
||||
expect(result).not_to include('[')
|
||||
end
|
||||
|
||||
it 'preserves bullet list markers' do
|
||||
content = "- first item\n- second item\n- third item"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('- first item')
|
||||
expect(result).to include('- second item')
|
||||
expect(result).to include('- third item')
|
||||
end
|
||||
|
||||
it 'preserves ordered list markers with numbering' do
|
||||
content = "1. first step\n2. second step\n3. third step"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('1. first step')
|
||||
expect(result).to include('2. second step')
|
||||
expect(result).to include('3. third step')
|
||||
end
|
||||
|
||||
it 'preserves newlines in plain text without list markers' do
|
||||
content = "Line 1\nLine 2\nLine 3"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include("Line 1\nLine 2\nLine 3")
|
||||
expect(result).not_to include('Line 1 Line 2')
|
||||
end
|
||||
|
||||
it 'preserves multiple consecutive newlines for spacing' do
|
||||
content = "Para 1\n\n\n\nPara 2"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.scan("\n").count).to eq(4)
|
||||
expect(result).to include("Para 1\n\n\n\nPara 2")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Telegram' do
|
||||
let(:channel_type) { 'Channel::Telegram' }
|
||||
|
||||
it 'converts to HTML format' do
|
||||
content = '**bold** _italic_ `code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<strong>bold</strong>')
|
||||
expect(result).to include('<em>italic</em>')
|
||||
expect(result).to include('<code>code</code>')
|
||||
end
|
||||
|
||||
it 'handles links' do
|
||||
content = '[link text](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<a href="https://example.com">link text</a>')
|
||||
end
|
||||
|
||||
it 'preserves single newlines' do
|
||||
content = "line 1\nline 2"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include("\n")
|
||||
expect(result).to include("line 1\nline 2")
|
||||
end
|
||||
|
||||
it 'preserves double newlines (paragraph breaks)' do
|
||||
content = "para 1\n\npara 2"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.scan("\n").count).to eq(2)
|
||||
expect(result).to include("para 1\n\npara 2")
|
||||
end
|
||||
|
||||
it 'preserves multiple consecutive newlines' do
|
||||
content = "para 1\n\n\n\npara 2"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.scan("\n").count).to eq(4)
|
||||
expect(result).to include("para 1\n\n\n\npara 2")
|
||||
end
|
||||
|
||||
it 'preserves newlines with varying amounts of whitespace between them' do
|
||||
# Test with 1 space, 3 spaces, 5 spaces, and tabs to ensure it handles any amount of whitespace
|
||||
content = "hello\n \n \n \n\t\nworld"
|
||||
result = described_class.new(content, channel_type).render
|
||||
# Whitespace-only lines are normalized, so we should have at least 5 newlines preserved
|
||||
expect(result.scan("\n").count).to be >= 5
|
||||
expect(result).to include('hello')
|
||||
expect(result).to include('world')
|
||||
# Should not collapse to just 1-2 newlines
|
||||
expect(result.scan("\n").count).to be > 3
|
||||
end
|
||||
|
||||
it 'converts strikethrough to HTML' do
|
||||
content = '~~strikethrough text~~'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<del>strikethrough text</del>')
|
||||
end
|
||||
|
||||
it 'converts blockquotes to HTML' do
|
||||
content = '> quoted text'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<blockquote>')
|
||||
expect(result).to include('quoted text')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Email' do
|
||||
let(:channel_type) { 'Channel::Email' }
|
||||
|
||||
it 'renders full HTML' do
|
||||
content = '**bold** _italic_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<strong>bold</strong>')
|
||||
expect(result).to include('<em>italic</em>')
|
||||
end
|
||||
|
||||
it 'renders ordered lists as HTML' do
|
||||
content = "1. first\n2. second"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<ol>')
|
||||
expect(result).to include('<li>first</li>')
|
||||
end
|
||||
|
||||
it 'converts strikethrough to HTML' do
|
||||
content = '~~strikethrough text~~'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<del>strikethrough text</del>')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::WebWidget' do
|
||||
let(:channel_type) { 'Channel::WebWidget' }
|
||||
|
||||
it 'renders full HTML like Email' do
|
||||
content = '**bold** _italic_ `code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<strong>bold</strong>')
|
||||
expect(result).to include('<em>italic</em>')
|
||||
expect(result).to include('<code>code</code>')
|
||||
end
|
||||
|
||||
it 'converts strikethrough to HTML' do
|
||||
content = '~~strikethrough text~~'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<del>strikethrough text</del>')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::FacebookPage' do
|
||||
let(:channel_type) { 'Channel::FacebookPage' }
|
||||
|
||||
it 'converts bold to single asterisk like Instagram' do
|
||||
content = '**bold text**'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('*bold text*')
|
||||
end
|
||||
|
||||
it 'strips unsupported formatting' do
|
||||
content = '`code` ~~strike~~'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('code ~~strike~~')
|
||||
end
|
||||
|
||||
it 'preserves bullet list markers like Instagram' do
|
||||
content = "- first item\n- second item"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('- first item')
|
||||
expect(result).to include('- second item')
|
||||
end
|
||||
|
||||
it 'preserves ordered list markers with numbering like Instagram' do
|
||||
content = "1. first step\n2. second step"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('1. first step')
|
||||
expect(result).to include('2. second step')
|
||||
end
|
||||
|
||||
it 'renders code blocks as plain text' do
|
||||
content = "```\ncode here\n```"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('code here')
|
||||
end
|
||||
|
||||
it 'handles code blocks with emojis and special characters without stack overflow' do
|
||||
content = " first line\n 🌐 second line\n"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq("first line\n🌐 second line")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::TwilioSms' do
|
||||
let(:channel_type) { 'Channel::TwilioSms' }
|
||||
|
||||
it 'strips all markdown like SMS when medium is sms' do
|
||||
content = '**bold** _italic_'
|
||||
channel = instance_double(Channel::TwilioSms, whatsapp?: false)
|
||||
result = described_class.new(content, channel_type, channel).render
|
||||
expect(result.strip).to eq('bold italic')
|
||||
end
|
||||
|
||||
it 'uses WhatsApp renderer when medium is whatsapp' do
|
||||
content = '**bold** _italic_ [link](https://example.com)'
|
||||
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
|
||||
result = described_class.new(content, channel_type, channel).render
|
||||
expect(result.strip).to eq('*bold* _italic_ https://example.com')
|
||||
end
|
||||
|
||||
it 'preserves newlines in Twilio WhatsApp' do
|
||||
content = "Line 1\nLine 2\nLine 3"
|
||||
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
|
||||
result = described_class.new(content, channel_type, channel).render
|
||||
expect(result).to include("Line 1\nLine 2\nLine 3")
|
||||
end
|
||||
|
||||
it 'preserves ordered list markers with numbering in Twilio WhatsApp' do
|
||||
content = "1. first step\n2. second step\n3. third step"
|
||||
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
|
||||
result = described_class.new(content, channel_type, channel).render
|
||||
expect(result).to include('1. first step')
|
||||
expect(result).to include('2. second step')
|
||||
expect(result).to include('3. third step')
|
||||
end
|
||||
|
||||
it 'preserves unordered list markers in Twilio WhatsApp' do
|
||||
content = "- item 1\n- item 2\n- item 3"
|
||||
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
|
||||
result = described_class.new(content, channel_type, channel).render
|
||||
expect(result).to include('- item 1')
|
||||
expect(result).to include('- item 2')
|
||||
expect(result).to include('- item 3')
|
||||
end
|
||||
|
||||
it 'backwards compatible when channel is not provided' do
|
||||
content = '**bold** _italic_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('bold italic')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Api' do
|
||||
let(:channel_type) { 'Channel::Api' }
|
||||
|
||||
it 'preserves markdown as-is' do
|
||||
content = '**bold** _italic_ `code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('**bold** _italic_ `code`')
|
||||
end
|
||||
|
||||
it 'preserves links with markdown syntax' do
|
||||
content = '[Click here](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('[Click here](https://example.com)')
|
||||
end
|
||||
|
||||
it 'preserves lists with markdown syntax' do
|
||||
content = "- Item 1\n- Item 2"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq("- Item 1\n- Item 2")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::TwitterProfile' do
|
||||
let(:channel_type) { 'Channel::TwitterProfile' }
|
||||
|
||||
it 'strips all markdown like SMS' do
|
||||
content = '**bold** [link](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('bold')
|
||||
expect(result).to include('link https://example.com')
|
||||
expect(result).not_to include('**')
|
||||
expect(result).not_to include('[')
|
||||
end
|
||||
|
||||
it 'preserves URLs from links' do
|
||||
content = '[Reset password](https://example.com/reset)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('Reset password https://example.com/reset')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when testing all formatting types' do
|
||||
let(:channel_type) { 'Channel::Whatsapp' }
|
||||
|
||||
it 'handles ordered lists with proper numbering' do
|
||||
content = "1. first\n2. second\n3. third"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('1. first')
|
||||
expect(result).to include('2. second')
|
||||
expect(result).to include('3. third')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is unknown' do
|
||||
let(:channel_type) { 'Channel::Unknown' }
|
||||
|
||||
it 'returns content as-is' do
|
||||
content = '**bold** _italic_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq(content)
|
||||
end
|
||||
end
|
||||
|
||||
# Shared test for all text-based channels that preserve multiple newlines
|
||||
# This tests the real-world scenario where frontend sends newlines with whitespace between them
|
||||
context 'when content has multiple newlines with whitespace between them' do
|
||||
# This mimics what frontends often send: newlines with spaces/tabs between them
|
||||
let(:content_with_whitespace_newlines) { "hello \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\nhello wow" }
|
||||
|
||||
%w[
|
||||
Channel::Telegram
|
||||
Channel::Whatsapp
|
||||
Channel::Instagram
|
||||
Channel::FacebookPage
|
||||
Channel::Line
|
||||
Channel::Sms
|
||||
].each do |channel_type|
|
||||
context "when channel is #{channel_type}" do
|
||||
it 'normalizes whitespace-only lines and preserves multiple newlines' do
|
||||
result = described_class.new(content_with_whitespace_newlines, channel_type).render
|
||||
# Should preserve most of the newlines (at least 10+)
|
||||
# The exact count may vary slightly by renderer, but should be significantly more than 1-2
|
||||
expect(result.scan("\n").count).to be >= 10
|
||||
# Should not collapse everything to just 1-2 newlines
|
||||
expect(result.scan("\n").count).to be > 5
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::TwilioSms with WhatsApp' do
|
||||
it 'normalizes whitespace-only lines and preserves multiple newlines' do
|
||||
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
|
||||
result = described_class.new(content_with_whitespace_newlines, 'Channel::TwilioSms', channel).render
|
||||
expect(result.scan("\n").count).to be >= 10
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
507
research/chatwoot/spec/services/messages/mention_service_spec.rb
Normal file
507
research/chatwoot/spec/services/messages/mention_service_spec.rb
Normal file
@@ -0,0 +1,507 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Messages::MentionService do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account: account) }
|
||||
let!(:first_agent) { create(:user, account: account) }
|
||||
let!(:second_agent) { create(:user, account: account) }
|
||||
let!(:third_agent) { create(:user, account: account) }
|
||||
let!(:admin_user) { create(:user, account: account, role: :administrator) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
let!(:team) { create(:team, account: account, name: 'Support Team') }
|
||||
let!(:empty_team) { create(:team, account: account, name: 'Empty Team') }
|
||||
let(:builder) { double }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: first_agent, inbox: inbox)
|
||||
create(:inbox_member, user: second_agent, inbox: inbox)
|
||||
create(:team_member, user: first_agent, team: team)
|
||||
create(:team_member, user: second_agent, team: team)
|
||||
conversation.reload
|
||||
allow(NotificationBuilder).to receive(:new).and_return(builder)
|
||||
allow(builder).to receive(:perform)
|
||||
allow(Conversations::UserMentionJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when message is not private' do
|
||||
it 'does not process mentions for public messages' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: false
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has no content' do
|
||||
it 'does not process mentions for empty messages' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: nil,
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has no mentions' do
|
||||
it 'does not process messages without mentions' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: 'just a regular message',
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'user mentions' do
|
||||
context 'when message contains single user mention' do
|
||||
it 'creates notifications for inbox member who was mentioned' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
[first_agent.id.to_s],
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
|
||||
it 'adds mentioned user as conversation participant' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to include(first_agent.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains multiple user mentions' do
|
||||
let(:message) do
|
||||
build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://user/#{second_agent.id}/#{second_agent.name}) " \
|
||||
"and (mention://user/#{first_agent.id}/#{first_agent.name}), please look into this?",
|
||||
private: true
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates notifications for all mentioned inbox members' do
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
|
||||
it 'adds all mentioned users to the participants list' do
|
||||
described_class.new(message: message).perform
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
|
||||
end
|
||||
|
||||
it 'passes unique user IDs to UserMentionJob' do
|
||||
described_class.new(message: message).perform
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mentioned user is not an inbox member' do
|
||||
let!(:non_member_user) { create(:user, account: account) }
|
||||
|
||||
it 'does not create notifications for non-inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{non_member_user.id}/#{non_member_user.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mentioned user is an admin' do
|
||||
it 'creates notifications for admin users even if not inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{admin_user.id}/#{admin_user.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: admin_user,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when same user is mentioned multiple times' do
|
||||
it 'creates only one notification per user' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name}) and again (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).once
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
[first_agent.id.to_s],
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'team mentions' do
|
||||
context 'when message contains single team mention' do
|
||||
it 'creates notifications for all team members who are inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
|
||||
it 'adds all team members as conversation participants' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
|
||||
end
|
||||
|
||||
it 'passes team member IDs to UserMentionJob' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team has members who are not inbox members' do
|
||||
let!(:non_inbox_team_member) { create(:user, account: account) }
|
||||
|
||||
before do
|
||||
create(:team_member, user: non_inbox_team_member, team: team)
|
||||
end
|
||||
|
||||
it 'only notifies team members who are also inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: first_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: second_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).not_to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: non_inbox_team_member, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team has admin members' do
|
||||
before do
|
||||
create(:team_member, user: admin_user, team: team)
|
||||
end
|
||||
|
||||
it 'includes admin team members in notifications' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: admin_user,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team is empty' do
|
||||
it 'does not create any notifications for empty teams' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{empty_team.id}/#{empty_team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team does not exist' do
|
||||
it 'does not create notifications for non-existent teams' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: 'hey (mention://team/99999/NonExistentTeam) please help',
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when same team is mentioned multiple times' do
|
||||
it 'creates only one notification per team member' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) and again (mention://team/#{team.id}/#{team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).exactly(2).times
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'mixed user and team mentions' do
|
||||
context 'when message contains both user and team mentions' do
|
||||
it 'creates notifications for both individual users and team members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://user/#{third_agent.id}/#{third_agent.name}) and (mention://team/#{team.id}/#{team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
# Make third_agent an inbox member
|
||||
create(:inbox_member, user: third_agent, inbox: inbox)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: third_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: first_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: second_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
end
|
||||
|
||||
it 'avoids duplicate notifications when user is mentioned directly and via team' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://user/#{first_agent.id}/#{first_agent.name}) and (mention://team/#{team.id}/#{team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
# first_agent should only receive one notification despite being mentioned directly and via team
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
).once
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
).once
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'cross-account validation' do
|
||||
let!(:other_account) { create(:account) }
|
||||
let!(:other_team) { create(:team, account: other_account) }
|
||||
let!(:other_user) { create(:user, account: other_account) }
|
||||
|
||||
before do
|
||||
create(:team_member, user: other_user, team: other_team)
|
||||
end
|
||||
|
||||
it 'does not process mentions for teams from other accounts' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{other_team.id}/#{other_team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
|
||||
it 'does not process mentions for users from other accounts' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://user/#{other_user.id}/#{other_user.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,109 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Messages::NewMessageNotificationService do
|
||||
context 'when message is not notifiable' do
|
||||
it 'will not create any notifications' do
|
||||
message = build(:message, message_type: :activity)
|
||||
expect(NotificationBuilder).not_to receive(:new)
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is notifiable' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assignee) { create(:user, account: account) }
|
||||
let(:participating_agent_1) { create(:user, account: account) }
|
||||
let(:participating_agent_2) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: assignee) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: participating_agent_1)
|
||||
create(:inbox_member, inbox: inbox, user: participating_agent_2)
|
||||
create(:inbox_member, inbox: inbox, user: assignee)
|
||||
create(:conversation_participant, conversation: conversation, user: participating_agent_1)
|
||||
create(:conversation_participant, conversation: conversation, user: participating_agent_2)
|
||||
create(:conversation_participant, conversation: conversation, user: assignee)
|
||||
end
|
||||
|
||||
context 'when message is created by a participant' do
|
||||
let(:message) { create(:message, conversation: conversation, account: account, sender: participating_agent_1) }
|
||||
|
||||
before do
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
|
||||
it 'creates notifications for other participating users' do
|
||||
expect(participating_agent_2.notifications.where(notification_type: 'participating_conversation_new_message', account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message)).to exist
|
||||
end
|
||||
|
||||
it 'creates notifications for assignee' do
|
||||
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message', account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message)).to exist
|
||||
end
|
||||
|
||||
it 'will not create notifications for the user who created the message' do
|
||||
expect(participating_agent_1.notifications.where(notification_type: 'participating_conversation_new_message',
|
||||
account: account, primary_actor: message.conversation,
|
||||
secondary_actor: message)).not_to exist
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is created by a contact' do
|
||||
let(:message) { create(:message, conversation: conversation, account: account) }
|
||||
|
||||
before do
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
|
||||
it 'creates notifications for assignee' do
|
||||
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message', account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message)).to exist
|
||||
end
|
||||
|
||||
it 'creates notifications for all participating users' do
|
||||
expect(participating_agent_1.notifications.where(notification_type: 'participating_conversation_new_message',
|
||||
account: account, primary_actor: message.conversation,
|
||||
secondary_actor: message)).to exist
|
||||
expect(participating_agent_2.notifications.where(notification_type: 'participating_conversation_new_message',
|
||||
account: account, primary_actor: message.conversation,
|
||||
secondary_actor: message)).to exist
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple notification conditions are met' do
|
||||
let(:message) { create(:message, conversation: conversation, account: account) }
|
||||
|
||||
before do
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
|
||||
it 'will not create participating notifications for the assignee if assignee notification was send' do
|
||||
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message',
|
||||
account: account, primary_actor: message.conversation,
|
||||
secondary_actor: message)).to exist
|
||||
expect(assignee.notifications.where(notification_type: 'participating_conversation_new_message',
|
||||
account: account, primary_actor: message.conversation,
|
||||
secondary_actor: message)).not_to exist
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is created by assignee' do
|
||||
let(:message) { create(:message, conversation: conversation, account: account, sender: assignee) }
|
||||
|
||||
before do
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
|
||||
it 'will not create notifications for the user who created the message' do
|
||||
expect(assignee.notifications.where(notification_type: 'participating_conversation_new_message',
|
||||
account: account, primary_actor: message.conversation,
|
||||
secondary_actor: message)).not_to exist
|
||||
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message',
|
||||
account: account, primary_actor: message.conversation,
|
||||
secondary_actor: message)).not_to exist
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,192 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Messages::SendEmailNotificationService do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
|
||||
let(:service) { described_class.new(message: message) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when email notification should be sent' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
allow(Redis::Alfred).to receive(:set).and_return(true)
|
||||
ActiveJob::Base.queue_adapter = :test
|
||||
end
|
||||
|
||||
it 'enqueues ConversationReplyEmailJob' do
|
||||
expect { service.perform }.to have_enqueued_job(ConversationReplyEmailJob).with(conversation.id, message.id).on_queue('mailers')
|
||||
end
|
||||
|
||||
it 'atomically sets redis key to prevent duplicate emails' do
|
||||
expected_key = format(Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
|
||||
|
||||
service.perform
|
||||
|
||||
expect(Redis::Alfred).to have_received(:set).with(expected_key, message.id, nx: true, ex: 1.hour.to_i)
|
||||
end
|
||||
|
||||
context 'when redis key already exists' do
|
||||
before do
|
||||
allow(Redis::Alfred).to receive(:set).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not enqueue job' do
|
||||
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
|
||||
end
|
||||
|
||||
it 'attempts atomic set once' do
|
||||
service.perform
|
||||
|
||||
expect(Redis::Alfred).to have_received(:set).once
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling concurrent requests' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
end
|
||||
|
||||
it 'prevents duplicate jobs under race conditions' do
|
||||
# Create 5 threads that simultaneously try to enqueue workers for the same conversation
|
||||
threads = Array.new(5) do
|
||||
Thread.new do
|
||||
msg = create(:message, conversation: conversation, message_type: 'outgoing')
|
||||
described_class.new(message: msg).perform
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
# Only ONE job should be scheduled despite 5 concurrent attempts
|
||||
jobs_for_conversation = ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job|
|
||||
job[:job] == ConversationReplyEmailJob && job[:args].first == conversation.id
|
||||
end
|
||||
expect(jobs_for_conversation.size).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when email notification should not be sent' do
|
||||
before do
|
||||
ActiveJob::Base.queue_adapter = :test
|
||||
end
|
||||
|
||||
context 'when message is not email notifiable' do
|
||||
let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
|
||||
|
||||
it 'does not enqueue job' do
|
||||
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has no email' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: nil)
|
||||
end
|
||||
|
||||
it 'does not enqueue job' do
|
||||
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account email rate limit is exceeded' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
allow_any_instance_of(Account).to receive(:within_email_rate_limit?).and_return(false) # rubocop:disable RSpec/AnyInstance
|
||||
end
|
||||
|
||||
it 'does not enqueue job' do
|
||||
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel does not support email notifications' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_sms, account: account)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
end
|
||||
|
||||
it 'does not enqueue job' do
|
||||
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#should_send_email_notification?' do
|
||||
context 'with WebWidget channel' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
end
|
||||
|
||||
it 'returns true when continuity_via_email is enabled' do
|
||||
expect(service.send(:should_send_email_notification?)).to be true
|
||||
end
|
||||
|
||||
context 'when continuity_via_email is disabled' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: false)) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:should_send_email_notification?)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with API channel' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_api, account: account)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(true)
|
||||
end
|
||||
|
||||
it 'returns true when email_continuity_on_api_channel feature is enabled' do
|
||||
expect(service.send(:should_send_email_notification?)).to be true
|
||||
end
|
||||
|
||||
context 'when email_continuity_on_api_channel feature is disabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(false)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:should_send_email_notification?)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with other channels' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_email, account: account)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:should_send_email_notification?)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Messages::StatusUpdateService do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:message) { create(:message, conversation: conversation, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when status is valid' do
|
||||
it 'updates the status of the message' do
|
||||
service = described_class.new(message, 'delivered')
|
||||
service.perform
|
||||
expect(message.reload.status).to eq('delivered')
|
||||
end
|
||||
|
||||
it 'clears external_error when status is not failed' do
|
||||
message.update!(status: 'failed', external_error: 'previous error')
|
||||
service = described_class.new(message, 'delivered')
|
||||
service.perform
|
||||
expect(message.reload.status).to eq('delivered')
|
||||
expect(message.reload.external_error).to be_nil
|
||||
end
|
||||
|
||||
it 'updates external_error when status is failed' do
|
||||
service = described_class.new(message, 'failed', 'some error')
|
||||
service.perform
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq('some error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status is invalid' do
|
||||
it 'returns false for invalid status' do
|
||||
service = described_class.new(message, 'invalid_status')
|
||||
expect(service.perform).to be false
|
||||
end
|
||||
|
||||
it 'prevents transition from read to delivered' do
|
||||
message.update!(status: 'read')
|
||||
service = described_class.new(message, 'delivered')
|
||||
expect(service.perform).to be false
|
||||
expect(message.reload.status).to eq('read')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Mfa::AuthenticationService do
|
||||
before do
|
||||
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
|
||||
user.enable_two_factor!
|
||||
user.update!(otp_required_for_login: true)
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe '#authenticate' do
|
||||
context 'with OTP code' do
|
||||
context 'when OTP is valid' do
|
||||
it 'returns true' do
|
||||
valid_otp = user.current_otp
|
||||
service = described_class.new(user: user, otp_code: valid_otp)
|
||||
expect(service.authenticate).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTP is invalid' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user, otp_code: '000000')
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTP is nil' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user, otp_code: nil)
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with backup code' do
|
||||
let(:backup_codes) { user.generate_backup_codes! }
|
||||
|
||||
context 'when backup code is valid' do
|
||||
it 'returns true and invalidates the code' do
|
||||
valid_code = backup_codes.first
|
||||
service = described_class.new(user: user, backup_code: valid_code)
|
||||
|
||||
expect(service.authenticate).to be_truthy
|
||||
|
||||
# Code should be invalidated after use
|
||||
user.reload
|
||||
expect(user.otp_backup_codes).to include('XXXXXXXX')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when backup code is invalid' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user, backup_code: 'invalid')
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when backup code has already been used' do
|
||||
it 'returns false' do
|
||||
valid_code = backup_codes.first
|
||||
# Use the code once
|
||||
service = described_class.new(user: user, backup_code: valid_code)
|
||||
service.authenticate
|
||||
|
||||
# Try to use it again
|
||||
service2 = described_class.new(user: user.reload, backup_code: valid_code)
|
||||
expect(service2.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with neither OTP nor backup code' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user)
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is nil' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: nil, otp_code: '123456')
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when both OTP and backup code are provided' do
|
||||
it 'uses OTP authentication first' do
|
||||
valid_otp = user.current_otp
|
||||
backup_codes = user.generate_backup_codes!
|
||||
|
||||
service = described_class.new(
|
||||
user: user,
|
||||
otp_code: valid_otp,
|
||||
backup_code: backup_codes.first
|
||||
)
|
||||
|
||||
expect(service.authenticate).to be_truthy
|
||||
# Backup code should not be consumed
|
||||
user.reload
|
||||
expect(user.otp_backup_codes).not_to include('XXXXXXXX')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
72
research/chatwoot/spec/services/mfa/token_service_spec.rb
Normal file
72
research/chatwoot/spec/services/mfa/token_service_spec.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Mfa::TokenService do
|
||||
before do
|
||||
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:token_service) { described_class.new(user: user) }
|
||||
|
||||
describe '#generate_token' do
|
||||
it 'generates a JWT token with user_id' do
|
||||
token = token_service.generate_token
|
||||
expect(token).to be_present
|
||||
expect(token).to be_a(String)
|
||||
end
|
||||
|
||||
it 'includes user_id in the payload' do
|
||||
token = token_service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expect(decoded['user_id']).to eq(user.id)
|
||||
end
|
||||
|
||||
it 'sets expiration to 5 minutes from now' do
|
||||
allow(Time).to receive(:now).and_return(Time.zone.parse('2024-01-01 12:00:00'))
|
||||
token = token_service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expected_exp = Time.zone.parse('2024-01-01 12:05:00').to_i
|
||||
expect(decoded['exp']).to eq(expected_exp)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#verify_token' do
|
||||
let(:valid_token) { token_service.generate_token }
|
||||
|
||||
context 'with valid token' do
|
||||
it 'returns the user' do
|
||||
verifier = described_class.new(token: valid_token)
|
||||
verified_user = verifier.verify_token
|
||||
expect(verified_user).to eq(user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid token' do
|
||||
it 'returns nil for malformed token' do
|
||||
verifier = described_class.new(token: 'invalid_token')
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for expired token' do
|
||||
expired_payload = { user_id: user.id, exp: 1.minute.ago.to_i }
|
||||
expired_token = JWT.encode(expired_payload, Rails.application.secret_key_base, 'HS256')
|
||||
verifier = described_class.new(token: expired_token)
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for non-existent user' do
|
||||
payload = { user_id: 999_999, exp: 5.minutes.from_now.to_i }
|
||||
token = JWT.encode(payload, Rails.application.secret_key_base, 'HS256')
|
||||
verifier = described_class.new(token: token)
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank token' do
|
||||
it 'returns nil' do
|
||||
verifier = described_class.new(token: nil)
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,89 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Microsoft::RefreshOauthTokenService do
|
||||
let!(:microsoft_channel) { create(:channel_email, :microsoft_email) }
|
||||
let!(:microsoft_channel_with_expired_token) do
|
||||
create(
|
||||
:channel_email, :microsoft_email, provider_config: {
|
||||
expires_on: Time.zone.now - 3600,
|
||||
access_token: SecureRandom.hex,
|
||||
refresh_token: SecureRandom.hex
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:new_tokens) do
|
||||
{
|
||||
access_token: SecureRandom.hex,
|
||||
refresh_token: SecureRandom.hex,
|
||||
expires_at: (Time.zone.now + 3600).to_i,
|
||||
token_type: 'bearer'
|
||||
}
|
||||
end
|
||||
|
||||
context 'when token is not expired' do
|
||||
it 'returns the existing access token' do
|
||||
service = described_class.new(channel: microsoft_channel)
|
||||
|
||||
expect(service.access_token).to eq(microsoft_channel.provider_config['access_token'])
|
||||
expect(microsoft_channel.reload.provider_config['refresh_token']).to eq(microsoft_channel.provider_config['refresh_token'])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on expired token or invalid expiry' do
|
||||
before do
|
||||
stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token').with(
|
||||
body: { 'grant_type' => 'refresh_token', 'refresh_token' => microsoft_channel_with_expired_token.provider_config['refresh_token'] }
|
||||
).to_return(status: 200, body: new_tokens.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
context 'when token is invalid' do
|
||||
it 'fetches new access token and refresh tokens' do
|
||||
with_modified_env AZURE_APP_ID: SecureRandom.uuid, AZURE_APP_SECRET: SecureRandom.hex do
|
||||
provider_config = microsoft_channel_with_expired_token.provider_config
|
||||
service = described_class.new(channel: microsoft_channel_with_expired_token)
|
||||
expect(service.access_token).not_to eq(provider_config['access_token'])
|
||||
|
||||
new_provider_config = microsoft_channel_with_expired_token.reload.provider_config
|
||||
expect(new_provider_config['access_token']).to eq(new_tokens[:access_token])
|
||||
expect(new_provider_config['refresh_token']).to eq(new_tokens[:refresh_token])
|
||||
expect(new_provider_config['expires_on']).to eq(Time.at(new_tokens[:expires_at]).utc.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when expiry time is missing' do
|
||||
it 'fetches new access token and refresh tokens' do
|
||||
with_modified_env AZURE_APP_ID: SecureRandom.uuid, AZURE_APP_SECRET: SecureRandom.hex do
|
||||
microsoft_channel_with_expired_token.provider_config['expires_on'] = nil
|
||||
microsoft_channel_with_expired_token.save!
|
||||
provider_config = microsoft_channel_with_expired_token.provider_config
|
||||
service = described_class.new(channel: microsoft_channel_with_expired_token)
|
||||
expect(service.access_token).not_to eq(provider_config['access_token'])
|
||||
|
||||
new_provider_config = microsoft_channel_with_expired_token.reload.provider_config
|
||||
expect(new_provider_config['access_token']).to eq(new_tokens[:access_token])
|
||||
expect(new_provider_config['refresh_token']).to eq(new_tokens[:refresh_token])
|
||||
expect(new_provider_config['expires_on']).to eq(Time.at(new_tokens[:expires_at]).utc.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when refresh token is not present in provider config and access token is expired' do
|
||||
it 'throws an error' do
|
||||
with_modified_env AZURE_APP_ID: SecureRandom.uuid, AZURE_APP_SECRET: SecureRandom.hex do
|
||||
microsoft_channel.update(
|
||||
provider_config: {
|
||||
access_token: SecureRandom.hex,
|
||||
expires_on: Time.zone.now - 3600
|
||||
}
|
||||
)
|
||||
|
||||
expect do
|
||||
described_class.new(channel: microsoft_channel).access_token
|
||||
end.to raise_error(RuntimeError, 'A refresh_token is not available')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Notification::EmailNotificationService do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, confirmed_at: Time.current) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:notification) { create(:notification, notification_type: :conversation_creation, user: agent, account: account, primary_actor: conversation) }
|
||||
let(:mailer) { double }
|
||||
let(:mailer_action) { double }
|
||||
|
||||
before do
|
||||
# Setup notification settings for the agent
|
||||
notification_setting = agent.notification_settings.find_by(account_id: account.id)
|
||||
notification_setting.selected_email_flags = [:email_conversation_creation]
|
||||
notification_setting.save!
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when notification is read' do
|
||||
before do
|
||||
notification.update!(read_at: Time.current)
|
||||
end
|
||||
|
||||
it 'does not send email' do
|
||||
expect(AgentNotifications::ConversationNotificationsMailer).not_to receive(:with)
|
||||
described_class.new(notification: notification).perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent is not confirmed' do
|
||||
before do
|
||||
agent.update!(confirmed_at: nil)
|
||||
end
|
||||
|
||||
it 'does not send email' do
|
||||
expect(AgentNotifications::ConversationNotificationsMailer).not_to receive(:with)
|
||||
described_class.new(notification: notification).perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent is confirmed' do
|
||||
before do
|
||||
allow(AgentNotifications::ConversationNotificationsMailer).to receive(:with).and_return(mailer)
|
||||
allow(mailer).to receive(:public_send).and_return(mailer_action)
|
||||
allow(mailer_action).to receive(:deliver_later)
|
||||
end
|
||||
|
||||
it 'sends email' do
|
||||
described_class.new(notification: notification).perform
|
||||
expect(mailer).to have_received(:public_send).with(
|
||||
'conversation_creation',
|
||||
conversation,
|
||||
agent,
|
||||
nil
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not subscribed to notification type' do
|
||||
before do
|
||||
notification_setting = agent.notification_settings.find_by(account_id: account.id)
|
||||
notification_setting.selected_email_flags = []
|
||||
notification_setting.save!
|
||||
end
|
||||
|
||||
it 'does not send email' do
|
||||
expect(AgentNotifications::ConversationNotificationsMailer).not_to receive(:with)
|
||||
described_class.new(notification: notification).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Notification::FcmService do
|
||||
let(:project_id) { 'test_project_id' }
|
||||
let(:credentials) { '{ "type": "service_account", "project_id": "test_project_id" }' }
|
||||
let(:fcm_service) { described_class.new(project_id, credentials) }
|
||||
let(:fcm_double) { instance_double(FCM) }
|
||||
let(:token_info) { { token: 'test_token', expires_at: 1.hour.from_now } }
|
||||
let(:creds_double) do
|
||||
instance_double(Google::Auth::ServiceAccountCredentials, fetch_access_token!: { 'access_token' => 'test_token', 'expires_in' => 3600 })
|
||||
end
|
||||
|
||||
before do
|
||||
allow(FCM).to receive(:new).and_return(fcm_double)
|
||||
allow(fcm_service).to receive(:generate_token).and_return(token_info)
|
||||
allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(creds_double)
|
||||
end
|
||||
|
||||
describe '#fcm_client' do
|
||||
it 'returns an FCM client' do
|
||||
expect(fcm_service.fcm_client).to eq(fcm_double)
|
||||
expect(FCM).to have_received(:new).with('test_token', anything, project_id)
|
||||
end
|
||||
|
||||
it 'generates a new token if expired' do
|
||||
allow(fcm_service).to receive(:generate_token).and_return(token_info)
|
||||
allow(fcm_service).to receive(:token_expired?).and_return(true)
|
||||
|
||||
expect(fcm_service.fcm_client).to eq(fcm_double)
|
||||
expect(FCM).to have_received(:new).with('test_token', anything, project_id)
|
||||
expect(fcm_service).to have_received(:generate_token)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#current_token' do
|
||||
it 'returns the current token if not expired' do
|
||||
fcm_service.instance_variable_set(:@token_info, token_info)
|
||||
expect(fcm_service.send(:current_token)).to eq('test_token')
|
||||
end
|
||||
|
||||
it 'generates a new token if expired' do
|
||||
expired_token_info = { token: 'expired_token', expires_at: 1.hour.ago }
|
||||
fcm_service.instance_variable_set(:@token_info, expired_token_info)
|
||||
allow(fcm_service).to receive(:generate_token).and_return(token_info)
|
||||
|
||||
expect(fcm_service.send(:current_token)).to eq('test_token')
|
||||
expect(fcm_service).to have_received(:generate_token)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_token' do
|
||||
it 'generates a new token' do
|
||||
allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(creds_double)
|
||||
|
||||
token = fcm_service.send(:generate_token)
|
||||
expect(token[:token]).to eq('test_token')
|
||||
expect(token[:expires_at]).to be_within(1.second).of(Time.zone.now + 3600)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#credentials_path' do
|
||||
it 'creates a StringIO with credentials' do
|
||||
string_io = fcm_service.send(:credentials_path)
|
||||
expect(string_io).to be_a(StringIO)
|
||||
expect(string_io.read).to eq(credentials)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Notification::PushNotificationService do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account: account) }
|
||||
let!(:notification) { create(:notification, user: user, account: user.accounts.first) }
|
||||
let(:fcm_double) { instance_double(FCM) }
|
||||
let(:fcm_service_double) { instance_double(Notification::FcmService, fcm_client: fcm_double) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when the push server returns success' do
|
||||
before do
|
||||
allow(WebPush).to receive(:payload_send).and_return(true)
|
||||
allow(Rails.logger).to receive(:info)
|
||||
allow(Notification::FcmService).to receive(:new).and_return(fcm_service_double)
|
||||
allow(fcm_double).to receive(:send_v1).and_return({ body: { 'results': [] }.to_json })
|
||||
allow(GlobalConfigService).to receive(:load).with('FIREBASE_PROJECT_ID', nil).and_return('test_project_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('FIREBASE_CREDENTIALS', nil).and_return('test_credentials')
|
||||
end
|
||||
|
||||
it 'sends webpush notifications for webpush subscription' do
|
||||
with_modified_env VAPID_PUBLIC_KEY: 'test' do
|
||||
create(:notification_subscription, user: notification.user)
|
||||
|
||||
described_class.new(notification: notification).perform
|
||||
expect(WebPush).to have_received(:payload_send)
|
||||
expect(Notification::FcmService).not_to have_received(:new)
|
||||
expect(Rails.logger).to have_received(:info).with("Browser push sent to #{user.email} with title #{notification.push_message_title}")
|
||||
end
|
||||
end
|
||||
|
||||
it 'sends a fcm notification for firebase subscription' do
|
||||
with_modified_env ENABLE_PUSH_RELAY_SERVER: 'false' do
|
||||
create(:notification_subscription, user: notification.user, subscription_type: 'fcm')
|
||||
|
||||
described_class.new(notification: notification).perform
|
||||
expect(Notification::FcmService).to have_received(:new)
|
||||
expect(fcm_double).to have_received(:send_v1)
|
||||
expect(WebPush).not_to have_received(:payload_send)
|
||||
expect(Rails.logger).to have_received(:info).with("FCM push sent to #{user.email} with title #{notification.push_message_title}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the push server returns error' do
|
||||
it 'sends webpush notifications for webpush subscription' do
|
||||
with_modified_env VAPID_PUBLIC_KEY: 'test' do
|
||||
mock_response = instance_double(Net::HTTPResponse, body: 'Subscription is invalid')
|
||||
mock_host = 'fcm.googleapis.com'
|
||||
|
||||
allow(WebPush).to receive(:payload_send).and_raise(WebPush::InvalidSubscription.new(mock_response, mock_host))
|
||||
allow(Rails.logger).to receive(:info)
|
||||
|
||||
create(:notification_subscription, :browser_push, user: notification.user)
|
||||
|
||||
expect(Rails.logger).to receive(:info) do |message|
|
||||
expect(message).to include('WebPush subscription expired:')
|
||||
end
|
||||
|
||||
described_class.new(notification: notification).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
756
research/chatwoot/spec/services/search_service_spec.rb
Normal file
756
research/chatwoot/spec/services/search_service_spec.rb
Normal file
@@ -0,0 +1,756 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe SearchService do
|
||||
subject(:search) { described_class.new(current_user: user, current_account: account, params: params, search_type: search_type) }
|
||||
|
||||
let(:search_type) { 'all' }
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
|
||||
let!(:harry) { create(:contact, name: 'Harry Potter', email: 'test@test.com', account_id: account.id) }
|
||||
let!(:conversation) { create(:conversation, contact: harry, inbox: inbox, account: account) }
|
||||
let!(:message) { create(:message, account: account, inbox: inbox, content: 'Harry Potter is a wizard') }
|
||||
let!(:portal) { create(:portal, account: account) }
|
||||
let(:article) do
|
||||
create(:article, title: 'Harry Potter Magic Guide', content: 'Learn about wizardry', account: account, portal: portal, author: user,
|
||||
status: 'published')
|
||||
end
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: user, inbox: inbox)
|
||||
Current.account = account
|
||||
end
|
||||
|
||||
after do
|
||||
Current.account = nil
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when search types' do
|
||||
let(:params) { { q: 'Potter' } }
|
||||
|
||||
it 'returns all for all' do
|
||||
search_type = 'all'
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
expect(search.perform.keys).to match_array(%i[contacts messages conversations articles])
|
||||
end
|
||||
|
||||
it 'returns contacts for contacts' do
|
||||
search_type = 'Contact'
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
expect(search.perform.keys).to match_array(%i[contacts])
|
||||
end
|
||||
|
||||
it 'returns messages for messages' do
|
||||
search_type = 'Message'
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
expect(search.perform.keys).to match_array(%i[messages])
|
||||
end
|
||||
|
||||
it 'returns conversations for conversations' do
|
||||
search_type = 'Conversation'
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
expect(search.perform.keys).to match_array(%i[conversations])
|
||||
end
|
||||
|
||||
it 'returns articles for articles' do
|
||||
search_type = 'Article'
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
expect(search.perform.keys).to match_array(%i[articles])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact search' do
|
||||
it 'searches across name, email, phone_number and identifier and returns in the order of contact last_activity_at' do
|
||||
# random contact
|
||||
create(:contact, account_id: account.id)
|
||||
# unresolved contact -> no identifying info
|
||||
# will not appear in search results
|
||||
create(:contact, name: 'Harry Potter', account_id: account.id)
|
||||
harry2 = create(:contact, email: 'HarryPotter@test.com', account_id: account.id, last_activity_at: 2.days.ago)
|
||||
harry3 = create(:contact, identifier: 'Potter123', account_id: account.id, last_activity_at: 1.day.ago)
|
||||
harry4 = create(:contact, identifier: 'Potter1235', account_id: account.id, last_activity_at: 2.minutes.ago)
|
||||
|
||||
params = { q: 'Potter ' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Contact')
|
||||
expect(search.perform[:contacts].map(&:id)).to eq([harry4.id, harry3.id, harry2.id, harry.id])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message search' do
|
||||
let!(:message2) { create(:message, account: account, inbox: inbox, content: 'harry is cool') }
|
||||
|
||||
it 'searches across message content and return in created_at desc' do
|
||||
# random messages in another account
|
||||
create(:message, content: 'Harry Potter is a wizard')
|
||||
# random messsage in inbox with out access
|
||||
create(:message, account: account, inbox: create(:inbox, account: account), content: 'Harry Potter is a wizard')
|
||||
params = { q: 'Harry' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
|
||||
expect(search.perform[:messages].map(&:id)).to eq([message2.id, message.id])
|
||||
end
|
||||
|
||||
context 'with feature flag for search type' do
|
||||
let(:params) { { q: 'Harry' } }
|
||||
let(:search_type) { 'Message' }
|
||||
|
||||
it 'uses LIKE search when search_with_gin feature is disabled' do
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(search_service).to receive(:filter_messages_with_like).and_call_original
|
||||
expect(search_service).not_to receive(:filter_messages_with_gin)
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
|
||||
it 'uses GIN search when search_with_gin feature is enabled' do
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(true)
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(search_service).to receive(:filter_messages_with_gin).and_call_original
|
||||
expect(search_service).not_to receive(:filter_messages_with_like)
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
|
||||
it 'returns same results regardless of search type' do
|
||||
# Create test messages
|
||||
message3 = create(:message, account: account, inbox: inbox, content: 'Harry is a wizard apprentice')
|
||||
|
||||
# Test with GIN search
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(true)
|
||||
gin_search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
gin_results = gin_search.perform[:messages].map(&:id)
|
||||
|
||||
# Test with LIKE search
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
|
||||
like_search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
like_results = like_search.perform[:messages].map(&:id)
|
||||
|
||||
# Both search types should return the same messages
|
||||
expect(gin_results).to match_array(like_results)
|
||||
expect(gin_results).to include(message.id, message2.id, message3.id)
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop:disable RSpec/MultipleMemoizedHelpers
|
||||
context 'when filtering messages with time, sender, and inbox', :opensearch do
|
||||
let!(:agent) { create(:user, account: account) }
|
||||
let!(:inbox2) { create(:inbox, account: account) }
|
||||
let!(:old_message) do
|
||||
create(:message, account: account, inbox: inbox, content: 'old wizard message', sender: harry, created_at: 80.days.ago)
|
||||
end
|
||||
let!(:recent_message) do
|
||||
create(:message, account: account, inbox: inbox, content: 'recent wizard message', sender: harry, created_at: 1.day.ago)
|
||||
end
|
||||
let!(:agent_message) do
|
||||
create(:message, account: account, inbox: inbox, content: 'wizard from agent', sender: agent, created_at: 1.day.ago)
|
||||
end
|
||||
let!(:inbox2_message) do
|
||||
create(:message, account: account, inbox: inbox2, content: 'wizard in inbox2', sender: harry, created_at: 1.day.ago)
|
||||
end
|
||||
|
||||
before do
|
||||
account.enable_features!('advanced_search')
|
||||
create(:inbox_member, inbox: inbox2, user: user)
|
||||
end
|
||||
|
||||
it 'filters messages by time range with LIKE search' do
|
||||
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
|
||||
params = { q: 'wizard', since: 50.days.ago.to_i, search_type: 'Message' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
|
||||
results = search.perform[:messages]
|
||||
|
||||
expect(results.map(&:id)).to include(recent_message.id, agent_message.id, inbox2_message.id)
|
||||
expect(results.map(&:id)).not_to include(old_message.id)
|
||||
end
|
||||
|
||||
it 'filters messages by time range with GIN search' do
|
||||
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(true)
|
||||
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
|
||||
params = { q: 'wizard', since: 50.days.ago.to_i, search_type: 'Message' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
|
||||
results = search.perform[:messages]
|
||||
|
||||
expect(results.map(&:id)).to include(recent_message.id, agent_message.id, inbox2_message.id)
|
||||
expect(results.map(&:id)).not_to include(old_message.id)
|
||||
end
|
||||
|
||||
it 'filters messages by sender (contact)' do
|
||||
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
|
||||
params = { q: 'wizard', from: "contact:#{harry.id}", search_type: 'Message' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
|
||||
results = search.perform[:messages]
|
||||
|
||||
expect(results.map(&:id)).to include(recent_message.id, old_message.id, inbox2_message.id)
|
||||
expect(results.map(&:id)).not_to include(agent_message.id)
|
||||
end
|
||||
|
||||
it 'filters messages by sender (agent)' do
|
||||
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
|
||||
params = { q: 'wizard', from: "agent:#{agent.id}", search_type: 'Message' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
|
||||
results = search.perform[:messages]
|
||||
|
||||
expect(results.map(&:id)).to include(agent_message.id)
|
||||
expect(results.map(&:id)).not_to include(recent_message.id, old_message.id, inbox2_message.id)
|
||||
end
|
||||
|
||||
it 'filters messages by inbox' do
|
||||
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
|
||||
params = { q: 'wizard', inbox_id: inbox2.id, search_type: 'Message' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
|
||||
results = search.perform[:messages]
|
||||
|
||||
expect(results.map(&:id)).to include(inbox2_message.id)
|
||||
expect(results.map(&:id)).not_to include(recent_message.id, old_message.id, agent_message.id)
|
||||
end
|
||||
|
||||
it 'combines multiple filters' do
|
||||
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
|
||||
params = { q: 'wizard', since: 50.days.ago.to_i, inbox_id: inbox.id, from: "contact:#{harry.id}", search_type: 'Message' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
|
||||
results = search.perform[:messages]
|
||||
|
||||
expect(results.map(&:id)).to include(recent_message.id)
|
||||
expect(results.map(&:id)).not_to include(old_message.id, agent_message.id, inbox2_message.id)
|
||||
end
|
||||
end
|
||||
# rubocop:enable RSpec/MultipleMemoizedHelpers
|
||||
end
|
||||
|
||||
context 'when conversation search' do
|
||||
it 'searches across conversations using contact information and order by created_at desc' do
|
||||
# random messages in another inbox
|
||||
random = create(:contact, account_id: account.id)
|
||||
create(:conversation, contact: random, inbox: inbox, account: account)
|
||||
conv2 = create(:conversation, contact: harry, inbox: inbox, account: account)
|
||||
params = { q: 'Harry' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Conversation')
|
||||
expect(search.perform[:conversations].map(&:id)).to eq([conv2.id, conversation.id])
|
||||
end
|
||||
|
||||
it 'searches across conversations with display id' do
|
||||
random = create(:contact, account_id: account.id, name: 'random', email: 'random@random.test', identifier: 'random')
|
||||
new_converstion = create(:conversation, contact: random, inbox: inbox, account: account)
|
||||
params = { q: new_converstion.display_id }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Conversation')
|
||||
expect(search.perform[:conversations].map(&:id)).to include new_converstion.id
|
||||
end
|
||||
end
|
||||
|
||||
context 'when article search' do
|
||||
it 'returns matching articles' do
|
||||
article2 = create(:article, title: 'Spellcasting Guide',
|
||||
account: account, portal: portal, author: user, status: 'published')
|
||||
article3 = create(:article, title: 'Spellcasting Manual',
|
||||
account: account, portal: portal, author: user, status: 'published')
|
||||
|
||||
params = { q: 'Spellcasting' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
|
||||
results = search.perform[:articles]
|
||||
|
||||
expect(results.length).to eq(2)
|
||||
expect(results.map(&:id)).to contain_exactly(article2.id, article3.id)
|
||||
end
|
||||
|
||||
it 'returns paginated results' do
|
||||
# Create many articles to test pagination
|
||||
16.times do |i|
|
||||
create(:article, title: "Magic Article #{i}", account: account, portal: portal, author: user, status: 'published')
|
||||
end
|
||||
|
||||
params = { q: 'Magic', page: 1 }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
|
||||
results = search.perform[:articles]
|
||||
|
||||
expect(results.length).to eq(15) # Default per_page is 15
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering contacts with time caps', :opensearch do
|
||||
let!(:old_contact) { create(:contact, name: 'Old Potter', email: 'old@test.com', account: account, last_activity_at: 100.days.ago) }
|
||||
let!(:recent_contact) { create(:contact, name: 'Recent Potter', email: 'recent@test.com', account: account, last_activity_at: 1.day.ago) }
|
||||
|
||||
before do
|
||||
account.enable_features!('advanced_search')
|
||||
end
|
||||
|
||||
it 'caps since to 90 days ago and excludes older contacts' do
|
||||
params = { q: 'Potter', since: 100.days.ago.to_i, search_type: 'Contact' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Contact')
|
||||
results = search.perform[:contacts]
|
||||
|
||||
expect(results.map(&:id)).not_to include(old_contact.id)
|
||||
expect(results.map(&:id)).to include(recent_contact.id)
|
||||
end
|
||||
|
||||
it 'caps until to 90 days from now' do
|
||||
params = { q: 'Potter', until: 100.days.from_now.to_i, search_type: 'Contact' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Contact')
|
||||
results = search.perform[:contacts]
|
||||
|
||||
# Both contacts should be included since their last_activity_at is before the capped time
|
||||
expect(results.map(&:id)).to include(recent_contact.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering conversations with time caps', :opensearch do
|
||||
let!(:old_conversation) { create(:conversation, contact: harry, inbox: inbox, account: account, last_activity_at: 100.days.ago) }
|
||||
let!(:recent_conversation) { create(:conversation, contact: harry, inbox: inbox, account: account, last_activity_at: 1.day.ago) }
|
||||
|
||||
before do
|
||||
account.enable_features!('advanced_search')
|
||||
end
|
||||
|
||||
it 'caps since to 90 days ago and excludes older conversations' do
|
||||
params = { q: 'Harry', since: 100.days.ago.to_i, search_type: 'Conversation' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Conversation')
|
||||
results = search.perform[:conversations]
|
||||
|
||||
expect(results.map(&:id)).not_to include(old_conversation.id)
|
||||
expect(results.map(&:id)).to include(recent_conversation.id)
|
||||
end
|
||||
|
||||
it 'caps until to 90 days from now' do
|
||||
params = { q: 'Harry', until: 100.days.from_now.to_i, search_type: 'Conversation' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Conversation')
|
||||
results = search.perform[:conversations]
|
||||
|
||||
# Both conversations should be included since their last_activity_at is before the capped time
|
||||
expect(results.map(&:id)).to include(recent_conversation.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering articles with time caps', :opensearch do
|
||||
let!(:old_article) do
|
||||
create(:article, title: 'Old Magic Guide', account: account, portal: portal, author: user, status: 'published', updated_at: 100.days.ago)
|
||||
end
|
||||
let!(:recent_article) do
|
||||
create(:article, title: 'Recent Magic Guide', account: account, portal: portal, author: user, status: 'published', updated_at: 1.day.ago)
|
||||
end
|
||||
|
||||
before do
|
||||
account.enable_features!('advanced_search')
|
||||
end
|
||||
|
||||
it 'caps since to 90 days ago and excludes older articles' do
|
||||
params = { q: 'Magic', since: 100.days.ago.to_i, search_type: 'Article' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
|
||||
results = search.perform[:articles]
|
||||
|
||||
expect(results.map(&:id)).not_to include(old_article.id)
|
||||
expect(results.map(&:id)).to include(recent_article.id)
|
||||
end
|
||||
|
||||
it 'caps until to 90 days from now' do
|
||||
params = { q: 'Magic', until: 100.days.from_now.to_i, search_type: 'Article' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
|
||||
results = search.perform[:articles]
|
||||
|
||||
# Both articles should be included since their updated_at is before the capped time
|
||||
expect(results.map(&:id)).to include(recent_article.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#message_base_query' do
|
||||
let(:params) { { q: 'test' } }
|
||||
let(:search_type) { 'Message' }
|
||||
|
||||
context 'when user is admin' do
|
||||
let(:admin_user) { create(:user) }
|
||||
let(:admin_search) do
|
||||
create(:account_user, account: account, user: admin_user, role: 'administrator')
|
||||
described_class.new(current_user: admin_user, current_account: account, params: params, search_type: search_type)
|
||||
end
|
||||
|
||||
it 'does not filter by inbox_id' do
|
||||
# Testing the private method itself seems like the best way to ensure
|
||||
# that the inboxes are not added to the search query
|
||||
base_query = admin_search.send(:message_base_query)
|
||||
|
||||
# Should only have the time filter, not inbox filter
|
||||
expect(base_query.to_sql).to include('created_at >= ')
|
||||
expect(base_query.to_sql).not_to include('inbox_id')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not admin' do
|
||||
before do
|
||||
account_user = account.account_users.find_or_create_by(user: user)
|
||||
account_user.update!(role: 'agent')
|
||||
end
|
||||
|
||||
it 'filters by accessible inbox_id when user has limited access' do
|
||||
# Create an additional inbox that user is NOT assigned to
|
||||
create(:inbox, account: account)
|
||||
|
||||
base_query = search.send(:message_base_query)
|
||||
|
||||
# Should have both time and inbox filters
|
||||
expect(base_query.to_sql).to include('created_at >= ')
|
||||
expect(base_query.to_sql).to include('inbox_id')
|
||||
end
|
||||
|
||||
context 'when user has access to all inboxes' do
|
||||
before do
|
||||
# Create additional inbox and assign user to all inboxes
|
||||
other_inbox = create(:inbox, account: account)
|
||||
create(:inbox_member, user: user, inbox: other_inbox)
|
||||
end
|
||||
|
||||
it 'skips inbox filtering as optimization' do
|
||||
base_query = search.send(:message_base_query)
|
||||
|
||||
# Should only have the time filter, not inbox filter
|
||||
expect(base_query.to_sql).to include('created_at >= ')
|
||||
expect(base_query.to_sql).not_to include('inbox_id')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#use_gin_search' do
|
||||
let(:params) { { q: 'test' } }
|
||||
|
||||
it 'checks if the account has the search_with_gin feature enabled' do
|
||||
expect(account).to receive(:feature_enabled?).with('search_with_gin')
|
||||
search.send(:use_gin_search)
|
||||
end
|
||||
|
||||
it 'returns true when search_with_gin feature is enabled' do
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(true)
|
||||
expect(search.send(:use_gin_search)).to be true
|
||||
end
|
||||
|
||||
it 'returns false when search_with_gin feature is disabled' do
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
|
||||
expect(search.send(:use_gin_search)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#advanced_search with filters', if: Message.respond_to?(:search) do
|
||||
let(:params) { { q: 'test' } }
|
||||
let(:search_type) { 'Message' }
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
|
||||
allow(Message).to receive(:search).and_return([])
|
||||
end
|
||||
|
||||
context 'when advanced_search feature flag is disabled' do
|
||||
it 'ignores filters and falls back to standard search' do
|
||||
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
|
||||
contact = create(:contact, account: account)
|
||||
inbox2 = create(:inbox, account: account)
|
||||
|
||||
params = { q: 'test', from: "contact:#{contact.id}", inbox_id: inbox2.id, since: 3.days.ago.to_i }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(search_service).not_to receive(:advanced_search)
|
||||
search_service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering by from parameter' do
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
|
||||
it 'filters messages from specific contact' do
|
||||
params = { q: 'test', from: "contact:#{contact.id}" }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_including(
|
||||
sender_type: 'Contact',
|
||||
sender_id: contact.id
|
||||
)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
|
||||
it 'filters messages from specific agent' do
|
||||
params = { q: 'test', from: "agent:#{agent.id}" }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_including(
|
||||
sender_type: 'User',
|
||||
sender_id: agent.id
|
||||
)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
|
||||
it 'ignores invalid from parameter format' do
|
||||
params = { q: 'test', from: 'invalid:format' }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_not_including(:sender_type, :sender_id)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering by time range' do
|
||||
it 'defaults to 90 days ago when no since parameter is provided' do
|
||||
params = { q: 'test' }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_including(
|
||||
created_at: hash_including(gte: be_within(1.second).of(Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS.days.ago))
|
||||
)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
|
||||
it 'silently caps since timestamp to 90 day limit when exceeded' do
|
||||
since_timestamp = (Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS * 2).days.ago.to_i
|
||||
params = { q: 'test', since: since_timestamp }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_including(
|
||||
created_at: hash_including(gte: be_within(1.second).of(Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS.days.ago))
|
||||
)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
|
||||
it 'filters messages since timestamp when within 90 day limit' do
|
||||
since_timestamp = 3.days.ago.to_i
|
||||
params = { q: 'test', since: since_timestamp }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_including(
|
||||
created_at: hash_including(gte: Time.zone.at(since_timestamp))
|
||||
)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
|
||||
it 'filters messages until timestamp' do
|
||||
until_timestamp = 5.days.ago.to_i
|
||||
params = { q: 'test', until: until_timestamp }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_including(
|
||||
created_at: hash_including(lte: Time.zone.at(until_timestamp))
|
||||
)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
|
||||
it 'filters messages within time range' do
|
||||
since_timestamp = 5.days.ago.to_i
|
||||
until_timestamp = 12.hours.ago.to_i
|
||||
params = { q: 'test', since: since_timestamp, until: until_timestamp }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_including(
|
||||
created_at: hash_including(
|
||||
gte: Time.zone.at(since_timestamp),
|
||||
lte: Time.zone.at(until_timestamp)
|
||||
)
|
||||
)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
|
||||
it 'silently caps until timestamp to 90 days from now when exceeded' do
|
||||
until_timestamp = 100.days.from_now.to_i
|
||||
params = { q: 'test', until: until_timestamp }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_including(
|
||||
created_at: hash_including(lte: be_within(1.second).of(90.days.from_now))
|
||||
)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering by inbox_id' do
|
||||
let!(:inbox2) { create(:inbox, account: account) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: user, inbox: inbox2)
|
||||
end
|
||||
|
||||
it 'filters messages from specific inbox' do
|
||||
params = { q: 'test', inbox_id: inbox2.id }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_including(inbox_id: inbox2.id)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
|
||||
it 'ignores inbox filter when user lacks access' do
|
||||
restricted_inbox = create(:inbox, account: account)
|
||||
params = { q: 'test', inbox_id: restricted_inbox.id }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_not_including(inbox_id: restricted_inbox.id)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when combining multiple filters' do
|
||||
it 'applies all filters together' do
|
||||
test_contact = create(:contact, account: account)
|
||||
test_inbox = create(:inbox, account: account)
|
||||
create(:inbox_member, user: user, inbox: test_inbox)
|
||||
|
||||
since_timestamp = 3.days.ago.to_i
|
||||
params = { q: 'test', from: "contact:#{test_contact.id}", inbox_id: test_inbox.id, since: since_timestamp }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
expect(Message).to receive(:search).with(
|
||||
'test',
|
||||
hash_including(
|
||||
where: hash_including(
|
||||
sender_type: 'Contact',
|
||||
sender_id: test_contact.id,
|
||||
inbox_id: test_inbox.id,
|
||||
created_at: hash_including(gte: Time.zone.at(since_timestamp))
|
||||
)
|
||||
)
|
||||
).and_return([])
|
||||
|
||||
search_service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#advanced_search_with_fallback' do
|
||||
let(:params) { { q: 'test' } }
|
||||
let(:search_type) { 'Message' }
|
||||
|
||||
context 'when Elasticsearch is unavailable' do
|
||||
it 'falls back to LIKE search when Elasticsearch connection fails' do
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
|
||||
|
||||
params = { q: 'test' }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
allow(search_service).to receive(:advanced_search).and_raise(Faraday::ConnectionFailed.new('Connection refused'))
|
||||
|
||||
expect(search_service).to receive(:filter_messages_with_like).and_call_original
|
||||
expect { search_service.perform }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'falls back to GIN search when Elasticsearch is unavailable and GIN is enabled' do
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(true)
|
||||
|
||||
params = { q: 'test' }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
allow(search_service).to receive(:advanced_search).and_raise(Searchkick::Error.new('Elasticsearch unavailable'))
|
||||
|
||||
expect(search_service).to receive(:filter_messages_with_gin).and_call_original
|
||||
expect { search_service.perform }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'applies filters correctly in SQL fallback when Elasticsearch fails' do
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
|
||||
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
|
||||
|
||||
test_contact = create(:contact, account: account)
|
||||
create(:message, account: account, inbox: inbox, content: 'test message', sender: test_contact, created_at: 1.day.ago)
|
||||
|
||||
params = { q: 'test', from: "contact:#{test_contact.id}", since: 2.days.ago.to_i }
|
||||
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
|
||||
|
||||
allow(search_service).to receive(:advanced_search).and_raise(Faraday::ConnectionFailed.new('Connection refused'))
|
||||
|
||||
results = search_service.perform[:messages]
|
||||
expect(results).not_to be_empty
|
||||
expect(results.first.sender_id).to eq(test_contact.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Sms::DeliveryStatusService do
|
||||
describe '#perform' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:sms_channel) { create(:channel_sms) }
|
||||
let!(:contact) { create(:contact, account: account, phone_number: '+12345') }
|
||||
let(:contact_inbox) { create(:contact_inbox, source_id: '+12345', contact: contact, inbox: sms_channel.inbox) }
|
||||
let!(:conversation) { create(:conversation, contact: contact, inbox: sms_channel.inbox, contact_inbox: contact_inbox) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when message delivery status is fired' do
|
||||
before do
|
||||
create(:message, account: account, inbox: sms_channel.inbox, conversation: conversation, status: :sent,
|
||||
source_id: 'SMd560ac79e4a4d36b3ce59f1f50471986')
|
||||
end
|
||||
|
||||
it 'updates the message if the message status is delivered' do
|
||||
params = {
|
||||
time: '2022-02-02T23:14:05.309Z',
|
||||
type: 'message-delivered',
|
||||
to: sms_channel.phone_number,
|
||||
description: 'ok',
|
||||
message: {
|
||||
'id': conversation.messages.last.source_id
|
||||
}
|
||||
}
|
||||
|
||||
described_class.new(params: params, inbox: sms_channel.inbox).perform
|
||||
expect(conversation.reload.messages.last.status).to eq('delivered')
|
||||
end
|
||||
|
||||
it 'updates the message if the message status is failed' do
|
||||
params = {
|
||||
time: '2022-02-02T23:14:05.309Z',
|
||||
type: 'message-failed',
|
||||
to: sms_channel.phone_number,
|
||||
description: 'Undeliverable',
|
||||
errorCode: 995,
|
||||
message: {
|
||||
'id': conversation.messages.last.source_id
|
||||
}
|
||||
}
|
||||
|
||||
described_class.new(params: params, inbox: sms_channel.inbox).perform
|
||||
expect(conversation.reload.messages.last.status).to eq('failed')
|
||||
|
||||
expect(conversation.reload.messages.last.external_error).to eq('995 - Undeliverable')
|
||||
end
|
||||
|
||||
it 'does not update the message if the status is not a support status' do
|
||||
params = {
|
||||
time: '2022-02-02T23:14:05.309Z',
|
||||
type: 'queued',
|
||||
to: sms_channel.phone_number,
|
||||
description: 'ok',
|
||||
message: {
|
||||
'id': conversation.messages.last.source_id
|
||||
}
|
||||
}
|
||||
|
||||
described_class.new(params: params, inbox: sms_channel.inbox).perform
|
||||
expect(conversation.reload.messages.last.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'does not update the message if the message is not present' do
|
||||
params = {
|
||||
time: '2022-02-02T23:14:05.309Z',
|
||||
type: 'message-delivered',
|
||||
to: sms_channel.phone_number,
|
||||
description: 'ok',
|
||||
message: {
|
||||
'id': '123'
|
||||
}
|
||||
}
|
||||
|
||||
described_class.new(params: params, inbox: sms_channel.inbox).perform
|
||||
expect(conversation.reload.messages.last.status).to eq('sent')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Sms::IncomingMessageService do
|
||||
describe '#perform' do
|
||||
let!(:sms_channel) { create(:channel_sms) }
|
||||
let(:params) do
|
||||
{
|
||||
|
||||
'id': '3232420-2323-234324',
|
||||
'owner': sms_channel.phone_number,
|
||||
'applicationId': '2342349-324234d-32432432',
|
||||
'time': '2022-02-02T23:14:05.262Z',
|
||||
'segmentCount': 1,
|
||||
'direction': 'in',
|
||||
'to': [
|
||||
sms_channel.phone_number
|
||||
],
|
||||
'from': '+14234234234',
|
||||
'text': 'test message'
|
||||
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
context 'when valid text message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
described_class.new(inbox: sms_channel.inbox, params: params).perform
|
||||
expect(sms_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('+1 423-423-4234')
|
||||
expect(sms_channel.inbox.messages.first.content).to eq(params[:text])
|
||||
end
|
||||
|
||||
it 'appends to last conversation when if conversation already exisits' do
|
||||
contact_inbox = create(:contact_inbox, inbox: sms_channel.inbox, source_id: params[:from])
|
||||
2.times.each { create(:conversation, inbox: sms_channel.inbox, contact_inbox: contact_inbox) }
|
||||
last_conversation = create(:conversation, inbox: sms_channel.inbox, contact_inbox: contact_inbox)
|
||||
described_class.new(inbox: sms_channel.inbox, params: params).perform
|
||||
# no new conversation should be created
|
||||
expect(sms_channel.inbox.conversations.count).to eq(3)
|
||||
# message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq(params[:text])
|
||||
end
|
||||
|
||||
it 'reopen last conversation if last conversation is resolved and lock to single conversation is enabled' do
|
||||
sms_channel.inbox.update(lock_to_single_conversation: true)
|
||||
contact_inbox = create(:contact_inbox, inbox: sms_channel.inbox, source_id: params[:from])
|
||||
last_conversation = create(:conversation, inbox: sms_channel.inbox, contact_inbox: contact_inbox)
|
||||
last_conversation.update(status: 'resolved')
|
||||
described_class.new(inbox: sms_channel.inbox, params: params).perform
|
||||
# no new conversation should be created
|
||||
expect(sms_channel.inbox.conversations.count).to eq(1)
|
||||
expect(sms_channel.inbox.conversations.open.last.messages.last.content).to eq(params[:text])
|
||||
expect(sms_channel.inbox.conversations.open.last.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'creates a new conversation if last conversation is resolved and lock to single conversation is disabled' do
|
||||
sms_channel.inbox.update(lock_to_single_conversation: false)
|
||||
contact_inbox = create(:contact_inbox, inbox: sms_channel.inbox, source_id: params[:from])
|
||||
last_conversation = create(:conversation, inbox: sms_channel.inbox, contact_inbox: contact_inbox)
|
||||
last_conversation.update(status: 'resolved')
|
||||
described_class.new(inbox: sms_channel.inbox, params: params).perform
|
||||
# new conversation should be created
|
||||
expect(sms_channel.inbox.conversations.count).to eq(2)
|
||||
# message appended to the last conversation
|
||||
expect(contact_inbox.conversations.last.messages.last.content).to eq(params[:text])
|
||||
end
|
||||
|
||||
it 'will not create a new conversation if last conversation is not resolved and lock to single conversation is disabled' do
|
||||
sms_channel.inbox.update(lock_to_single_conversation: false)
|
||||
contact_inbox = create(:contact_inbox, inbox: sms_channel.inbox, source_id: params[:from])
|
||||
last_conversation = create(:conversation, inbox: sms_channel.inbox, contact_inbox: contact_inbox)
|
||||
last_conversation.update(status: Conversation.statuses.except('resolved').keys.sample)
|
||||
described_class.new(inbox: sms_channel.inbox, params: params).perform
|
||||
# new conversation should be created
|
||||
expect(sms_channel.inbox.conversations.count).to eq(1)
|
||||
# message appended to the last conversation
|
||||
expect(contact_inbox.conversations.last.messages.last.content).to eq(params[:text])
|
||||
end
|
||||
|
||||
it 'creates attachment messages and ignores .smil files' do
|
||||
stub_request(:get, 'http://test.com/test.png').to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: {})
|
||||
stub_request(:get, 'http://test.com/test2.png').to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: {})
|
||||
|
||||
media_params = { 'media': [
|
||||
'http://test.com/test.smil',
|
||||
'http://test.com/test.png',
|
||||
'http://test.com/test2.png'
|
||||
] }.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: sms_channel.inbox, params: params.merge(media_params)).perform
|
||||
expect(sms_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('+1 423-423-4234')
|
||||
expect(sms_channel.inbox.messages.first.content).to eq('test message')
|
||||
expect(sms_channel.inbox.messages.first.attachments.present?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Sms::OneoffSmsCampaignService do
|
||||
subject(:sms_campaign_service) { described_class.new(campaign: campaign) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:sms_channel) { create(:channel_sms, account: account) }
|
||||
let!(:sms_inbox) { create(:inbox, channel: sms_channel, account: account) }
|
||||
let(:label1) { create(:label, account: account) }
|
||||
let(:label2) { create(:label, account: account) }
|
||||
let!(:campaign) do
|
||||
create(:campaign, inbox: sms_inbox, account: account,
|
||||
audience: [{ type: 'Label', id: label1.id }, { type: 'Label', id: label2.id }])
|
||||
end
|
||||
|
||||
describe 'perform' do
|
||||
before do
|
||||
stub_request(:post, 'https://messaging.bandwidth.com/api/v2/users/1/messages').to_return(
|
||||
status: 200,
|
||||
body: { 'id' => '1' }.to_json,
|
||||
headers: {}
|
||||
)
|
||||
allow_any_instance_of(described_class).to receive(:channel).and_return(sms_channel) # rubocop:disable RSpec/AnyInstance
|
||||
end
|
||||
|
||||
it 'raises error if the campaign is completed' do
|
||||
campaign.completed!
|
||||
|
||||
expect { sms_campaign_service.perform }.to raise_error 'Completed Campaign'
|
||||
end
|
||||
|
||||
it 'raises error invalid campaign when its not a oneoff sms campaign' do
|
||||
campaign = create(:campaign)
|
||||
|
||||
expect { described_class.new(campaign: campaign).perform }.to raise_error "Invalid campaign #{campaign.id}"
|
||||
end
|
||||
|
||||
it 'send messages to contacts in the audience and marks the campaign completed' do
|
||||
contact_with_label1, contact_with_label2, contact_with_both_labels = FactoryBot.create_list(:contact, 3, :with_phone_number, account: account)
|
||||
contact_with_label1.update_labels([label1.title])
|
||||
contact_with_label2.update_labels([label2.title])
|
||||
contact_with_both_labels.update_labels([label1.title, label2.title])
|
||||
sms_campaign_service.perform
|
||||
assert_requested(:post, 'https://messaging.bandwidth.com/api/v2/users/1/messages', times: 3)
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'uses liquid template service to process campaign message' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(Liquid::CampaignTemplateService).to receive(:new).with(campaign: campaign, contact: contact).and_call_original
|
||||
|
||||
sms_campaign_service.perform
|
||||
end
|
||||
|
||||
it 'continues processing contacts when sending message raises an error' do
|
||||
contact_error, contact_success = FactoryBot.create_list(:contact, 2, :with_phone_number, account: account)
|
||||
contact_error.update_labels([label1.title])
|
||||
contact_success.update_labels([label1.title])
|
||||
|
||||
error_message = 'SMS provider error'
|
||||
|
||||
expect(sms_channel).to receive(:send_text_message).with(contact_error.phone_number, anything).and_raise(StandardError, error_message)
|
||||
expect(sms_channel).to receive(:send_text_message).with(contact_success.phone_number, anything).and_return(nil)
|
||||
|
||||
expect(Rails.logger).to receive(:error).with("[SMS Campaign #{campaign.id}] Failed to send to #{contact_error.phone_number}: #{error_message}")
|
||||
|
||||
sms_campaign_service.perform
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Sms::SendOnSmsService do
|
||||
describe '#perform' do
|
||||
context 'when a valid message' do
|
||||
let(:sms_request) { double }
|
||||
let!(:sms_channel) { create(:channel_sms) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, inbox: sms_channel.inbox, source_id: '+123456789') }
|
||||
let!(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: sms_channel.inbox) }
|
||||
|
||||
it 'calls channel.send_message' do
|
||||
message = create(:message, message_type: :outgoing, content: 'test',
|
||||
conversation: conversation)
|
||||
allow(HTTParty).to receive(:post).and_return(sms_request)
|
||||
allow(sms_request).to receive(:success?).and_return(true)
|
||||
allow(sms_request).to receive(:parsed_response).and_return({ 'id' => '123456789' })
|
||||
expect(HTTParty).to receive(:post).with(
|
||||
'https://messaging.bandwidth.com/api/v2/users/1/messages',
|
||||
basic_auth: { username: '1', password: '1' },
|
||||
headers: { 'Content-Type' => 'application/json' },
|
||||
body: { 'to' => '+123456789', 'from' => sms_channel.phone_number, 'text' => 'test', 'applicationId' => '1' }.to_json
|
||||
)
|
||||
described_class.new(message: message).perform
|
||||
expect(message.reload.source_id).to eq('123456789')
|
||||
end
|
||||
|
||||
it 'calls channel.send_message with attachments' do
|
||||
message = build(:message, message_type: :outgoing, content: 'test',
|
||||
conversation: conversation)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment2 = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment2.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
allow(HTTParty).to receive(:post).and_return(sms_request)
|
||||
allow(sms_request).to receive(:success?).and_return(true)
|
||||
allow(sms_request).to receive(:parsed_response).and_return({ 'id' => '123456789' })
|
||||
allow(attachment).to receive(:download_url).and_return('url1')
|
||||
allow(attachment2).to receive(:download_url).and_return('url2')
|
||||
expect(HTTParty).to receive(:post).with(
|
||||
'https://messaging.bandwidth.com/api/v2/users/1/messages',
|
||||
basic_auth: { username: '1', password: '1' },
|
||||
headers: { 'Content-Type' => 'application/json' },
|
||||
body: { 'to' => '+123456789', 'from' => sms_channel.phone_number, 'text' => 'test', 'applicationId' => '1',
|
||||
'media' => %w[url1 url2] }.to_json
|
||||
)
|
||||
described_class.new(message: message).perform
|
||||
expect(message.reload.source_id).to eq('123456789')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,485 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Telegram::IncomingMessageService do
|
||||
before do
|
||||
stub_request(:any, /api.telegram.org/).to_return(headers: { content_type: 'application/json' }, body: {}.to_json, status: 200)
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return(
|
||||
status: 200,
|
||||
body: File.read('spec/assets/sample.png'),
|
||||
headers: {}
|
||||
)
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.mov').to_return(
|
||||
status: 200,
|
||||
body: File.read('spec/assets/sample.mov'),
|
||||
headers: {}
|
||||
)
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.mp3').to_return(
|
||||
status: 200,
|
||||
body: File.read('spec/assets/sample.mp3'),
|
||||
headers: {}
|
||||
)
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.ogg').to_return(
|
||||
status: 200,
|
||||
body: File.read('spec/assets/sample.ogg'),
|
||||
headers: {}
|
||||
)
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.pdf').to_return(
|
||||
status: 200,
|
||||
body: File.read('spec/assets/sample.pdf'),
|
||||
headers: {}
|
||||
)
|
||||
end
|
||||
|
||||
let!(:telegram_channel) { create(:channel_telegram) }
|
||||
let!(:message_params) do
|
||||
{
|
||||
'message_id' => 1,
|
||||
'from' => {
|
||||
'id' => 23, 'is_bot' => false, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'language_code' => 'en'
|
||||
},
|
||||
'chat' => { 'id' => 23, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'type' => 'private' },
|
||||
'date' => 1_631_132_077
|
||||
}
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when valid text message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => { 'text' => 'test' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.content).to eq('test')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid caption params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => { 'caption' => 'test' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23)
|
||||
expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan')
|
||||
expect(telegram_channel.inbox.messages.first.content).to eq('test')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when group messages' do
|
||||
it 'doesnot create conversations, message and contacts' do
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'message_id' => 1,
|
||||
'from' => {
|
||||
'id' => 23, 'is_bot' => false, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'language_code' => 'en'
|
||||
},
|
||||
'chat' => { 'id' => 23, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'type' => 'group' },
|
||||
'date' => 1_631_132_077, 'text' => 'test'
|
||||
}
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business connection messages' do
|
||||
subject do
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
end
|
||||
|
||||
let(:business_message_params) { message_params.merge('business_connection_id' => 'eooW3KF5WB5HxTD7T826') }
|
||||
let(:params) do
|
||||
{
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'business_message' => { 'text' => 'test' }.deep_merge(business_message_params)
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
subject
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23,
|
||||
'business_connection_id' => 'eooW3KF5WB5HxTD7T826' })
|
||||
contact = Contact.all.first
|
||||
expect(contact.name).to eq('Sojan Jose')
|
||||
expect(contact.additional_attributes['language_code']).to eq('en')
|
||||
message = telegram_channel.inbox.messages.first
|
||||
expect(message.content).to eq('test')
|
||||
expect(message.message_type).to eq('incoming')
|
||||
expect(message.sender).to eq(contact)
|
||||
end
|
||||
|
||||
context 'when sender is your business account' do
|
||||
let(:business_message_params) do
|
||||
message_params.merge(
|
||||
'business_connection_id' => 'eooW3KF5WB5HxTD7T826',
|
||||
'from' => {
|
||||
'id' => 42, 'is_bot' => false, 'first_name' => 'John', 'last_name' => 'Doe', 'username' => 'johndoe', 'language_code' => 'en'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
subject
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23,
|
||||
'business_connection_id' => 'eooW3KF5WB5HxTD7T826' })
|
||||
contact = Contact.all.first
|
||||
expect(contact.name).to eq('Sojan Jose')
|
||||
# TODO: The language code is not present when we send the first message to the client.
|
||||
# Should we update it when the user replies?
|
||||
expect(contact.additional_attributes['language_code']).to be_nil
|
||||
message = telegram_channel.inbox.messages.first
|
||||
expect(message.content).to eq('test')
|
||||
expect(message.message_type).to eq('outgoing')
|
||||
expect(message.sender).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid audio messages params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.mp3')
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'audio' => {
|
||||
'file_id' => 'AwADBAADbXXXXXXXXXXXGBdhD2l6_XX',
|
||||
'duration' => 243,
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'file_size' => 3_897_500,
|
||||
'title' => 'Test music file'
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23)
|
||||
expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid image attachment params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.png')
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'photo' => [{
|
||||
'file_id' => 'AgACAgUAAxkBAAODYV3aGZlD6vhzKsE2WNmblsr6zKwAAi-tMRvCoeBWNQ1ENVBzJdwBAAMCAANzAAMhBA',
|
||||
'file_unique_id' => 'AQADL60xG8Kh4FZ4', 'file_size' => 1883, 'width' => 90, 'height' => 67
|
||||
}]
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid sticker attachment params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.png')
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'sticker' => {
|
||||
'emoji' => '👍', 'width' => 512, 'height' => 512, 'set_name' => 'a834556273_by_HopSins_1_anim', 'is_animated' => 1,
|
||||
'thumb' => {
|
||||
'file_id' => 'AAMCAQADGQEAA0dhXpKorj9CiRpNX3QOn7YPZ6XS4AAC4wADcVG-MexptyOf8SbfAQAHbQADIQQ',
|
||||
'file_unique_id' => 'AQAD4wADcVG-MXI', 'file_size' => 4690, 'width' => 128, 'height' => 128
|
||||
},
|
||||
'file_id' => 'CAACAgEAAxkBAANHYV6SqK4_QokaTV90Dp-2D2el0uAAAuMAA3FRvjHsabcjn_Em3yEE',
|
||||
'file_unique_id' => 'AgAD4wADcVG-MQ',
|
||||
'file_size' => 7340
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid video messages params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.mov')
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'video' => {
|
||||
'duration' => 1, 'width' => 720, 'height' => 1280, 'file_name' => 'IMG_2170.MOV', 'mime_type' => 'video/mp4', 'thumb' => {
|
||||
'file_id' => 'AAMCBQADGQEAA4ZhXd78Xz6_c6gCzbdIkgGiXJcwwwACqwMAAp3x8Fbhf3EWamgCWAEAB20AAyEE', 'file_unique_id' => 'AQADqwMAAp3x8FZy',
|
||||
'file_size' => 11_462, 'width' => 180, 'height' => 320
|
||||
}, 'file_id' => 'BAACAgUAAxkBAAOGYV3e_F8-v3OoAs23SJIBolyXMMMAAqsDAAKd8fBW4X9xFmpoAlghBA', 'file_unique_id' => 'AgADqwMAAp3x8FY',
|
||||
'file_size' => 291_286
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid video_note messages params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.mov')
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'video_note' => {
|
||||
'duration' => 3,
|
||||
'length' => 240,
|
||||
'thumb' => {
|
||||
'file_id' => 'AAMCBQADGQEAA4ZhXd78Xz6_c6gCzbdIkgGiXJcwwwACqwMAAp3x8Fbhf3EWamgCWAEAB20AAyEE',
|
||||
'file_unique_id' => 'AQADqwMAAp3x8FZy',
|
||||
'file_size' => 11_462,
|
||||
'width' => 240,
|
||||
'height' => 240
|
||||
},
|
||||
'file_id' => 'DQACAgUAAxkBAAIBY2FdJlhf8PC2E3IalXSvXWO5m8GBAALJAwACwqHgVhb0truM0uhwIQQ',
|
||||
'file_unique_id' => 'AgADyQMAAsKh4FY',
|
||||
'file_size' => 132_446
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid voice attachment params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.ogg')
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'voice' => {
|
||||
'duration' => 2, 'mime_type' => 'audio/ogg', 'file_id' => 'AwACAgUAAxkBAANjYVwnWF_w8LYTchqVdK9dY7mbwYEAAskDAALCoeBWFvS2u4zS6HAhBA',
|
||||
'file_unique_id' => 'AgADyQMAAsKh4FY', 'file_size' => 11_833
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid document message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.pdf')
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'document' => {
|
||||
'file_id' => 'AwADBAADbXXXXXXXXXXXGBdhD2l6_XX',
|
||||
'file_name' => 'Screenshot 2021-09-27 at 2.01.14 PM.png',
|
||||
'mime_type' => 'application/png',
|
||||
'file_size' => 536_392
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('file')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API call to get the download path returns an error' do
|
||||
it 'does not process the attachment' do
|
||||
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return(nil)
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'document' => {
|
||||
'file_id' => 'AwADBAADbXXXXXXXXXXXGBdhD2l6_XX',
|
||||
'file_name' => 'Screenshot 2021-09-27 at 2.01.14 PM.png',
|
||||
'mime_type' => 'application/png',
|
||||
'file_size' => 536_392
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.messages.first.attachments.count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid location message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'location': {
|
||||
'latitude': 37.7893768,
|
||||
'longitude': -122.3895553
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('location')
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts if venue is present' do
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'location': {
|
||||
'latitude': 37.7893768,
|
||||
'longitude': -122.3895553
|
||||
},
|
||||
venue: {
|
||||
title: 'San Francisco'
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
|
||||
attachment = telegram_channel.inbox.messages.first.attachments.first
|
||||
expect(attachment.file_type).to eq('location')
|
||||
expect(attachment.coordinates_lat).to eq(37.7893768)
|
||||
expect(attachment.coordinates_long).to eq(-122.3895553)
|
||||
expect(attachment.fallback_title).to eq('San Francisco')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid callback_query params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'callback_query' => {
|
||||
'id' => '2342342309929423',
|
||||
'from' => {
|
||||
'id' => 5_171_248,
|
||||
'is_bot' => false,
|
||||
'first_name' => 'Sojan',
|
||||
'last_name' => 'Jose',
|
||||
'username' => 'sojan',
|
||||
'language_code' => 'en',
|
||||
'is_premium' => true
|
||||
},
|
||||
'message' => message_params,
|
||||
'chat_instance' => '-89923842384923492',
|
||||
'data' => 'Option 1'
|
||||
}
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(5_171_248)
|
||||
expect(telegram_channel.inbox.messages.first.content).to eq('Option 1')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid contact message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'contact': {
|
||||
'phone_number': '+918660944581'
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('contact')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock to single conversation is enabled' do
|
||||
before do
|
||||
# ensure message_params exists in this context and has from.id
|
||||
message_params[:from] ||= {}
|
||||
message_params[:from][:id] ||= 23
|
||||
end
|
||||
|
||||
it 'reopens last conversation if last conversation is resolved' do
|
||||
telegram_channel.inbox.update!(lock_to_single_conversation: true)
|
||||
contact_inbox = ContactInbox.find_or_create_by(inbox: telegram_channel.inbox, source_id: message_params[:from][:id]) do |ci|
|
||||
ci.contact = create(:contact)
|
||||
end
|
||||
resolved_conversation = create(:conversation, inbox: telegram_channel.inbox, contact_inbox: contact_inbox, status: :resolved)
|
||||
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => { 'text' => 'test' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
|
||||
expect(telegram_channel.inbox.conversations.count).to eq(1)
|
||||
expect(resolved_conversation.reload.messages.last.content).to eq('test')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock to single conversation is disabled' do
|
||||
before do
|
||||
# ensure message_params exists in this context and has from.id
|
||||
message_params[:from] ||= {}
|
||||
message_params[:from][:id] ||= 23
|
||||
end
|
||||
|
||||
it 'creates new conversation if last conversation is resolved' do
|
||||
telegram_channel.inbox.update!(lock_to_single_conversation: false)
|
||||
contact_inbox = ContactInbox.find_or_create_by(inbox: telegram_channel.inbox, source_id: message_params[:from][:id]) do |ci|
|
||||
ci.contact = create(:contact)
|
||||
end
|
||||
_resolved_conversation = create(:conversation, inbox: telegram_channel.inbox, contact_inbox: contact_inbox, status: :resolved)
|
||||
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => { 'text' => 'test' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
|
||||
expect(telegram_channel.inbox.conversations.count).to eq(2)
|
||||
expect(telegram_channel.inbox.conversations.last.messages.first.content).to eq('test')
|
||||
expect(telegram_channel.inbox.conversations.last.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'appends to last conversation if last conversation is not resolved' do
|
||||
telegram_channel.inbox.update!(lock_to_single_conversation: false)
|
||||
contact_inbox = ContactInbox.find_or_create_by(inbox: telegram_channel.inbox, source_id: message_params[:from][:id]) do |ci|
|
||||
ci.contact = create(:contact)
|
||||
end
|
||||
open_conversation = create(:conversation, inbox: telegram_channel.inbox, contact_inbox: contact_inbox, status: :open)
|
||||
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => { 'text' => 'test' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
|
||||
expect(telegram_channel.inbox.conversations.count).to eq(1)
|
||||
expect(open_conversation.reload.messages.last.content).to eq('test')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,130 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Telegram::SendAttachmentsService do
|
||||
describe '#perform' do
|
||||
let(:channel) { create(:channel_telegram) }
|
||||
let(:message) { build(:message, conversation: create(:conversation, inbox: channel.inbox)) }
|
||||
let(:service) { described_class.new(message: message) }
|
||||
let(:telegram_api_url) { channel.telegram_api_url }
|
||||
|
||||
before do
|
||||
allow(channel).to receive(:chat_id).and_return('chat123')
|
||||
|
||||
stub_request(:post, "#{telegram_api_url}/sendMediaGroup")
|
||||
.to_return(status: 200, body: { ok: true, result: [{ message_id: 'media' }] }.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
stub_request(:post, "#{telegram_api_url}/sendDocument")
|
||||
.to_return(status: 200, body: { ok: true, result: { message_id: 'document' } }.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'sends all types of attachments in seperate groups and returns the last successful message ID from the batch' do
|
||||
attach_files(message)
|
||||
result = service.perform
|
||||
expect(result).to eq('document')
|
||||
# videos and images are sent in a media group
|
||||
# audio is sent in another group
|
||||
expect(a_request(:post, "#{telegram_api_url}/sendMediaGroup")).to have_been_made.times(2)
|
||||
expect(a_request(:post, "#{telegram_api_url}/sendDocument")).to have_been_made.once
|
||||
end
|
||||
|
||||
context 'when all attachments are documents' do
|
||||
before do
|
||||
2.times { attach_file_to_message(message, 'file', 'sample.pdf', 'application/pdf') }
|
||||
message.save!
|
||||
end
|
||||
|
||||
it 'sends documents individually and returns the message ID of the first successful document' do
|
||||
result = service.perform
|
||||
expect(result).to eq('document')
|
||||
expect(a_request(:post, "#{telegram_api_url}/sendDocument")).to have_been_made.times(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when this is business chat' do
|
||||
before { allow(channel).to receive(:business_connection_id).and_return('eooW3KF5WB5HxTD7T826') }
|
||||
|
||||
it 'sends all types of attachments in seperate groups and returns the last successful message ID from the batch' do
|
||||
attach_files(message)
|
||||
service.perform
|
||||
expect(a_request(:post, "#{telegram_api_url}/sendMediaGroup")
|
||||
.with { |req| req.body =~ /business_connection_id.+eooW3KF5WB5HxTD7T826/m })
|
||||
.to have_been_made.times(2)
|
||||
|
||||
expect(a_request(:post, "#{telegram_api_url}/sendDocument")
|
||||
.with { |req| req.body =~ /business_connection_id.+eooW3KF5WB5HxTD7T826/m })
|
||||
.to have_been_made.once
|
||||
end
|
||||
end
|
||||
|
||||
context 'when all attachments are photo and video' do
|
||||
before do
|
||||
2.times { attach_file_to_message(message, 'image', 'sample.png', 'image/png') }
|
||||
attach_file_to_message(message, 'video', 'sample.mp4', 'video/mp4')
|
||||
message.save!
|
||||
end
|
||||
|
||||
it 'sends in a single media group and returns the message ID' do
|
||||
result = service.perform
|
||||
expect(result).to eq('media')
|
||||
expect(a_request(:post, "#{telegram_api_url}/sendMediaGroup")).to have_been_made.once
|
||||
end
|
||||
end
|
||||
|
||||
context 'when all attachments are audio' do
|
||||
before do
|
||||
2.times { attach_file_to_message(message, 'audio', 'sample.mp3', 'audio/mpeg') }
|
||||
message.save!
|
||||
end
|
||||
|
||||
it 'sends audio messages in single media group and returns the message ID' do
|
||||
result = service.perform
|
||||
expect(result).to eq('media')
|
||||
expect(a_request(:post, "#{telegram_api_url}/sendMediaGroup")).to have_been_made.once
|
||||
end
|
||||
end
|
||||
|
||||
context 'when all attachments are photos, videos, and audio' do
|
||||
before do
|
||||
attach_file_to_message(message, 'image', 'sample.png', 'image/png')
|
||||
attach_file_to_message(message, 'video', 'sample.mp4', 'video/mp4')
|
||||
attach_file_to_message(message, 'audio', 'sample.mp3', 'audio/mpeg')
|
||||
message.save!
|
||||
end
|
||||
|
||||
it 'sends photos and videos in a media group and audio in a separate group' do
|
||||
result = service.perform
|
||||
expect(result).to eq('media')
|
||||
expect(a_request(:post, "#{telegram_api_url}/sendMediaGroup")).to have_been_made.times(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an attachment fails to send' do
|
||||
before do
|
||||
stub_request(:post, "#{telegram_api_url}/sendDocument")
|
||||
.to_return(status: 500, body: { ok: false,
|
||||
description: 'Internal server error' }.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'logs an error, stops processing, and returns nil' do
|
||||
attach_files(message)
|
||||
expect(Rails.logger).to receive(:error).at_least(:once)
|
||||
result = service.perform
|
||||
expect(result).to be_nil
|
||||
expect(a_request(:post, "#{telegram_api_url}/sendDocument")).to have_been_made.once
|
||||
end
|
||||
end
|
||||
|
||||
def attach_files(message)
|
||||
attach_file_to_message(message, 'file', 'sample.pdf', 'application/pdf')
|
||||
attach_file_to_message(message, 'image', 'sample.png', 'image/png')
|
||||
attach_file_to_message(message, 'video', 'sample.mp4', 'video/mp4')
|
||||
attach_file_to_message(message, 'audio', 'sample.mp3', 'audio/mpeg')
|
||||
message.save!
|
||||
end
|
||||
|
||||
def attach_file_to_message(message, type, filename, content_type)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: type)
|
||||
attachment.file.attach(io: Rails.root.join("spec/assets/#{filename}").open, filename: filename, content_type: content_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Telegram::SendOnTelegramService do
|
||||
describe '#perform' do
|
||||
context 'when a valid message' do
|
||||
it 'calls channel.send_message_on_telegram' do
|
||||
telegram_request = double
|
||||
telegram_channel = create(:channel_telegram)
|
||||
message = create(:message, message_type: :outgoing, content: 'test',
|
||||
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
|
||||
allow(HTTParty).to receive(:post).and_return(telegram_request)
|
||||
allow(telegram_request).to receive(:success?).and_return(true)
|
||||
allow(telegram_request).to receive(:parsed_response).and_return({ 'result' => { 'message_id' => 'telegram_123' } })
|
||||
described_class.new(message: message).perform
|
||||
expect(message.source_id).to eq('telegram_123')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Telegram::UpdateMessageService do
|
||||
let!(:telegram_channel) { create(:channel_telegram) }
|
||||
let(:common_message_params) do
|
||||
{
|
||||
'from': {
|
||||
'id': 123,
|
||||
'username': 'sojan'
|
||||
},
|
||||
'chat': {
|
||||
'id': 789,
|
||||
'type': 'private'
|
||||
},
|
||||
'date': Time.now.to_i,
|
||||
'edit_date': Time.now.to_i
|
||||
}
|
||||
end
|
||||
|
||||
let(:text_update_params) do
|
||||
{
|
||||
'update_id': 1,
|
||||
'edited_message': common_message_params.merge(
|
||||
'message_id': 48,
|
||||
'text': 'updated message'
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
let(:caption_update_params) do
|
||||
{
|
||||
'update_id': 2,
|
||||
'edited_message': common_message_params.merge(
|
||||
'message_id': 49,
|
||||
'caption': 'updated caption'
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when valid update message params' do
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: telegram_channel.inbox, source_id: common_message_params[:chat][:id]) }
|
||||
let(:conversation) { create(:conversation, contact_inbox: contact_inbox) }
|
||||
|
||||
it 'updates the message text when text is present' do
|
||||
message = create(:message, conversation: conversation, source_id: text_update_params[:edited_message][:message_id])
|
||||
described_class.new(inbox: telegram_channel.inbox, params: text_update_params.with_indifferent_access).perform
|
||||
expect(message.reload.content).to eq('updated message')
|
||||
end
|
||||
|
||||
it 'updates the message caption when caption is present' do
|
||||
message = create(:message, conversation: conversation, source_id: caption_update_params[:edited_message][:message_id])
|
||||
described_class.new(inbox: telegram_channel.inbox, params: caption_update_params.with_indifferent_access).perform
|
||||
expect(message.reload.content).to eq('updated caption')
|
||||
end
|
||||
|
||||
context 'when business message' do
|
||||
let(:text_update_params) do
|
||||
{
|
||||
'update_id': 1,
|
||||
'edited_business_message': common_message_params.merge(
|
||||
'message_id': 48,
|
||||
'text': 'updated message'
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
it 'updates the message text when text is present' do
|
||||
message = create(:message, conversation: conversation, source_id: text_update_params[:edited_business_message][:message_id])
|
||||
described_class.new(inbox: telegram_channel.inbox, params: text_update_params.with_indifferent_access).perform
|
||||
expect(message.reload.content).to eq('updated message')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invalid update message params' do
|
||||
it 'will not raise errors' do
|
||||
expect do
|
||||
described_class.new(inbox: telegram_channel.inbox, params: {}).perform
|
||||
end.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
117
research/chatwoot/spec/services/tiktok/message_service_spec.rb
Normal file
117
research/chatwoot/spec/services/tiktok/message_service_spec.rb
Normal file
@@ -0,0 +1,117 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tiktok::MessageService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_tiktok, account: account, business_id: 'biz-123') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates an incoming text message' do
|
||||
content = {
|
||||
type: 'text',
|
||||
message_id: 'tt-msg-1',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
text: { body: 'Hello from TikTok' },
|
||||
from: 'Alice',
|
||||
from_user: { id: 'user-1' },
|
||||
to: 'Biz',
|
||||
to_user: { id: 'biz-123' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
expect do
|
||||
service = described_class.new(channel: channel, content: content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
service.perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.inbox).to eq(inbox)
|
||||
expect(message.message_type).to eq('incoming')
|
||||
expect(message.content).to eq('Hello from TikTok')
|
||||
expect(message.source_id).to eq('tt-msg-1')
|
||||
expect(message.sender).to eq(contact)
|
||||
expect(message.content_attributes['is_unsupported']).to be_nil
|
||||
end
|
||||
|
||||
it 'creates an incoming unsupported message for non-supported types' do
|
||||
content = {
|
||||
type: 'sticker',
|
||||
message_id: 'tt-msg-2',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
from: 'Alice',
|
||||
from_user: { id: 'user-1' },
|
||||
to: 'Biz',
|
||||
to_user: { id: 'biz-123' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
service = described_class.new(channel: channel, content: content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
service.perform
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to be_nil
|
||||
expect(message.content_attributes['is_unsupported']).to be true
|
||||
end
|
||||
|
||||
it 'creates an incoming embed attachment for share_post messages' do
|
||||
content = {
|
||||
type: 'share_post',
|
||||
message_id: 'tt-msg-3',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
share_post: { embed_url: 'https://www.tiktok.com/embed/123' },
|
||||
from: 'Alice',
|
||||
from_user: { id: 'user-1' },
|
||||
to: 'Biz',
|
||||
to_user: { id: 'biz-123' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
service = described_class.new(channel: channel, content: content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
service.perform
|
||||
|
||||
message = Message.last
|
||||
expect(message.attachments.count).to eq(1)
|
||||
attachment = message.attachments.last
|
||||
expect(attachment.file_type).to eq('embed')
|
||||
expect(attachment.external_url).to eq('https://www.tiktok.com/embed/123')
|
||||
end
|
||||
|
||||
it 'creates an incoming image attachment when media is present' do
|
||||
content = {
|
||||
type: 'image',
|
||||
message_id: 'tt-msg-4',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
image: { media_id: 'media-1' },
|
||||
from: 'Alice',
|
||||
from_user: { id: 'user-1' },
|
||||
to: 'Biz',
|
||||
to_user: { id: 'biz-123' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
tempfile = Tempfile.new(['tiktok', '.png'])
|
||||
tempfile.write('fake-image')
|
||||
tempfile.rewind
|
||||
tempfile.define_singleton_method(:original_filename) { 'tiktok.png' }
|
||||
tempfile.define_singleton_method(:content_type) { 'image/png' }
|
||||
|
||||
service = described_class.new(channel: channel, content: content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
allow(service).to receive(:fetch_attachment).and_return(tempfile)
|
||||
|
||||
service.perform
|
||||
|
||||
message = Message.last
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.last.file_type).to eq('image')
|
||||
expect(message.attachments.last.file).to be_attached
|
||||
ensure
|
||||
tempfile.close!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tiktok::ReadStatusService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_tiktok, account: account, business_id: 'biz-123') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
|
||||
let!(:conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox,
|
||||
additional_attributes: { conversation_id: 'tt-conv-1' }
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'enqueues Conversations::UpdateMessageStatusJob for inbound read events' do
|
||||
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
|
||||
|
||||
content = {
|
||||
conversation_id: 'tt-conv-1',
|
||||
read: { last_read_timestamp: 1_700_000_000_000 },
|
||||
from_user: { id: 'user-1' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
described_class.new(channel: channel, content: content).perform
|
||||
|
||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, kind_of(Time))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tiktok::SendOnTiktokService do
|
||||
let(:tiktok_client) { instance_double(Tiktok::Client) }
|
||||
let(:status_update_service) { instance_double(Messages::StatusUpdateService, perform: true) }
|
||||
|
||||
let(:channel) { create(:channel_tiktok, business_id: 'biz-123') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: inbox.account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
|
||||
let(:conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox,
|
||||
additional_attributes: { conversation_id: 'tt-conv-1' }
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(channel).to receive(:validated_access_token).and_return('valid-access-token')
|
||||
allow(Tiktok::Client).to receive(:new).and_return(tiktok_client)
|
||||
allow(Messages::StatusUpdateService).to receive(:new).and_return(status_update_service)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'sends outgoing text message and updates source_id' do
|
||||
allow(tiktok_client).to receive(:send_text_message).and_return('tt-msg-123')
|
||||
|
||||
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Hello')
|
||||
message.update!(source_id: nil)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(tiktok_client).to have_received(:send_text_message).with('tt-conv-1', 'Hello', referenced_message_id: nil)
|
||||
expect(message.reload.source_id).to eq('tt-msg-123')
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
|
||||
end
|
||||
|
||||
it 'sends outgoing image message when a single attachment is present' do
|
||||
allow(tiktok_client).to receive(:send_media_message).and_return('tt-msg-124')
|
||||
|
||||
message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first, referenced_message_id: nil)
|
||||
expect(message.reload.source_id).to eq('tt-msg-124')
|
||||
end
|
||||
|
||||
it 'marks message as failed when sending multiple attachments' do
|
||||
allow(tiktok_client).to receive(:send_media_message)
|
||||
|
||||
message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
|
||||
a1 = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
a1.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
a2 = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
a2.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', kind_of(String))
|
||||
expect(tiktok_client).not_to have_received(:send_media_message)
|
||||
end
|
||||
end
|
||||
end
|
||||
57
research/chatwoot/spec/services/tiktok/token_service_spec.rb
Normal file
57
research/chatwoot/spec/services/tiktok/token_service_spec.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tiktok::TokenService do
|
||||
let(:channel) do
|
||||
create(
|
||||
:channel_tiktok,
|
||||
access_token: 'old-access-token',
|
||||
refresh_token: 'old-refresh-token',
|
||||
expires_at: 1.minute.ago,
|
||||
refresh_token_expires_at: 1.day.from_now
|
||||
)
|
||||
end
|
||||
|
||||
describe '#access_token' do
|
||||
it 'returns current token when it is still valid' do
|
||||
channel.update!(expires_at: 10.minutes.from_now)
|
||||
allow(Tiktok::AuthClient).to receive(:renew_short_term_access_token)
|
||||
|
||||
token = described_class.new(channel: channel).access_token
|
||||
|
||||
expect(token).to eq('old-access-token')
|
||||
expect(Tiktok::AuthClient).not_to have_received(:renew_short_term_access_token)
|
||||
end
|
||||
|
||||
it 'refreshes access token when expired and refresh token is valid' do
|
||||
lock_manager = instance_double(Redis::LockManager, lock: true, unlock: true)
|
||||
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
|
||||
|
||||
allow(Tiktok::AuthClient).to receive(:renew_short_term_access_token).and_return(
|
||||
{
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_at: 1.day.from_now,
|
||||
refresh_token_expires_at: 30.days.from_now
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
token = described_class.new(channel: channel).access_token
|
||||
|
||||
expect(token).to eq('new-access-token')
|
||||
expect(channel.reload.access_token).to eq('new-access-token')
|
||||
expect(channel.refresh_token).to eq('new-refresh-token')
|
||||
end
|
||||
|
||||
it 'prompts reauthorization when both access and refresh tokens are expired' do
|
||||
channel.update!(expires_at: 1.day.ago, refresh_token_expires_at: 1.minute.ago)
|
||||
|
||||
allow(channel).to receive(:reauthorization_required?).and_return(false)
|
||||
allow(channel).to receive(:prompt_reauthorization!)
|
||||
|
||||
token = described_class.new(channel: channel).access_token
|
||||
|
||||
expect(token).to eq('old-access-token')
|
||||
expect(channel).to have_received(:prompt_reauthorization!)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Twilio::DeliveryStatusService do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:twilio_channel) do
|
||||
create(:channel_twilio_sms, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
let!(:contact) { create(:contact, account: account, phone_number: '+12345') }
|
||||
let(:contact_inbox) { create(:contact_inbox, source_id: '+12345', contact: contact, inbox: twilio_channel.inbox) }
|
||||
let!(:conversation) { create(:conversation, contact: contact, inbox: twilio_channel.inbox, contact_inbox: contact_inbox) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when message delivery status is fired' do
|
||||
before do
|
||||
create(:message, account: account, inbox: twilio_channel.inbox, conversation: conversation, status: :sent,
|
||||
source_id: 'SMd560ac79e4a4d36b3ce59f1f50471986')
|
||||
end
|
||||
|
||||
it 'updates the message if the status is delivered' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
MessageSid: conversation.messages.last.source_id,
|
||||
MessageStatus: 'delivered'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(conversation.reload.messages.last.status).to eq('delivered')
|
||||
end
|
||||
|
||||
it 'updates the message if the status is read' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
MessageSid: conversation.messages.last.source_id,
|
||||
MessageStatus: 'read'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(conversation.reload.messages.last.status).to eq('read')
|
||||
end
|
||||
|
||||
it 'does not update the message if the status is not a support status' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
MessageSid: conversation.messages.last.source_id,
|
||||
MessageStatus: 'queued'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(conversation.reload.messages.last.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'updates message status to failed if message status is undelivered' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
MessageSid: conversation.messages.last.source_id,
|
||||
MessageStatus: 'undelivered',
|
||||
ErrorCode: '30002',
|
||||
ErrorMessage: 'Account suspended'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(conversation.reload.messages.last.status).to eq('failed')
|
||||
expect(conversation.reload.messages.last.external_error).to eq('30002 - Account suspended')
|
||||
end
|
||||
|
||||
it 'updates message status to failed and updates the error message if message status is failed' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
MessageSid: conversation.messages.last.source_id,
|
||||
MessageStatus: 'failed',
|
||||
ErrorCode: '30008',
|
||||
ErrorMessage: 'Unknown error'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(conversation.reload.messages.last.status).to eq('failed')
|
||||
expect(conversation.reload.messages.last.external_error).to eq('30008 - Unknown error')
|
||||
end
|
||||
|
||||
it 'updates the error message if message status is undelivered and error message is not present' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
MessageSid: conversation.messages.last.source_id,
|
||||
MessageStatus: 'failed',
|
||||
ErrorCode: '30008'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(conversation.reload.messages.last.status).to eq('failed')
|
||||
expect(conversation.reload.messages.last.external_error).to eq('Error code: 30008')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,631 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Twilio::IncomingMessageService do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:twilio_channel) do
|
||||
create(:channel_twilio_sms, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
let!(:contact) { create(:contact, account: account, phone_number: '+12345') }
|
||||
let(:contact_inbox) { create(:contact_inbox, source_id: '+12345', contact: contact, inbox: twilio_channel.inbox) }
|
||||
let!(:conversation) { create(:conversation, contact: contact, inbox: twilio_channel.inbox, contact_inbox: contact_inbox) }
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates a new message in existing conversation' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'testing3'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(conversation.reload.messages.last.content).to eq('testing3')
|
||||
end
|
||||
|
||||
it 'removes null bytes' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: "remove\u0000 null bytes\u0000"
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(conversation.reload.messages.last.content).to eq('remove null bytes')
|
||||
end
|
||||
|
||||
it 'wont throw error when the body is empty' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(conversation.reload.messages.last.content).to be_nil
|
||||
end
|
||||
|
||||
it 'creates a new conversation when payload is from different number' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+123456',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'new conversation'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(twilio_channel.inbox.conversations.count).to eq(2)
|
||||
end
|
||||
|
||||
# Since we support the case with phone number as well. the previous case is with accoud_sid and messaging_service_sid
|
||||
context 'with a phone number' do
|
||||
let!(:twilio_channel) do
|
||||
create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'creates a new message in existing conversation' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
To: twilio_channel.phone_number,
|
||||
Body: 'testing3'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(conversation.reload.messages.last.content).to eq('testing3')
|
||||
end
|
||||
|
||||
it 'creates a new conversation when payload is from different number' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+123456',
|
||||
AccountSid: 'ACxxx',
|
||||
To: twilio_channel.phone_number,
|
||||
Body: 'new conversation'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(twilio_channel.inbox.conversations.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'reopen last conversation if last conversation is resolved and lock to single conversation is enabled' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
To: twilio_channel.phone_number,
|
||||
Body: 'testing3'
|
||||
}
|
||||
|
||||
twilio_channel.inbox.update(lock_to_single_conversation: true)
|
||||
conversation.update(status: 'resolved')
|
||||
described_class.new(params: params).perform
|
||||
# message appended to the last conversation
|
||||
expect(conversation.reload.messages.last.content).to eq('testing3')
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'creates a new conversation if last conversation is resolved and lock to single conversation is disabled' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
To: twilio_channel.phone_number,
|
||||
Body: 'testing3'
|
||||
}
|
||||
|
||||
twilio_channel.inbox.update(lock_to_single_conversation: false)
|
||||
conversation.update(status: 'resolved')
|
||||
described_class.new(params: params).perform
|
||||
expect(twilio_channel.inbox.conversations.count).to eq(2)
|
||||
expect(twilio_channel.inbox.conversations.last.messages.last.content).to eq('testing3')
|
||||
end
|
||||
|
||||
it 'will not create a new conversation if last conversation is not resolved and lock to single conversation is disabled' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
To: twilio_channel.phone_number,
|
||||
Body: 'testing3'
|
||||
}
|
||||
|
||||
twilio_channel.inbox.update(lock_to_single_conversation: false)
|
||||
conversation.update(status: Conversation.statuses.except('resolved').keys.sample)
|
||||
described_class.new(params: params).perform
|
||||
expect(twilio_channel.inbox.conversations.count).to eq(1)
|
||||
expect(twilio_channel.inbox.conversations.last.messages.last.content).to eq('testing3')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple channels configured' do
|
||||
before do
|
||||
2.times.each do
|
||||
create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'ACxxx', messaging_service_sid: nil,
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a new conversation in appropriate channel' do
|
||||
twilio_sms_channel = create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+123456',
|
||||
AccountSid: 'ACxxx',
|
||||
To: twilio_sms_channel.phone_number,
|
||||
Body: 'new conversation'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
expect(twilio_sms_channel.inbox.conversations.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a message with an attachment is received' do
|
||||
before do
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
||||
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
|
||||
end
|
||||
|
||||
let(:params_with_attachment) do
|
||||
{
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'testing3',
|
||||
NumMedia: '1',
|
||||
MediaContentType0: 'image/jpeg',
|
||||
MediaUrl0: 'https://chatwoot-assets.local/sample.png'
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new message with media in existing conversation' do
|
||||
described_class.new(params: params_with_attachment).perform
|
||||
expect(conversation.reload.messages.last.content).to eq('testing3')
|
||||
expect(conversation.reload.messages.last.attachments.count).to eq(1)
|
||||
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there is an error downloading the attachment' do
|
||||
before do
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
||||
.to_raise(Down::Error.new('Download error'))
|
||||
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
||||
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
|
||||
end
|
||||
|
||||
let(:params_with_attachment_error) do
|
||||
{
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'testing3',
|
||||
NumMedia: '1',
|
||||
MediaContentType0: 'image/jpeg',
|
||||
MediaUrl0: 'https://chatwoot-assets.local/sample.png'
|
||||
}
|
||||
end
|
||||
|
||||
it 'retries downloading the attachment without a token after an error' do
|
||||
expect do
|
||||
described_class.new(params: params_with_attachment_error).perform
|
||||
end.not_to raise_error
|
||||
|
||||
expect(conversation.reload.messages.last.content).to eq('testing3')
|
||||
expect(conversation.reload.messages.last.attachments.count).to eq(1)
|
||||
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a message with multiple attachments is received' do
|
||||
before do
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
||||
.to_return(status: 200, body: 'image data 1', headers: { 'Content-Type' => 'image/png' })
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.jpg')
|
||||
.to_return(status: 200, body: 'image data 2', headers: { 'Content-Type' => 'image/jpeg' })
|
||||
end
|
||||
|
||||
let(:params_with_multiple_attachments) do
|
||||
{
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'testing multiple media',
|
||||
NumMedia: '2',
|
||||
MediaContentType0: 'image/png',
|
||||
MediaUrl0: 'https://chatwoot-assets.local/sample.png',
|
||||
MediaContentType1: 'image/jpeg',
|
||||
MediaUrl1: 'https://chatwoot-assets.local/sample.jpg'
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new message with multiple media attachments in existing conversation' do
|
||||
described_class.new(params: params_with_multiple_attachments).perform
|
||||
expect(conversation.reload.messages.last.content).to eq('testing multiple media')
|
||||
expect(conversation.reload.messages.last.attachments.count).to eq(2)
|
||||
expect(conversation.reload.messages.last.attachments.map(&:file_type)).to contain_exactly('image', 'image')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a location message is received' do
|
||||
let(:params_with_location) do
|
||||
{
|
||||
SmsSid: 'SMxx',
|
||||
From: '+12345',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
MessageType: 'location',
|
||||
Latitude: '12.160894393921',
|
||||
Longitude: '75.265205383301'
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a message with location attachment' do
|
||||
described_class.new(params: params_with_location).perform
|
||||
|
||||
message = conversation.reload.messages.last
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.first.file_type).to eq('location')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ProfileName is provided for WhatsApp' do
|
||||
it 'uses ProfileName as contact name' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Hello with profile name',
|
||||
ProfileName: 'John Doe'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
|
||||
expect(contact.name).to eq('John Doe')
|
||||
end
|
||||
|
||||
it 'falls back to formatted phone number when ProfileName is blank' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Hello without profile name',
|
||||
ProfileName: ''
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
|
||||
expect(contact.name).to eq('1234567890')
|
||||
end
|
||||
|
||||
it 'uses formatted phone number when ProfileName is not provided' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Regular SMS message'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
|
||||
expect(contact.name).to eq('1234567890')
|
||||
end
|
||||
|
||||
it 'updates existing contact name when current name matches phone number' do
|
||||
# Create contact with phone number as name
|
||||
existing_contact = create(:contact,
|
||||
account: twilio_channel.inbox.account,
|
||||
name: '+1234567890',
|
||||
phone_number: '+1234567890')
|
||||
create(:contact_inbox,
|
||||
contact: existing_contact,
|
||||
inbox: twilio_channel.inbox,
|
||||
source_id: '+1234567890')
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Hello',
|
||||
ProfileName: 'Jane Smith'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq('Jane Smith')
|
||||
end
|
||||
|
||||
it 'does not update contact name when current name is different from phone number' do
|
||||
# Create contact with human name
|
||||
existing_contact = create(:contact,
|
||||
account: twilio_channel.inbox.account,
|
||||
name: 'John Doe',
|
||||
phone_number: '+1234567890')
|
||||
create(:contact_inbox,
|
||||
contact: existing_contact,
|
||||
inbox: twilio_channel.inbox,
|
||||
source_id: '+1234567890')
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Hello',
|
||||
ProfileName: 'Jane Smith'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq('John Doe') # Should not change
|
||||
end
|
||||
|
||||
it 'updates contact name when current name matches formatted phone number' do
|
||||
# Create contact with formatted phone number as name
|
||||
existing_contact = create(:contact,
|
||||
account: twilio_channel.inbox.account,
|
||||
name: '1234567890',
|
||||
phone_number: '+1234567890')
|
||||
create(:contact_inbox,
|
||||
contact: existing_contact,
|
||||
inbox: twilio_channel.inbox,
|
||||
source_id: '+1234567890')
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Hello',
|
||||
ProfileName: 'Alice Johnson'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq('Alice Johnson')
|
||||
end
|
||||
|
||||
describe 'When the incoming number is a Brazilian number in new format with 9 included' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts if contact does not exist' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5541988887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil',
|
||||
ProfileName: 'João Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('João Silva')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
|
||||
end
|
||||
|
||||
it 'appends to existing contact if contact inbox exists' do
|
||||
# Create existing contact with same format
|
||||
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
|
||||
inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5541988887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Another message from Brazil',
|
||||
ProfileName: 'João Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# No new conversation should be created
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Another message from Brazil')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When incoming number is a Brazilian number in old format without the 9 included' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'appends to existing contact when contact inbox exists in old format' do
|
||||
# Create existing contact with old format (12 digits)
|
||||
old_contact = create(:contact, account: account, phone_number: '+554188887777')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+554188887777', contact: old_contact, inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+554188887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil old format',
|
||||
ProfileName: 'Maria Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# No new conversation should be created
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Brazil old format')
|
||||
end
|
||||
|
||||
it 'appends to existing contact when contact inbox exists in new format' do
|
||||
# Create existing contact with new format (13 digits)
|
||||
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
|
||||
inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
# Incoming message with old format (12 digits)
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+554188887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil',
|
||||
ProfileName: 'João Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# Should find and use existing contact, not create duplicate
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the existing conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Brazil')
|
||||
# Should use the existing contact's source_id (normalized format)
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
|
||||
end
|
||||
|
||||
it 'creates contact inbox with incoming number when no existing contact' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+554188887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil',
|
||||
ProfileName: 'Carlos Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Silva')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+554188887777')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When the incoming number is an Argentine number with 9 after country code' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts if contact does not exist' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5491123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Carlos Mendoza'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Mendoza')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5491123456789')
|
||||
end
|
||||
|
||||
it 'appends to existing contact if contact inbox exists with normalized format' do
|
||||
# Create existing contact with normalized format (without 9 after country code)
|
||||
normalized_contact = create(:contact, account: account, phone_number: '+541123456789')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: normalized_contact,
|
||||
inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
# Incoming message with 9 after country code
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5491123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Carlos Mendoza'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# Should find and use existing contact, not create duplicate
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the existing conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
|
||||
# Should use the normalized source_id from existing contact
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When incoming number is an Argentine number without 9 after country code' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'appends to existing contact when contact inbox exists with same format' do
|
||||
# Create existing contact with same format (without 9)
|
||||
contact = create(:contact, account: account, phone_number: '+541123456789')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: contact, inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+541123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Ana García'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# No new conversation should be created
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
|
||||
end
|
||||
|
||||
it 'creates contact inbox with incoming number when no existing contact' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+541123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Diego López'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Diego López')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Twilio::OneoffSmsCampaignService do
|
||||
subject(:sms_campaign_service) { described_class.new(campaign: campaign) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
|
||||
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
|
||||
let(:label1) { create(:label, account: account) }
|
||||
let(:label2) { create(:label, account: account) }
|
||||
let!(:campaign) do
|
||||
create(:campaign, inbox: twilio_inbox, account: account,
|
||||
audience: [{ type: 'Label', id: label1.id }, { type: 'Label', id: label2.id }])
|
||||
end
|
||||
let(:twilio_client) { double }
|
||||
let(:twilio_messages) { double }
|
||||
|
||||
describe 'perform' do
|
||||
before do
|
||||
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
|
||||
allow(twilio_client).to receive(:messages).and_return(twilio_messages)
|
||||
end
|
||||
|
||||
it 'raises error if the campaign is completed' do
|
||||
campaign.completed!
|
||||
|
||||
expect { sms_campaign_service.perform }.to raise_error 'Completed Campaign'
|
||||
end
|
||||
|
||||
it 'raises error invalid campaign when its not a oneoff sms campaign' do
|
||||
campaign = create(:campaign)
|
||||
|
||||
expect { described_class.new(campaign: campaign).perform }.to raise_error "Invalid campaign #{campaign.id}"
|
||||
end
|
||||
|
||||
it 'send messages to contacts in the audience and marks the campaign completed' do
|
||||
contact_with_label1, contact_with_label2, contact_with_both_labels = FactoryBot.create_list(:contact, 3, :with_phone_number, account: account)
|
||||
contact_with_label1.update_labels([label1.title])
|
||||
contact_with_label2.update_labels([label2.title])
|
||||
contact_with_both_labels.update_labels([label1.title, label2.title])
|
||||
expect(twilio_messages).to receive(:create).with(
|
||||
body: campaign.message,
|
||||
messaging_service_sid: twilio_sms.messaging_service_sid,
|
||||
to: contact_with_label1.phone_number,
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status'
|
||||
).once
|
||||
expect(twilio_messages).to receive(:create).with(
|
||||
body: campaign.message,
|
||||
messaging_service_sid: twilio_sms.messaging_service_sid,
|
||||
to: contact_with_label2.phone_number,
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status'
|
||||
).once
|
||||
expect(twilio_messages).to receive(:create).with(
|
||||
body: campaign.message,
|
||||
messaging_service_sid: twilio_sms.messaging_service_sid,
|
||||
to: contact_with_both_labels.phone_number,
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status'
|
||||
).once
|
||||
|
||||
sms_campaign_service.perform
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'uses liquid template service to process campaign message' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(Liquid::CampaignTemplateService).to receive(:new).with(campaign: campaign, contact: contact).and_call_original
|
||||
expect(twilio_messages).to receive(:create).once
|
||||
|
||||
sms_campaign_service.perform
|
||||
end
|
||||
|
||||
it 'continues processing contacts when Twilio raises an error' do
|
||||
contact_error, contact_success = FactoryBot.create_list(:contact, 2, :with_phone_number, account: account)
|
||||
contact_error.update_labels([label1.title])
|
||||
contact_success.update_labels([label1.title])
|
||||
|
||||
error = Twilio::REST::TwilioError.new("The 'To' number #{contact_error.phone_number} is not a valid phone number.")
|
||||
|
||||
allow(twilio_messages).to receive(:create).and_return(nil)
|
||||
|
||||
expect(twilio_messages).to receive(:create).with(
|
||||
body: campaign.message,
|
||||
messaging_service_sid: twilio_sms.messaging_service_sid,
|
||||
to: contact_error.phone_number,
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status'
|
||||
).and_raise(error)
|
||||
|
||||
expect(twilio_messages).to receive(:create).with(
|
||||
body: campaign.message,
|
||||
messaging_service_sid: twilio_sms.messaging_service_sid,
|
||||
to: contact_success.phone_number,
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status'
|
||||
).once
|
||||
|
||||
expect(Rails.logger).to receive(:error).with(
|
||||
"[Twilio Campaign #{campaign.id}] Failed to send to #{contact_error.phone_number}: #{error.message}"
|
||||
)
|
||||
|
||||
sms_campaign_service.perform
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,253 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Twilio::SendOnTwilioService do
|
||||
subject(:outgoing_message_service) { described_class.new(message: message) }
|
||||
|
||||
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||
let(:messages_double) { double }
|
||||
let(:message_record_double) { double }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
|
||||
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
|
||||
let!(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: twilio_inbox) }
|
||||
let(:conversation) { create(:conversation, contact: contact, inbox: twilio_inbox, contact_inbox: contact_inbox) }
|
||||
|
||||
before do
|
||||
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
|
||||
allow(twilio_client).to receive(:messages).and_return(messages_double)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let!(:widget_inbox) { create(:inbox, account: account) }
|
||||
let!(:twilio_whatsapp) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
|
||||
let!(:twilio_whatsapp_inbox) { create(:inbox, channel: twilio_whatsapp, account: account) }
|
||||
|
||||
context 'without reply' do
|
||||
it 'if message is private' do
|
||||
message = create(:message, message_type: 'outgoing', private: true, inbox: twilio_inbox, account: account)
|
||||
described_class.new(message: message).perform
|
||||
expect(twilio_client).not_to have_received(:messages)
|
||||
expect(message.reload.source_id).to be_nil
|
||||
end
|
||||
|
||||
it 'if inbox channel is not twilio' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: widget_inbox, account: account)
|
||||
expect { described_class.new(message: message).perform }.to raise_error 'Invalid channel service was called'
|
||||
expect(twilio_client).not_to have_received(:messages)
|
||||
end
|
||||
|
||||
it 'if message is not outgoing' do
|
||||
message = create(:message, message_type: 'incoming', inbox: twilio_inbox, account: account)
|
||||
described_class.new(message: message).perform
|
||||
expect(twilio_client).not_to have_received(:messages)
|
||||
expect(message.reload.source_id).to be_nil
|
||||
end
|
||||
|
||||
it 'if message has an source id' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, source_id: SecureRandom.uuid)
|
||||
described_class.new(message: message).perform
|
||||
expect(twilio_client).not_to have_received(:messages)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with reply' do
|
||||
it 'if message is sent from chatwoot and is outgoing' do
|
||||
allow(messages_double).to receive(:create).and_return(message_record_double)
|
||||
allow(message_record_double).to receive(:sid).and_return('1234')
|
||||
|
||||
outgoing_message = create(
|
||||
:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, conversation: conversation
|
||||
)
|
||||
described_class.new(message: outgoing_message).perform
|
||||
|
||||
expect(outgoing_message.reload.source_id).to eq('1234')
|
||||
end
|
||||
end
|
||||
|
||||
it 'if outgoing message has attachment and is for whatsapp' do
|
||||
# check for message attachment url
|
||||
allow(messages_double).to receive(:create).with(hash_including(media_url: [anything])).and_return(message_record_double)
|
||||
allow(message_record_double).to receive(:sid).and_return('1234')
|
||||
|
||||
message = build(
|
||||
:message, message_type: 'outgoing', inbox: twilio_whatsapp_inbox, account: account, conversation: conversation
|
||||
)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
described_class.new(message: message).perform
|
||||
expect(messages_double).to have_received(:create).with(hash_including(media_url: [anything]))
|
||||
end
|
||||
|
||||
it 'if outgoing message has attachment and is for sms' do
|
||||
# check for message attachment url
|
||||
allow(messages_double).to receive(:create).with(hash_including(media_url: [anything])).and_return(message_record_double)
|
||||
allow(message_record_double).to receive(:sid).and_return('1234')
|
||||
|
||||
message = build(
|
||||
:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, conversation: conversation
|
||||
)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
described_class.new(message: message).perform
|
||||
expect(messages_double).to have_received(:create).with(hash_including(media_url: [anything]))
|
||||
end
|
||||
|
||||
it 'if message is sent from chatwoot fails' do
|
||||
allow(messages_double).to receive(:create).and_raise(Twilio::REST::TwilioError)
|
||||
|
||||
outgoing_message = create(
|
||||
:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, conversation: conversation
|
||||
)
|
||||
described_class.new(message: outgoing_message).perform
|
||||
expect(outgoing_message.reload.status).to eq('failed')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#send_csat_template_message' do
|
||||
let(:test_message) { create(:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, conversation: conversation) }
|
||||
let(:service) { described_class.new(message: test_message) }
|
||||
let(:mock_twilio_message) { instance_double(Twilio::REST::Api::V2010::AccountContext::MessageInstance, sid: 'SM123456789') }
|
||||
|
||||
# Test parameters defined using let statements
|
||||
let(:test_params) do
|
||||
{
|
||||
phone_number: '+1234567890',
|
||||
content_sid: 'HX123456789',
|
||||
content_variables: { '1' => 'conversation-uuid-123' }
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(twilio_sms).to receive(:send_message_from).and_return({ from: '+0987654321' })
|
||||
allow(twilio_sms).to receive(:respond_to?).and_return(true)
|
||||
allow(twilio_sms).to receive(:twilio_delivery_status_index_url).and_return('http://localhost:3000/twilio/delivery_status')
|
||||
end
|
||||
|
||||
context 'when template message is sent successfully' do
|
||||
before do
|
||||
allow(messages_double).to receive(:create).and_return(mock_twilio_message)
|
||||
end
|
||||
|
||||
it 'sends template message with correct parameters' do
|
||||
expected_params = {
|
||||
to: test_params[:phone_number],
|
||||
content_sid: test_params[:content_sid],
|
||||
content_variables: test_params[:content_variables].to_json,
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status',
|
||||
from: '+0987654321'
|
||||
}
|
||||
|
||||
result = service.send_csat_template_message(**test_params)
|
||||
|
||||
expect(messages_double).to have_received(:create).with(expected_params)
|
||||
expect(result).to eq({ success: true, message_id: 'SM123456789' })
|
||||
end
|
||||
|
||||
it 'sends template message without content variables when empty' do
|
||||
expected_params = {
|
||||
to: test_params[:phone_number],
|
||||
content_sid: test_params[:content_sid],
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status',
|
||||
from: '+0987654321'
|
||||
}
|
||||
|
||||
result = service.send_csat_template_message(
|
||||
phone_number: test_params[:phone_number],
|
||||
content_sid: test_params[:content_sid]
|
||||
)
|
||||
|
||||
expect(messages_double).to have_received(:create).with(expected_params)
|
||||
expect(result).to eq({ success: true, message_id: 'SM123456789' })
|
||||
end
|
||||
|
||||
it 'includes custom status callback when channel supports it' do
|
||||
allow(twilio_sms).to receive(:respond_to?).and_return(true)
|
||||
allow(twilio_sms).to receive(:twilio_delivery_status_index_url).and_return('https://example.com/webhook')
|
||||
|
||||
expected_params = {
|
||||
to: test_params[:phone_number],
|
||||
content_sid: test_params[:content_sid],
|
||||
content_variables: test_params[:content_variables].to_json,
|
||||
status_callback: 'https://example.com/webhook',
|
||||
from: '+0987654321'
|
||||
}
|
||||
|
||||
service.send_csat_template_message(**test_params)
|
||||
|
||||
expect(messages_double).to have_received(:create).with(expected_params)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Twilio API returns an error' do
|
||||
before do
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'handles Twilio::REST::TwilioError' do
|
||||
allow(messages_double).to receive(:create).and_raise(Twilio::REST::TwilioError, 'Invalid phone number')
|
||||
|
||||
result = service.send_csat_template_message(**test_params)
|
||||
|
||||
expect(result).to eq({ success: false, error: 'Invalid phone number' })
|
||||
expect(Rails.logger).to have_received(:error).with('Failed to send Twilio template message: Invalid phone number')
|
||||
end
|
||||
|
||||
it 'handles Twilio API errors' do
|
||||
allow(messages_double).to receive(:create).and_raise(Twilio::REST::TwilioError, 'Content template not found')
|
||||
|
||||
result = service.send_csat_template_message(**test_params)
|
||||
|
||||
expect(result).to eq({ success: false, error: 'Content template not found' })
|
||||
expect(Rails.logger).to have_received(:error).with('Failed to send Twilio template message: Content template not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with parameter handling' do
|
||||
before do
|
||||
allow(messages_double).to receive(:create).and_return(mock_twilio_message)
|
||||
end
|
||||
|
||||
it 'handles empty content_variables hash' do
|
||||
expected_params = {
|
||||
to: test_params[:phone_number],
|
||||
content_sid: test_params[:content_sid],
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status',
|
||||
from: '+0987654321'
|
||||
}
|
||||
|
||||
service.send_csat_template_message(
|
||||
phone_number: test_params[:phone_number],
|
||||
content_sid: test_params[:content_sid],
|
||||
content_variables: {}
|
||||
)
|
||||
|
||||
expect(messages_double).to have_received(:create).with(expected_params)
|
||||
end
|
||||
|
||||
it 'converts content_variables to JSON when present' do
|
||||
variables = { '1' => 'test-uuid', '2' => 'another-value' }
|
||||
expected_params = {
|
||||
to: test_params[:phone_number],
|
||||
content_sid: test_params[:content_sid],
|
||||
content_variables: variables.to_json,
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status',
|
||||
from: '+0987654321'
|
||||
}
|
||||
|
||||
service.send_csat_template_message(
|
||||
phone_number: test_params[:phone_number],
|
||||
content_sid: test_params[:content_sid],
|
||||
content_variables: variables
|
||||
)
|
||||
|
||||
expect(messages_double).to have_received(:create).with(expected_params)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,598 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Twilio::TemplateProcessorService do
|
||||
subject(:processor_service) { described_class.new(channel: twilio_channel, template_params: template_params, message: message) }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
|
||||
let!(:contact) { create(:contact, account: account) }
|
||||
let!(:inbox) { create(:inbox, channel: twilio_channel, account: account) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
|
||||
let!(:conversation) { create(:conversation, contact: contact, inbox: inbox, contact_inbox: contact_inbox) }
|
||||
let!(:message) { create(:message, conversation: conversation, account: account) }
|
||||
|
||||
let(:content_templates) do
|
||||
{
|
||||
'templates' => [
|
||||
{
|
||||
'content_sid' => 'HX123456789',
|
||||
'friendly_name' => 'hello_world',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'text',
|
||||
'media_type' => nil,
|
||||
'variables' => {},
|
||||
'category' => 'utility',
|
||||
'body' => 'Hello World!'
|
||||
},
|
||||
{
|
||||
'content_sid' => 'HX987654321',
|
||||
'friendly_name' => 'greet',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'text',
|
||||
'media_type' => nil,
|
||||
'variables' => { '1' => 'John' },
|
||||
'category' => 'utility',
|
||||
'body' => 'Hello {{1}}!'
|
||||
},
|
||||
{
|
||||
'content_sid' => 'HX555666777',
|
||||
'friendly_name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'media',
|
||||
'media_type' => 'image',
|
||||
'variables' => { '1' => 'https://example.com/image.jpg', '2' => 'iPhone', '3' => '$999' },
|
||||
'category' => 'marketing',
|
||||
'body' => 'Check out {{2}} for {{3}}'
|
||||
},
|
||||
{
|
||||
'content_sid' => 'HX111222333',
|
||||
'friendly_name' => 'welcome_message',
|
||||
'language' => 'en_US',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'quick_reply',
|
||||
'media_type' => nil,
|
||||
'variables' => {},
|
||||
'category' => 'utility',
|
||||
'body' => 'Welcome! How can we help?'
|
||||
},
|
||||
{
|
||||
'content_sid' => 'HX444555666',
|
||||
'friendly_name' => 'order_status',
|
||||
'language' => 'es',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'text',
|
||||
'media_type' => nil,
|
||||
'variables' => { '1' => 'Juan', '2' => 'ORD123' },
|
||||
'category' => 'utility',
|
||||
'body' => 'Hola {{1}}, tu pedido {{2}} está confirmado'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
twilio_channel.update!(content_templates: content_templates)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
context 'with blank template_params' do
|
||||
let(:template_params) { nil }
|
||||
|
||||
it 'returns nil values' do
|
||||
result = processor_service.call
|
||||
|
||||
expect(result).to eq([nil, nil])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty template_params' do
|
||||
let(:template_params) { {} }
|
||||
|
||||
it 'returns nil values' do
|
||||
result = processor_service.call
|
||||
|
||||
expect(result).to eq([nil, nil])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with template not found' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'nonexistent_template',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns nil values' do
|
||||
result = processor_service.call
|
||||
|
||||
expect(result).to eq([nil, nil])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with text templates' do
|
||||
context 'with simple text template (no variables)' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'hello_world',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns content_sid and empty variables' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX123456789')
|
||||
expect(content_variables).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with text template using processed_params format' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'greet',
|
||||
'language' => 'en',
|
||||
'processed_params' => {
|
||||
'1' => 'Alice',
|
||||
'2' => 'Premium User'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes key-value parameters correctly' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX987654321')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'Alice',
|
||||
'2' => 'Premium User'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with text template using WhatsApp Cloud API format' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'greet',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Bob' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes WhatsApp format parameters correctly' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX987654321')
|
||||
expect(content_variables).to eq({ '1' => 'Bob' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple body parameters' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'greet',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Charlie' },
|
||||
{ 'type' => 'text', 'text' => 'VIP Member' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes multiple parameters with sequential indexing' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX987654321')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'Charlie',
|
||||
'2' => 'VIP Member'
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with quick reply templates' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'welcome_message',
|
||||
'language' => 'en_US'
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes quick reply templates like text templates' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX111222333')
|
||||
expect(content_variables).to eq({})
|
||||
end
|
||||
|
||||
context 'with quick reply template having body parameters' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'welcome_message',
|
||||
'language' => 'en_US',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Diana' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes body parameters for quick reply templates' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX111222333')
|
||||
expect(content_variables).to eq({ '1' => 'Diana' })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with media templates' do
|
||||
context 'with media template using processed_params format' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'processed_params' => {
|
||||
'1' => 'https://cdn.example.com/product.jpg',
|
||||
'2' => 'MacBook Pro',
|
||||
'3' => '$2499'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes key-value parameters for media templates' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'https://cdn.example.com/product.jpg',
|
||||
'2' => 'MacBook Pro',
|
||||
'3' => '$2499'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with media template using WhatsApp Cloud API format' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'header',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'image',
|
||||
'image' => { 'link' => 'https://example.com/product-image.jpg' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Samsung Galaxy' },
|
||||
{ 'type' => 'text', 'text' => '$899' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes media header and body parameters correctly' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'https://example.com/product-image.jpg',
|
||||
'2' => 'Samsung Galaxy',
|
||||
'3' => '$899'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with video media template' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'header',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'video',
|
||||
'video' => { 'link' => 'https://example.com/demo.mp4' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Product Demo' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes video media parameters correctly' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'https://example.com/demo.mp4',
|
||||
'2' => 'Product Demo'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with document media template' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'header',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'document',
|
||||
'document' => { 'link' => 'https://example.com/brochure.pdf' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Product Brochure' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes document media parameters correctly' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'https://example.com/brochure.pdf',
|
||||
'2' => 'Product Brochure'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with header parameter without media link' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'header',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Header Text' }
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Body Text' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'skips header without media and processes body parameters' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({ '1' => 'Body Text' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mixed component types' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'header',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'image',
|
||||
'image' => { 'link' => 'https://example.com/header.jpg' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'First param' },
|
||||
{ 'type' => 'text', 'text' => 'Second param' }
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'footer',
|
||||
'parameters' => []
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes supported components and ignores unsupported ones' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'https://example.com/header.jpg',
|
||||
'2' => 'First param',
|
||||
'3' => 'Second param'
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with language matching' do
|
||||
context 'with exact language match' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'order_status',
|
||||
'language' => 'es'
|
||||
}
|
||||
end
|
||||
|
||||
it 'finds template with exact language match' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX444555666')
|
||||
expect(content_variables).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with default language fallback' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'hello_world'
|
||||
# No language specified, should default to 'en'
|
||||
}
|
||||
end
|
||||
|
||||
it 'defaults to English when no language specified' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX123456789')
|
||||
expect(content_variables).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with unapproved template status' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'unapproved_template',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
unapproved_template = {
|
||||
'content_sid' => 'HX_UNAPPROVED',
|
||||
'friendly_name' => 'unapproved_template',
|
||||
'language' => 'en',
|
||||
'status' => 'pending',
|
||||
'template_type' => 'text',
|
||||
'variables' => {},
|
||||
'body' => 'This is unapproved'
|
||||
}
|
||||
|
||||
updated_templates = content_templates['templates'] + [unapproved_template]
|
||||
twilio_channel.update!(
|
||||
content_templates: { 'templates' => updated_templates }
|
||||
)
|
||||
end
|
||||
|
||||
it 'ignores templates that are not approved' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to be_nil
|
||||
expect(content_variables).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with unknown template type' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'unknown_type',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
unknown_template = {
|
||||
'content_sid' => 'HX_UNKNOWN',
|
||||
'friendly_name' => 'unknown_type',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'catalog',
|
||||
'variables' => {},
|
||||
'body' => 'Catalog template'
|
||||
}
|
||||
|
||||
updated_templates = content_templates['templates'] + [unknown_template]
|
||||
twilio_channel.update!(
|
||||
content_templates: { 'templates' => updated_templates }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns empty content variables for unknown template types' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX_UNKNOWN')
|
||||
expect(content_variables).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'template finding behavior' do
|
||||
context 'with no content_templates' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'hello_world',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
twilio_channel.update!(content_templates: {})
|
||||
end
|
||||
|
||||
it 'returns nil values when content_templates is empty' do
|
||||
result = processor_service.call
|
||||
|
||||
expect(result).to eq([nil, nil])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil content_templates' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'hello_world',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
twilio_channel.update!(content_templates: nil)
|
||||
end
|
||||
|
||||
it 'returns nil values when content_templates is nil' do
|
||||
result = processor_service.call
|
||||
|
||||
expect(result).to eq([nil, nil])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,367 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Twilio::TemplateSyncService do
|
||||
subject(:sync_service) { described_class.new(channel: twilio_channel) }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
|
||||
|
||||
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||
let(:content_api) { double }
|
||||
let(:contents_list) { double }
|
||||
|
||||
# Mock Twilio template objects
|
||||
let(:text_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX123456789',
|
||||
friendly_name: 'hello_world',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => 'Hello World!' } }
|
||||
)
|
||||
end
|
||||
|
||||
let(:media_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX987654321',
|
||||
friendly_name: 'product_showcase',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: { '1' => 'iPhone', '2' => '$999' },
|
||||
types: {
|
||||
'twilio/media' => {
|
||||
'body' => 'Check out {{1}} for {{2}}',
|
||||
'media' => ['https://example.com/image.jpg']
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:quick_reply_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX555666777',
|
||||
friendly_name: 'welcome_message',
|
||||
language: 'en_US',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/quick-reply' => {
|
||||
'body' => 'Welcome! How can we help?',
|
||||
'actions' => [
|
||||
{ 'id' => 'support', 'title' => 'Support' },
|
||||
{ 'id' => 'sales', 'title' => 'Sales' }
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:catalog_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX111222333',
|
||||
friendly_name: 'product_catalog',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/catalog' => {
|
||||
'body' => 'Check our catalog',
|
||||
'catalog_id' => 'catalog123'
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:call_to_action_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX444555666',
|
||||
friendly_name: 'payment_reminder',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/call-to-action' => {
|
||||
'body' => 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
|
||||
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
|
||||
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
|
||||
'actions' => [
|
||||
{ 'id' => 'make_payment', 'title' => 'Make Payment', 'url' => 'https://example.com/payment' }
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template, call_to_action_template] }
|
||||
|
||||
before do
|
||||
allow(twilio_channel).to receive(:send).and_call_original
|
||||
allow(twilio_channel).to receive(:send).with(:client).and_return(twilio_client)
|
||||
allow(twilio_client).to receive(:content).and_return(content_api)
|
||||
allow(content_api).to receive(:v1).and_return(content_api)
|
||||
allow(content_api).to receive(:contents).and_return(contents_list)
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return(templates)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
context 'with successful sync' do
|
||||
it 'fetches templates from Twilio and updates the channel' do
|
||||
freeze_time do
|
||||
result = sync_service.call
|
||||
|
||||
expect(result).to be_truthy
|
||||
expect(contents_list).to have_received(:list).with(limit: 1000)
|
||||
|
||||
twilio_channel.reload
|
||||
expect(twilio_channel.content_templates).to be_present
|
||||
expect(twilio_channel.content_templates['templates']).to be_an(Array)
|
||||
expect(twilio_channel.content_templates['templates'].size).to eq(5)
|
||||
expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
it 'correctly formats text templates' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
text_template_data = twilio_channel.content_templates['templates'].find do |t|
|
||||
t['friendly_name'] == 'hello_world'
|
||||
end
|
||||
|
||||
expect(text_template_data).to include(
|
||||
'content_sid' => 'HX123456789',
|
||||
'friendly_name' => 'hello_world',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'text',
|
||||
'media_type' => nil,
|
||||
'variables' => {},
|
||||
'category' => 'utility',
|
||||
'body' => 'Hello World!'
|
||||
)
|
||||
end
|
||||
|
||||
it 'correctly formats media templates' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
media_template_data = twilio_channel.content_templates['templates'].find do |t|
|
||||
t['friendly_name'] == 'product_showcase'
|
||||
end
|
||||
|
||||
expect(media_template_data).to include(
|
||||
'content_sid' => 'HX987654321',
|
||||
'friendly_name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'media',
|
||||
'media_type' => nil, # Would be derived from media content if present
|
||||
'variables' => { '1' => 'iPhone', '2' => '$999' },
|
||||
'category' => 'utility',
|
||||
'body' => 'Check out {{1}} for {{2}}'
|
||||
)
|
||||
end
|
||||
|
||||
it 'correctly formats quick reply templates' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
quick_reply_template_data = twilio_channel.content_templates['templates'].find do |t|
|
||||
t['friendly_name'] == 'welcome_message'
|
||||
end
|
||||
|
||||
expect(quick_reply_template_data).to include(
|
||||
'content_sid' => 'HX555666777',
|
||||
'friendly_name' => 'welcome_message',
|
||||
'language' => 'en_US',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'quick_reply',
|
||||
'media_type' => nil,
|
||||
'variables' => {},
|
||||
'category' => 'utility',
|
||||
'body' => 'Welcome! How can we help?'
|
||||
)
|
||||
end
|
||||
|
||||
it 'correctly formats call-to-action templates with variables' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
call_to_action_data = twilio_channel.content_templates['templates'].find do |t|
|
||||
t['friendly_name'] == 'payment_reminder'
|
||||
end
|
||||
|
||||
expect(call_to_action_data).to include(
|
||||
'content_sid' => 'HX444555666',
|
||||
'friendly_name' => 'payment_reminder',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'call_to_action',
|
||||
'media_type' => nil,
|
||||
'variables' => {},
|
||||
'category' => 'utility'
|
||||
)
|
||||
|
||||
expected_body = 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
|
||||
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
|
||||
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience'
|
||||
expect(call_to_action_data['body']).to eq(expected_body)
|
||||
expect(call_to_action_data['body']).to match(/{{date}}/)
|
||||
end
|
||||
|
||||
it 'categorizes marketing templates correctly' do
|
||||
marketing_template = instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX_MARKETING',
|
||||
friendly_name: 'promo_offer_50_off',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => '50% off sale!' } }
|
||||
)
|
||||
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([marketing_template])
|
||||
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
marketing_data = twilio_channel.content_templates['templates'].first
|
||||
|
||||
expect(marketing_data['category']).to eq('marketing')
|
||||
end
|
||||
|
||||
it 'categorizes authentication templates correctly' do
|
||||
auth_template = instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX_AUTH',
|
||||
friendly_name: 'otp_verification',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => 'Your OTP is {{1}}' } }
|
||||
)
|
||||
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([auth_template])
|
||||
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
auth_data = twilio_channel.content_templates['templates'].first
|
||||
|
||||
expect(auth_data['category']).to eq('authentication')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with API error' do
|
||||
before do
|
||||
allow(contents_list).to receive(:list).and_raise(Twilio::REST::TwilioError.new('API Error'))
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'handles Twilio::REST::TwilioError gracefully' do
|
||||
result = sync_service.call
|
||||
|
||||
expect(result).to be_falsey
|
||||
expect(Rails.logger).to have_received(:error).with('Twilio template sync failed: API Error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with generic error' do
|
||||
before do
|
||||
allow(contents_list).to receive(:list).and_raise(StandardError, 'Connection failed')
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'propagates non-Twilio errors' do
|
||||
expect { sync_service.call }.to raise_error(StandardError, 'Connection failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty templates list' do
|
||||
before do
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([])
|
||||
end
|
||||
|
||||
it 'updates channel with empty templates array' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
expect(twilio_channel.content_templates['templates']).to eq([])
|
||||
expect(twilio_channel.content_templates_last_updated).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'template categorization behavior' do
|
||||
it 'defaults to utility category for unrecognized patterns' do
|
||||
generic_template = instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX_GENERIC',
|
||||
friendly_name: 'order_status',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => 'Order updated' } }
|
||||
)
|
||||
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([generic_template])
|
||||
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
template_data = twilio_channel.content_templates['templates'].first
|
||||
|
||||
expect(template_data['category']).to eq('utility')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'template type detection' do
|
||||
context 'with multiple type definitions' do
|
||||
let(:mixed_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX_MIXED',
|
||||
friendly_name: 'mixed_type',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/media' => { 'body' => 'Media content' },
|
||||
'twilio/text' => { 'body' => 'Text content' }
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([mixed_template])
|
||||
end
|
||||
|
||||
it 'prioritizes media type for type detection but text for body extraction' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
template_data = twilio_channel.content_templates['templates'].first
|
||||
|
||||
# derive_template_type prioritizes media
|
||||
expect(template_data['template_type']).to eq('media')
|
||||
# but extract_body_content prioritizes text
|
||||
expect(template_data['body']).to eq('Text content')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Twilio::WebhookSetupService do
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||
|
||||
before do
|
||||
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'with a messaging service sid' do
|
||||
let(:channel_twilio_sms) { create(:channel_twilio_sms) }
|
||||
|
||||
let(:messaging) { instance_double(Twilio::REST::Messaging) }
|
||||
let(:services) { instance_double(Twilio::REST::Messaging::V1::ServiceContext) }
|
||||
|
||||
before do
|
||||
allow(twilio_client).to receive(:messaging).and_return(messaging)
|
||||
allow(messaging).to receive(:services).with(channel_twilio_sms.messaging_service_sid).and_return(services)
|
||||
allow(services).to receive(:update)
|
||||
end
|
||||
|
||||
it 'updates the messaging service' do
|
||||
described_class.new(inbox: channel_twilio_sms.inbox).perform
|
||||
|
||||
expect(services).to have_received(:update)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a phone number' do
|
||||
let(:channel_twilio_sms) { create(:channel_twilio_sms, :with_phone_number) }
|
||||
|
||||
let(:phone_double) { double }
|
||||
let(:phone_record_double) { double }
|
||||
|
||||
before do
|
||||
allow(phone_double).to receive(:update)
|
||||
allow(phone_record_double).to receive(:sid).and_return('1234')
|
||||
end
|
||||
|
||||
it 'logs error if phone_number is not found' do
|
||||
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
|
||||
allow(phone_double).to receive(:list).and_return([])
|
||||
|
||||
described_class.new(inbox: channel_twilio_sms.inbox).perform
|
||||
|
||||
expect(phone_double).not_to have_received(:update)
|
||||
end
|
||||
|
||||
it 'update webhook_url if phone_number is found' do
|
||||
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
|
||||
allow(phone_double).to receive(:list).and_return([phone_record_double])
|
||||
|
||||
described_class.new(inbox: channel_twilio_sms.inbox).perform
|
||||
|
||||
expect(phone_double).to have_received(:update).with(
|
||||
sms_method: 'POST',
|
||||
sms_url: twilio_callback_index_url
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Twitter::SendOnTwitterService do
|
||||
subject(:send_reply_service) { described_class.new(message: message) }
|
||||
|
||||
let(:twitter_client) { instance_double(Twitty::Facade) }
|
||||
let(:twitter_response) { instance_double(Twitty::Response) }
|
||||
let(:account) { create(:account) }
|
||||
let(:widget_inbox) { create(:inbox, account: account) }
|
||||
let(:twitter_channel) { create(:channel_twitter_profile, account: account) }
|
||||
let(:twitter_inbox) { create(:inbox, channel: twitter_channel, account: account) }
|
||||
let(:contact) { create(:contact, account: account, additional_attributes: { screen_name: 'test_user' }) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: twitter_inbox) }
|
||||
let(:dm_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
contact: contact,
|
||||
inbox: twitter_inbox,
|
||||
contact_inbox: contact_inbox,
|
||||
additional_attributes: { type: 'direct_message' }
|
||||
)
|
||||
end
|
||||
let(:tweet_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
contact: contact,
|
||||
inbox: twitter_inbox,
|
||||
contact_inbox: contact_inbox,
|
||||
additional_attributes: { type: 'tweet', tweet_id: '1234' }
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Twitty::Facade).to receive(:new).and_return(twitter_client)
|
||||
allow(twitter_client).to receive(:send_direct_message).and_return(true)
|
||||
allow(twitter_client).to receive(:send_tweet_reply).and_return(twitter_response)
|
||||
allow(twitter_response).to receive(:status).and_return('200')
|
||||
allow(twitter_response).to receive(:body).and_return(JSON.parse({ id_str: '12345' }.to_json))
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'without reply' do
|
||||
it 'if inbox channel is not twitter profile' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: widget_inbox, account: account)
|
||||
expect { described_class.new(message: message).perform }.to raise_error 'Invalid channel service was called'
|
||||
expect(twitter_client).not_to have_received(:send_direct_message)
|
||||
end
|
||||
|
||||
it 'if message is private' do
|
||||
message = create(:message, message_type: 'outgoing', private: true, inbox: twitter_inbox, account: account)
|
||||
described_class.new(message: message).perform
|
||||
expect(twitter_client).not_to have_received(:send_direct_message)
|
||||
end
|
||||
|
||||
it 'if message has source_id' do
|
||||
message = create(:message, message_type: 'outgoing', source_id: '123', inbox: twitter_inbox, account: account)
|
||||
described_class.new(message: message).perform
|
||||
expect(twitter_client).not_to have_received(:send_direct_message)
|
||||
end
|
||||
|
||||
it 'if message is not outgoing' do
|
||||
message = create(:message, message_type: 'incoming', inbox: twitter_inbox, account: account)
|
||||
described_class.new(message: message).perform
|
||||
expect(twitter_client).not_to have_received(:send_direct_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with reply' do
|
||||
it 'if conversation is a direct message' do
|
||||
create(:message, message_type: :incoming, inbox: twitter_inbox, account: account, conversation: dm_conversation)
|
||||
message = create(:message, message_type: :outgoing, inbox: twitter_inbox, account: account, conversation: dm_conversation)
|
||||
described_class.new(message: message).perform
|
||||
expect(twitter_client).to have_received(:send_direct_message)
|
||||
end
|
||||
|
||||
context 'when conversation is a tweet' do
|
||||
it 'creates a response with correct reply if reply to message is incoming' do
|
||||
create(
|
||||
:message,
|
||||
message_type: :incoming,
|
||||
sender: contact,
|
||||
source_id: 'test-source-id-1',
|
||||
inbox: twitter_inbox,
|
||||
account: account,
|
||||
conversation: tweet_conversation
|
||||
)
|
||||
message = create(:message, message_type: :outgoing, inbox: twitter_inbox, account: account, conversation: tweet_conversation)
|
||||
described_class.new(message: message).perform
|
||||
expect(twitter_client).to have_received(:send_tweet_reply).with(
|
||||
reply_to_tweet_id: 'test-source-id-1',
|
||||
tweet: "@test_user #{message.content}"
|
||||
)
|
||||
expect(message.reload.source_id).to eq '12345'
|
||||
end
|
||||
|
||||
it 'creates a response with correct reply if reply to message is outgoing' do
|
||||
outgoing_message = create(
|
||||
:message,
|
||||
message_type: :outgoing,
|
||||
source_id: 'test-source-id-1',
|
||||
inbox: twitter_inbox,
|
||||
account: account,
|
||||
conversation: tweet_conversation
|
||||
)
|
||||
reply_message = create(
|
||||
:message,
|
||||
message_type: :outgoing,
|
||||
inbox: twitter_inbox,
|
||||
account: account,
|
||||
conversation: tweet_conversation,
|
||||
in_reply_to: outgoing_message.id
|
||||
)
|
||||
described_class.new(message: reply_message).perform
|
||||
expect(twitter_client).to have_received(:send_tweet_reply).with(
|
||||
reply_to_tweet_id: 'test-source-id-1',
|
||||
tweet: "@#{twitter_inbox.name} #{reply_message.content}"
|
||||
)
|
||||
expect(reply_message.reload.source_id).to eq '12345'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Twitter::WebhookSubscribeService do
|
||||
subject(:webhook_subscribe_service) { described_class.new(inbox_id: twitter_inbox.id) }
|
||||
|
||||
let(:twitter_client) { instance_double(Twitty::Facade) }
|
||||
let(:twitter_success_response) { instance_double(Twitty::Response, status: '200', body: { message: 'Valid' }) }
|
||||
let(:twitter_error_response) { instance_double(Twitty::Response, status: '422', body: { message: 'Invalid request' }) }
|
||||
let(:account) { create(:account) }
|
||||
let(:twitter_channel) { create(:channel_twitter_profile, account: account) }
|
||||
let(:twitter_inbox) { create(:inbox, channel: twitter_channel, account: account) }
|
||||
|
||||
before do
|
||||
allow(Twitty::Facade).to receive(:new).and_return(twitter_client)
|
||||
allow(twitter_client).to receive(:register_webhook).and_return(twitter_success_response)
|
||||
allow(twitter_client).to receive(:unregister_webhook).and_return(twitter_success_response)
|
||||
allow(twitter_client).to receive(:fetch_subscriptions).and_return(instance_double(Twitty::Response, status: '204', body: { message: 'Valid' }))
|
||||
allow(twitter_client).to receive(:create_subscription).and_return(instance_double(Twitty::Response, status: '204', body: { message: 'Valid' }))
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when webhook is not registered' do
|
||||
it 'calls register_webhook' do
|
||||
allow(twitter_client).to receive(:fetch_webhooks).and_return(
|
||||
instance_double(Twitty::Response, status: '200', body: {})
|
||||
)
|
||||
webhook_subscribe_service.perform
|
||||
expect(twitter_client).not_to have_received(:unregister_webhook)
|
||||
expect(twitter_client).to have_received(:register_webhook)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid webhook is registered' do
|
||||
it 'calls unregister_webhook and then register webhook' do
|
||||
allow(twitter_client).to receive(:fetch_webhooks).and_return(
|
||||
instance_double(Twitty::Response, status: '200',
|
||||
body: [{ 'url' => webhook_subscribe_service.send(:twitter_url) }])
|
||||
)
|
||||
webhook_subscribe_service.perform
|
||||
expect(twitter_client).not_to have_received(:unregister_webhook)
|
||||
expect(twitter_client).not_to have_received(:register_webhook)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invalid webhook is registered' do
|
||||
it 'calls unregister_webhook and then register webhook' do
|
||||
allow(twitter_client).to receive(:fetch_webhooks).and_return(
|
||||
instance_double(Twitty::Response, status: '200',
|
||||
body: [{ 'url' => 'invalid_url' }])
|
||||
)
|
||||
webhook_subscribe_service.perform
|
||||
expect(twitter_client).to have_received(:unregister_webhook)
|
||||
expect(twitter_client).to have_received(:register_webhook)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when correct webhook is present' do
|
||||
it 'calls create subscription if subscription is not present' do
|
||||
allow(twitter_client).to receive(:fetch_webhooks).and_return(
|
||||
instance_double(Twitty::Response, status: '200',
|
||||
body: [{ 'url' => webhook_subscribe_service.send(:twitter_url) }])
|
||||
)
|
||||
allow(twitter_client).to receive(:fetch_subscriptions).and_return(instance_double(Twitty::Response, status: '500'))
|
||||
webhook_subscribe_service.perform
|
||||
expect(twitter_client).to have_received(:create_subscription)
|
||||
end
|
||||
|
||||
it 'does not call create subscription if subscription is already present' do
|
||||
allow(twitter_client).to receive(:fetch_webhooks).and_return(
|
||||
instance_double(Twitty::Response, status: '200',
|
||||
body: [{ 'url' => webhook_subscribe_service.send(:twitter_url) }])
|
||||
)
|
||||
webhook_subscribe_service.perform
|
||||
expect(twitter_client).not_to have_received(:create_subscription)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,129 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::ChannelCreationService do
|
||||
let(:account) { create(:account) }
|
||||
let(:waba_info) { { waba_id: 'test_waba_id', business_name: 'Test Business' } }
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: 'test_phone_id',
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
}
|
||||
end
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:service) { described_class.new(account, waba_info, phone_info, access_token) }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
# Stub the webhook teardown service to prevent HTTP calls during cleanup
|
||||
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
|
||||
allow(Whatsapp::WebhookTeardownService).to receive(:new).and_return(teardown_service)
|
||||
allow(teardown_service).to receive(:perform)
|
||||
|
||||
# Clean up any existing channels to avoid phone number conflicts
|
||||
Channel::Whatsapp.destroy_all
|
||||
|
||||
# Stub the webhook setup service to prevent HTTP calls during tests
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
|
||||
# Stub the provider validation and sync_templates
|
||||
allow(Channel::Whatsapp).to receive(:new).and_wrap_original do |method, *args|
|
||||
channel = method.call(*args)
|
||||
allow(channel).to receive(:validate_provider_config)
|
||||
allow(channel).to receive(:sync_templates)
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel does not exist' do
|
||||
it 'creates a new channel' do
|
||||
expect { service.perform }.to change(Channel::Whatsapp, :count).by(1)
|
||||
end
|
||||
|
||||
it 'creates channel with correct attributes' do
|
||||
channel = service.perform
|
||||
expect(channel.phone_number).to eq('+1234567890')
|
||||
expect(channel.provider).to eq('whatsapp_cloud')
|
||||
expect(channel.provider_config['api_key']).to eq(access_token)
|
||||
expect(channel.provider_config['phone_number_id']).to eq('test_phone_id')
|
||||
expect(channel.provider_config['business_account_id']).to eq('test_waba_id')
|
||||
expect(channel.provider_config['source']).to eq('embedded_signup')
|
||||
end
|
||||
|
||||
it 'creates an inbox for the channel' do
|
||||
channel = service.perform
|
||||
inbox = channel.inbox
|
||||
expect(inbox).not_to be_nil
|
||||
expect(inbox.name).to eq('Test Business WhatsApp')
|
||||
expect(inbox.account).to eq(account)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel already exists for the phone number' do
|
||||
let(:different_account) { create(:account) }
|
||||
|
||||
before do
|
||||
create(:channel_whatsapp, account: different_account, phone_number: '+1234567890',
|
||||
provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
|
||||
end
|
||||
|
||||
it 'raises an error even if the channel belongs to a different account' do
|
||||
expect { service.perform }.to raise_error(
|
||||
RuntimeError,
|
||||
I18n.t('errors.whatsapp.phone_number_already_exists', phone_number: '+1234567890')
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when required parameters are missing' do
|
||||
it 'raises error when account is nil' do
|
||||
service = described_class.new(nil, waba_info, phone_info, access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Account is required')
|
||||
end
|
||||
|
||||
it 'raises error when waba_info is nil' do
|
||||
service = described_class.new(account, nil, phone_info, access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'WABA info is required')
|
||||
end
|
||||
|
||||
it 'raises error when phone_info is nil' do
|
||||
service = described_class.new(account, waba_info, nil, access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Phone info is required')
|
||||
end
|
||||
|
||||
it 'raises error when access_token is blank' do
|
||||
service = described_class.new(account, waba_info, phone_info, '')
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business_name is in different places' do
|
||||
context 'when business_name is only in phone_info' do
|
||||
let(:waba_info) { { waba_id: 'test_waba_id' } }
|
||||
|
||||
it 'uses business_name from phone_info' do
|
||||
channel = service.perform
|
||||
expect(channel.inbox.name).to eq('Test Business WhatsApp')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business_name is only in waba_info' do
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: 'test_phone_id',
|
||||
phone_number: '+1234567890',
|
||||
verified: true
|
||||
}
|
||||
end
|
||||
|
||||
it 'uses business_name from waba_info' do
|
||||
channel = service.perform
|
||||
expect(channel.inbox.name).to eq('Test Business WhatsApp')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,376 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Whatsapp::CsatTemplateService do
|
||||
let(:account) { create(:account) }
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
|
||||
end
|
||||
let(:inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
|
||||
let(:service) { described_class.new(whatsapp_channel) }
|
||||
|
||||
let(:expected_template_name) { "customer_satisfaction_survey_#{whatsapp_channel.inbox.id}" }
|
||||
let(:template_config) do
|
||||
{
|
||||
message: 'How would you rate your experience?',
|
||||
button_text: 'Rate Us',
|
||||
language: 'en',
|
||||
base_url: 'https://example.com',
|
||||
template_name: expected_template_name
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with('WHATSAPP_CLOUD_BASE_URL', anything).and_return('https://graph.facebook.com')
|
||||
end
|
||||
|
||||
describe '#generate_template_name' do
|
||||
context 'when no existing template' do
|
||||
it 'returns base name as-is' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq('new_template_name')
|
||||
end
|
||||
|
||||
it 'returns base name when template key is missing' do
|
||||
whatsapp_channel.inbox.update!(csat_config: { 'other_config' => 'value' })
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq('new_template_name')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when existing template has no versioned name' do
|
||||
it 'starts versioning from 1' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => expected_template_name }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_1")
|
||||
end
|
||||
|
||||
it 'starts versioning from 1 for custom name' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => 'custom_survey' }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_1")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when existing template has versioned name' do
|
||||
it 'increments version number' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => "#{expected_template_name}_1" }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_2")
|
||||
end
|
||||
|
||||
it 'increments higher version numbers' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => "#{expected_template_name}_5" }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_6")
|
||||
end
|
||||
|
||||
it 'handles double digit version numbers' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => "#{expected_template_name}_12" }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_13")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when existing template has non-matching versioned name' do
|
||||
it 'starts versioning from 1' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => 'different_survey_3' }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq("#{expected_template_name}_1")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template name is blank' do
|
||||
it 'returns base name' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => '' }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq('new_template_name')
|
||||
end
|
||||
|
||||
it 'returns base name when template name is nil' do
|
||||
whatsapp_channel.inbox.update!(csat_config: {
|
||||
'template' => { 'name' => nil }
|
||||
})
|
||||
result = service.send(:generate_template_name, 'new_template_name')
|
||||
expect(result).to eq('new_template_name')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_template_request_body' do
|
||||
it 'builds correct request structure' do
|
||||
result = service.send(:build_template_request_body, template_config)
|
||||
|
||||
expect(result).to eq({
|
||||
name: expected_template_name,
|
||||
language: 'en',
|
||||
category: 'MARKETING',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
text: 'How would you rate your experience?'
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
type: 'URL',
|
||||
text: 'Rate Us',
|
||||
url: 'https://example.com/survey/responses/{{1}}',
|
||||
example: ['12345']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
it 'uses default language when not provided' do
|
||||
config_without_language = template_config.except(:language)
|
||||
result = service.send(:build_template_request_body, config_without_language)
|
||||
expect(result[:language]).to eq('en')
|
||||
end
|
||||
|
||||
it 'uses default button text when not provided' do
|
||||
config_without_button = template_config.except(:button_text)
|
||||
result = service.send(:build_template_request_body, config_without_button)
|
||||
expect(result[:components][1][:buttons][0][:text]).to eq('Please rate us')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_template' do
|
||||
let(:mock_response) do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
double('response', :success? => true, :body => '{}', '[]' => { 'id' => '123', 'name' => 'template_name' })
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
end
|
||||
|
||||
before do
|
||||
allow(HTTParty).to receive(:post).and_return(mock_response)
|
||||
inbox.update!(csat_config: {})
|
||||
end
|
||||
|
||||
it 'creates template with generated name' do
|
||||
expected_body = {
|
||||
name: expected_template_name,
|
||||
language: 'en',
|
||||
category: 'MARKETING',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
text: 'How would you rate your experience?'
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
type: 'URL',
|
||||
text: 'Rate Us',
|
||||
url: 'https://example.com/survey/responses/{{1}}',
|
||||
example: ['12345']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(HTTParty).to receive(:post).with(
|
||||
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates",
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}",
|
||||
'Content-Type' => 'application/json'
|
||||
},
|
||||
body: expected_body.to_json
|
||||
)
|
||||
|
||||
service.create_template(template_config)
|
||||
end
|
||||
|
||||
it 'returns success response on successful creation' do
|
||||
allow(mock_response).to receive(:[]).with('id').and_return('template_123')
|
||||
allow(mock_response).to receive(:[]).with('name').and_return(expected_template_name)
|
||||
|
||||
result = service.create_template(template_config)
|
||||
|
||||
expect(result).to eq({
|
||||
success: true,
|
||||
template_id: 'template_123',
|
||||
template_name: expected_template_name,
|
||||
language: 'en',
|
||||
status: 'PENDING'
|
||||
})
|
||||
end
|
||||
|
||||
context 'when API call fails' do
|
||||
let(:error_response) do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
double('response', success?: false, code: 400, body: '{"error": "Invalid template"}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
end
|
||||
|
||||
before do
|
||||
allow(HTTParty).to receive(:post).and_return(error_response)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
result = service.create_template(template_config)
|
||||
|
||||
expect(result).to eq({
|
||||
success: false,
|
||||
error: 'Template creation failed',
|
||||
response_body: '{"error": "Invalid template"}'
|
||||
})
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with('WhatsApp template creation failed: 400 - {"error": "Invalid template"}')
|
||||
service.create_template(template_config)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#delete_template' do
|
||||
it 'makes DELETE request to correct endpoint' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: true, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
|
||||
expect(HTTParty).to receive(:delete).with(
|
||||
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=test_template",
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
).and_return(mock_response)
|
||||
|
||||
result = service.delete_template('test_template')
|
||||
expect(result).to eq({ success: true, response_body: '{}' })
|
||||
end
|
||||
|
||||
it 'uses default template name when none provided' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: true, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
|
||||
expect(HTTParty).to receive(:delete).with(
|
||||
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=#{expected_template_name}",
|
||||
anything
|
||||
).and_return(mock_response)
|
||||
|
||||
service.delete_template
|
||||
end
|
||||
|
||||
it 'returns failure response when API call fails' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: false, body: '{"error": "Template not found"}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
allow(HTTParty).to receive(:delete).and_return(mock_response)
|
||||
|
||||
result = service.delete_template('test_template')
|
||||
expect(result).to eq({ success: false, response_body: '{"error": "Template not found"}' })
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_template_status' do
|
||||
it 'makes GET request to correct endpoint' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: true, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
allow(mock_response).to receive(:[]).with('data').and_return([{
|
||||
'id' => '123',
|
||||
'name' => 'test_template',
|
||||
'status' => 'APPROVED',
|
||||
'language' => 'en'
|
||||
}])
|
||||
|
||||
expect(HTTParty).to receive(:get).with(
|
||||
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=test_template",
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
).and_return(mock_response)
|
||||
|
||||
service.get_template_status('test_template')
|
||||
end
|
||||
|
||||
it 'returns success response when template exists' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: true, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
allow(mock_response).to receive(:[]).with('data').and_return([{
|
||||
'id' => '123',
|
||||
'name' => 'test_template',
|
||||
'status' => 'APPROVED',
|
||||
'language' => 'en'
|
||||
}])
|
||||
allow(HTTParty).to receive(:get).and_return(mock_response)
|
||||
|
||||
result = service.get_template_status('test_template')
|
||||
|
||||
expect(result).to eq({
|
||||
success: true,
|
||||
template: {
|
||||
id: '123',
|
||||
name: 'test_template',
|
||||
status: 'APPROVED',
|
||||
language: 'en'
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns failure response when template not found' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: true, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
allow(mock_response).to receive(:[]).with('data').and_return([])
|
||||
allow(HTTParty).to receive(:get).and_return(mock_response)
|
||||
|
||||
result = service.get_template_status('test_template')
|
||||
expect(result).to eq({ success: false, error: 'Template not found' })
|
||||
end
|
||||
|
||||
it 'returns failure response when API call fails' do
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
mock_response = double('response', success?: false, body: '{}')
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
allow(HTTParty).to receive(:get).and_return(mock_response)
|
||||
|
||||
result = service.get_template_status('test_template')
|
||||
expect(result).to eq({ success: false, error: 'Template not found' })
|
||||
end
|
||||
|
||||
context 'when API raises an exception' do
|
||||
before do
|
||||
allow(HTTParty).to receive(:get).and_raise(StandardError, 'Network error')
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'handles exceptions gracefully' do
|
||||
result = service.get_template_status('test_template')
|
||||
expect(result).to eq({ success: false, error: 'Network error' })
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with('Error fetching template status: Network error')
|
||||
service.get_template_status('test_template')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,243 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::EmbeddedSignupService do
|
||||
let(:account) { create(:account) }
|
||||
let(:params) do
|
||||
{
|
||||
code: 'test_authorization_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id',
|
||||
phone_number_id: 'test_phone_number_id'
|
||||
}
|
||||
end
|
||||
let(:service) { described_class.new(account: account, params: params) }
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: params[:phone_number_id],
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
}
|
||||
end
|
||||
let(:channel) { instance_double(Channel::Whatsapp) }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(GlobalConfig).to receive(:clear_cache)
|
||||
|
||||
# Mock service dependencies
|
||||
token_exchange = instance_double(Whatsapp::TokenExchangeService)
|
||||
allow(Whatsapp::TokenExchangeService).to receive(:new).with(params[:code]).and_return(token_exchange)
|
||||
allow(token_exchange).to receive(:perform).and_return(access_token)
|
||||
|
||||
phone_service = instance_double(Whatsapp::PhoneInfoService)
|
||||
allow(Whatsapp::PhoneInfoService).to receive(:new)
|
||||
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(phone_service)
|
||||
allow(phone_service).to receive(:perform).and_return(phone_info)
|
||||
|
||||
validation_service = instance_double(Whatsapp::TokenValidationService)
|
||||
allow(Whatsapp::TokenValidationService).to receive(:new)
|
||||
.with(access_token, params[:waba_id]).and_return(validation_service)
|
||||
allow(validation_service).to receive(:perform)
|
||||
|
||||
channel_creation = instance_double(Whatsapp::ChannelCreationService)
|
||||
allow(Whatsapp::ChannelCreationService).to receive(:new)
|
||||
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
|
||||
.and_return(channel_creation)
|
||||
allow(channel_creation).to receive(:perform).and_return(channel)
|
||||
|
||||
allow(channel).to receive(:setup_webhooks)
|
||||
allow(channel).to receive(:phone_number).and_return('+1234567890')
|
||||
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'CLOUD_API',
|
||||
throughput: { 'level' => 'STANDARD' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
end
|
||||
|
||||
it 'creates channel and sets up webhooks' do
|
||||
expect(channel).to receive(:setup_webhooks)
|
||||
|
||||
result = service.perform
|
||||
expect(result).to eq(channel)
|
||||
end
|
||||
|
||||
it 'checks health status after channel creation' do
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
expect(health_service).to receive(:fetch_health_status)
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
context 'when channel is in pending state' do
|
||||
it 'prompts reauthorization for pending channel' do
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'NOT_APPLICABLE',
|
||||
throughput: { 'level' => 'STANDARD' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
|
||||
expect(channel).to receive(:prompt_reauthorization!)
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'prompts reauthorization when throughput level is NOT_APPLICABLE' do
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'CLOUD_API',
|
||||
throughput: { 'level' => 'NOT_APPLICABLE' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
|
||||
expect(channel).to receive(:prompt_reauthorization!)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is healthy' do
|
||||
it 'does not prompt reauthorization for healthy channel' do
|
||||
expect(channel).not_to receive(:prompt_reauthorization!)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when parameters are invalid' do
|
||||
it 'raises ArgumentError for missing parameters' do
|
||||
invalid_service = described_class.new(account: account, params: { code: '', business_id: '', waba_id: '' })
|
||||
expect { invalid_service.perform }.to raise_error(ArgumentError, /Required parameters are missing/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when service fails' do
|
||||
it 'logs and re-raises errors' do
|
||||
token_exchange = instance_double(Whatsapp::TokenExchangeService)
|
||||
allow(Whatsapp::TokenExchangeService).to receive(:new).and_return(token_exchange)
|
||||
allow(token_exchange).to receive(:perform).and_raise('Token error')
|
||||
|
||||
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
|
||||
expect { service.perform }.to raise_error('Token error')
|
||||
end
|
||||
|
||||
it 'prompts reauthorization when webhook setup fails' do
|
||||
# Create a real channel to test the actual webhook failure behavior
|
||||
real_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
|
||||
# Mock the channel creation to return our real channel
|
||||
channel_creation = instance_double(Whatsapp::ChannelCreationService)
|
||||
allow(Whatsapp::ChannelCreationService).to receive(:new).and_return(channel_creation)
|
||||
allow(channel_creation).to receive(:perform).and_return(real_channel)
|
||||
|
||||
# Mock webhook setup to fail
|
||||
allow(real_channel).to receive(:perform_webhook_setup).and_raise('Webhook setup error')
|
||||
|
||||
# Verify channel is not marked for reauthorization initially
|
||||
expect(real_channel.reauthorization_required?).to be false
|
||||
|
||||
# The service completes successfully even if webhook fails (webhook error is rescued in setup_webhooks)
|
||||
result = service.perform
|
||||
expect(result).to eq(real_channel)
|
||||
|
||||
# Verify the channel is now marked for reauthorization
|
||||
expect(real_channel.reauthorization_required?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'with reauthorization flow' do
|
||||
let(:inbox_id) { 123 }
|
||||
let(:reauth_service) { instance_double(Whatsapp::ReauthorizationService) }
|
||||
let(:service_with_inbox) do
|
||||
described_class.new(account: account, params: params, inbox_id: inbox_id)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Whatsapp::ReauthorizationService).to receive(:new).with(
|
||||
account: account,
|
||||
inbox_id: inbox_id,
|
||||
phone_number_id: params[:phone_number_id],
|
||||
business_id: params[:business_id]
|
||||
).and_return(reauth_service)
|
||||
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
|
||||
|
||||
allow(channel).to receive(:phone_number).and_return('+1234567890')
|
||||
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'CLOUD_API',
|
||||
throughput: { 'level' => 'STANDARD' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
end
|
||||
|
||||
it 'uses ReauthorizationService and sets up webhooks' do
|
||||
expect(reauth_service).to receive(:perform)
|
||||
expect(channel).to receive(:setup_webhooks)
|
||||
|
||||
result = service_with_inbox.perform
|
||||
expect(result).to eq(channel)
|
||||
end
|
||||
|
||||
context 'with real channel requiring reauthorization' do
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, phone_number: '+1234567890',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:service_with_real_inbox) { described_class.new(account: account, params: params, inbox_id: inbox.id) }
|
||||
|
||||
before do
|
||||
inbox.update!(channel: whatsapp_channel)
|
||||
whatsapp_channel.prompt_reauthorization!
|
||||
|
||||
setup_reauthorization_mocks
|
||||
setup_health_service_mock
|
||||
end
|
||||
|
||||
it 'clears reauthorization flag when reauthorization completes' do
|
||||
expect(whatsapp_channel.reauthorization_required?).to be true
|
||||
result = service_with_real_inbox.perform
|
||||
expect(result).to eq(whatsapp_channel)
|
||||
expect(whatsapp_channel.reauthorization_required?).to be false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_reauthorization_mocks
|
||||
reauth_service = instance_double(Whatsapp::ReauthorizationService)
|
||||
allow(Whatsapp::ReauthorizationService).to receive(:new).with(
|
||||
account: account,
|
||||
inbox_id: inbox.id,
|
||||
phone_number_id: params[:phone_number_id],
|
||||
business_id: params[:business_id]
|
||||
).and_return(reauth_service)
|
||||
|
||||
allow(reauth_service).to receive(:perform) do
|
||||
whatsapp_channel.reauthorized!
|
||||
whatsapp_channel
|
||||
end
|
||||
|
||||
allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true)
|
||||
end
|
||||
|
||||
def setup_health_service_mock
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'CLOUD_API',
|
||||
throughput: { 'level' => 'STANDARD' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user