From c9823d9409b217fa045ae2bfa879eba3c6ce9d58 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Mon, 17 Nov 2025 10:08:25 +0530
Subject: [PATCH] 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
---
> [!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).
>
> 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).
---------
Co-authored-by: Muhsin Keloth
Co-authored-by: Shivam Mishra
---
app/jobs/auto_assignment/assignment_job.rb | 22 ++
.../periodic_assignment_job.rb | 19 ++
.../concerns/auto_assignment_handler.rb | 8 +-
.../concerns/inbox_agent_availability.rb | 28 ++
app/models/inbox.rb | 5 +
.../auto_assignment/assignment_service.rb | 90 +++++
app/services/auto_assignment/rate_limiter.rb | 49 +++
.../auto_assignment/round_robin_selector.rb | 16 +
config/schedule.yml | 6 +
.../app/models/enterprise/concerns/inbox.rb | 1 +
.../enterprise/inbox_agent_availability.rb | 31 ++
.../auto_assignment/assignment_service.rb | 94 ++++++
.../auto_assignment/balanced_selector.rb | 26 ++
.../auto_assignment/capacity_service.rb | 25 ++
lib/redis/alfred.rb | 47 ++-
lib/redis/redis_keys.rb | 5 +
.../assignment_service_spec.rb | 185 +++++++++++
.../auto_assignment/balanced_selector_spec.rb | 88 +++++
.../auto_assignment/capacity_service_spec.rb | 119 +++++++
.../auto_assignment/assignment_job_spec.rb | 85 +++++
.../periodic_assignment_job_spec.rb | 123 +++++++
.../assignment_service_spec.rb | 310 ++++++++++++++++++
.../auto_assignment/rate_limiter_spec.rb | 167 ++++++++++
.../round_robin_selector_spec.rb | 78 +++++
24 files changed, 1622 insertions(+), 5 deletions(-)
create mode 100644 app/jobs/auto_assignment/assignment_job.rb
create mode 100644 app/jobs/auto_assignment/periodic_assignment_job.rb
create mode 100644 app/models/concerns/inbox_agent_availability.rb
create mode 100644 app/services/auto_assignment/assignment_service.rb
create mode 100644 app/services/auto_assignment/rate_limiter.rb
create mode 100644 app/services/auto_assignment/round_robin_selector.rb
create mode 100644 enterprise/app/models/enterprise/inbox_agent_availability.rb
create mode 100644 enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
create mode 100644 enterprise/app/services/enterprise/auto_assignment/balanced_selector.rb
create mode 100644 enterprise/app/services/enterprise/auto_assignment/capacity_service.rb
create mode 100644 spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb
create mode 100644 spec/enterprise/services/enterprise/auto_assignment/balanced_selector_spec.rb
create mode 100644 spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb
create mode 100644 spec/jobs/auto_assignment/assignment_job_spec.rb
create mode 100644 spec/jobs/auto_assignment/periodic_assignment_job_spec.rb
create mode 100644 spec/services/auto_assignment/assignment_service_spec.rb
create mode 100644 spec/services/auto_assignment/rate_limiter_spec.rb
create mode 100644 spec/services/auto_assignment/round_robin_selector_spec.rb
diff --git a/app/jobs/auto_assignment/assignment_job.rb b/app/jobs/auto_assignment/assignment_job.rb
new file mode 100644
index 000000000..9c6760ecc
--- /dev/null
+++ b/app/jobs/auto_assignment/assignment_job.rb
@@ -0,0 +1,22 @@
+class AutoAssignment::AssignmentJob < ApplicationJob
+ queue_as :default
+
+ def perform(inbox_id:)
+ inbox = Inbox.find_by(id: inbox_id)
+ return unless inbox
+
+ service = AutoAssignment::AssignmentService.new(inbox: inbox)
+
+ assigned_count = service.perform_bulk_assignment(limit: bulk_assignment_limit)
+ Rails.logger.info "Assigned #{assigned_count} conversations for inbox #{inbox.id}"
+ rescue StandardError => e
+ Rails.logger.error "Bulk assignment failed for inbox #{inbox_id}: #{e.message}"
+ raise e if Rails.env.test?
+ end
+
+ private
+
+ def bulk_assignment_limit
+ ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i
+ end
+end
diff --git a/app/jobs/auto_assignment/periodic_assignment_job.rb b/app/jobs/auto_assignment/periodic_assignment_job.rb
new file mode 100644
index 000000000..63500507e
--- /dev/null
+++ b/app/jobs/auto_assignment/periodic_assignment_job.rb
@@ -0,0 +1,19 @@
+class AutoAssignment::PeriodicAssignmentJob < ApplicationJob
+ queue_as :scheduled_jobs
+
+ def perform
+ Account.find_in_batches do |accounts|
+ accounts.each do |account|
+ next unless account.feature_enabled?('assignment_v2')
+
+ account.inboxes.joins(:assignment_policy).find_in_batches do |inboxes|
+ inboxes.each do |inbox|
+ next unless inbox.auto_assignment_v2_enabled?
+
+ AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/auto_assignment_handler.rb b/app/models/concerns/auto_assignment_handler.rb
index de5fdae7d..a1198200a 100644
--- a/app/models/concerns/auto_assignment_handler.rb
+++ b/app/models/concerns/auto_assignment_handler.rb
@@ -14,7 +14,13 @@ module AutoAssignmentHandler
return unless conversation_status_changed_to_open?
return unless should_run_auto_assignment?
- ::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
+ if inbox.auto_assignment_v2_enabled?
+ # Use new assignment system
+ AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
+ else
+ # Use legacy assignment system
+ AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
+ end
end
def should_run_auto_assignment?
diff --git a/app/models/concerns/inbox_agent_availability.rb b/app/models/concerns/inbox_agent_availability.rb
new file mode 100644
index 000000000..289da7db9
--- /dev/null
+++ b/app/models/concerns/inbox_agent_availability.rb
@@ -0,0 +1,28 @@
+module InboxAgentAvailability
+ extend ActiveSupport::Concern
+
+ def available_agents
+ online_agent_ids = fetch_online_agent_ids
+ return inbox_members.none if online_agent_ids.empty?
+
+ inbox_members
+ .joins(:user)
+ .where(users: { id: online_agent_ids })
+ .includes(:user)
+ end
+
+ def member_ids_with_assignment_capacity
+ member_ids
+ end
+
+ private
+
+ def fetch_online_agent_ids
+ OnlineStatusTracker.get_available_users(account_id)
+ .select { |_key, value| value.eql?('online') }
+ .keys
+ .map(&:to_i)
+ end
+end
+
+InboxAgentAvailability.prepend_mod_with('InboxAgentAvailability')
diff --git a/app/models/inbox.rb b/app/models/inbox.rb
index b0f13d222..894c57bcc 100644
--- a/app/models/inbox.rb
+++ b/app/models/inbox.rb
@@ -44,6 +44,7 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
+ include InboxAgentAvailability
# Not allowing characters:
validates :name, presence: true
@@ -190,6 +191,10 @@ class Inbox < ApplicationRecord
members.ids
end
+ def auto_assignment_v2_enabled?
+ account.feature_enabled?('assignment_v2')
+ end
+
private
def default_name_for_blank_name
diff --git a/app/services/auto_assignment/assignment_service.rb b/app/services/auto_assignment/assignment_service.rb
new file mode 100644
index 000000000..59e7b5a7b
--- /dev/null
+++ b/app/services/auto_assignment/assignment_service.rb
@@ -0,0 +1,90 @@
+class AutoAssignment::AssignmentService
+ pattr_initialize [:inbox!]
+
+ def perform_bulk_assignment(limit: 100)
+ return 0 unless inbox.auto_assignment_v2_enabled?
+
+ assigned_count = 0
+
+ unassigned_conversations(limit).each do |conversation|
+ assigned_count += 1 if perform_for_conversation(conversation)
+ end
+
+ assigned_count
+ end
+
+ private
+
+ def perform_for_conversation(conversation)
+ return false unless assignable?(conversation)
+
+ agent = find_available_agent
+ return false unless agent
+
+ assign_conversation(conversation, agent)
+ end
+
+ def assignable?(conversation)
+ conversation.status == 'open' &&
+ conversation.assignee_id.nil?
+ end
+
+ def unassigned_conversations(limit)
+ scope = inbox.conversations.unassigned.open
+
+ scope = if assignment_config['conversation_priority'].to_s == 'longest_waiting'
+ scope.reorder(last_activity_at: :asc, created_at: :asc)
+ else
+ scope.reorder(created_at: :asc)
+ end
+
+ scope.limit(limit)
+ end
+
+ def find_available_agent
+ agents = filter_agents_by_rate_limit(inbox.available_agents)
+ return nil if agents.empty?
+
+ round_robin_selector.select_agent(agents)
+ end
+
+ def filter_agents_by_rate_limit(agents)
+ agents.select do |agent_member|
+ rate_limiter = build_rate_limiter(agent_member.user)
+ rate_limiter.within_limit?
+ end
+ end
+
+ def assign_conversation(conversation, agent)
+ conversation.update!(assignee: agent)
+
+ rate_limiter = build_rate_limiter(agent)
+ rate_limiter.track_assignment(conversation)
+
+ dispatch_assignment_event(conversation, agent)
+ true
+ end
+
+ def dispatch_assignment_event(conversation, agent)
+ Rails.configuration.dispatcher.dispatch(
+ Events::Types::ASSIGNEE_CHANGED,
+ Time.zone.now,
+ conversation: conversation,
+ user: agent
+ )
+ end
+
+ def build_rate_limiter(agent)
+ AutoAssignment::RateLimiter.new(inbox: inbox, agent: agent)
+ end
+
+ def round_robin_selector
+ @round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
+ end
+
+ def assignment_config
+ @assignment_config ||= inbox.auto_assignment_config || {}
+ end
+end
+
+AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
diff --git a/app/services/auto_assignment/rate_limiter.rb b/app/services/auto_assignment/rate_limiter.rb
new file mode 100644
index 000000000..d22f1f9b7
--- /dev/null
+++ b/app/services/auto_assignment/rate_limiter.rb
@@ -0,0 +1,49 @@
+class AutoAssignment::RateLimiter
+ pattr_initialize [:inbox!, :agent!]
+
+ def within_limit?
+ return true unless enabled?
+
+ current_count < limit
+ end
+
+ def track_assignment(conversation)
+ return unless enabled?
+
+ assignment_key = build_assignment_key(conversation.id)
+ Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
+ end
+
+ def current_count
+ return 0 unless enabled?
+
+ pattern = assignment_key_pattern
+ Redis::Alfred.keys_count(pattern)
+ end
+
+ private
+
+ def enabled?
+ limit.present? && limit.positive?
+ end
+
+ def limit
+ config&.fair_distribution_limit&.to_i || Math
+ end
+
+ def window
+ config&.fair_distribution_window&.to_i || 24.hours.to_i
+ end
+
+ def config
+ @config ||= inbox.assignment_policy
+ end
+
+ def assignment_key_pattern
+ format(Redis::RedisKeys::ASSIGNMENT_KEY_PATTERN, inbox_id: inbox.id, agent_id: agent.id)
+ end
+
+ def build_assignment_key(conversation_id)
+ format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation_id)
+ end
+end
diff --git a/app/services/auto_assignment/round_robin_selector.rb b/app/services/auto_assignment/round_robin_selector.rb
new file mode 100644
index 000000000..65e72090d
--- /dev/null
+++ b/app/services/auto_assignment/round_robin_selector.rb
@@ -0,0 +1,16 @@
+class AutoAssignment::RoundRobinSelector
+ pattr_initialize [:inbox!]
+
+ def select_agent(available_agents)
+ return nil if available_agents.empty?
+
+ agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
+ round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
+ end
+
+ private
+
+ def round_robin_service
+ @round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
+ end
+end
diff --git a/config/schedule.yml b/config/schedule.yml
index b5b21b4ce..c039719fa 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -53,3 +53,9 @@ bulk_auto_assignment_job:
cron: '*/15 * * * *'
class: 'Inboxes::BulkAutoAssignmentJob'
queue: scheduled_jobs
+
+# executed every 30 minutes for assignment_v2
+periodic_assignment_job:
+ cron: '*/30 * * * *'
+ class: 'AutoAssignment::PeriodicAssignmentJob'
+ queue: scheduled_jobs
diff --git a/enterprise/app/models/enterprise/concerns/inbox.rb b/enterprise/app/models/enterprise/concerns/inbox.rb
index 6f450bed1..0de61db23 100644
--- a/enterprise/app/models/enterprise/concerns/inbox.rb
+++ b/enterprise/app/models/enterprise/concerns/inbox.rb
@@ -6,5 +6,6 @@ module Enterprise::Concerns::Inbox
has_one :captain_assistant,
through: :captain_inbox,
class_name: 'Captain::Assistant'
+ has_many :inbox_capacity_limits, dependent: :destroy
end
end
diff --git a/enterprise/app/models/enterprise/inbox_agent_availability.rb b/enterprise/app/models/enterprise/inbox_agent_availability.rb
new file mode 100644
index 000000000..886b55278
--- /dev/null
+++ b/enterprise/app/models/enterprise/inbox_agent_availability.rb
@@ -0,0 +1,31 @@
+module Enterprise::InboxAgentAvailability
+ extend ActiveSupport::Concern
+
+ def member_ids_with_assignment_capacity
+ return member_ids unless capacity_filtering_enabled?
+
+ # Get online agents with capacity
+ agents = available_agents
+ agents = filter_by_capacity(agents)
+ agents.map(&:user_id)
+ end
+
+ private
+
+ def filter_by_capacity(inbox_members_scope)
+ return inbox_members_scope unless capacity_filtering_enabled?
+
+ inbox_members_scope.select do |inbox_member|
+ capacity_service.agent_has_capacity?(inbox_member.user, self)
+ end
+ end
+
+ def capacity_filtering_enabled?
+ account.feature_enabled?('assignment_v2') &&
+ account.account_users.joins(:agent_capacity_policy).exists?
+ end
+
+ def capacity_service
+ @capacity_service ||= Enterprise::AutoAssignment::CapacityService.new
+ end
+end
diff --git a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
new file mode 100644
index 000000000..4f3da08ee
--- /dev/null
+++ b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
@@ -0,0 +1,94 @@
+module Enterprise::AutoAssignment::AssignmentService
+ private
+
+ # Override assignment config to use policy if available
+ def assignment_config
+ return super unless policy
+
+ {
+ 'conversation_priority' => policy.conversation_priority,
+ 'fair_distribution_limit' => policy.fair_distribution_limit,
+ 'fair_distribution_window' => policy.fair_distribution_window,
+ 'balanced' => policy.balanced?
+ }.compact
+ end
+
+ # Extend agent finding to add capacity checks
+ def find_available_agent
+ agents = filter_agents_by_rate_limit(inbox.available_agents)
+ agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
+ return nil if agents.empty?
+
+ selector = policy&.balanced? ? balanced_selector : round_robin_selector
+ selector.select_agent(agents)
+ end
+
+ def filter_agents_by_capacity(agents)
+ return agents unless capacity_filtering_enabled?
+
+ capacity_service = Enterprise::AutoAssignment::CapacityService.new
+ agents.select { |agent_member| capacity_service.agent_has_capacity?(agent_member.user, inbox) }
+ end
+
+ def capacity_filtering_enabled?
+ account.feature_enabled?('assignment_v2') &&
+ account.account_users.joins(:agent_capacity_policy).exists?
+ end
+
+ def round_robin_selector
+ @round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
+ end
+
+ def balanced_selector
+ @balanced_selector ||= Enterprise::AutoAssignment::BalancedSelector.new(inbox: inbox)
+ end
+
+ def policy
+ @policy ||= inbox.assignment_policy
+ end
+
+ def account
+ inbox.account
+ end
+
+ # Override to apply exclusion rules
+ def unassigned_conversations(limit)
+ scope = inbox.conversations.unassigned.open
+
+ # Apply exclusion rules from capacity policy or assignment policy
+ scope = apply_exclusion_rules(scope)
+
+ # Apply conversation priority using enum methods if policy exists
+ scope = if policy&.longest_waiting?
+ scope.reorder(last_activity_at: :asc, created_at: :asc)
+ else
+ scope.reorder(created_at: :asc)
+ end
+
+ scope.limit(limit)
+ end
+
+ def apply_exclusion_rules(scope)
+ capacity_policy = inbox.inbox_capacity_limits.first&.agent_capacity_policy
+ return scope unless capacity_policy
+
+ exclusion_rules = capacity_policy.exclusion_rules || {}
+ scope = apply_label_exclusions(scope, exclusion_rules['excluded_labels'])
+ apply_age_exclusions(scope, exclusion_rules['exclude_older_than_hours'])
+ end
+
+ def apply_label_exclusions(scope, excluded_labels)
+ return scope if excluded_labels.blank?
+
+ scope.tagged_with(excluded_labels, exclude: true, on: :labels)
+ end
+
+ def apply_age_exclusions(scope, hours_threshold)
+ return scope if hours_threshold.blank?
+
+ hours = hours_threshold.to_i
+ return scope unless hours.positive?
+
+ scope.where('conversations.created_at >= ?', hours.hours.ago)
+ end
+end
diff --git a/enterprise/app/services/enterprise/auto_assignment/balanced_selector.rb b/enterprise/app/services/enterprise/auto_assignment/balanced_selector.rb
new file mode 100644
index 000000000..57017b6bb
--- /dev/null
+++ b/enterprise/app/services/enterprise/auto_assignment/balanced_selector.rb
@@ -0,0 +1,26 @@
+class Enterprise::AutoAssignment::BalancedSelector
+ pattr_initialize [:inbox!]
+
+ def select_agent(available_agents)
+ return nil if available_agents.empty?
+
+ agent_users = available_agents.map(&:user)
+ assignment_counts = fetch_assignment_counts(agent_users)
+
+ agent_users.min_by { |user| assignment_counts[user.id] || 0 }
+ end
+
+ private
+
+ def fetch_assignment_counts(users)
+ user_ids = users.map(&:id)
+
+ counts = inbox.conversations
+ .open
+ .where(assignee_id: user_ids)
+ .group(:assignee_id)
+ .count
+
+ Hash.new(0).merge(counts)
+ end
+end
diff --git a/enterprise/app/services/enterprise/auto_assignment/capacity_service.rb b/enterprise/app/services/enterprise/auto_assignment/capacity_service.rb
new file mode 100644
index 000000000..f82da9267
--- /dev/null
+++ b/enterprise/app/services/enterprise/auto_assignment/capacity_service.rb
@@ -0,0 +1,25 @@
+class Enterprise::AutoAssignment::CapacityService
+ def agent_has_capacity?(user, inbox)
+ # Get the account_user for this specific account
+ account_user = user.account_users.find_by(account: inbox.account)
+
+ # If no account_user or no capacity policy, agent has unlimited capacity
+ return true unless account_user&.agent_capacity_policy
+
+ policy = account_user.agent_capacity_policy
+
+ # Check if there's a specific limit for this inbox
+ inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox)
+
+ # If no specific limit for this inbox, agent has unlimited capacity for this inbox
+ return true unless inbox_limit
+
+ # Count current open conversations for this agent in this inbox
+ current_count = user.assigned_conversations
+ .where(inbox: inbox, status: :open)
+ .count
+
+ # Agent has capacity if current count is below the limit
+ current_count < inbox_limit.conversation_limit
+ end
+end
diff --git a/lib/redis/alfred.rb b/lib/redis/alfred.rb
index b3e156420..e7e04ed60 100644
--- a/lib/redis/alfred.rb
+++ b/lib/redis/alfred.rb
@@ -35,6 +35,25 @@ module Redis::Alfred
$alfred.with { |conn| conn.exists?(key) }
end
+ # set expiry on a key in seconds
+ def expire(key, seconds)
+ $alfred.with { |conn| conn.expire(key, seconds) }
+ end
+
+ # scan keys matching a pattern
+ def scan_each(match: nil, count: 100, &)
+ $alfred.with do |conn|
+ conn.scan_each(match: match, count: count, &)
+ end
+ end
+
+ # count keys matching a pattern
+ def keys_count(pattern)
+ count = 0
+ scan_each(match: pattern) { count += 1 }
+ count
+ end
+
# list operations
def llen(key)
@@ -81,8 +100,15 @@ module Redis::Alfred
# sorted set operations
# add score and value for a key
- def zadd(key, score, value)
- $alfred.with { |conn| conn.zadd(key, score, value) }
+ # Modern Redis syntax: zadd(key, [[score, member], ...])
+ def zadd(key, score, value = nil)
+ if value.nil? && score.is_a?(Array)
+ # New syntax: score is actually an array of [score, member] pairs
+ $alfred.with { |conn| conn.zadd(key, score) }
+ else
+ # Support old syntax for backward compatibility
+ $alfred.with { |conn| conn.zadd(key, [[score, value]]) }
+ end
end
# get score of a value for key
@@ -90,9 +116,22 @@ module Redis::Alfred
$alfred.with { |conn| conn.zscore(key, value) }
end
+ # count members in a sorted set with scores within the given range
+ def zcount(key, min_score, max_score)
+ $alfred.with { |conn| conn.zcount(key, min_score, max_score) }
+ end
+
+ # get the number of members in a sorted set
+ def zcard(key)
+ $alfred.with { |conn| conn.zcard(key) }
+ end
+
# get values by score
- def zrangebyscore(key, range_start, range_end)
- $alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end) }
+ def zrangebyscore(key, range_start, range_end, with_scores: false, limit: nil)
+ options = {}
+ options[:with_scores] = with_scores if with_scores
+ options[:limit] = limit if limit
+ $alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end, **options) }
end
# remove values by score
diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb
index fa64d1043..f5d297e5f 100644
--- a/lib/redis/redis_keys.rb
+++ b/lib/redis/redis_keys.rb
@@ -42,4 +42,9 @@ module Redis::RedisKeys
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%s::%s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%s'.freeze
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%s'.freeze
+
+ ## Auto Assignment Keys
+ # Track conversation assignments to agents for rate limiting
+ ASSIGNMENT_KEY = 'ASSIGNMENT::%d::AGENT::%d::CONVERSATION::%d'.freeze
+ ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%d::AGENT::%d::*'.freeze
end
diff --git a/spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb
new file mode 100644
index 000000000..432f397be
--- /dev/null
+++ b/spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb
@@ -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
diff --git a/spec/enterprise/services/enterprise/auto_assignment/balanced_selector_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/balanced_selector_spec.rb
new file mode 100644
index 000000000..a2340ab0e
--- /dev/null
+++ b/spec/enterprise/services/enterprise/auto_assignment/balanced_selector_spec.rb
@@ -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
diff --git a/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb
new file mode 100644
index 000000000..98d7c2172
--- /dev/null
+++ b/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb
@@ -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
diff --git a/spec/jobs/auto_assignment/assignment_job_spec.rb b/spec/jobs/auto_assignment/assignment_job_spec.rb
new file mode 100644
index 000000000..d13f9fef8
--- /dev/null
+++ b/spec/jobs/auto_assignment/assignment_job_spec.rb
@@ -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
diff --git a/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb b/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb
new file mode 100644
index 000000000..4b06decd0
--- /dev/null
+++ b/spec/jobs/auto_assignment/periodic_assignment_job_spec.rb
@@ -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
diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb
new file mode 100644
index 000000000..535277714
--- /dev/null
+++ b/spec/services/auto_assignment/assignment_service_spec.rb
@@ -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
diff --git a/spec/services/auto_assignment/rate_limiter_spec.rb b/spec/services/auto_assignment/rate_limiter_spec.rb
new file mode 100644
index 000000000..48a5367e3
--- /dev/null
+++ b/spec/services/auto_assignment/rate_limiter_spec.rb
@@ -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
diff --git a/spec/services/auto_assignment/round_robin_selector_spec.rb b/spec/services/auto_assignment/round_robin_selector_spec.rb
new file mode 100644
index 000000000..05c1dcd73
--- /dev/null
+++ b/spec/services/auto_assignment/round_robin_selector_spec.rb
@@ -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