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:
Tanmay Deep Sharma
2025-11-17 10:08:25 +05:30
committed by GitHub
parent 2c4e65d68e
commit c9823d9409
24 changed files with 1622 additions and 5 deletions

View File

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

View File

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

View File

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

View 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

View 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

View 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

View 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

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