Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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('![sticker-52002738](https://stickershop.line-scdn.net/stickershop/v1/sticker/52002738/android/sticker.png)')
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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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