diff --git a/spec/enterprise/auto_assignment/assignment_service_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb similarity index 100% rename from spec/enterprise/auto_assignment/assignment_service_spec.rb rename to spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb diff --git a/spec/enterprise/services/enterprise/auto_assignment/balanced_selector_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/balanced_selector_spec.rb new file mode 100644 index 000000000..5a75ac969 --- /dev/null +++ b/spec/enterprise/services/enterprise/auto_assignment/balanced_selector_spec.rb @@ -0,0 +1,95 @@ +require 'rails_helper' + +RSpec.describe Enterprise::AutoAssignment::BalancedSelector 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) } + + before do + @member1 = create(:inbox_member, inbox: inbox, user: agent1) + @member2 = create(:inbox_member, inbox: inbox, user: agent2) + @member3 = create(:inbox_member, inbox: inbox, user: agent3) + end + + describe '#select_agent' do + context 'when selecting based on workload' do + let(:available_agents) { [@member1, @member2, @member3] } + + it 'selects the agent with least open conversations' do + # Agent1 has 3 open conversations + 3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'open') } + + # Agent2 has 1 open conversation + create(:conversation, inbox: inbox, assignee: agent2, status: 'open') + + # Agent3 has 2 open conversations + 2.times { create(:conversation, inbox: inbox, assignee: agent3, status: 'open') } + + selected_agent = selector.select_agent(available_agents) + + # Should select agent2 as they have the least conversations + expect(selected_agent).to eq(agent2) + end + + it 'considers only open conversations' do + # Agent1 has 1 open and 3 resolved conversations + create(:conversation, inbox: inbox, assignee: agent1, status: 'open') + 3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'resolved') } + + # Agent2 has 2 open conversations + 2.times { create(:conversation, inbox: inbox, assignee: agent2, status: 'open') } + + selected_agent = selector.select_agent([member1, @member2]) + + # Should select agent1 as they have fewer open conversations + expect(selected_agent).to eq(agent1) + end + + it 'uses round robin as fallback when agents have equal workload' do + # All agents have same number of conversations + [@member1, @member2, @member3].each do |member| + create(:conversation, inbox: inbox, assignee: member.user, status: 'open') + end + + round_robin_service = instance_double(AutoAssignment::InboxRoundRobinService) + allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service) + + expect(round_robin_service).to receive(:available_agent).with( + allowed_agent_ids: [agent1.id.to_s, agent2.id.to_s, agent3.id.to_s] + ) + + selector.select_agent(available_agents) + 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 + selected_agent = selector.select_agent([@member1]) + expect(selected_agent).to eq(agent1) + end + end + + context 'with new agents (no conversations)' do + it 'prioritizes agents with no conversations' do + # Agent1 and 2 have conversations + create(:conversation, inbox: inbox, assignee: agent1, status: 'open') + create(:conversation, inbox: inbox, assignee: agent2, status: 'open') + + # Agent3 is new with no conversations + selected_agent = selector.select_agent(available_agents) + + expect(selected_agent).to eq(agent3) + end + end + end +end \ No newline at end of file diff --git a/spec/enterprise/auto_assignment/capacity_service_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb similarity index 100% rename from spec/enterprise/auto_assignment/capacity_service_spec.rb rename to spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb diff --git a/spec/jobs/auto_assignment/assignment_job_spec.rb b/spec/jobs/auto_assignment/assignment_job_spec.rb new file mode 100644 index 000000000..f1366a966 --- /dev/null +++ b/spec/jobs/auto_assignment/assignment_job_spec.rb @@ -0,0 +1,83 @@ +require 'rails_helper' + +RSpec.describe AutoAssignment::AssignmentJob, type: :job do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) } + let(:agent) { create(:user, account: account, role: :agent, availability: :online) } + + before do + create(:inbox_member, inbox: inbox, user: agent) + end + + describe '#perform' do + context 'when inbox exists' do + context 'when auto assignment is enabled' do + it 'calls the assignment service' do + service = instance_double(AutoAssignment::AssignmentService) + allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service) + expect(service).to receive(:perform_bulk_assignment).with(limit: 100).and_return(5) + + described_class.new.perform(inbox_id: inbox.id) + end + + it 'logs the assignment count' do + service = instance_double(AutoAssignment::AssignmentService) + allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service) + allow(service).to receive(:perform_bulk_assignment).and_return(3) + + expect(Rails.logger).to receive(:info).with("Assigned 3 conversations for inbox #{inbox.id}") + + described_class.new.perform(inbox_id: inbox.id) + end + + it 'uses custom bulk limit from environment' do + allow(ENV).to receive(:fetch).with('AUTO_ASSIGNMENT_BULK_LIMIT', 100).and_return('50') + + service = instance_double(AutoAssignment::AssignmentService) + allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service) + expect(service).to receive(:perform_bulk_assignment).with(limit: 50).and_return(2) + + described_class.new.perform(inbox_id: inbox.id) + end + end + + context 'when auto assignment is disabled' do + before { inbox.update!(enable_auto_assignment: false) } + + it 'returns early without processing' do + expect(AutoAssignment::AssignmentService).not_to receive(:new) + + described_class.new.perform(inbox_id: inbox.id) + end + end + end + + context 'when inbox does not exist' do + it 'returns early without processing' do + expect(AutoAssignment::AssignmentService).not_to receive(:new) + + described_class.new.perform(inbox_id: 999999) + end + end + + context 'when an error occurs' do + it 'logs the error and re-raises in test environment' do + service = instance_double(AutoAssignment::AssignmentService) + allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service) + allow(service).to receive(:perform_bulk_assignment).and_raise(StandardError, 'Something went wrong') + + expect(Rails.logger).to receive(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong") + + expect { + described_class.new.perform(inbox_id: inbox.id) + }.to raise_error(StandardError, 'Something went wrong') + end + end + end + + describe 'job configuration' do + it 'is queued in the default queue' do + expect(described_class.queue_name).to eq('default') + end + end +end \ No newline at end of file diff --git a/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb b/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb new file mode 100644 index 000000000..69efcb6a0 --- /dev/null +++ b/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb @@ -0,0 +1,92 @@ +require 'rails_helper' + +RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) } + let(:assignment_policy) { create(:assignment_policy, inbox: inbox) } + let(:agent) { create(:user, account: account, role: :agent) } + + before do + create(:inbox_member, inbox: inbox, user: agent) + end + + describe '#perform' do + context 'when account has assignment_v2 feature enabled' do + before do + allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true) + end + + context 'when inbox has auto_assignment_v2 enabled' do + before do + allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(true) + end + + it 'queues assignment job for eligible inboxes' do + expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id) + + described_class.new.perform + end + + it 'processes multiple accounts' do + account2 = create(:account) + inbox2 = create(:inbox, account: account2, enable_auto_assignment: true) + create(:assignment_policy, inbox: inbox2) + allow(account2).to receive(:feature_enabled?).with('assignment_v2').and_return(true) + allow(inbox2).to receive(:auto_assignment_v2_enabled?).and_return(true) + + expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id) + expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox2.id) + + described_class.new.perform + end + end + + context 'when inbox does not have auto_assignment_v2 enabled' do + before do + allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(false) + end + + it 'does not queue assignment job' do + expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later) + + described_class.new.perform + end + end + end + + context 'when account does not have assignment_v2 feature enabled' do + before do + allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(false) + end + + it 'does not process the account' do + expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later) + + described_class.new.perform + end + end + + context 'with batch processing' do + it 'processes accounts in batches' do + # Create multiple accounts + 5.times do |i| + acc = create(:account) + inb = create(:inbox, account: acc, enable_auto_assignment: true) + create(:assignment_policy, inbox: inb) + allow(acc).to receive(:feature_enabled?).with('assignment_v2').and_return(true) + allow(inb).to receive(:auto_assignment_v2_enabled?).and_return(true) + end + + expect(Account).to receive(:find_in_batches).and_call_original + + described_class.new.perform + end + end + end + + describe 'job configuration' do + it 'is queued in the scheduled_jobs queue' do + expect(described_class.queue_name).to eq('scheduled_jobs') + end + end +end \ No newline at end of file diff --git a/spec/services/auto_assignment/assignment_service_basic_spec.rb b/spec/services/auto_assignment/assignment_service_basic_spec.rb deleted file mode 100644 index b85381a71..000000000 --- a/spec/services/auto_assignment/assignment_service_basic_spec.rb +++ /dev/null @@ -1,82 +0,0 @@ -require 'rails_helper' - -RSpec.describe AutoAssignment::AssignmentService do - let(:account) { create(:account) } - let(:inbox) { create(:inbox, account: account) } - let(:agent) { create(:user, account: account, role: :agent, availability: :online) } - let(:service) { described_class.new(inbox: inbox) } - - before do - create(:inbox_member, inbox: inbox, user: agent) - allow(OnlineStatusTracker).to receive(:get_available_users) - .and_return({ agent.id.to_s => 'online' }) - end - - def create_test_conversation(attrs = {}) - conversation = build(:conversation, attrs.reverse_merge(inbox: inbox, assignee: nil, status: :open)) - allow(conversation).to receive(:run_auto_assignment).and_return(nil) - conversation.save! - conversation - end - - describe 'basic assignment' do - context 'when auto assignment is enabled' do - before { inbox.update!(enable_auto_assignment: true) } - - it 'assigns an available agent to conversation' do - conversation = create_test_conversation - - assigned_count = service.perform_bulk_assignment(limit: 1) - - expect(assigned_count).to eq(1) - expect(conversation.reload.assignee).to eq(agent) - end - - it 'returns 0 when no agents are online' do - allow(OnlineStatusTracker).to receive(:get_available_users).and_return({}) - conversation = create_test_conversation - - assigned_count = service.perform_bulk_assignment(limit: 1) - - expect(assigned_count).to eq(0) - expect(conversation.reload.assignee).to be_nil - end - end - - context 'when auto assignment is disabled' do - before { inbox.update!(enable_auto_assignment: false) } - - it 'does not assign any agent' do - conversation = create_test_conversation - - assigned_count = service.perform_bulk_assignment(limit: 1) - - expect(assigned_count).to eq(0) - expect(conversation.reload.assignee).to be_nil - end - end - end - - describe 'assignment conditions' do - before { inbox.update!(enable_auto_assignment: true) } - - it 'only assigns to open conversations' do - resolved_conversation = create_test_conversation(status: 'resolved') - - assigned_count = service.perform_bulk_assignment(limit: 1) - - expect(assigned_count).to eq(0) - expect(resolved_conversation.reload.assignee).to be_nil - end - - it 'does not reassign already assigned conversations' do - other_agent = create(:user, account: account, role: :agent) - assigned_conversation = create_test_conversation(assignee: other_agent) - - assigned_count = service.perform_bulk_assignment(limit: 1) - - expect(assigned_count).to eq(0) - expect(assigned_conversation.reload.assignee).to eq(other_agent) - end - end -end diff --git a/spec/services/auto_assignment/assignment_service_fair_distribution_spec.rb b/spec/services/auto_assignment/assignment_service_fair_distribution_spec.rb deleted file mode 100644 index b3a86e8b8..000000000 --- a/spec/services/auto_assignment/assignment_service_fair_distribution_spec.rb +++ /dev/null @@ -1,136 +0,0 @@ -require 'rails_helper' - -RSpec.describe AutoAssignment::AssignmentService do - let(:account) { create(:account) } - let(:inbox) do - create(:inbox, - account: account, - enable_auto_assignment: true, - auto_assignment_config: { - 'fair_distribution_limit' => 2, - 'fair_distribution_window' => 3600 - }) - end - let(:service) { 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) } - - before do - create(:inbox_member, inbox: inbox, user: agent1) - create(:inbox_member, inbox: inbox, user: agent2) - allow(OnlineStatusTracker).to receive(:get_available_users) - .and_return({ - agent1.id.to_s => 'online', - agent2.id.to_s => 'online' - }) - # Clean up Redis keys for this inbox - clean_redis_keys - end - - after do - clean_redis_keys - end - - def clean_redis_keys - # Clean up assignment keys for both agents - pattern1 = "assignment:#{inbox.id}:agent:#{agent1.id}:*" - pattern2 = "assignment:#{inbox.id}:agent:#{agent2.id}:*" - - Redis::Alfred.scan_each(match: pattern1) { |key| Redis::Alfred.delete(key) } - Redis::Alfred.scan_each(match: pattern2) { |key| Redis::Alfred.delete(key) } - end - - def create_test_conversation - conversation = build(:conversation, inbox: inbox, assignee: nil, status: :open) - allow(conversation).to receive(:run_auto_assignment).and_return(nil) - conversation.save! - conversation - end - - describe 'with fair distribution enabled' do - it 'respects the assignment limit per agent' do - # Each agent can handle 2 conversations - conversations = Array.new(4) { create_test_conversation } - - # Assign first 4 conversations (2 per agent) - assigned_count = service.perform_bulk_assignment(limit: 4) - expect(assigned_count).to eq(4) - - # Verify distribution - assigned_to_agent1 = conversations.count { |c| c.reload.assignee == agent1 } - assigned_to_agent2 = conversations.count { |c| c.reload.assignee == agent2 } - - expect(assigned_to_agent1).to eq(2) - expect(assigned_to_agent2).to eq(2) - - # Fifth conversation should not be assigned (both agents at limit) - fifth_conversation = create_test_conversation - additional_assigned = service.perform_bulk_assignment(limit: 1) - expect(additional_assigned).to eq(0) - expect(fifth_conversation.reload.assignee).to be_nil - end - - it 'tracks assignments using individual Redis keys' do - conversation = create_test_conversation - service.perform_bulk_assignment(limit: 1) - - # Check that assignment key exists - pattern = "assignment:#{inbox.id}:agent:#{conversation.reload.assignee.id}:*" - count = Redis::Alfred.keys_count(pattern) - expect(count).to eq(1) - end - - it 'allows new assignments after window expires' do - # Create 2 conversations and force assignment to agent1 - conversations = Array.new(2) { create_test_conversation } - allow(service).to receive(:round_robin_selector).and_return( - instance_double(AutoAssignment::RoundRobinSelector, select_agent: agent1) - ) - - # Assign both to agent1 - assigned_count = service.perform_bulk_assignment(limit: 2) - expect(assigned_count).to eq(2) - - conversations.each do |c| - expect(c.reload.assignee).to eq(agent1) - end - - # Agent1 is now at limit - rate_limiter = AutoAssignment::RateLimiter.new(inbox: inbox, agent: agent1) - expect(rate_limiter.within_limit?).to be false - - # Clear Redis keys to simulate time window expiry - clean_redis_keys - - # Agent1 should be available again - expect(rate_limiter.within_limit?).to be true - - # New assignment should work - new_conversation = create_test_conversation - allow(service).to receive(:round_robin_selector).and_return( - instance_double(AutoAssignment::RoundRobinSelector, select_agent: agent1) - ) - assigned_count = service.perform_bulk_assignment(limit: 1) - expect(assigned_count).to eq(1) - expect(new_conversation.reload.assignee).to eq(agent1) - end - end - - describe 'without fair distribution' do - before do - inbox.update!(auto_assignment_config: {}) - end - - it 'assigns without limits' do - # Create more conversations than would be allowed with limits - conversations = Array.new(5) { create_test_conversation } - - assigned_count = service.perform_bulk_assignment(limit: 5) - expect(assigned_count).to eq(5) - - conversations.each do |conversation| - expect(conversation.reload.assignee).not_to be_nil - end - end - end -end diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb index 686b98be7..2c9c26758 100644 --- a/spec/services/auto_assignment/assignment_service_spec.rb +++ b/spec/services/auto_assignment/assignment_service_spec.rb @@ -6,38 +6,63 @@ RSpec.describe AutoAssignment::AssignmentService do 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 create(:inbox_member, inbox: inbox, user: agent) end - def create_test_conversation(attrs = {}) - conversation = build(:conversation, attrs.reverse_merge(inbox: inbox, assignee: nil)) - # Skip the after_save callback to test our service directly - allow(conversation).to receive(:run_auto_assignment).and_return(nil) - conversation.save! - conversation - end - - describe 'assignment behavior' do - before do - allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' }) - end - + describe '#perform_bulk_assignment' do context 'when auto assignment is enabled' do - it 'assigns conversation to available agent' do - conversation = create_test_conversation + before do + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' }) + end + it 'assigns conversations to available agents' do assigned_count = service.perform_bulk_assignment(limit: 1) expect(assigned_count).to eq(1) expect(conversation.reload.assignee).to eq(agent) end - it 'dispatches assignee changed event' do - conversation = create_test_conversation - allow(Rails.configuration.dispatcher).to receive(:dispatch).and_call_original + 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 { create(:conversation, inbox: inbox, assignee: nil) } + + 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 + resolved_conversation = create(:conversation, inbox: inbox, assignee: nil, status: 'resolved') + + assigned_count = 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 + assigned_conversation = create(:conversation, inbox: inbox, assignee: agent) + unassigned_conversation = create(:conversation, inbox: inbox, assignee: nil) + + assigned_count = service.perform_bulk_assignment(limit: 10) + + expect(assigned_count).to eq(2) # Original conversation + unassigned_conversation + expect(assigned_conversation.reload.assignee).to eq(agent) + end + + it 'dispatches assignee changed event' do expect(Rails.configuration.dispatcher).to receive(:dispatch).with( Events::Types::ASSIGNEE_CHANGED, anything, @@ -46,155 +71,143 @@ RSpec.describe AutoAssignment::AssignmentService do service.perform_bulk_assignment(limit: 1) end - - it 'returns 0 when no agents available' do - create_test_conversation - allow(OnlineStatusTracker).to receive(:get_available_users).and_return({}) - - assigned_count = service.perform_bulk_assignment(limit: 1) - - expect(assigned_count).to eq(0) - end end - context 'when conversation already assigned' do - it 'does not reassign' do - conversation = create_test_conversation(assignee: agent) + context 'when auto assignment is disabled' do + before { inbox.update!(enable_auto_assignment: false) } - assigned_count = service.perform_bulk_assignment(limit: 1) - - expect(assigned_count).to eq(0) - expect(conversation.reload.assignee).to eq(agent) - end - end - - context 'when conversation is not open' do - it 'does not assign' do - conversation = create_test_conversation(status: 'resolved') - - assigned_count = service.perform_bulk_assignment(limit: 1) + 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 - end - - describe '#perform_bulk_assignment' do - before do - allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' }) - 3.times { create_test_conversation(status: :open) } - end - - it 'assigns multiple conversations' do - assigned_count = service.perform_bulk_assignment(limit: 2) - expect(assigned_count).to eq(2) - end - - it 'respects the limit parameter' do - assigned_count = service.perform_bulk_assignment(limit: 1) - expect(assigned_count).to eq(1) - expect(inbox.conversations.unassigned.count).to eq(2) - end - - context 'when auto assignment disabled' do - before { inbox.update!(enable_auto_assignment: false) } - - it 'returns 0' do - expect(service.perform_bulk_assignment).to eq(0) - end - end - end - - describe 'with rate limiting' do - let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) } - - 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' - }) - allow(inbox).to receive(:auto_assignment_config).and_return({ - 'fair_distribution_limit' => 2, - 'fair_distribution_window' => 3600 - }) - end - - it 'filters agents based on rate limits' do - # Agent 1 has reached limit - rate_limiter_agent1 = instance_double(AutoAssignment::RateLimiter, within_limit?: false) - allow(AutoAssignment::RateLimiter).to receive(:new) - .with(inbox: inbox, agent: agent) - .and_return(rate_limiter_agent1) - - # Agent 2 is within limit - rate_limiter_agent2 = instance_double(AutoAssignment::RateLimiter, within_limit?: true, track_assignment: true) - allow(AutoAssignment::RateLimiter).to receive(:new) - .with(inbox: inbox, agent: agent2) - .and_return(rate_limiter_agent2) - - conversation = create_test_conversation - - assigned_count = service.perform_bulk_assignment(limit: 1) - - expect(assigned_count).to eq(1) - expect(conversation.reload.assignee).to eq(agent2) - end - - it 'tracks assignments in Redis' do - conversation = create_test_conversation - - rate_limiter = instance_double(AutoAssignment::RateLimiter, within_limit?: true) - allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter) - - expect(rate_limiter).to receive(:track_assignment).with(conversation) - - service.perform_bulk_assignment(limit: 1) - end - end - - describe 'conversation priority' do - before do - allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' }) - end - - context 'with longest_waiting priority' do - let!(:old_conversation) do - create_test_conversation(status: :open, created_at: 2.hours.ago, last_activity_at: 2.hours.ago) - end - let!(:new_conversation) do - create_test_conversation(status: :open, created_at: 1.hour.ago, last_activity_at: 1.hour.ago) - end + context 'with conversation priority' do before do - allow(inbox).to receive(:auto_assignment_config).and_return({ - 'conversation_priority' => 'longest_waiting' - }) + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' }) end - it 'assigns oldest conversation first' do - service.perform_bulk_assignment(limit: 1) + context 'when priority is longest_waiting' do + before do + allow(inbox).to receive(:auto_assignment_config).and_return({ 'conversation_priority' => 'longest_waiting' }) + end - expect(old_conversation.reload.assignee).to eq(agent) - expect(new_conversation.reload.assignee).to be_nil + it 'assigns conversations with oldest last_activity_at first' do + old_conversation = create(:conversation, + inbox: inbox, + assignee: nil, + created_at: 2.hours.ago, + last_activity_at: 2.hours.ago) + new_conversation = create(:conversation, + inbox: inbox, + assignee: nil, + created_at: 1.hour.ago, + last_activity_at: 1.hour.ago) + + 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, assignee: nil, created_at: 2.hours.ago) + new_conversation = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) + + 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 default priority' do - let!(:first_created) do - create_test_conversation(status: :open, created_at: 2.hours.ago) - end - let!(:second_created) do - create_test_conversation(status: :open, created_at: 1.hour.ago) + 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 - it 'assigns by creation time' do - service.perform_bulk_assignment(limit: 1) + 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 - expect(first_created.reload.assignee).to eq(agent) - expect(second_created.reload.assignee).to be_nil + it 'respects the assignment limit per agent' do + # Mock agent1 at limit + allow_any_instance_of(AutoAssignment::RateLimiter).to receive(:within_limit?) do |limiter| + limiter.agent == agent ? false : true + end + + unassigned_conversation = create(:conversation, inbox: inbox, assignee: nil) + + service.perform_bulk_assignment(limit: 1) + + expect(unassigned_conversation.reload.assignee).to eq(agent2) + end + + it 'tracks assignments in Redis' do + rate_limiter = instance_double(AutoAssignment::RateLimiter, within_limit?: true) + allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter) + + expect(rate_limiter).to receive(:track_assignment).with(conversation) + + service.perform_bulk_assignment(limit: 1) + end + + it 'allows assignments after window expires' do + # Simulate time passing for rate limit window + Timecop.freeze(Time.current) do + 2.times do + convo = create(:conversation, inbox: inbox, assignee: nil) + service.perform_bulk_assignment(limit: 1) + expect(convo.reload.assignee).not_to be_nil + end + end + + # Move forward past the window + Timecop.freeze(Time.current + 2.hours) do + new_convo = create(:conversation, inbox: inbox, assignee: nil) + service.perform_bulk_assignment(limit: 1) + expect(new_convo.reload.assignee).not_to be_nil + end + end + end + + context 'when fair distribution is disabled' do + it 'assigns without rate limiting' do + 5.times { create(:conversation, inbox: inbox, assignee: nil) } + + expect(AutoAssignment::RateLimiter).not_to receive(:new) + + 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 = 4.times.map { 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 end -end +end \ No newline at end of file diff --git a/spec/services/auto_assignment/round_robin_selector_spec.rb b/spec/services/auto_assignment/round_robin_selector_spec.rb new file mode 100644 index 000000000..94030753e --- /dev/null +++ b/spec/services/auto_assignment/round_robin_selector_spec.rb @@ -0,0 +1,55 @@ +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) } + + before do + @member1 = create(:inbox_member, inbox: inbox, user: agent1) + @member2 = create(:inbox_member, inbox: inbox, user: agent2) + @member3 = create(:inbox_member, inbox: inbox, user: agent3) + 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 + 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 + round_robin_service = instance_double(AutoAssignment::InboxRoundRobinService) + allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service) + + 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 + selected_agent = selector.select_agent([@member1]) + expect(selected_agent).to eq(agent1) + end + end + end +end \ No newline at end of file