feat: Assignment service (v2) (#12320)
## Linear Link ## Description This PR introduces a new robust auto-assignment system for conversations in Chatwoot. The system replaces the existing round-robin assignment with a more sophisticated service-based architecture that supports multiple assignment strategies, rate limiting, and Enterprise features like capacity-based assignment and balanced distribution. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Unit test cases - Test conversations getting assigned on status change to open - Test the job directly via rails console ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a new service-based auto-assignment system with scheduled jobs, rate limiting, enterprise capacity/balanced selection, and wiring via inbox/handler; includes Redis helpers and comprehensive tests. > > - **Auto-assignment v2 (core services)**: > - Add `AutoAssignment::AssignmentService` with bulk assignment, configurable conversation priority, RR selection, and per-agent rate limiting via `AutoAssignment::RateLimiter`. > - Add `AutoAssignment::RoundRobinSelector` for agent selection. > - **Jobs & scheduling**: > - Add `AutoAssignment::AssignmentJob` (per-inbox bulk assign; env-based limit) and `AutoAssignment::PeriodicAssignmentJob` (batch over accounts/inboxes). > - Schedule periodic run in `config/schedule.yml` (`periodic_assignment_job`). > - **Model/concerns wiring**: > - Include `InboxAgentAvailability` in `Inbox`; add `Inbox#auto_assignment_v2_enabled?`. > - Update `AutoAssignmentHandler` to trigger v2 job when `auto_assignment_v2_enabled?`, else fallback to legacy. > - **Enterprise extensions**: > - Add `Enterprise::InboxAgentAvailability` (capacity-aware filtering) and `Enterprise::Concerns::Inbox` association `inbox_capacity_limits`. > - Extend service via `Enterprise::AutoAssignment::AssignmentService` (policy-driven config, capacity filtering, exclusion rules) and add selectors/services: `BalancedSelector`, `CapacityService`. > - **Infrastructure**: > - Enhance `Redis::Alfred` with `expire`, key scan/count, and extended ZSET helpers (`zadd`, `zcount`, `zcard`, `zrangebyscore`). > - **Tests**: > - Add specs for jobs, core service, rate limiter, RR selector, and enterprise features (capacity, balanced selection, exclusions). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0ebe187c8aea73765b0122a44b18d6f465c2477f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
committed by
GitHub
parent
2c4e65d68e
commit
c9823d9409
@@ -0,0 +1,185 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
|
||||
let(:account) { create(:account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:agent1) { create(:user, account: account, name: 'Agent 1') }
|
||||
let(:agent2) { create(:user, account: account, name: 'Agent 2') }
|
||||
let(:assignment_service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
|
||||
|
||||
before do
|
||||
# Create inbox members
|
||||
create(:inbox_member, inbox: inbox, user: agent1)
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
|
||||
# Link inbox to assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
|
||||
# Set agents as online
|
||||
OnlineStatusTracker.update_presence(account.id, 'User', agent1.id)
|
||||
OnlineStatusTracker.set_status(account.id, agent1.id, 'online')
|
||||
OnlineStatusTracker.update_presence(account.id, 'User', agent2.id)
|
||||
OnlineStatusTracker.set_status(account.id, agent2.id, 'online')
|
||||
end
|
||||
|
||||
describe 'exclusion rules' do
|
||||
let(:capacity_policy) { create(:agent_capacity_policy, account: account) }
|
||||
let(:label1) { create(:label, account: account, title: 'high-priority') }
|
||||
let(:label2) { create(:label, account: account, title: 'vip') }
|
||||
|
||||
before do
|
||||
create(:inbox_capacity_limit, inbox: inbox, agent_capacity_policy: capacity_policy, conversation_limit: 10)
|
||||
inbox.enable_auto_assignment = true
|
||||
inbox.save!
|
||||
end
|
||||
|
||||
context 'when excluding conversations by label' do
|
||||
let!(:conversation_with_label) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
let!(:conversation_without_label) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
conversation_with_label.update_labels([label1.title])
|
||||
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'excluded_labels' => [label1.title]
|
||||
})
|
||||
end
|
||||
|
||||
it 'excludes conversations with specified labels' do
|
||||
# First check conversations are unassigned
|
||||
expect(conversation_with_label.assignee).to be_nil
|
||||
expect(conversation_without_label.assignee).to be_nil
|
||||
|
||||
# Run bulk assignment
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only the conversation without label should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation_with_label.reload.assignee).to be_nil
|
||||
expect(conversation_without_label.reload.assignee).to be_present
|
||||
end
|
||||
|
||||
it 'handles bulk assignment correctly' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only 1 conversation should be assigned (the one without label)
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation_with_label.reload.assignee).to be_nil
|
||||
expect(conversation_without_label.reload.assignee).to be_present
|
||||
end
|
||||
|
||||
it 'excludes conversations with multiple labels' do
|
||||
conversation_without_label.update_labels([label2.title])
|
||||
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'excluded_labels' => [label1.title, label2.title]
|
||||
})
|
||||
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Both conversations should be excluded
|
||||
expect(assigned_count).to eq(0)
|
||||
expect(conversation_with_label.reload.assignee).to be_nil
|
||||
expect(conversation_without_label.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when excluding conversations by age' do
|
||||
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago) }
|
||||
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) }
|
||||
|
||||
before do
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'exclude_older_than_hours' => 24
|
||||
})
|
||||
end
|
||||
|
||||
it 'excludes conversations older than specified hours' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only recent conversation should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(old_conversation.reload.assignee).to be_nil
|
||||
expect(recent_conversation.reload.assignee).to be_present
|
||||
end
|
||||
|
||||
it 'handles different time thresholds' do
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'exclude_older_than_hours' => 2
|
||||
})
|
||||
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only conversation created within 2 hours should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(recent_conversation.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when combining exclusion rules' do
|
||||
it 'applies both exclusion rules' do
|
||||
# Create conversations
|
||||
old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
|
||||
old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
|
||||
recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
|
||||
recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
|
||||
|
||||
# Add labels
|
||||
old_conversation_with_label.update_labels([label1.title])
|
||||
recent_conversation_with_label.update_labels([label1.title])
|
||||
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'excluded_labels' => [label1.title],
|
||||
'exclude_older_than_hours' => 24
|
||||
})
|
||||
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only recent conversation without label should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(old_conversation_with_label.reload.assignee).to be_nil
|
||||
expect(old_conversation_without_label.reload.assignee).to be_nil
|
||||
expect(recent_conversation_with_label.reload.assignee).to be_nil
|
||||
expect(recent_conversation_without_label.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exclusion rules are empty' do
|
||||
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
capacity_policy.update!(exclusion_rules: {})
|
||||
end
|
||||
|
||||
it 'assigns all eligible conversations' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(2)
|
||||
expect(conversation1.reload.assignee).to be_present
|
||||
expect(conversation2.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no capacity policy exists' do
|
||||
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
InboxCapacityLimit.destroy_all
|
||||
end
|
||||
|
||||
it 'assigns all eligible conversations without exclusions' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(2)
|
||||
expect(conversation1.reload.assignee).to be_present
|
||||
expect(conversation2.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
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) }
|
||||
let(:member1) { create(:inbox_member, inbox: inbox, user: agent1) }
|
||||
let(:member2) { create(:inbox_member, inbox: inbox, user: agent2) }
|
||||
let(:member3) { create(:inbox_member, inbox: inbox, user: agent3) }
|
||||
|
||||
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 'selects any agent 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
|
||||
|
||||
selected_agent = selector.select_agent(available_agents)
|
||||
|
||||
# Should select one of the agents (when equal, min_by returns the first one it finds)
|
||||
expect([agent1, agent2, agent3]).to include(selected_agent)
|
||||
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([member1, member2, member3])
|
||||
|
||||
expect(selected_agent).to eq(agent3)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
|
||||
|
||||
# Assignment policy with rate limiting
|
||||
let(:assignment_policy) do
|
||||
create(:assignment_policy,
|
||||
account: account,
|
||||
enabled: true,
|
||||
fair_distribution_limit: 5,
|
||||
fair_distribution_window: 3600)
|
||||
end
|
||||
|
||||
# Agent capacity policy
|
||||
let(:agent_capacity_policy) do
|
||||
create(:agent_capacity_policy, account: account, name: 'Limited Capacity')
|
||||
end
|
||||
|
||||
# Agents with different capacity settings
|
||||
let(:agent_with_capacity) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent_without_capacity) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent_at_capacity) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
|
||||
before do
|
||||
# Create inbox assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
|
||||
# Set inbox capacity limit
|
||||
create(:inbox_capacity_limit,
|
||||
agent_capacity_policy: agent_capacity_policy,
|
||||
inbox: inbox,
|
||||
conversation_limit: 3)
|
||||
|
||||
# Assign capacity policy to specific agents
|
||||
agent_with_capacity.account_users.find_by(account: account)
|
||||
.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
|
||||
agent_at_capacity.account_users.find_by(account: account)
|
||||
.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
|
||||
# Create inbox members
|
||||
create(:inbox_member, inbox: inbox, user: agent_with_capacity)
|
||||
create(:inbox_member, inbox: inbox, user: agent_without_capacity)
|
||||
create(:inbox_member, inbox: inbox, user: agent_at_capacity)
|
||||
|
||||
# Mock online status
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
|
||||
agent_with_capacity.id.to_s => 'online',
|
||||
agent_without_capacity.id.to_s => 'online',
|
||||
agent_at_capacity.id.to_s => 'online'
|
||||
})
|
||||
|
||||
# Enable assignment_v2 feature
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
|
||||
# Create existing assignments for agent_at_capacity (at limit)
|
||||
3.times do
|
||||
create(:conversation, inbox: inbox, assignee: agent_at_capacity, status: :open)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'capacity filtering' do
|
||||
it 'excludes agents at capacity' do
|
||||
# Get available agents respecting capacity
|
||||
capacity_service = described_class.new
|
||||
online_agents = inbox.available_agents
|
||||
filtered_agents = online_agents.select do |inbox_member|
|
||||
capacity_service.agent_has_capacity?(inbox_member.user, inbox)
|
||||
end
|
||||
available_users = filtered_agents.map(&:user)
|
||||
|
||||
expect(available_users).to include(agent_with_capacity)
|
||||
expect(available_users).to include(agent_without_capacity) # No capacity policy = unlimited
|
||||
expect(available_users).not_to include(agent_at_capacity) # At capacity limit
|
||||
end
|
||||
|
||||
it 'respects inbox-specific capacity limits' do
|
||||
capacity_service = described_class.new
|
||||
|
||||
expect(capacity_service.agent_has_capacity?(agent_with_capacity, inbox)).to be true
|
||||
expect(capacity_service.agent_has_capacity?(agent_without_capacity, inbox)).to be true
|
||||
expect(capacity_service.agent_has_capacity?(agent_at_capacity, inbox)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'assignment with capacity' do
|
||||
let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
|
||||
|
||||
it 'assigns to agents with available capacity' do
|
||||
# Create conversation before assignment
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open)
|
||||
|
||||
# Mock the selector to prefer agent_at_capacity (but should skip due to capacity)
|
||||
selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(selector)
|
||||
allow(selector).to receive(:select_agent) do |agents|
|
||||
agents.map(&:user).find { |u| [agent_with_capacity, agent_without_capacity].include?(u) }
|
||||
end
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 1)
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation.reload.assignee).to be_in([agent_with_capacity, agent_without_capacity])
|
||||
expect(conversation.reload.assignee).not_to eq(agent_at_capacity)
|
||||
end
|
||||
|
||||
it 'returns false when all agents are at capacity' do
|
||||
# Fill up remaining agents
|
||||
3.times { create(:conversation, inbox: inbox, assignee: agent_with_capacity, status: :open) }
|
||||
|
||||
# agent_without_capacity has no limit, so should still be available
|
||||
conversation2 = create(:conversation, inbox: inbox, assignee: nil, status: :open)
|
||||
assigned_count = service.perform_bulk_assignment(limit: 1)
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation2.reload.assignee).to eq(agent_without_capacity)
|
||||
end
|
||||
end
|
||||
end
|
||||
85
spec/jobs/auto_assignment/assignment_job_spec.rb
Normal file
85
spec/jobs/auto_assignment/assignment_job_spec.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
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 'calls the service which handles the disabled state' 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(0)
|
||||
|
||||
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: 999_999)
|
||||
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 do
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end.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
|
||||
123
spec/jobs/auto_assignment/periodic_assignment_job_spec.rb
Normal file
123
spec/jobs/auto_assignment/periodic_assignment_job_spec.rb
Normal file
@@ -0,0 +1,123 @@
|
||||
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, account: account) }
|
||||
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
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)
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account])
|
||||
end
|
||||
|
||||
context 'when inbox has auto_assignment_v2 enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
inbox_relation = instance_double(ActiveRecord::Relation)
|
||||
allow(account).to receive(:inboxes).and_return(inbox_relation)
|
||||
allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
|
||||
allow(inbox_relation).to receive(:find_in_batches).and_yield([inbox])
|
||||
end
|
||||
|
||||
it 'queues assignment job for eligible inboxes' do
|
||||
inbox_assignment_policy # ensure it exists
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
|
||||
it 'processes multiple accounts' do
|
||||
inbox_assignment_policy # ensure it exists
|
||||
account2 = create(:account)
|
||||
inbox2 = create(:inbox, account: account2, enable_auto_assignment: true)
|
||||
policy2 = create(:assignment_policy, account: account2)
|
||||
create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2)
|
||||
|
||||
allow(account2).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
allow(inbox2).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
|
||||
inbox_relation2 = instance_double(ActiveRecord::Relation)
|
||||
allow(account2).to receive(:inboxes).and_return(inbox_relation2)
|
||||
allow(inbox_relation2).to receive(:joins).with(:assignment_policy).and_return(inbox_relation2)
|
||||
allow(inbox_relation2).to receive(:find_in_batches).and_yield([inbox2])
|
||||
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account]).and_yield([account2])
|
||||
|
||||
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)
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account])
|
||||
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
|
||||
accounts = []
|
||||
# Create multiple accounts
|
||||
5.times do |_i|
|
||||
acc = create(:account)
|
||||
inb = create(:inbox, account: acc, enable_auto_assignment: true)
|
||||
policy = create(:assignment_policy, account: acc)
|
||||
create(:inbox_assignment_policy, inbox: inb, assignment_policy: policy)
|
||||
allow(acc).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
allow(inb).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
|
||||
inbox_relation = instance_double(ActiveRecord::Relation)
|
||||
allow(acc).to receive(:inboxes).and_return(inbox_relation)
|
||||
allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
|
||||
allow(inbox_relation).to receive(:find_in_batches).and_yield([inb])
|
||||
|
||||
accounts << acc
|
||||
end
|
||||
|
||||
allow(Account).to receive(:find_in_batches) do |&block|
|
||||
accounts.each { |acc| block.call([acc]) }
|
||||
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
|
||||
310
spec/services/auto_assignment/assignment_service_spec.rb
Normal file
310
spec/services/auto_assignment/assignment_service_spec.rb
Normal file
@@ -0,0 +1,310 @@
|
||||
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
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
# 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
|
||||
end
|
||||
end
|
||||
167
spec/services/auto_assignment/rate_limiter_spec.rb
Normal file
167
spec/services/auto_assignment/rate_limiter_spec.rb
Normal file
@@ -0,0 +1,167 @@
|
||||
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 'does not track the assignment' do
|
||||
expect(Redis::Alfred).not_to receive(:set)
|
||||
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
|
||||
78
spec/services/auto_assignment/round_robin_selector_spec.rb
Normal file
78
spec/services/auto_assignment/round_robin_selector_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user