mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-04 13:07:55 +00:00
feat: add assignment service
This commit is contained in:
28
app/jobs/auto_assignment/assignment_job.rb
Normal file
28
app/jobs/auto_assignment/assignment_job.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
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)
|
||||
success_message = I18n.t('jobs.auto_assignment.assignment_job.bulk_assignment_success',
|
||||
assigned_count: assigned_count,
|
||||
inbox_id: inbox.id)
|
||||
Rails.logger.info success_message
|
||||
rescue StandardError => e
|
||||
error_message = I18n.t('jobs.auto_assignment.assignment_job.bulk_assignment_failed',
|
||||
inbox_id: inbox.id,
|
||||
error_message: e.message)
|
||||
Rails.logger.error error_message
|
||||
raise e if Rails.env.test?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bulk_assignment_limit
|
||||
ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i
|
||||
end
|
||||
end
|
||||
15
app/jobs/auto_assignment/periodic_assignment_job.rb
Normal file
15
app/jobs/auto_assignment/periodic_assignment_job.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class AutoAssignment::PeriodicAssignmentJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
def perform
|
||||
Account.find_each do |account|
|
||||
next unless account.feature_enabled?('assignment_v2')
|
||||
|
||||
account.inboxes.joins(:assignment_policy).find_each do |inbox|
|
||||
next unless inbox.assignment_v2_enabled?
|
||||
|
||||
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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_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?
|
||||
|
||||
28
app/models/concerns/inbox_agent_availability.rb
Normal file
28
app/models/concerns/inbox_agent_availability.rb
Normal file
@@ -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')
|
||||
@@ -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_enabled?
|
||||
account.feature_enabled?('assignment_v2') && assignment_policy.present? && assignment_policy.enabled?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_name_for_blank_name
|
||||
|
||||
99
app/services/auto_assignment/assignment_service.rb
Normal file
99
app/services/auto_assignment/assignment_service.rb
Normal file
@@ -0,0 +1,99 @@
|
||||
class AutoAssignment::AssignmentService
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def perform_for_conversation(conversation)
|
||||
return false unless assignable?(conversation)
|
||||
|
||||
agent = find_available_agent
|
||||
return false unless agent
|
||||
|
||||
assign_conversation(conversation, agent)
|
||||
end
|
||||
|
||||
def perform_bulk_assignment(limit: 100)
|
||||
return 0 unless inbox.enable_auto_assignment?
|
||||
|
||||
assigned_count = 0
|
||||
|
||||
unassigned_conversations(limit).find_each do |conversation|
|
||||
assigned_count += 1 if perform_for_conversation(conversation)
|
||||
end
|
||||
|
||||
assigned_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assignable?(conversation)
|
||||
inbox.enable_auto_assignment? &&
|
||||
conversation.status == 'open' &&
|
||||
conversation.assignee_id.nil?
|
||||
end
|
||||
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations.unassigned.open
|
||||
|
||||
# Apply conversation priority from config
|
||||
scope = apply_conversation_priority(scope)
|
||||
scope.limit(limit)
|
||||
end
|
||||
|
||||
def apply_conversation_priority(scope)
|
||||
case assignment_config['conversation_priority']
|
||||
when 'longest_waiting'
|
||||
scope.order(last_activity_at: :asc, created_at: :asc)
|
||||
else
|
||||
scope.order(created_at: :asc)
|
||||
end
|
||||
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
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "AutoAssignment failed for conversation #{conversation.id}: #{e.message}"
|
||||
false
|
||||
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')
|
||||
49
app/services/auto_assignment/rate_limiter.rb
Normal file
49
app/services/auto_assignment/rate_limiter.rb
Normal file
@@ -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
|
||||
end
|
||||
|
||||
def window
|
||||
config['fair_distribution_window']&.to_i || 3600
|
||||
end
|
||||
|
||||
def config
|
||||
@config ||= inbox.auto_assignment_config || {}
|
||||
end
|
||||
|
||||
def assignment_key_pattern
|
||||
"assignment:#{inbox.id}:agent:#{agent.id}:*"
|
||||
end
|
||||
|
||||
def build_assignment_key(conversation_id)
|
||||
"assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{conversation_id}"
|
||||
end
|
||||
end
|
||||
16
app/services/auto_assignment/round_robin_selector.rb
Normal file
16
app/services/auto_assignment/round_robin_selector.rb
Normal file
@@ -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
|
||||
@@ -374,6 +374,11 @@ en:
|
||||
%{format_messages}
|
||||
agent_capacity_policy:
|
||||
inbox_already_assigned: 'Inbox has already been assigned to this policy'
|
||||
jobs:
|
||||
auto_assignment:
|
||||
assignment_job:
|
||||
bulk_assignment_success: 'Assigned %{assigned_count} conversations for inbox %{inbox_id}'
|
||||
bulk_assignment_failed: 'Bulk assignment failed for inbox %{inbox_id}: %{error_message}'
|
||||
portals:
|
||||
send_instructions:
|
||||
email_required: 'Email is required'
|
||||
|
||||
@@ -46,3 +46,9 @@ delete_accounts_job:
|
||||
cron: '0 1 * * *'
|
||||
class: 'Internal::DeleteAccountsJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed every 30 minutes for assignment_v2
|
||||
periodic_assignment_job:
|
||||
cron: '*/30 * * * *'
|
||||
class: 'AutoAssignment::PeriodicAssignmentJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
20
enterprise/app/models/enterprise/assignment_policy.rb
Normal file
20
enterprise/app/models/enterprise/assignment_policy.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
module Enterprise::AssignmentPolicy
|
||||
def assignment_order=(value)
|
||||
if value.to_s == 'balanced'
|
||||
write_attribute(:assignment_order, 1)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def assignment_order
|
||||
value = read_attribute(:assignment_order)
|
||||
return 'balanced' if value == 1
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def balanced?
|
||||
self[:assignment_order] == 1
|
||||
end
|
||||
end
|
||||
31
enterprise/app/models/enterprise/inbox_agent_availability.rb
Normal file
31
enterprise/app/models/enterprise/inbox_agent_availability.rb
Normal file
@@ -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) if capacity_filtering_enabled?
|
||||
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
|
||||
@@ -0,0 +1,60 @@
|
||||
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
|
||||
|
||||
# Override to check policy first
|
||||
def assignment_enabled?
|
||||
return policy.enabled? if policy
|
||||
|
||||
super
|
||||
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
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
|
||||
def agent_capacity_status(user, inbox)
|
||||
account_user = user.account_users.find_by(account: inbox.account)
|
||||
return { has_capacity: true, current: 0, limit: nil } unless account_user&.agent_capacity_policy
|
||||
|
||||
policy = account_user.agent_capacity_policy
|
||||
inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox)
|
||||
return { has_capacity: true, current: 0, limit: nil } unless inbox_limit
|
||||
|
||||
current_count = user.assigned_conversations
|
||||
.where(inbox: inbox, status: :open)
|
||||
.count
|
||||
|
||||
{
|
||||
has_capacity: current_count < inbox_limit.conversation_limit,
|
||||
current: current_count,
|
||||
limit: inbox_limit.conversation_limit
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
138
spec/enterprise/auto_assignment/capacity_service_spec.rb
Normal file
138
spec/enterprise/auto_assignment/capacity_service_spec.rb
Normal file
@@ -0,0 +1,138 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
# 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
|
||||
available = inbox.available_agents(check_capacity: true)
|
||||
available_users = available.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) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil, status: :open) }
|
||||
|
||||
it 'assigns to agents with available capacity' do
|
||||
# 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
|
||||
|
||||
expect(service.perform_for_conversation(conversation)).to be true
|
||||
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)
|
||||
expect(service.perform_for_conversation(conversation2)).to be true
|
||||
expect(conversation2.reload.assignee).to eq(agent_without_capacity)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'capacity status' do
|
||||
it 'provides accurate capacity status for agent at capacity' do
|
||||
capacity_service = described_class.new
|
||||
status = capacity_service.agent_capacity_status(agent_at_capacity, inbox)
|
||||
|
||||
expect(status[:has_capacity]).to be false
|
||||
expect(status[:current]).to eq(3)
|
||||
expect(status[:limit]).to eq(3)
|
||||
end
|
||||
|
||||
it 'provides accurate capacity status for agent with capacity' do
|
||||
capacity_service = described_class.new
|
||||
status = capacity_service.agent_capacity_status(agent_with_capacity, inbox)
|
||||
|
||||
expect(status[:has_capacity]).to be true
|
||||
expect(status[:current]).to eq(0)
|
||||
expect(status[:limit]).to eq(3)
|
||||
end
|
||||
|
||||
it 'provides accurate capacity status for agent without limit' do
|
||||
capacity_service = described_class.new
|
||||
status = capacity_service.agent_capacity_status(agent_without_capacity, inbox)
|
||||
|
||||
expect(status[:has_capacity]).to be true
|
||||
expect(status[:limit]).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,3 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::AgentAssignmentService do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::AssignmentService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:service) { described_class.new(inbox: inbox) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users)
|
||||
.and_return({ agent.id.to_s => 'online' })
|
||||
end
|
||||
|
||||
def create_test_conversation(attrs = {})
|
||||
conversation = build(:conversation, attrs.reverse_merge(inbox: inbox, assignee: nil, status: :open))
|
||||
allow(conversation).to receive(:run_auto_assignment).and_return(nil)
|
||||
conversation.save!
|
||||
conversation
|
||||
end
|
||||
|
||||
describe 'basic assignment' do
|
||||
context 'when auto assignment is enabled' do
|
||||
before { inbox.update!(enable_auto_assignment: true) }
|
||||
|
||||
it 'assigns an available agent to conversation' do
|
||||
conversation = create_test_conversation
|
||||
|
||||
result = service.perform_for_conversation(conversation)
|
||||
|
||||
expect(result).to be true
|
||||
expect(conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
it 'returns false when no agents are online' do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
|
||||
conversation = create_test_conversation
|
||||
|
||||
result = service.perform_for_conversation(conversation)
|
||||
|
||||
expect(result).to be false
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when auto assignment is disabled' do
|
||||
before { inbox.update!(enable_auto_assignment: false) }
|
||||
|
||||
it 'does not assign any agent' do
|
||||
conversation = create_test_conversation
|
||||
|
||||
result = service.perform_for_conversation(conversation)
|
||||
|
||||
expect(result).to be false
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'assignment conditions' do
|
||||
before { inbox.update!(enable_auto_assignment: true) }
|
||||
|
||||
it 'only assigns to open conversations' do
|
||||
resolved_conversation = create_test_conversation(status: 'resolved')
|
||||
|
||||
result = service.perform_for_conversation(resolved_conversation)
|
||||
|
||||
expect(result).to be false
|
||||
end
|
||||
|
||||
it 'does not reassign already assigned conversations' do
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
assigned_conversation = create_test_conversation(assignee: other_agent)
|
||||
|
||||
result = service.perform_for_conversation(assigned_conversation)
|
||||
|
||||
expect(result).to be false
|
||||
expect(assigned_conversation.reload.assignee).to eq(other_agent)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,126 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::AssignmentService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) do
|
||||
create(:inbox,
|
||||
account: account,
|
||||
enable_auto_assignment: true,
|
||||
auto_assignment_config: {
|
||||
'fair_distribution_limit' => 2,
|
||||
'fair_distribution_window' => 3600
|
||||
})
|
||||
end
|
||||
let(:service) { described_class.new(inbox: inbox) }
|
||||
let(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent1)
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users)
|
||||
.and_return({
|
||||
agent1.id.to_s => 'online',
|
||||
agent2.id.to_s => 'online'
|
||||
})
|
||||
# Clean up Redis keys for this inbox
|
||||
clean_redis_keys
|
||||
end
|
||||
|
||||
after do
|
||||
clean_redis_keys
|
||||
end
|
||||
|
||||
def clean_redis_keys
|
||||
# Clean up assignment keys for both agents
|
||||
pattern1 = "assignment:#{inbox.id}:agent:#{agent1.id}:*"
|
||||
pattern2 = "assignment:#{inbox.id}:agent:#{agent2.id}:*"
|
||||
|
||||
Redis::Alfred.scan_each(match: pattern1) { |key| Redis::Alfred.delete(key) }
|
||||
Redis::Alfred.scan_each(match: pattern2) { |key| Redis::Alfred.delete(key) }
|
||||
end
|
||||
|
||||
def create_test_conversation
|
||||
conversation = build(:conversation, inbox: inbox, assignee: nil, status: :open)
|
||||
allow(conversation).to receive(:run_auto_assignment).and_return(nil)
|
||||
conversation.save!
|
||||
conversation
|
||||
end
|
||||
|
||||
describe 'with fair distribution enabled' do
|
||||
it 'respects the assignment limit per agent' do
|
||||
# Each agent can handle 2 conversations
|
||||
conversations = Array.new(4) { create_test_conversation }
|
||||
|
||||
# First 4 conversations should be assigned (2 per agent)
|
||||
conversations.each do |conv|
|
||||
expect(service.perform_for_conversation(conv)).to be true
|
||||
end
|
||||
|
||||
# Verify distribution
|
||||
assigned_to_agent1 = conversations.count { |c| c.reload.assignee == agent1 }
|
||||
assigned_to_agent2 = conversations.count { |c| c.reload.assignee == agent2 }
|
||||
|
||||
expect(assigned_to_agent1).to eq(2)
|
||||
expect(assigned_to_agent2).to eq(2)
|
||||
|
||||
# Fifth conversation should fail (both agents at limit)
|
||||
fifth_conversation = create_test_conversation
|
||||
expect(service.perform_for_conversation(fifth_conversation)).to be false
|
||||
expect(fifth_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'tracks assignments using individual Redis keys' do
|
||||
conversation = create_test_conversation
|
||||
service.perform_for_conversation(conversation)
|
||||
|
||||
# Check that assignment key exists
|
||||
pattern = "assignment:#{inbox.id}:agent:#{conversation.reload.assignee.id}:*"
|
||||
count = Redis::Alfred.keys_count(pattern)
|
||||
expect(count).to eq(1)
|
||||
end
|
||||
|
||||
it 'allows new assignments after window expires' do
|
||||
# Assign 2 conversations to agent1
|
||||
2.times do
|
||||
conversation = create_test_conversation
|
||||
allow(service).to receive(:round_robin_selector).and_return(
|
||||
instance_double(AutoAssignment::RoundRobinSelector, select_agent: agent1)
|
||||
)
|
||||
service.perform_for_conversation(conversation)
|
||||
end
|
||||
|
||||
# Agent1 is now at limit
|
||||
rate_limiter = AutoAssignment::RateLimiter.new(inbox: inbox, agent: agent1)
|
||||
expect(rate_limiter.within_limit?).to be false
|
||||
|
||||
# Clear Redis keys to simulate time window expiry
|
||||
clean_redis_keys
|
||||
|
||||
# Agent1 should be available again
|
||||
expect(rate_limiter.within_limit?).to be true
|
||||
|
||||
# New assignment should work
|
||||
new_conversation = create_test_conversation
|
||||
allow(service).to receive(:round_robin_selector).and_return(
|
||||
instance_double(AutoAssignment::RoundRobinSelector, select_agent: agent1)
|
||||
)
|
||||
expect(service.perform_for_conversation(new_conversation)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'without fair distribution' do
|
||||
before do
|
||||
inbox.update!(auto_assignment_config: {})
|
||||
end
|
||||
|
||||
it 'assigns without limits' do
|
||||
# Create more conversations than would be allowed with limits
|
||||
5.times do
|
||||
conversation = create_test_conversation
|
||||
expect(service.perform_for_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).not_to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
186
spec/services/auto_assignment/assignment_service_spec.rb
Normal file
186
spec/services/auto_assignment/assignment_service_spec.rb
Normal file
@@ -0,0 +1,186 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::AssignmentService do
|
||||
let(:account) { create(:account) }
|
||||
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) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
end
|
||||
|
||||
def create_test_conversation(attrs = {})
|
||||
conversation = build(:conversation, attrs.reverse_merge(inbox: inbox, assignee: nil))
|
||||
# Skip the after_save callback to test our service directly
|
||||
allow(conversation).to receive(:run_auto_assignment).and_return(nil)
|
||||
conversation.save!
|
||||
conversation
|
||||
end
|
||||
|
||||
describe '#perform_for_conversation' do
|
||||
let(:conversation) { create_test_conversation }
|
||||
|
||||
before do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
|
||||
end
|
||||
|
||||
context 'when auto assignment is enabled' do
|
||||
it 'assigns conversation to available agent' do
|
||||
expect(service.perform_for_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
it 'dispatches assignee changed event' do
|
||||
# The conversation update triggers its own event through callbacks
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch).and_call_original
|
||||
|
||||
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
|
||||
Events::Types::ASSIGNEE_CHANGED,
|
||||
anything,
|
||||
hash_including(conversation: conversation, user: agent)
|
||||
)
|
||||
|
||||
service.perform_for_conversation(conversation)
|
||||
end
|
||||
|
||||
it 'returns false when no agents available' do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
|
||||
expect(service.perform_for_conversation(conversation)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation already assigned' do
|
||||
let(:conversation) { create_test_conversation(assignee: agent) }
|
||||
|
||||
it 'does not reassign' do
|
||||
expect(service.perform_for_conversation(conversation)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is not open' do
|
||||
let(:conversation) { create_test_conversation(status: 'resolved') }
|
||||
|
||||
it 'does not assign' do
|
||||
expect(service.perform_for_conversation(conversation)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform_bulk_assignment' do
|
||||
before do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
|
||||
3.times { create_test_conversation(status: :open) }
|
||||
end
|
||||
|
||||
it 'assigns multiple conversations' do
|
||||
assigned_count = service.perform_bulk_assignment(limit: 2)
|
||||
expect(assigned_count).to eq(2)
|
||||
end
|
||||
|
||||
it 'respects the limit parameter' do
|
||||
assigned_count = service.perform_bulk_assignment(limit: 1)
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(inbox.conversations.unassigned.count).to eq(2)
|
||||
end
|
||||
|
||||
context 'when auto assignment disabled' do
|
||||
before { inbox.update!(enable_auto_assignment: false) }
|
||||
|
||||
it 'returns 0' do
|
||||
expect(service.perform_bulk_assignment).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'with rate limiting' do
|
||||
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
|
||||
agent.id.to_s => 'online',
|
||||
agent2.id.to_s => 'online'
|
||||
})
|
||||
allow(inbox).to receive(:auto_assignment_config).and_return({
|
||||
'fair_distribution_limit' => 2,
|
||||
'fair_distribution_window' => 3600
|
||||
})
|
||||
end
|
||||
|
||||
it 'filters agents based on rate limits' do
|
||||
# Agent 1 has reached limit
|
||||
rate_limiter_agent1 = instance_double(AutoAssignment::RateLimiter, within_limit?: false)
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new)
|
||||
.with(inbox: inbox, agent: agent)
|
||||
.and_return(rate_limiter_agent1)
|
||||
|
||||
# Agent 2 is within limit
|
||||
rate_limiter_agent2 = instance_double(AutoAssignment::RateLimiter, within_limit?: true, track_assignment: true)
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new)
|
||||
.with(inbox: inbox, agent: agent2)
|
||||
.and_return(rate_limiter_agent2)
|
||||
|
||||
conversation = create_test_conversation
|
||||
|
||||
expect(service.perform_for_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).to eq(agent2)
|
||||
end
|
||||
|
||||
it 'tracks assignments in Redis' do
|
||||
conversation = create_test_conversation
|
||||
|
||||
rate_limiter = instance_double(AutoAssignment::RateLimiter, within_limit?: true)
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
|
||||
|
||||
expect(rate_limiter).to receive(:track_assignment).with(conversation)
|
||||
|
||||
service.perform_for_conversation(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'conversation priority' do
|
||||
before do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
|
||||
end
|
||||
|
||||
context 'with longest_waiting priority' do
|
||||
let!(:old_conversation) do
|
||||
create_test_conversation(status: :open, created_at: 2.hours.ago, last_activity_at: 2.hours.ago)
|
||||
end
|
||||
let!(:new_conversation) do
|
||||
create_test_conversation(status: :open, created_at: 1.hour.ago, last_activity_at: 1.hour.ago)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_config).and_return({
|
||||
'conversation_priority' => 'longest_waiting'
|
||||
})
|
||||
end
|
||||
|
||||
it 'assigns oldest conversation first' do
|
||||
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 'with default priority' do
|
||||
let!(:first_created) do
|
||||
create_test_conversation(status: :open, created_at: 2.hours.ago)
|
||||
end
|
||||
let!(:second_created) do
|
||||
create_test_conversation(status: :open, created_at: 1.hour.ago)
|
||||
end
|
||||
|
||||
it 'assigns by creation time' do
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(first_created.reload.assignee).to eq(agent)
|
||||
expect(second_created.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,4 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe AutoAssignment::InboxRoundRobinService do
|
||||
RSpec.describe AutoAssignment::InboxRoundRobinService do
|
||||
subject(:inbox_round_robin_service) { described_class.new(inbox: inbox) }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
|
||||
139
spec/services/auto_assignment/rate_limiter_spec.rb
Normal file
139
spec/services/auto_assignment/rate_limiter_spec.rb
Normal file
@@ -0,0 +1,139 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::RateLimiter do
|
||||
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(:auto_assignment_config).and_return({})
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(rate_limiter.within_limit?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rate limiting is enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_config).and_return({
|
||||
'fair_distribution_limit' => 5,
|
||||
'fair_distribution_window' => 3600
|
||||
})
|
||||
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(:auto_assignment_config).and_return({})
|
||||
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
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_config).and_return({
|
||||
'fair_distribution_limit' => 5,
|
||||
'fair_distribution_window' => 3600
|
||||
})
|
||||
end
|
||||
|
||||
it 'creates a Redis key with correct expiry' do
|
||||
expected_key = "assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{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(:auto_assignment_config).and_return({})
|
||||
end
|
||||
|
||||
it 'returns 0' do
|
||||
expect(rate_limiter.current_count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rate limiting is enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_config).and_return({
|
||||
'fair_distribution_limit' => 5,
|
||||
'fair_distribution_window' => 3600
|
||||
})
|
||||
end
|
||||
|
||||
it 'counts matching Redis keys' do
|
||||
pattern = "assignment:#{inbox.id}:agent:#{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
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_config).and_return({
|
||||
'fair_distribution_limit' => 10,
|
||||
'fair_distribution_window' => 7200
|
||||
})
|
||||
end
|
||||
|
||||
it 'uses the custom window value' do
|
||||
expected_key = "assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{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
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_config).and_return({
|
||||
'fair_distribution_limit' => 10
|
||||
})
|
||||
end
|
||||
|
||||
it 'uses the default window value of 3600' do
|
||||
expected_key = "assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{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
|
||||
end
|
||||
Reference in New Issue
Block a user