add initial asssignment v2 changes, added capacity and agent policies
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AssignmentV2::AssignmentJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(inbox_id: nil, conversation_id: nil)
|
||||
if conversation_id
|
||||
assign_single_conversation(conversation_id)
|
||||
elsif inbox_id
|
||||
assign_inbox_conversations(inbox_id)
|
||||
else
|
||||
Rails.logger.error 'AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_single_conversation(conversation_id)
|
||||
conversation = Conversation.find_by(id: conversation_id)
|
||||
return unless conversation
|
||||
|
||||
service = AssignmentV2::AssignmentService.new(inbox: conversation.inbox)
|
||||
service.perform_for_conversation(conversation)
|
||||
end
|
||||
|
||||
def assign_inbox_conversations(inbox_id)
|
||||
inbox = Inbox.find_by(id: inbox_id)
|
||||
return unless inbox
|
||||
return unless inbox.assignment_v2_enabled?
|
||||
|
||||
service = AssignmentV2::AssignmentService.new(inbox: inbox)
|
||||
assigned_count = service.perform_bulk_assignment
|
||||
|
||||
Rails.logger.info "AssignmentV2::AssignmentJob: Assigned #{assigned_count} conversations for inbox #{inbox_id}"
|
||||
end
|
||||
end
|
||||
@@ -28,6 +28,7 @@ class Account < ApplicationRecord
|
||||
include Reportable
|
||||
include Featurable
|
||||
include CacheKeys
|
||||
include AssignmentV2FeatureFlag
|
||||
|
||||
SETTINGS_PARAMS_SCHEMA = {
|
||||
'type': 'object',
|
||||
@@ -97,6 +98,9 @@ class Account < ApplicationRecord
|
||||
has_many :webhooks, dependent: :destroy_async
|
||||
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
|
||||
has_many :working_hours, dependent: :destroy_async
|
||||
|
||||
# Assignment V2 associations
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
|
||||
has_one_attached :contacts_export
|
||||
|
||||
@@ -158,6 +162,7 @@ class Account < ApplicationRecord
|
||||
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def notify_creation
|
||||
|
||||
@@ -28,6 +28,7 @@ class AccountUser < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :user
|
||||
belongs_to :inviter, class_name: 'User', optional: true
|
||||
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy', optional: true
|
||||
|
||||
enum role: { agent: 0, administrator: 1 }
|
||||
enum availability: { online: 0, offline: 1, busy: 2 }
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: assignment_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# account_id :bigint not null
|
||||
# name :string(255) not null
|
||||
# description :text
|
||||
# assignment_order :integer not null, default: 0
|
||||
# conversation_priority :integer not null, default: 0
|
||||
# fair_distribution_limit :integer not null, default: 10
|
||||
# fair_distribution_window :integer not null, default: 3600
|
||||
# enabled :boolean not null, default: true
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_assignment_policies_on_account_id (account_id)
|
||||
# unique_assignment_policy_name_per_account (account_id,name) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
#
|
||||
|
||||
class AssignmentPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
# Enums
|
||||
enum assignment_order: { round_robin: 0, balanced: 1 }
|
||||
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
|
||||
|
||||
# Associations
|
||||
belongs_to :account
|
||||
has_many :inbox_assignment_policies, dependent: :destroy
|
||||
has_many :inboxes, through: :inbox_assignment_policies
|
||||
|
||||
# Validations
|
||||
validates :name, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :name, length: { maximum: 255 }
|
||||
validates :description, length: { maximum: 1000 }
|
||||
validates :fair_distribution_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
|
||||
validates :fair_distribution_window, presence: true, numericality: { greater_than: 60, less_than_or_equal_to: 86_400 }
|
||||
validates :assignment_order, inclusion: { in: assignment_orders.keys }
|
||||
validates :conversation_priority, inclusion: { in: conversation_priorities.keys }
|
||||
|
||||
# Validate balanced assignment is only available for enterprise
|
||||
validate :validate_balanced_assignment_enterprise_only
|
||||
|
||||
# Server-side validation to prevent bypass
|
||||
before_save :enforce_enterprise_features
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
scope :disabled, -> { where(enabled: false) }
|
||||
|
||||
# Callbacks
|
||||
after_update_commit :clear_assignment_caches
|
||||
after_destroy :clear_assignment_caches
|
||||
|
||||
def can_use_balanced_assignment?
|
||||
account.feature_enabled?(:enterprise_agent_capacity) if account.respond_to?(:feature_enabled?)
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
assignment_order: assignment_order,
|
||||
conversation_priority: conversation_priority,
|
||||
fair_distribution_limit: fair_distribution_limit,
|
||||
fair_distribution_window: fair_distribution_window,
|
||||
enabled: enabled
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_balanced_assignment_enterprise_only
|
||||
return unless balanced?
|
||||
return if can_use_balanced_assignment?
|
||||
|
||||
errors.add(:assignment_order, 'Balanced assignment is only available for enterprise accounts')
|
||||
end
|
||||
|
||||
def enforce_enterprise_features
|
||||
# Server-side enforcement to prevent API bypass
|
||||
if balanced? && !can_use_balanced_assignment?
|
||||
# Force to round_robin if enterprise not available
|
||||
self.assignment_order = 'round_robin'
|
||||
Rails.logger.warn("Assignment V2: Forced assignment_order to round_robin for non-enterprise account #{account_id}")
|
||||
end
|
||||
end
|
||||
|
||||
def clear_assignment_caches
|
||||
# Clear Redis caches when policy is updated
|
||||
Rails.cache.delete("assignment_v2:policy:#{id}")
|
||||
inboxes.find_each do |inbox|
|
||||
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -14,11 +14,18 @@ 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.assignment_v2_enabled?
|
||||
# Use Assignment V2 system
|
||||
AssignmentV2::AssignmentJob.perform_later(conversation_id: 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?
|
||||
return false unless inbox.enable_auto_assignment?
|
||||
# Check auto assignment is enabled (either legacy or v2)
|
||||
return false unless inbox.auto_assignment_enabled?
|
||||
|
||||
# run only if assignee is blank or doesn't have access to inbox
|
||||
assignee.blank? || inbox.members.exclude?(assignee)
|
||||
|
||||
@@ -44,6 +44,7 @@ class Inbox < ApplicationRecord
|
||||
include Avatarable
|
||||
include OutOfOffisable
|
||||
include AccountCacheRevalidator
|
||||
include AssignmentV2FeatureFlag
|
||||
|
||||
# Not allowing characters:
|
||||
validates :name, presence: true
|
||||
@@ -71,6 +72,10 @@ class Inbox < ApplicationRecord
|
||||
has_one :agent_bot, through: :agent_bot_inbox
|
||||
has_many :webhooks, dependent: :destroy_async
|
||||
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
||||
|
||||
# Assignment V2 associations
|
||||
has_one :inbox_assignment_policy, dependent: :destroy
|
||||
has_one :assignment_policy, through: :inbox_assignment_policy
|
||||
|
||||
enum sender_name_type: { friendly: 0, professional: 1 }
|
||||
|
||||
@@ -184,8 +189,136 @@ class Inbox < ApplicationRecord
|
||||
members.ids
|
||||
end
|
||||
|
||||
# Assignment V2 methods
|
||||
def assignment_v2_enabled?
|
||||
account.assignment_v2_enabled? && assignment_policy.present? && assignment_policy.enabled?
|
||||
end
|
||||
|
||||
def auto_assignment_enabled?
|
||||
if assignment_v2_enabled?
|
||||
assignment_policy.present? && assignment_policy.enabled?
|
||||
else
|
||||
enable_auto_assignment?
|
||||
end
|
||||
end
|
||||
|
||||
# Returns inbox members who are available for assignment
|
||||
# This method performs all filtering upfront at the database level for optimal performance
|
||||
#
|
||||
# Filters applied:
|
||||
# 1. Online status - Only agents marked as 'online' in OnlineStatusTracker
|
||||
# 2. Capacity limits (Enterprise) - Agents who haven't reached their conversation limit
|
||||
# 3. Rate limiting - Agents who haven't exceeded rate limits (when implemented)
|
||||
# 4. User exclusions - Specific users can be excluded (e.g., for reassignment)
|
||||
#
|
||||
# @param options [Hash] Additional filter options
|
||||
# @option options [Boolean] :check_capacity (true) Whether to check capacity limits
|
||||
# @option options [Boolean] :check_rate_limits (false) Whether to check rate limits
|
||||
# @option options [Array<Integer>] :exclude_user_ids Users to exclude from results
|
||||
#
|
||||
# @return [ActiveRecord::Relation<InboxMember>] Available inbox members with preloaded users
|
||||
#
|
||||
# @example Get all available agents
|
||||
# inbox.available_agents
|
||||
#
|
||||
# @example Get available agents excluding specific users
|
||||
# inbox.available_agents(exclude_user_ids: [1, 2, 3])
|
||||
#
|
||||
# @example Get available agents without capacity check (faster but less accurate)
|
||||
# inbox.available_agents(check_capacity: false)
|
||||
def available_agents(options = {})
|
||||
options = { check_capacity: true }.merge(options)
|
||||
|
||||
# Get online agent IDs
|
||||
online_agent_ids = fetch_online_agent_ids
|
||||
return inbox_members.none if online_agent_ids.empty?
|
||||
|
||||
# Base query - only online agents
|
||||
scope = inbox_members
|
||||
.joins(:user)
|
||||
.where(users: { id: online_agent_ids })
|
||||
.includes(:user)
|
||||
|
||||
# Exclude specific users if requested
|
||||
if options[:exclude_user_ids].present?
|
||||
scope = scope.where.not(users: { id: options[:exclude_user_ids] })
|
||||
end
|
||||
|
||||
# Apply capacity filtering for enterprise accounts
|
||||
if options[:check_capacity] && enterprise_capacity_enabled?
|
||||
scope = filter_by_capacity(scope)
|
||||
end
|
||||
|
||||
# Apply rate limiting if implemented
|
||||
if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
|
||||
scope = filter_by_rate_limits(scope)
|
||||
end
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def fetch_online_agent_ids
|
||||
OnlineStatusTracker.get_available_users(account_id)
|
||||
.select { |_key, value| value.eql?('online') }
|
||||
.keys
|
||||
.map(&:to_i)
|
||||
end
|
||||
|
||||
def enterprise_capacity_enabled?
|
||||
defined?(Enterprise) &&
|
||||
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
|
||||
end
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless defined?(Enterprise::InboxCapacityLimit)
|
||||
|
||||
# For simple cases without capacity policies, return all agents
|
||||
if !account.account_users.joins(:agent_capacity_policy).exists?
|
||||
return inbox_members_scope
|
||||
end
|
||||
|
||||
# Get current assignment counts for all agents
|
||||
assignment_counts = conversations
|
||||
.where(status: :open)
|
||||
.where.not(assignee_id: nil)
|
||||
.group(:assignee_id)
|
||||
.count
|
||||
|
||||
# Filter agents based on capacity
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
user = inbox_member.user
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
|
||||
# If no capacity policy, allow assignment
|
||||
next true unless account_user&.agent_capacity_policy_id
|
||||
|
||||
# Check if there's a limit for this inbox
|
||||
capacity_limit = Enterprise::InboxCapacityLimit
|
||||
.where(agent_capacity_policy_id: account_user.agent_capacity_policy_id)
|
||||
.find_by(inbox_id: id)
|
||||
|
||||
# If no limit defined for this inbox, allow assignment
|
||||
next true unless capacity_limit&.conversation_limit
|
||||
|
||||
# Check current assignments against limit
|
||||
current_count = assignment_counts[user.id] || 0
|
||||
current_count < capacity_limit.conversation_limit
|
||||
end
|
||||
end
|
||||
|
||||
def filter_by_rate_limits(inbox_members_scope)
|
||||
# Filter out agents who have exceeded rate limits
|
||||
return inbox_members_scope unless assignment_policy&.enabled?
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
|
||||
rate_limiter.within_limits?
|
||||
end
|
||||
end
|
||||
|
||||
def default_name_for_blank_name
|
||||
email? ? display_name_from_email : ''
|
||||
end
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: inbox_assignment_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# inbox_id :bigint not null
|
||||
# assignment_policy_id :bigint not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
|
||||
# index_inbox_assignment_policies_on_inbox_id (inbox_id)
|
||||
# unique_inbox_assignment_policy (inbox_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (assignment_policy_id => assignment_policies.id)
|
||||
# fk_rails_... (inbox_id => inboxes.id)
|
||||
#
|
||||
|
||||
class InboxAssignmentPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
# Associations
|
||||
belongs_to :inbox
|
||||
belongs_to :assignment_policy
|
||||
|
||||
# Validations
|
||||
validates :inbox_id, uniqueness: true
|
||||
validate :inbox_belongs_to_same_account
|
||||
|
||||
# Delegations
|
||||
delegate :account, to: :inbox
|
||||
delegate :name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled?,
|
||||
to: :assignment_policy, prefix: :policy
|
||||
|
||||
# Callbacks
|
||||
after_create_commit :clear_inbox_cache
|
||||
after_update_commit :clear_inbox_cache
|
||||
after_destroy_commit :clear_inbox_cache
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: true }) }
|
||||
scope :disabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: false }) }
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
inbox_id: inbox_id,
|
||||
assignment_policy_id: assignment_policy_id,
|
||||
policy: assignment_policy.webhook_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox_belongs_to_same_account
|
||||
return unless inbox && assignment_policy
|
||||
|
||||
if inbox.account_id != assignment_policy.account_id
|
||||
errors.add(:inbox, 'must belong to the same account as the assignment policy')
|
||||
end
|
||||
end
|
||||
|
||||
def clear_inbox_cache
|
||||
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox_id}")
|
||||
update_account_cache
|
||||
end
|
||||
end
|
||||
@@ -100,6 +100,10 @@ class User < ApplicationRecord
|
||||
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
|
||||
# rubocop:enable Rails/HasManyOrHasOneDependent
|
||||
|
||||
# Assignment V2 Enterprise associations
|
||||
has_one :agent_capacity_policy_user, dependent: :destroy, class_name: 'Enterprise::AgentCapacityPolicyUser'
|
||||
has_one :agent_capacity_policy, through: :agent_capacity_policy_user, class_name: 'Enterprise::AgentCapacityPolicy'
|
||||
|
||||
before_validation :set_password_and_uid, on: :create
|
||||
after_destroy :remove_macros
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AssignmentV2::AssignmentService
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def perform_for_conversation(conversation)
|
||||
return false unless can_assign?(conversation)
|
||||
|
||||
agent = find_agent_for_conversation(conversation)
|
||||
return false unless agent
|
||||
|
||||
assign_conversation_to_agent(conversation, agent)
|
||||
end
|
||||
|
||||
def perform_bulk_assignment(limit: 50)
|
||||
return 0 unless assignment_enabled?
|
||||
|
||||
conversations = unassigned_conversations(limit)
|
||||
assigned_count = 0
|
||||
|
||||
conversations.find_each do |conversation|
|
||||
assigned_count += 1 if perform_for_conversation(conversation)
|
||||
end
|
||||
|
||||
assigned_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def policy
|
||||
@policy ||= inbox.assignment_policy
|
||||
end
|
||||
|
||||
def assignment_enabled?
|
||||
policy&.enabled?
|
||||
end
|
||||
|
||||
def can_assign?(conversation)
|
||||
assignment_enabled? &&
|
||||
conversation.status == 'open' &&
|
||||
conversation.assignee_id.nil?
|
||||
end
|
||||
|
||||
def find_agent_for_conversation(conversation)
|
||||
available_agents = inbox.available_agents(check_rate_limits: true)
|
||||
|
||||
if available_agents.empty?
|
||||
log_no_agents_available
|
||||
return nil
|
||||
end
|
||||
|
||||
selector_service.select_agent(available_agents)
|
||||
end
|
||||
|
||||
def selector_service
|
||||
@selector_service ||= case policy.assignment_order
|
||||
when 'round_robin'
|
||||
AssignmentV2::RoundRobinSelector.new(inbox: inbox)
|
||||
when 'balanced'
|
||||
if enterprise_enabled? && policy.can_use_balanced_assignment?
|
||||
Enterprise::AssignmentV2::BalancedSelector.new(inbox: inbox)
|
||||
else
|
||||
AssignmentV2::RoundRobinSelector.new(inbox: inbox)
|
||||
end
|
||||
else
|
||||
AssignmentV2::RoundRobinSelector.new(inbox: inbox)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations
|
||||
.unassigned
|
||||
.open
|
||||
|
||||
# Apply conversation priority ordering
|
||||
scope = case policy.conversation_priority
|
||||
when 'earliest_created'
|
||||
scope.order(created_at: :asc)
|
||||
when 'longest_waiting'
|
||||
scope.order(last_activity_at: :asc, created_at: :asc)
|
||||
else
|
||||
scope.order(created_at: :asc)
|
||||
end
|
||||
|
||||
scope.limit(limit)
|
||||
end
|
||||
|
||||
def assign_conversation_to_agent(conversation, agent)
|
||||
conversation.update!(assignee: agent)
|
||||
create_assignment_activity(conversation, agent)
|
||||
record_assignment_in_rate_limiter(conversation, agent)
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "AssignmentV2: Failed to assign conversation #{conversation.id}: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def create_assignment_activity(conversation, agent)
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
ASSIGNEE_CHANGED,
|
||||
Time.zone.now,
|
||||
conversation: conversation,
|
||||
user: agent
|
||||
)
|
||||
end
|
||||
|
||||
def enterprise_enabled?
|
||||
@enterprise_enabled ||= defined?(Enterprise)
|
||||
end
|
||||
|
||||
def log_no_agents_available
|
||||
Rails.logger.warn("AssignmentV2: No agents available for inbox #{inbox.id}")
|
||||
end
|
||||
|
||||
def record_assignment_in_rate_limiter(conversation, agent)
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: inbox, user: agent)
|
||||
rate_limiter.record_assignment(conversation)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "AssignmentV2: Failed to record assignment in rate limiter: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Rate limiter for assignment operations
|
||||
# Uses Redis to track assignment counts per agent per time window
|
||||
# based on assignment policy's fair_distribution_limit and fair_distribution_window
|
||||
class AssignmentV2::RateLimiter
|
||||
pattr_initialize [:inbox!, :user!]
|
||||
|
||||
# Check if the user has exceeded rate limits
|
||||
# @return [Boolean] true if within limits, false if exceeded
|
||||
def within_limits?
|
||||
return true unless policy_exists?
|
||||
|
||||
current_count < rate_limit
|
||||
end
|
||||
|
||||
# Record an assignment for rate limiting purposes
|
||||
# @param conversation [Conversation] The conversation being assigned
|
||||
def record_assignment(conversation)
|
||||
return unless policy_exists?
|
||||
|
||||
key = rate_limit_key
|
||||
$alfred.with do |redis|
|
||||
redis.multi do |multi|
|
||||
multi.incr(key)
|
||||
multi.expire(key, time_window)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Get current rate limit status for the user
|
||||
# @return [Hash] Rate limit status information
|
||||
def status
|
||||
if policy_exists?
|
||||
{
|
||||
within_limits: within_limits?,
|
||||
current_count: current_count,
|
||||
limit: rate_limit,
|
||||
reset_at: Time.at(next_window_start)
|
||||
}
|
||||
else
|
||||
{
|
||||
within_limits: true,
|
||||
current_count: 0,
|
||||
limit: Float::INFINITY,
|
||||
reset_at: nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def policy
|
||||
@policy ||= inbox.assignment_policy
|
||||
end
|
||||
|
||||
def policy_exists?
|
||||
policy.present? && policy.enabled?
|
||||
end
|
||||
|
||||
def current_count
|
||||
key = rate_limit_key
|
||||
$alfred.with { |redis| redis.get(key).to_i }
|
||||
end
|
||||
|
||||
def rate_limit
|
||||
policy&.fair_distribution_limit || 10
|
||||
end
|
||||
|
||||
def time_window
|
||||
policy&.fair_distribution_window || 3600
|
||||
end
|
||||
|
||||
def rate_limit_key
|
||||
"assignment_v2:rate_limit:#{user.id}:#{current_window}"
|
||||
end
|
||||
|
||||
def current_window
|
||||
(Time.current.to_i / time_window) * time_window
|
||||
end
|
||||
|
||||
def next_window_start
|
||||
current_window + time_window
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AssignmentV2::RoundRobinSelector
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def select_agent(available_agents)
|
||||
return nil if available_agents.empty?
|
||||
|
||||
# Extract user IDs from inbox members
|
||||
agent_user_ids = available_agents.map { |inbox_member| inbox_member.user_id }.map(&:to_s)
|
||||
|
||||
# Use Redis queue for round robin
|
||||
selected_user_id = round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
|
||||
return nil unless selected_user_id
|
||||
|
||||
# Return the user object
|
||||
available_agents.find { |inbox_member| inbox_member.user_id.to_s == selected_user_id }&.user
|
||||
end
|
||||
|
||||
def add_agent_to_queue(user_id)
|
||||
round_robin_service.add_agent_to_queue(user_id)
|
||||
end
|
||||
|
||||
def remove_agent_from_queue(user_id)
|
||||
round_robin_service.remove_agent_from_queue(user_id)
|
||||
end
|
||||
|
||||
def reset_queue
|
||||
round_robin_service.reset_queue
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def round_robin_service
|
||||
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateAssignmentPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :assignment_policies do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.string :name, null: false, limit: 255
|
||||
t.text :description
|
||||
t.integer :assignment_order, null: false, default: 0 # 0: round_robin, 1: balanced
|
||||
t.integer :conversation_priority, null: false, default: 0 # 0: earliest_created, 1: longest_waiting
|
||||
t.integer :fair_distribution_limit, null: false, default: 10
|
||||
t.integer :fair_distribution_window, null: false, default: 3600 # seconds
|
||||
t.boolean :enabled, null: false, default: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :assignment_policies, [:account_id, :name], unique: true, name: 'unique_assignment_policy_name_per_account'
|
||||
add_index :assignment_policies, :enabled, name: 'index_assignment_policies_on_enabled'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateInboxAssignmentPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :inbox_assignment_policies do |t|
|
||||
t.references :inbox, null: false, index: true
|
||||
t.references :assignment_policy, null: false, index: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :inbox_assignment_policies, :inbox_id, unique: true, name: 'unique_inbox_assignment_policy'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateEnterpriseAgentCapacityPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :enterprise_agent_capacity_policies do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.string :name, null: false, limit: 255
|
||||
t.text :description
|
||||
t.jsonb :exclusion_rules, default: {}, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :enterprise_agent_capacity_policies, [:account_id, :name], unique: true, name: 'unique_capacity_policy_name_per_account'
|
||||
add_index :enterprise_agent_capacity_policies, :exclusion_rules, using: :gin, name: 'index_capacity_policies_on_exclusion_rules'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateEnterpriseInboxCapacityLimits < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :enterprise_inbox_capacity_limits do |t|
|
||||
t.references :agent_capacity_policy, null: false, index: { name: 'index_inbox_limits_on_capacity_policy' }
|
||||
t.references :inbox, null: false, index: true
|
||||
t.integer :conversation_limit, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :enterprise_inbox_capacity_limits, [:agent_capacity_policy_id, :inbox_id], unique: true, name: 'unique_policy_inbox_limit'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddAgentCapacityPolicyToAccountUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_reference :account_users, :agent_capacity_policy, null: true, index: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,133 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: enterprise_agent_capacity_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# account_id :bigint not null
|
||||
# name :string(255) not null
|
||||
# description :text
|
||||
# exclusion_rules :jsonb default: {}
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_enterprise_agent_capacity_policies_on_account_id (account_id)
|
||||
# unique_capacity_policy_name_per_account (account_id,name) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
#
|
||||
|
||||
module Enterprise
|
||||
class AgentCapacityPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
self.table_name = 'enterprise_agent_capacity_policies'
|
||||
|
||||
# Associations
|
||||
belongs_to :account
|
||||
has_many :account_users, dependent: :nullify, foreign_key: :agent_capacity_policy_id
|
||||
has_many :users, through: :account_users
|
||||
has_many :inbox_capacity_limits, dependent: :destroy, class_name: 'Enterprise::InboxCapacityLimit'
|
||||
has_many :inboxes, through: :inbox_capacity_limits
|
||||
|
||||
# Validations
|
||||
validates :name, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :name, length: { maximum: 255 }
|
||||
validates :description, length: { maximum: 1000 }
|
||||
validates :exclusion_rules, json: { schema: exclusion_rules_schema }
|
||||
|
||||
# Callbacks
|
||||
before_save :validate_inbox_access
|
||||
after_update_commit :invalidate_capacity_caches
|
||||
after_destroy :invalidate_capacity_caches
|
||||
|
||||
# Scopes
|
||||
scope :with_users, -> { joins(:account_users) }
|
||||
scope :for_inbox, ->(inbox) { joins(:inbox_capacity_limits).where(enterprise_inbox_capacity_limits: { inbox: inbox }) }
|
||||
|
||||
def add_user(user)
|
||||
# Find the account_user for this account and user
|
||||
account_user = account.account_users.find_by!(user: user)
|
||||
|
||||
# Update the capacity policy reference
|
||||
account_user.update!(agent_capacity_policy_id: id)
|
||||
invalidate_user_capacity_cache(user)
|
||||
end
|
||||
|
||||
def remove_user(user)
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
account_user&.update!(agent_capacity_policy_id: nil)
|
||||
invalidate_user_capacity_cache(user)
|
||||
end
|
||||
|
||||
def set_inbox_limit(inbox, limit)
|
||||
inbox_capacity_limit = inbox_capacity_limits.find_or_initialize_by(inbox: inbox)
|
||||
inbox_capacity_limit.conversation_limit = limit
|
||||
inbox_capacity_limit.save!
|
||||
|
||||
invalidate_inbox_capacity_cache(inbox)
|
||||
end
|
||||
|
||||
def remove_inbox_limit(inbox)
|
||||
inbox_capacity_limits.where(inbox: inbox).destroy_all
|
||||
invalidate_inbox_capacity_cache(inbox)
|
||||
end
|
||||
|
||||
def get_inbox_limit(inbox)
|
||||
inbox_capacity_limits.find_by(inbox: inbox)&.conversation_limit
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
exclusion_rules: exclusion_rules,
|
||||
users_count: users.count,
|
||||
inboxes_count: inboxes.count
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_inbox_access
|
||||
# Ensure all specified inboxes belong to the same account
|
||||
invalid_inboxes = inbox_capacity_limits.joins(:inbox)
|
||||
.where.not(inboxes: { account_id: account_id })
|
||||
|
||||
if invalid_inboxes.exists?
|
||||
errors.add(:inbox_capacity_limits, 'contains inboxes from different accounts')
|
||||
throw :abort
|
||||
end
|
||||
end
|
||||
|
||||
def invalidate_capacity_caches
|
||||
users.find_each { |user| invalidate_user_capacity_cache(user) }
|
||||
inboxes.find_each { |inbox| invalidate_inbox_capacity_cache(inbox) }
|
||||
end
|
||||
|
||||
def invalidate_user_capacity_cache(user)
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:#{user.id}:*")
|
||||
end
|
||||
|
||||
def invalidate_inbox_capacity_cache(inbox)
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:*:#{inbox.id}")
|
||||
end
|
||||
|
||||
def self.exclusion_rules_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
labels: { type: 'array', items: { type: 'string' } },
|
||||
hours_threshold: { type: 'integer', minimum: 1, maximum: 168 }
|
||||
},
|
||||
additionalProperties: false
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: enterprise_inbox_capacity_limits
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# agent_capacity_policy_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
# conversation_limit :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_inbox_limits_on_capacity_policy (agent_capacity_policy_id)
|
||||
# index_enterprise_inbox_capacity_limits_on_inbox_id (inbox_id)
|
||||
# unique_policy_inbox_limit (agent_capacity_policy_id,inbox_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (agent_capacity_policy_id => enterprise_agent_capacity_policies.id)
|
||||
# fk_rails_... (inbox_id => inboxes.id)
|
||||
#
|
||||
|
||||
module Enterprise
|
||||
class InboxCapacityLimit < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
self.table_name = 'enterprise_inbox_capacity_limits'
|
||||
|
||||
# Associations
|
||||
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy'
|
||||
belongs_to :inbox
|
||||
|
||||
# Validations
|
||||
validates :agent_capacity_policy_id, uniqueness: { scope: :inbox_id }
|
||||
validates :conversation_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 1000 }
|
||||
|
||||
# Delegations
|
||||
delegate :account, to: :agent_capacity_policy
|
||||
delegate :name, :description, :exclusion_rules, to: :agent_capacity_policy, prefix: :policy
|
||||
|
||||
# Callbacks
|
||||
after_create_commit :invalidate_inbox_cache
|
||||
after_update_commit :invalidate_inbox_cache
|
||||
after_destroy_commit :invalidate_inbox_cache
|
||||
|
||||
# Scopes
|
||||
scope :for_inbox, ->(inbox) { where(inbox: inbox) }
|
||||
scope :for_policy, ->(policy) { where(agent_capacity_policy: policy) }
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
inbox_id: inbox_id,
|
||||
agent_capacity_policy_id: agent_capacity_policy_id,
|
||||
conversation_limit: conversation_limit,
|
||||
policy: agent_capacity_policy.webhook_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invalidate_inbox_cache
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:*:#{inbox_id}")
|
||||
update_account_cache
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Enterprise
|
||||
class AssignmentV2::BalancedSelector
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def select_agent(available_agents)
|
||||
return nil if available_agents.empty?
|
||||
|
||||
# Since agents are already filtered by capacity, we can compute workload distribution
|
||||
agents_with_workload = compute_agent_workloads(available_agents)
|
||||
return nil if agents_with_workload.empty?
|
||||
|
||||
# Select agent with lowest current workload
|
||||
best_agent_data = agents_with_workload.min_by { |data| data[:current_assignments] }
|
||||
best_agent_data[:agent]
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "AssignmentV2: Balanced selection failed: #{e.message}"
|
||||
# Fallback to simple selection
|
||||
available_agents.first&.user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def compute_agent_workloads(available_agents)
|
||||
available_agents.map do |inbox_member|
|
||||
agent = inbox_member.user
|
||||
|
||||
# Count current assignments
|
||||
current_assignments = agent.assigned_conversations
|
||||
.where(inbox: inbox)
|
||||
.open
|
||||
.count
|
||||
|
||||
{
|
||||
agent: agent,
|
||||
current_assignments: current_assignments
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Enterprise
|
||||
class AssignmentV2::CapacityService
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def filter_agents_by_capacity(inbox_members)
|
||||
inbox_members.select do |inbox_member|
|
||||
agent = inbox_member.user
|
||||
has_available_capacity?(agent)
|
||||
end
|
||||
end
|
||||
|
||||
def get_agent_capacity(agent)
|
||||
account_user = agent.account_users.find_by(account: inbox.account)
|
||||
policy = account_user&.agent_capacity_policy
|
||||
|
||||
unless policy
|
||||
return {
|
||||
total_capacity: Float::INFINITY,
|
||||
current_assignments: 0,
|
||||
available_capacity: Float::INFINITY
|
||||
}
|
||||
end
|
||||
|
||||
inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox)
|
||||
|
||||
unless inbox_limit
|
||||
return {
|
||||
total_capacity: Float::INFINITY,
|
||||
current_assignments: 0,
|
||||
available_capacity: Float::INFINITY
|
||||
}
|
||||
end
|
||||
|
||||
current_count = agent.assigned_conversations
|
||||
.where(inbox: inbox)
|
||||
.open
|
||||
.count
|
||||
|
||||
{
|
||||
total_capacity: inbox_limit.conversation_limit,
|
||||
current_assignments: current_count,
|
||||
available_capacity: inbox_limit.conversation_limit - current_count
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def has_available_capacity?(agent)
|
||||
capacity_data = get_agent_capacity(agent)
|
||||
capacity_data[:available_capacity].positive?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
namespace :assignment_v2 do
|
||||
desc 'Enable Assignment V2 for an account with default policy'
|
||||
task :enable, [:account_id] => :environment do |_task, args|
|
||||
account = Account.find(args[:account_id])
|
||||
|
||||
# Create default assignment policy if not exists
|
||||
policy = account.assignment_policies.find_or_create_by!(name: 'Default Policy') do |p|
|
||||
p.description = 'Default round-robin assignment policy'
|
||||
p.assignment_order = 'round_robin'
|
||||
p.conversation_priority = 'earliest_created'
|
||||
p.enabled = true
|
||||
end
|
||||
|
||||
puts "Assignment V2 enabled for account #{account.name} with policy #{policy.name}"
|
||||
end
|
||||
|
||||
desc 'Disable Assignment V2 for an account'
|
||||
task :disable, [:account_id] => :environment do |_task, args|
|
||||
account = Account.find(args[:account_id])
|
||||
|
||||
# Disable all assignment policies
|
||||
account.assignment_policies.update_all(enabled: false)
|
||||
|
||||
puts "Assignment V2 disabled for account #{account.name}"
|
||||
end
|
||||
|
||||
desc 'Run assignment for all enabled inboxes'
|
||||
task run_all: :environment do
|
||||
count = 0
|
||||
|
||||
Inbox.joins(:assignment_policy)
|
||||
.where(assignment_policies: { enabled: true })
|
||||
.find_each do |inbox|
|
||||
AssignmentV2::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||
count += 1
|
||||
end
|
||||
|
||||
puts "Queued assignment jobs for #{count} inboxes"
|
||||
end
|
||||
|
||||
desc 'Show Assignment V2 status'
|
||||
task status: :environment do
|
||||
puts 'Assignment V2 Status'
|
||||
puts '==================='
|
||||
|
||||
total_policies = AssignmentPolicy.count
|
||||
enabled_policies = AssignmentPolicy.enabled.count
|
||||
|
||||
puts "Total Assignment Policies: #{total_policies}"
|
||||
puts "Enabled Policies: #{enabled_policies}"
|
||||
puts
|
||||
|
||||
inboxes_with_v2 = Inbox.joins(:assignment_policy).count
|
||||
total_inboxes = Inbox.count
|
||||
|
||||
puts "Total Inboxes: #{total_inboxes}"
|
||||
puts "Inboxes with Assignment V2: #{inboxes_with_v2}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :assignment_policy do
|
||||
account
|
||||
sequence(:name) { |n| "Assignment Policy #{n}" }
|
||||
description { 'Test assignment policy' }
|
||||
assignment_order { :round_robin }
|
||||
conversation_priority { :earliest_created }
|
||||
fair_distribution_limit { 10 }
|
||||
fair_distribution_window { 3600 }
|
||||
enabled { true }
|
||||
|
||||
trait :balanced do
|
||||
assignment_order { :balanced }
|
||||
end
|
||||
|
||||
trait :disabled do
|
||||
enabled { false }
|
||||
end
|
||||
|
||||
trait :longest_waiting do
|
||||
conversation_priority { :longest_waiting }
|
||||
end
|
||||
|
||||
trait :with_high_limit do
|
||||
fair_distribution_limit { 50 }
|
||||
end
|
||||
|
||||
trait :with_short_window do
|
||||
fair_distribution_window { 300 } # 5 minutes
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :enterprise_agent_capacity_policy, class: 'Enterprise::AgentCapacityPolicy' do
|
||||
account
|
||||
sequence(:name) { |n| "Capacity Policy #{n}" }
|
||||
description { 'Test capacity policy' }
|
||||
exclusion_rules { {} }
|
||||
|
||||
trait :with_label_exclusion do
|
||||
exclusion_rules { { 'labels' => ['vip', 'urgent'] } }
|
||||
end
|
||||
|
||||
trait :with_time_exclusion do
|
||||
exclusion_rules { { 'hours_threshold' => 24 } }
|
||||
end
|
||||
|
||||
trait :with_combined_exclusions do
|
||||
exclusion_rules do
|
||||
{
|
||||
'labels' => ['vip', 'urgent'],
|
||||
'hours_threshold' => 48
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :enterprise_inbox_capacity_limit, class: 'Enterprise::InboxCapacityLimit' do
|
||||
association :agent_capacity_policy, factory: :enterprise_agent_capacity_policy
|
||||
inbox
|
||||
conversation_limit { 10 }
|
||||
|
||||
trait :low_limit do
|
||||
conversation_limit { 3 }
|
||||
end
|
||||
|
||||
trait :high_limit do
|
||||
conversation_limit { 50 }
|
||||
end
|
||||
|
||||
trait :max_limit do
|
||||
conversation_limit { 1000 }
|
||||
end
|
||||
|
||||
# Ensure inbox and policy belong to same account
|
||||
after(:build) do |limit|
|
||||
if limit.inbox && limit.agent_capacity_policy
|
||||
limit.agent_capacity_policy.account = limit.inbox.account
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :inbox_assignment_policy do
|
||||
inbox
|
||||
assignment_policy
|
||||
|
||||
# Ensure inbox and policy belong to same account
|
||||
after(:build) do |inbox_policy|
|
||||
if inbox_policy.inbox && inbox_policy.assignment_policy
|
||||
inbox_policy.assignment_policy.account = inbox_policy.inbox.account
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,369 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Assignment V2 Full Flow', type: :integration do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
# Create agents with different availability
|
||||
let!(:agent1) { create(:user, account: account, name: 'Agent 1', role: :agent, availability: :online) }
|
||||
let!(:agent2) { create(:user, account: account, name: 'Agent 2', role: :agent, availability: :online) }
|
||||
let!(:agent3) { create(:user, account: account, name: 'Agent 3', role: :agent, availability: :busy) }
|
||||
let!(:agent4) { create(:user, account: account, name: 'Agent 4', role: :agent, availability: :offline) }
|
||||
|
||||
before do
|
||||
# Make agents members of inbox
|
||||
[agent1, agent2, agent3, agent4].each do |agent|
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
end
|
||||
|
||||
# Clear Redis to ensure clean state
|
||||
Redis::Alfred.flushdb
|
||||
end
|
||||
|
||||
describe 'Round Robin Assignment' do
|
||||
let(:assignment_policy) do
|
||||
create(:assignment_policy,
|
||||
account: account,
|
||||
name: 'Round Robin Policy',
|
||||
assignment_order: :round_robin,
|
||||
conversation_priority: :earliest_created,
|
||||
enabled: true)
|
||||
end
|
||||
|
||||
let!(:inbox_assignment_policy) do
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
end
|
||||
|
||||
it 'assigns conversations in round-robin fashion to online agents only' do
|
||||
# Create unassigned conversations
|
||||
conversations = create_list(:conversation, 6, inbox: inbox, assignee: nil, status: :open)
|
||||
|
||||
# Process assignments
|
||||
service = AssignmentV2::AssignmentService.new(inbox)
|
||||
assigned_count = service.assign_conversations
|
||||
|
||||
expect(assigned_count).to eq(6)
|
||||
|
||||
# Verify all conversations are assigned
|
||||
conversations.each(&:reload)
|
||||
expect(conversations.map(&:assignee).compact.count).to eq(6)
|
||||
|
||||
# Verify only online agents received assignments
|
||||
assigned_agents = conversations.map(&:assignee).uniq
|
||||
expect(assigned_agents).to match_array([agent1, agent2])
|
||||
|
||||
# Verify round-robin distribution
|
||||
agent1_count = conversations.count { |c| c.assignee == agent1 }
|
||||
agent2_count = conversations.count { |c| c.assignee == agent2 }
|
||||
expect([agent1_count, agent2_count]).to match_array([3, 3])
|
||||
end
|
||||
|
||||
it 'respects conversation priority order' do
|
||||
# Create conversations with different creation times
|
||||
old_conv = create(:conversation, inbox: inbox, assignee: nil, created_at: 2.hours.ago)
|
||||
mid_conv = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
|
||||
new_conv = create(:conversation, inbox: inbox, assignee: nil, created_at: 5.minutes.ago)
|
||||
|
||||
# Assign only 2 conversations
|
||||
service = AssignmentV2::AssignmentService.new(inbox)
|
||||
service.assign_conversations(limit: 2)
|
||||
|
||||
# Oldest conversations should be assigned first
|
||||
expect(old_conv.reload.assignee).not_to be_nil
|
||||
expect(mid_conv.reload.assignee).not_to be_nil
|
||||
expect(new_conv.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'handles agent availability changes mid-assignment' do
|
||||
conversations = create_list(:conversation, 4, inbox: inbox, assignee: nil)
|
||||
|
||||
# Assign first batch
|
||||
service = AssignmentV2::AssignmentService.new(inbox)
|
||||
service.assign_conversations(limit: 2)
|
||||
|
||||
# Make agent1 offline
|
||||
agent1.update!(availability: :offline)
|
||||
|
||||
# Assign remaining conversations
|
||||
service.assign_conversations(limit: 2)
|
||||
|
||||
# All remaining should go to agent2
|
||||
remaining_assignments = conversations.reload.last(2).map(&:assignee)
|
||||
expect(remaining_assignments).to all(eq(agent2))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Balanced Assignment' do
|
||||
let(:assignment_policy) do
|
||||
create(:assignment_policy,
|
||||
account: account,
|
||||
name: 'Balanced Policy',
|
||||
assignment_order: :balanced,
|
||||
enabled: true)
|
||||
end
|
||||
|
||||
let!(:inbox_assignment_policy) do
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock enterprise features
|
||||
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
end
|
||||
|
||||
it 'assigns to agent with least conversations' do
|
||||
# Create existing load imbalance
|
||||
create_list(:conversation, 5, inbox: inbox, assignee: agent1, status: :open)
|
||||
create_list(:conversation, 2, inbox: inbox, assignee: agent2, status: :open)
|
||||
|
||||
# Create new conversations
|
||||
new_conversations = create_list(:conversation, 3, inbox: inbox, assignee: nil)
|
||||
|
||||
# Process assignments
|
||||
service = AssignmentV2::AssignmentService.new(inbox)
|
||||
service.assign_conversations
|
||||
|
||||
# All should go to agent2 (less loaded)
|
||||
new_conversations.each(&:reload)
|
||||
expect(new_conversations.map(&:assignee)).to all(eq(agent2))
|
||||
|
||||
# Final count should be more balanced
|
||||
expect(agent1.assigned_conversations.open.where(inbox: inbox).count).to eq(5)
|
||||
expect(agent2.assigned_conversations.open.where(inbox: inbox).count).to eq(5)
|
||||
end
|
||||
|
||||
it 'only counts open conversations for balancing' do
|
||||
# Agent1 has many resolved conversations (shouldn't count)
|
||||
create_list(:conversation, 10, inbox: inbox, assignee: agent1, status: :resolved)
|
||||
# Agent1 has 1 open conversation
|
||||
create(:conversation, inbox: inbox, assignee: agent1, status: :open)
|
||||
|
||||
# Agent2 has 3 open conversations
|
||||
create_list(:conversation, 3, inbox: inbox, assignee: agent2, status: :open)
|
||||
|
||||
# New conversation should go to agent1
|
||||
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
service = AssignmentV2::AssignmentService.new(inbox)
|
||||
service.assign_conversation(new_conversation)
|
||||
|
||||
expect(new_conversation.reload.assignee).to eq(agent1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Enterprise Capacity Management' do
|
||||
let(:assignment_policy) do
|
||||
create(:assignment_policy,
|
||||
account: account,
|
||||
assignment_order: :balanced,
|
||||
enabled: true)
|
||||
end
|
||||
|
||||
let!(:inbox_assignment_policy) do
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
end
|
||||
|
||||
let(:capacity_policy) do
|
||||
create(:enterprise_agent_capacity_policy, account: account, name: 'Limited Capacity')
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock enterprise features
|
||||
stub_const('Enterprise', Module.new)
|
||||
stub_const('Enterprise::AgentCapacityPolicy', Class.new(ApplicationRecord))
|
||||
stub_const('Enterprise::InboxCapacityLimit', Class.new(ApplicationRecord))
|
||||
|
||||
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
|
||||
# Set up capacity limits
|
||||
agent1.account_users.first.update!(agent_capacity_policy: capacity_policy)
|
||||
agent2.account_users.first.update!(agent_capacity_policy: capacity_policy)
|
||||
|
||||
create(:enterprise_inbox_capacity_limit,
|
||||
agent_capacity_policy: capacity_policy,
|
||||
inbox: inbox,
|
||||
conversation_limit: 3)
|
||||
end
|
||||
|
||||
it 'respects agent capacity limits' do
|
||||
# Fill agent1 to capacity
|
||||
create_list(:conversation, 3, inbox: inbox, assignee: agent1, status: :open)
|
||||
|
||||
# Create new conversations
|
||||
new_conversations = create_list(:conversation, 4, inbox: inbox, assignee: nil)
|
||||
|
||||
# Mock capacity manager
|
||||
capacity_manager = instance_double('Enterprise::AssignmentV2::CapacityManager')
|
||||
allow(Enterprise::AssignmentV2::CapacityManager).to receive(:new).and_return(capacity_manager)
|
||||
|
||||
# Agent1 at capacity, agent2 has room
|
||||
allow(capacity_manager).to receive(:get_agent_capacity).with(agent1, inbox).and_return(
|
||||
{ available_capacity: 0, current_assignments: 3, total_capacity: 3 }
|
||||
)
|
||||
allow(capacity_manager).to receive(:get_agent_capacity).with(agent2, inbox).and_return(
|
||||
{ available_capacity: 3, current_assignments: 0, total_capacity: 3 }
|
||||
)
|
||||
|
||||
# Process assignments
|
||||
service = AssignmentV2::AssignmentService.new(inbox)
|
||||
assigned_count = service.assign_conversations
|
||||
|
||||
# Only 3 should be assigned (agent2's capacity)
|
||||
expect(assigned_count).to eq(3)
|
||||
|
||||
# All should go to agent2
|
||||
assigned_conversations = new_conversations.select { |c| c.reload.assignee.present? }
|
||||
expect(assigned_conversations.map(&:assignee)).to all(eq(agent2))
|
||||
end
|
||||
|
||||
it 'handles capacity policy with exclusion rules' do
|
||||
# Update capacity policy with exclusion rules
|
||||
capacity_policy.update!(
|
||||
exclusion_rules: {
|
||||
'labels' => ['urgent'],
|
||||
'hours_threshold' => 24
|
||||
}
|
||||
)
|
||||
|
||||
# Create urgent label
|
||||
urgent_label = create(:label, account: account, title: 'urgent')
|
||||
|
||||
# Create mixed conversations for agent1
|
||||
regular_conv = create(:conversation, inbox: inbox, assignee: agent1, status: :open)
|
||||
urgent_conv = create(:conversation, inbox: inbox, assignee: agent1, status: :open)
|
||||
create(:conversation_label, conversation: urgent_conv, label: urgent_label)
|
||||
old_conv = create(:conversation, inbox: inbox, assignee: agent1, status: :open, created_at: 2.days.ago)
|
||||
|
||||
# Mock capacity calculation with exclusions
|
||||
capacity_manager = instance_double('Enterprise::AssignmentV2::CapacityManager')
|
||||
allow(Enterprise::AssignmentV2::CapacityManager).to receive(:new).and_return(capacity_manager)
|
||||
|
||||
# Only regular conversation counts toward capacity
|
||||
allow(capacity_manager).to receive(:get_agent_capacity).with(agent1, inbox).and_return(
|
||||
{ available_capacity: 2, current_assignments: 1, total_capacity: 3 }
|
||||
)
|
||||
|
||||
# New conversation should still be assignable
|
||||
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
service = AssignmentV2::AssignmentService.new(inbox)
|
||||
expect(service.assign_conversation(new_conversation)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Team-based Assignment' do
|
||||
let(:team) { create(:team, account: account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
before do
|
||||
# Add only agent1 and agent2 to team
|
||||
create(:team_member, team: team, user: agent1)
|
||||
create(:team_member, team: team, user: agent2)
|
||||
end
|
||||
|
||||
it 'assigns only to team members when conversation has team' do
|
||||
# Create conversation with team
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil, team: team)
|
||||
|
||||
# Mock team filtering in service
|
||||
service = AssignmentV2::AssignmentService.new(inbox)
|
||||
|
||||
# Should only consider team members
|
||||
100.times do
|
||||
conversation.update!(assignee: nil)
|
||||
service.assign_conversation(conversation)
|
||||
expect(conversation.reload.assignee).to be_in([agent1, agent2])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Feature Flag Control' do
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
before do
|
||||
# Mock feature flag
|
||||
allow(inbox).to receive(:assignment_v2_enabled?).and_return(false)
|
||||
end
|
||||
|
||||
it 'falls back to legacy assignment when V2 is disabled' do
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
# Enable auto assignment
|
||||
inbox.update!(enable_auto_assignment: true)
|
||||
|
||||
# Should use legacy service
|
||||
expect(::AutoAssignment::AgentAssignmentService).to receive(:new).and_call_original
|
||||
|
||||
# Trigger assignment through model callback
|
||||
conversation.update!(status: :open)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Concurrent Assignment Handling' do
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
it 'handles multiple simultaneous assignment jobs' do
|
||||
conversations = create_list(:conversation, 10, inbox: inbox, assignee: nil)
|
||||
|
||||
# Simulate concurrent job execution
|
||||
threads = []
|
||||
|
||||
3.times do
|
||||
threads << Thread.new do
|
||||
AssignmentV2::AssignmentJob.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
# All conversations should be assigned without duplicates
|
||||
conversations.each(&:reload)
|
||||
assigned_count = conversations.count { |c| c.assignee.present? }
|
||||
|
||||
expect(assigned_count).to eq(10)
|
||||
|
||||
# No conversation should have been assigned multiple times
|
||||
assignment_counts = conversations.group_by(&:assignee).transform_values(&:count)
|
||||
expect(assignment_counts.values.sum).to eq(10)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Error Recovery' do
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
it 'continues assignment after individual conversation failure' do
|
||||
conversations = create_list(:conversation, 5, inbox: inbox, assignee: nil)
|
||||
|
||||
# Make one conversation invalid
|
||||
conversations[2].update_column(:status, 'invalid_status')
|
||||
|
||||
service = AssignmentV2::AssignmentService.new(inbox)
|
||||
assigned_count = service.assign_conversations
|
||||
|
||||
# Should assign 4 out of 5
|
||||
expect(assigned_count).to eq(4)
|
||||
|
||||
# Invalid conversation remains unassigned
|
||||
expect(conversations[2].reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'recovers from Redis failures' do
|
||||
# Simulate Redis connection failure
|
||||
allow(Redis::Alfred).to receive(:lpop).and_raise(Redis::CannotConnectError)
|
||||
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
service = AssignmentV2::AssignmentService.new(inbox)
|
||||
|
||||
# Should fall back to database-based assignment
|
||||
expect(service.assign_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).not_to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,182 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentV2::AssignmentJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'with conversation_id' do
|
||||
it 'assigns a single conversation' do
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox).and_return(service)
|
||||
expect(service).to receive(:assign_conversation).with(conversation)
|
||||
|
||||
described_class.new.perform(conversation_id: conversation.id)
|
||||
end
|
||||
|
||||
it 'handles non-existent conversation gracefully' do
|
||||
expect(AssignmentV2::AssignmentService).not_to receive(:new)
|
||||
|
||||
# Should not raise error
|
||||
expect {
|
||||
described_class.new.perform(conversation_id: 999999)
|
||||
}.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'with inbox_id' do
|
||||
let!(:agent) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let!(:conversations) { create_list(:conversation, 3, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
end
|
||||
|
||||
it 'assigns multiple conversations for inbox' do
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox).and_return(service)
|
||||
expect(service).to receive(:assign_conversations).and_return(3)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
|
||||
it 'logs the number of assigned conversations' do
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
allow(AssignmentV2::AssignmentService).to receive(:new).with(inbox).and_return(service)
|
||||
allow(service).to receive(:assign_conversations).and_return(2)
|
||||
|
||||
expect(Rails.logger).to receive(:info).with("AssignmentJob: Assigned 2 conversations for inbox #{inbox.id}")
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
|
||||
it 'skips assignment when inbox has no policy' do
|
||||
inbox_assignment_policy.destroy!
|
||||
|
||||
expect(AssignmentV2::AssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
|
||||
it 'skips assignment when policy is disabled' do
|
||||
assignment_policy.update!(enabled: false)
|
||||
|
||||
expect(AssignmentV2::AssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
|
||||
it 'handles non-existent inbox gracefully' do
|
||||
expect(AssignmentV2::AssignmentService).not_to receive(:new)
|
||||
|
||||
# Should not raise error
|
||||
expect {
|
||||
described_class.new.perform(inbox_id: 999999)
|
||||
}.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'without parameters' do
|
||||
it 'logs error when no parameters provided' do
|
||||
expect(Rails.logger).to receive(:error).with('AssignmentJob: No inbox_id or conversation_id provided')
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
|
||||
it 'does not attempt assignment' do
|
||||
expect(AssignmentV2::AssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'with both parameters' do
|
||||
it 'prioritizes conversation_id over inbox_id' do
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox).and_return(service)
|
||||
expect(service).to receive(:assign_conversation).with(conversation)
|
||||
expect(service).not_to receive(:assign_conversations)
|
||||
|
||||
described_class.new.perform(conversation_id: conversation.id, inbox_id: inbox.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'uses the default queue' do
|
||||
expect(described_class.new.queue_name).to eq('default')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'error handling' do
|
||||
context 'when assignment service raises error' do
|
||||
it 'propagates the error for retry' do
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
|
||||
allow(service).to receive(:assign_conversation).and_raise(StandardError, 'Assignment failed')
|
||||
|
||||
expect {
|
||||
described_class.new.perform(conversation_id: conversation.id)
|
||||
}.to raise_error(StandardError, 'Assignment failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when database connection fails' do
|
||||
it 'raises error for retry' do
|
||||
allow(Conversation).to receive(:find_by).and_raise(ActiveRecord::ConnectionNotEstablished)
|
||||
|
||||
expect {
|
||||
described_class.new.perform(conversation_id: conversation.id)
|
||||
}.to raise_error(ActiveRecord::ConnectionNotEstablished)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'concurrency and idempotency' do
|
||||
it 'handles concurrent job execution safely' do
|
||||
# Create multiple jobs for same inbox
|
||||
jobs = []
|
||||
3.times { jobs << described_class.new }
|
||||
|
||||
# All should execute without issues
|
||||
expect {
|
||||
jobs.each { |job| job.perform(inbox_id: inbox.id) }
|
||||
}.not_to raise_error
|
||||
end
|
||||
|
||||
it 'is idempotent for conversation assignment' do
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
|
||||
|
||||
# First call assigns
|
||||
allow(service).to receive(:assign_conversation).and_return(true)
|
||||
described_class.new.perform(conversation_id: conversation.id)
|
||||
|
||||
# Second call should handle already assigned conversation
|
||||
allow(service).to receive(:assign_conversation).and_return(false)
|
||||
described_class.new.perform(conversation_id: conversation.id)
|
||||
|
||||
# No errors should occur
|
||||
end
|
||||
end
|
||||
|
||||
describe 'performance considerations' do
|
||||
it 'processes large inbox assignments in batches' do
|
||||
# Create many unassigned conversations
|
||||
create_list(:conversation, 100, inbox: inbox, assignee: nil)
|
||||
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
|
||||
|
||||
# Service should be called with default limit
|
||||
expect(service).to receive(:assign_conversations).with(no_args).and_return(50)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,223 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentV2::ContinuousAssignmentJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when inbox exists and assignment should run' do
|
||||
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil, status: :open) }
|
||||
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil, status: :open) }
|
||||
|
||||
it 'runs assignment orchestrator' do
|
||||
orchestrator_double = instance_double(AssignmentV2::AssignmentOrchestrator)
|
||||
expect(AssignmentV2::AssignmentOrchestrator).to receive(:new).with(inbox).and_return(orchestrator_double)
|
||||
expect(orchestrator_double).to receive(:assign_conversations).with(limit: 50).and_return(2)
|
||||
|
||||
described_class.new.perform(inbox.id)
|
||||
end
|
||||
|
||||
it 'logs assignment start and completion' do
|
||||
allow_any_instance_of(AssignmentV2::AssignmentOrchestrator).to receive(:assign_conversations).and_return(2)
|
||||
|
||||
expect(Rails.logger).to receive(:info).with("Assignment V2: Running continuous assignment for inbox #{inbox.id}")
|
||||
expect(Rails.logger).to receive(:info).with("Assignment V2: Completed continuous assignment for inbox #{inbox.id}, made 2 assignments")
|
||||
|
||||
described_class.new.perform(inbox.id)
|
||||
end
|
||||
|
||||
context 'when more conversations need processing' do
|
||||
it 'schedules next run when batch size equals assignments made' do
|
||||
allow_any_instance_of(AssignmentV2::AssignmentOrchestrator).to receive(:assign_conversations).and_return(50)
|
||||
allow(inbox.conversations.unassigned.open).to receive(:exists?).and_return(true)
|
||||
|
||||
expect(described_class).to receive(:set).with(wait: anything).and_return(described_class)
|
||||
expect(described_class).to receive(:perform_later).with(inbox.id)
|
||||
|
||||
described_class.new.perform(inbox.id, batch_size: 50)
|
||||
end
|
||||
|
||||
it 'does not schedule next run when fewer assignments made than batch size' do
|
||||
allow_any_instance_of(AssignmentV2::AssignmentOrchestrator).to receive(:assign_conversations).and_return(25)
|
||||
|
||||
expect(described_class).not_to receive(:set)
|
||||
|
||||
described_class.new.perform(inbox.id, batch_size: 50)
|
||||
end
|
||||
|
||||
it 'does not schedule next run when no more unassigned conversations' do
|
||||
allow_any_instance_of(AssignmentV2::AssignmentOrchestrator).to receive(:assign_conversations).and_return(50)
|
||||
allow(inbox.conversations.unassigned.open).to receive(:exists?).and_return(false)
|
||||
|
||||
expect(described_class).not_to receive(:set)
|
||||
|
||||
described_class.new.perform(inbox.id, batch_size: 50)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox does not exist' do
|
||||
it 'logs error and does not raise exception' do
|
||||
expect(Rails.logger).to receive(:error).with("Assignment V2: Inbox 999 not found")
|
||||
|
||||
expect { described_class.new.perform(999) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assignment should not run' do
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_v2_enabled?).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns early without running assignment' do
|
||||
expect(AssignmentV2::AssignmentOrchestrator).not_to receive(:new)
|
||||
|
||||
described_class.new.perform(inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assignment policy is disabled' do
|
||||
before do
|
||||
assignment_policy.update!(enabled: false)
|
||||
end
|
||||
|
||||
it 'returns early without running assignment' do
|
||||
expect(AssignmentV2::AssignmentOrchestrator).not_to receive(:new)
|
||||
|
||||
described_class.new.perform(inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no unassigned conversations exist' do
|
||||
it 'returns early without running assignment' do
|
||||
expect(AssignmentV2::AssignmentOrchestrator).not_to receive(:new)
|
||||
|
||||
described_class.new.perform(inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assignment fails with error' do
|
||||
before do
|
||||
create(:conversation, inbox: inbox, assignee: nil, status: :open)
|
||||
allow_any_instance_of(AssignmentV2::AssignmentOrchestrator).to receive(:assign_conversations).and_raise(StandardError, 'Assignment failed')
|
||||
end
|
||||
|
||||
it 'logs error and re-raises exception' do
|
||||
expect(Rails.logger).to receive(:error).with("Assignment V2: Continuous assignment failed for inbox #{inbox.id}: Assignment failed")
|
||||
|
||||
expect { described_class.new.perform(inbox.id) }.to raise_error(StandardError, 'Assignment failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.trigger' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox) }
|
||||
|
||||
context 'when inbox has assignment v2 enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
end
|
||||
|
||||
it 'enqueues job for conversation inbox' do
|
||||
expect(described_class).to receive(:perform_later).with(inbox.id, batch_size: 10)
|
||||
|
||||
described_class.trigger(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox does not have assignment v2 enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_v2_enabled?).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not enqueue job' do
|
||||
expect(described_class).not_to receive(:perform_later)
|
||||
|
||||
described_class.trigger(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.process_all_inboxes' do
|
||||
let(:account2) { create(:account) }
|
||||
let(:inbox2) { create(:inbox, account: account2) }
|
||||
let(:policy2) { create(:assignment_policy, account: account2) }
|
||||
let!(:inbox_policy2) { create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2) }
|
||||
|
||||
before do
|
||||
allow(inbox).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
allow(inbox2).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
|
||||
create(:conversation, inbox: inbox, assignee: nil, status: :open)
|
||||
create(:conversation, inbox: inbox2, assignee: nil, status: :open)
|
||||
end
|
||||
|
||||
it 'processes all inboxes with enabled assignment policies' do
|
||||
expect(Rails.logger).to receive(:info).with(/Scheduling bulk assignment for inbox #{inbox.id}/)
|
||||
expect(Rails.logger).to receive(:info).with(/Scheduling bulk assignment for inbox #{inbox2.id}/)
|
||||
|
||||
expect(described_class).to receive(:perform_later).with(inbox.id)
|
||||
expect(described_class).to receive(:perform_later).with(inbox2.id)
|
||||
|
||||
described_class.process_all_inboxes
|
||||
end
|
||||
|
||||
it 'skips inboxes without unassigned conversations' do
|
||||
# Remove unassigned conversations
|
||||
Conversation.update_all(status: :resolved)
|
||||
|
||||
expect(described_class).not_to receive(:perform_later)
|
||||
|
||||
described_class.process_all_inboxes
|
||||
end
|
||||
|
||||
it 'skips inboxes without assignment v2 enabled' do
|
||||
allow(inbox).to receive(:assignment_v2_enabled?).and_return(false)
|
||||
|
||||
expect(described_class).to receive(:perform_later).with(inbox2.id)
|
||||
expect(described_class).not_to receive(:perform_later).with(inbox.id)
|
||||
|
||||
described_class.process_all_inboxes
|
||||
end
|
||||
end
|
||||
|
||||
describe 'delay calculation' do
|
||||
let(:job_instance) { described_class.new }
|
||||
|
||||
it 'calculates delay with base time and jitter' do
|
||||
delay = job_instance.send(:calculate_delay, inbox)
|
||||
|
||||
expect(delay).to be >= 30.seconds
|
||||
expect(delay).to be <= 40.seconds
|
||||
end
|
||||
|
||||
it 'adds randomization to prevent thundering herd' do
|
||||
delays = []
|
||||
10.times do
|
||||
delays << job_instance.send(:calculate_delay, inbox)
|
||||
end
|
||||
|
||||
# All delays should be different due to randomization
|
||||
expect(delays.uniq.size).to be > 1
|
||||
end
|
||||
end
|
||||
|
||||
describe 'retry configuration' do
|
||||
it 'has proper retry configuration' do
|
||||
expect(described_class.get_sidekiq_options['retry']).to eq(3)
|
||||
end
|
||||
|
||||
it 'uses exponentially longer wait times' do
|
||||
# This tests the retry_on configuration
|
||||
expect(described_class.instance_variable_get(:@retry_callbacks)).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,124 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentPolicy, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to have_many(:inbox_assignment_policies).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:inboxes).through(:inbox_assignment_policies) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:account_id) }
|
||||
it { is_expected.to validate_length_of(:name).is_at_most(255) }
|
||||
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:fair_distribution_limit) }
|
||||
it { is_expected.to validate_numericality_of(:fair_distribution_limit).is_greater_than(0).is_less_than_or_equal_to(100) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:fair_distribution_window) }
|
||||
it { is_expected.to validate_numericality_of(:fair_distribution_window).is_greater_than(60).is_less_than_or_equal_to(86_400) }
|
||||
|
||||
context 'balanced assignment validation' do
|
||||
let(:enterprise_account) { create(:account) }
|
||||
|
||||
before do
|
||||
allow(enterprise_account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
end
|
||||
|
||||
it 'allows balanced assignment for enterprise accounts' do
|
||||
policy = build(:assignment_policy, account: enterprise_account, assignment_order: :balanced)
|
||||
expect(policy).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects balanced assignment for non-enterprise accounts' do
|
||||
policy = build(:assignment_policy, account: account, assignment_order: :balanced)
|
||||
expect(policy).not_to be_valid
|
||||
expect(policy.errors[:assignment_order]).to include('Balanced assignment is only available for enterprise accounts')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it { is_expected.to define_enum_for(:assignment_order).with_values(round_robin: 0, balanced: 1) }
|
||||
it { is_expected.to define_enum_for(:conversation_priority).with_values(earliest_created: 0, longest_waiting: 1) }
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let!(:enabled_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:disabled_policy) { create(:assignment_policy, account: account, enabled: false) }
|
||||
|
||||
it 'filters enabled policies' do
|
||||
expect(AssignmentPolicy.enabled).to include(enabled_policy)
|
||||
expect(AssignmentPolicy.enabled).not_to include(disabled_policy)
|
||||
end
|
||||
|
||||
it 'filters disabled policies' do
|
||||
expect(AssignmentPolicy.disabled).to include(disabled_policy)
|
||||
expect(AssignmentPolicy.disabled).not_to include(enabled_policy)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_use_balanced_assignment?' do
|
||||
context 'when account has enterprise agent capacity feature' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(assignment_policy.can_use_balanced_assignment?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account does not have enterprise features' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(assignment_policy.can_use_balanced_assignment?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#webhook_data' do
|
||||
it 'returns correct data structure' do
|
||||
data = assignment_policy.webhook_data
|
||||
|
||||
expect(data).to include(
|
||||
id: assignment_policy.id,
|
||||
name: assignment_policy.name,
|
||||
description: assignment_policy.description,
|
||||
assignment_order: assignment_policy.assignment_order,
|
||||
conversation_priority: assignment_policy.conversation_priority,
|
||||
fair_distribution_limit: assignment_policy.fair_distribution_limit,
|
||||
fair_distribution_window: assignment_policy.fair_distribution_window,
|
||||
enabled: assignment_policy.enabled
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'cache invalidation' do
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let!(:inbox_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
it 'clears assignment caches on update' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:policy:#{assignment_policy.id}")
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
|
||||
assignment_policy.update!(name: 'Updated Policy')
|
||||
end
|
||||
|
||||
it 'clears assignment caches on destroy' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:policy:#{assignment_policy.id}")
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
|
||||
assignment_policy.destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,161 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe InboxAssignmentPolicy, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:inbox) }
|
||||
it { is_expected.to belong_to(:assignment_policy) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { inbox_assignment_policy }
|
||||
|
||||
it { is_expected.to validate_uniqueness_of(:inbox_id) }
|
||||
|
||||
context 'inbox and policy from different accounts' do
|
||||
let(:other_account) { create(:account) }
|
||||
let(:other_policy) { create(:assignment_policy, account: other_account) }
|
||||
|
||||
it 'validates inbox belongs to same account as policy' do
|
||||
invalid_policy = build(:inbox_assignment_policy, inbox: inbox, assignment_policy: other_policy)
|
||||
|
||||
expect(invalid_policy).not_to be_valid
|
||||
expect(invalid_policy.errors[:inbox]).to include('must belong to the same account as the assignment policy')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'delegations' do
|
||||
it 'delegates account to inbox' do
|
||||
expect(inbox_assignment_policy.account).to eq(account)
|
||||
end
|
||||
|
||||
it 'delegates policy attributes' do
|
||||
expect(inbox_assignment_policy.policy_name).to eq(assignment_policy.name)
|
||||
expect(inbox_assignment_policy.policy_description).to eq(assignment_policy.description)
|
||||
expect(inbox_assignment_policy.policy_assignment_order).to eq(assignment_policy.assignment_order)
|
||||
expect(inbox_assignment_policy.policy_conversation_priority).to eq(assignment_policy.conversation_priority)
|
||||
expect(inbox_assignment_policy.policy_fair_distribution_limit).to eq(assignment_policy.fair_distribution_limit)
|
||||
expect(inbox_assignment_policy.policy_fair_distribution_window).to eq(assignment_policy.fair_distribution_window)
|
||||
expect(inbox_assignment_policy.policy_enabled?).to eq(assignment_policy.enabled?)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let!(:enabled_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:disabled_policy) { create(:assignment_policy, account: account, enabled: false) }
|
||||
let(:inbox2) { create(:inbox, account: account) }
|
||||
let!(:enabled_inbox_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: enabled_policy) }
|
||||
let!(:disabled_inbox_policy) { create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: disabled_policy) }
|
||||
|
||||
describe '.enabled' do
|
||||
it 'returns only inbox policies with enabled assignment policies' do
|
||||
expect(InboxAssignmentPolicy.enabled).to include(enabled_inbox_policy)
|
||||
expect(InboxAssignmentPolicy.enabled).not_to include(disabled_inbox_policy)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.disabled' do
|
||||
it 'returns only inbox policies with disabled assignment policies' do
|
||||
expect(InboxAssignmentPolicy.disabled).to include(disabled_inbox_policy)
|
||||
expect(InboxAssignmentPolicy.disabled).not_to include(enabled_inbox_policy)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#webhook_data' do
|
||||
it 'returns correct data structure' do
|
||||
data = inbox_assignment_policy.webhook_data
|
||||
|
||||
expect(data).to include(
|
||||
id: inbox_assignment_policy.id,
|
||||
inbox_id: inbox.id,
|
||||
assignment_policy_id: assignment_policy.id
|
||||
)
|
||||
|
||||
expect(data[:policy]).to eq(assignment_policy.webhook_data)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'cache management' do
|
||||
it 'clears inbox cache on create' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
end
|
||||
|
||||
it 'clears inbox cache on update' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
|
||||
inbox_assignment_policy.update!(updated_at: Time.current)
|
||||
end
|
||||
|
||||
it 'clears inbox cache on destroy' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
|
||||
inbox_assignment_policy.destroy!
|
||||
end
|
||||
|
||||
it 'updates account cache' do
|
||||
# AccountCacheRevalidator concern should trigger cache update
|
||||
expect(inbox_assignment_policy).to receive(:update_account_cache)
|
||||
|
||||
inbox_assignment_policy.send(:clear_inbox_cache)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'business logic constraints' do
|
||||
it 'prevents multiple policies per inbox' do
|
||||
policy2 = create(:assignment_policy, account: account)
|
||||
|
||||
# First policy already exists
|
||||
expect {
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: policy2)
|
||||
}.to raise_error(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
|
||||
it 'allows reassigning to different policy' do
|
||||
policy2 = create(:assignment_policy, account: account)
|
||||
|
||||
expect {
|
||||
inbox_assignment_policy.update!(assignment_policy: policy2)
|
||||
}.not_to raise_error
|
||||
|
||||
expect(inbox_assignment_policy.reload.assignment_policy).to eq(policy2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'edge cases' do
|
||||
it 'handles nil associations gracefully' do
|
||||
# Build without saving to test nil handling
|
||||
policy = build(:inbox_assignment_policy, inbox: nil, assignment_policy: nil)
|
||||
|
||||
expect { policy.valid? }.not_to raise_error
|
||||
expect(policy).not_to be_valid
|
||||
end
|
||||
|
||||
it 'handles policy deletion cascade' do
|
||||
inbox_policy_id = inbox_assignment_policy.id
|
||||
|
||||
# Deleting policy should delete inbox assignment
|
||||
assignment_policy.destroy!
|
||||
|
||||
expect(InboxAssignmentPolicy.find_by(id: inbox_policy_id)).to be_nil
|
||||
end
|
||||
|
||||
it 'handles inbox deletion cascade' do
|
||||
inbox_policy_id = inbox_assignment_policy.id
|
||||
|
||||
# Deleting inbox should delete inbox assignment
|
||||
inbox.destroy!
|
||||
|
||||
expect(InboxAssignmentPolicy.find_by(id: inbox_policy_id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,237 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentV2::AssignmentOrchestrator, type: :service 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(:agent1) { create(:user, account: account) }
|
||||
let(:agent2) { create(:user, account: account) }
|
||||
let(:orchestrator) { described_class.new(inbox) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent1)
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
allow(inbox).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
it 'sets up orchestrator with inbox and policy' do
|
||||
expect(orchestrator.inbox).to eq(inbox)
|
||||
expect(orchestrator.policy).to eq(assignment_policy)
|
||||
end
|
||||
|
||||
it 'initializes rate limiter when policy exists' do
|
||||
expect(orchestrator.instance_variable_get(:@rate_limiter)).to be_present
|
||||
end
|
||||
|
||||
it 'initializes metrics tracker' do
|
||||
expect(orchestrator.metrics).to be_a(described_class::AssignmentMetrics)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#assign_conversations' do
|
||||
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil, status: :open) }
|
||||
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil, status: :open) }
|
||||
|
||||
context 'when assignment is possible' do
|
||||
before do
|
||||
allow_any_instance_of(AssignmentV2::RoundRobinSelector).to receive(:select_agent).and_return(agent1)
|
||||
allow_any_instance_of(AssignmentV2::RateLimiter).to receive(:agent_within_limits?).and_return(true)
|
||||
end
|
||||
|
||||
it 'assigns conversations to agents' do
|
||||
expect(orchestrator.assign_conversations(limit: 2)).to eq(2)
|
||||
|
||||
expect(conversation1.reload.assignee).to eq(agent1)
|
||||
expect(conversation2.reload.assignee).to eq(agent1)
|
||||
end
|
||||
|
||||
it 'creates audit logs for assignments' do
|
||||
expect { orchestrator.assign_conversations(limit: 2) }.to change { conversation1.messages.activity.count }.by(1)
|
||||
end
|
||||
|
||||
it 'triggers assignment notifications' do
|
||||
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
|
||||
'conversation.assigned',
|
||||
anything,
|
||||
hash_including(conversation: conversation1, assignee: agent1)
|
||||
).once
|
||||
|
||||
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
|
||||
'conversation.assigned',
|
||||
anything,
|
||||
hash_including(conversation: conversation2, assignee: agent1)
|
||||
).once
|
||||
|
||||
orchestrator.assign_conversations(limit: 2)
|
||||
end
|
||||
|
||||
it 'records metrics for successful assignments' do
|
||||
orchestrator.assign_conversations(limit: 2)
|
||||
|
||||
metrics = orchestrator.metrics.instance_variable_get(:@assignments)
|
||||
expect(metrics.size).to eq(2)
|
||||
expect(metrics.first).to include(
|
||||
conversation_id: conversation1.id,
|
||||
agent_id: agent1.id,
|
||||
policy_id: assignment_policy.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no agent is available' do
|
||||
before do
|
||||
allow_any_instance_of(AssignmentV2::RoundRobinSelector).to receive(:select_agent).and_return(nil)
|
||||
end
|
||||
|
||||
it 'does not assign conversations' do
|
||||
expect(orchestrator.assign_conversations(limit: 2)).to eq(0)
|
||||
|
||||
expect(conversation1.reload.assignee).to be_nil
|
||||
expect(conversation2.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rate limiter blocks assignment' do
|
||||
before do
|
||||
allow_any_instance_of(AssignmentV2::RoundRobinSelector).to receive(:select_agent).and_return(agent1)
|
||||
allow_any_instance_of(AssignmentV2::RateLimiter).to receive(:agent_within_limits?).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not perform assignment' do
|
||||
expect(orchestrator.assign_conversations(limit: 2)).to eq(0)
|
||||
|
||||
expect(conversation1.reload.assignee).to be_nil
|
||||
expect(conversation2.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assignment fails due to database error' do
|
||||
before do
|
||||
allow_any_instance_of(AssignmentV2::RoundRobinSelector).to receive(:select_agent).and_return(agent1)
|
||||
allow_any_instance_of(AssignmentV2::RateLimiter).to receive(:agent_within_limits?).and_return(true)
|
||||
allow(conversation1).to receive(:update!).and_raise(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
|
||||
it 'continues with other conversations' do
|
||||
expect(Rails.logger).to receive(:error).with(/Assignment failed/)
|
||||
|
||||
result = orchestrator.assign_conversations(limit: 2)
|
||||
expect(result).to eq(1) # Only conversation2 succeeds
|
||||
expect(conversation2.reload.assignee).to eq(agent1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#assign_conversation' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil, status: :open) }
|
||||
|
||||
context 'when assignment succeeds' do
|
||||
before do
|
||||
allow_any_instance_of(AssignmentV2::RoundRobinSelector).to receive(:select_agent).and_return(agent1)
|
||||
allow_any_instance_of(AssignmentV2::RateLimiter).to receive(:agent_within_limits?).and_return(true)
|
||||
end
|
||||
|
||||
it 'returns true and assigns conversation' do
|
||||
expect(orchestrator.assign_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).to eq(agent1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is already assigned' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: agent2, status: :open) }
|
||||
|
||||
it 'returns false without changing assignment' do
|
||||
expect(orchestrator.assign_conversation(conversation)).to be false
|
||||
expect(conversation.reload.assignee).to eq(agent2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enterprise balanced assignment' do
|
||||
let(:enterprise_account) { create(:account) }
|
||||
let(:enterprise_inbox) { create(:inbox, account: enterprise_account) }
|
||||
let(:balanced_policy) { create(:assignment_policy, account: enterprise_account, assignment_order: :balanced) }
|
||||
let!(:enterprise_inbox_policy) { create(:inbox_assignment_policy, inbox: enterprise_inbox, assignment_policy: balanced_policy) }
|
||||
let(:enterprise_orchestrator) { described_class.new(enterprise_inbox) }
|
||||
|
||||
before do
|
||||
allow(enterprise_inbox).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
allow(enterprise_account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
stub_const('Enterprise', Module.new)
|
||||
end
|
||||
|
||||
it 'uses balanced selector for enterprise accounts' do
|
||||
conversation = create(:conversation, inbox: enterprise_inbox, assignee: nil, status: :open)
|
||||
|
||||
balanced_selector_double = instance_double('Enterprise::AssignmentV2::BalancedSelector')
|
||||
expect(Enterprise::AssignmentV2::BalancedSelector).to receive(:new).with(enterprise_inbox, balanced_policy).and_return(balanced_selector_double)
|
||||
expect(balanced_selector_double).to receive(:select_agent).and_return(agent1)
|
||||
|
||||
allow_any_instance_of(AssignmentV2::RateLimiter).to receive(:agent_within_limits?).and_return(true)
|
||||
|
||||
enterprise_orchestrator.assign_conversation(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_assign?' do
|
||||
it 'returns true when policy is enabled and inbox has auto assignment' do
|
||||
expect(orchestrator.send(:can_assign?)).to be true
|
||||
end
|
||||
|
||||
it 'returns false when policy is disabled' do
|
||||
assignment_policy.update!(enabled: false)
|
||||
expect(orchestrator.send(:can_assign?)).to be false
|
||||
end
|
||||
|
||||
it 'returns false when inbox auto assignment is disabled' do
|
||||
inbox.update!(enable_auto_assignment: false)
|
||||
expect(orchestrator.send(:can_assign?)).to be false
|
||||
end
|
||||
|
||||
it 'returns false when no policy exists' do
|
||||
inbox_assignment_policy.destroy!
|
||||
orchestrator_without_policy = described_class.new(inbox)
|
||||
expect(orchestrator_without_policy.send(:can_assign?)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'conversation prioritization' do
|
||||
let!(:oldest_conversation) { create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 2.hours.ago) }
|
||||
let!(:newest_conversation) { create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 1.hour.ago) }
|
||||
|
||||
context 'with earliest_created priority' do
|
||||
before do
|
||||
assignment_policy.update!(conversation_priority: :earliest_created)
|
||||
allow_any_instance_of(AssignmentV2::RoundRobinSelector).to receive(:select_agent).and_return(agent1)
|
||||
allow_any_instance_of(AssignmentV2::RateLimiter).to receive(:agent_within_limits?).and_return(true)
|
||||
end
|
||||
|
||||
it 'processes oldest conversation first' do
|
||||
orchestrator.assign_conversations(limit: 1)
|
||||
expect(oldest_conversation.reload.assignee).to eq(agent1)
|
||||
expect(newest_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with longest_waiting priority' do
|
||||
before do
|
||||
assignment_policy.update!(conversation_priority: :longest_waiting)
|
||||
oldest_conversation.update!(last_activity_at: 3.hours.ago)
|
||||
newest_conversation.update!(last_activity_at: 30.minutes.ago)
|
||||
|
||||
allow_any_instance_of(AssignmentV2::RoundRobinSelector).to receive(:select_agent).and_return(agent1)
|
||||
allow_any_instance_of(AssignmentV2::RateLimiter).to receive(:agent_within_limits?).and_return(true)
|
||||
end
|
||||
|
||||
it 'processes conversation with longest wait time first' do
|
||||
orchestrator.assign_conversations(limit: 1)
|
||||
expect(oldest_conversation.reload.assignee).to eq(agent1)
|
||||
expect(newest_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,267 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentV2::AssignmentService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
let(:service) { described_class.new(inbox) }
|
||||
|
||||
# Create agents
|
||||
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: :offline) }
|
||||
|
||||
# Make agents members of inbox
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent1)
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
create(:inbox_member, inbox: inbox, user: agent3)
|
||||
end
|
||||
|
||||
describe '#assign_conversation' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
context 'when policy is enabled' do
|
||||
it 'assigns conversation to an available agent' do
|
||||
expect(service.assign_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).to be_in([agent1, agent2])
|
||||
end
|
||||
|
||||
it 'dispatches assignment event' do
|
||||
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
|
||||
'assignee.changed',
|
||||
anything,
|
||||
hash_including(conversation: conversation, user: anything)
|
||||
)
|
||||
service.assign_conversation(conversation)
|
||||
end
|
||||
|
||||
it 'returns false when no agents are available' do
|
||||
agent1.update!(availability: :offline)
|
||||
agent2.update!(availability: :offline)
|
||||
|
||||
expect(service.assign_conversation(conversation)).to be false
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when policy is disabled' do
|
||||
before { assignment_policy.update!(enabled: false) }
|
||||
|
||||
it 'does not assign conversation' do
|
||||
expect(service.assign_conversation(conversation)).to be false
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is already assigned' do
|
||||
before { conversation.update!(assignee: agent1) }
|
||||
|
||||
it 'does not reassign conversation' do
|
||||
expect(service.assign_conversation(conversation)).to be false
|
||||
expect(conversation.reload.assignee).to eq(agent1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with round robin assignment' do
|
||||
before { assignment_policy.update!(assignment_order: :round_robin) }
|
||||
|
||||
it 'assigns agents in rotation' do
|
||||
conversations = create_list(:conversation, 4, inbox: inbox, assignee: nil)
|
||||
|
||||
# Clear any existing round robin cache
|
||||
Rails.cache.delete("assignment_v2:round_robin:#{inbox.id}")
|
||||
|
||||
assignments = conversations.map do |conv|
|
||||
service.assign_conversation(conv)
|
||||
conv.reload.assignee
|
||||
end
|
||||
|
||||
# Should rotate between available agents
|
||||
expect(assignments[0]).to be_in([agent1, agent2])
|
||||
expect(assignments[1]).to be_in([agent1, agent2])
|
||||
expect(assignments[0]).not_to eq(assignments[1]) # Different agents
|
||||
expect(assignments[2]).to eq(assignments[0]) # Back to first agent
|
||||
expect(assignments[3]).to eq(assignments[1]) # Back to second agent
|
||||
end
|
||||
end
|
||||
|
||||
context 'with balanced assignment' do
|
||||
before do
|
||||
assignment_policy.update!(assignment_order: :balanced)
|
||||
# Mock enterprise feature check
|
||||
allow(inbox.account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
end
|
||||
|
||||
it 'assigns to agent with least conversations' do
|
||||
# Create existing assignments
|
||||
create_list(:conversation, 3, inbox: inbox, assignee: agent1, status: :open)
|
||||
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
|
||||
|
||||
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
expect(service.assign_conversation(new_conversation)).to be true
|
||||
expect(new_conversation.reload.assignee).to eq(agent2)
|
||||
end
|
||||
|
||||
it 'only counts open and pending conversations' do
|
||||
# Create resolved conversations (should not count)
|
||||
create_list(:conversation, 5, inbox: inbox, assignee: agent1, status: :resolved)
|
||||
|
||||
# Create open conversation
|
||||
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
|
||||
|
||||
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
expect(service.assign_conversation(new_conversation)).to be true
|
||||
expect(new_conversation.reload.assignee).to eq(agent1) # Less active conversations
|
||||
end
|
||||
end
|
||||
|
||||
context 'error handling' do
|
||||
it 'returns false and logs error on assignment failure' do
|
||||
allow_any_instance_of(Conversation).to receive(:update!).and_raise(ActiveRecord::RecordInvalid)
|
||||
expect(Rails.logger).to receive(:error).with(/Assignment failed/)
|
||||
|
||||
expect(service.assign_conversation(conversation)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#assign_conversations' do
|
||||
let!(:conversations) { create_list(:conversation, 5, inbox: inbox, assignee: nil, status: :open) }
|
||||
|
||||
context 'when policy is enabled' do
|
||||
it 'assigns multiple conversations' do
|
||||
assigned_count = service.assign_conversations(limit: 3)
|
||||
|
||||
expect(assigned_count).to eq(3)
|
||||
expect(inbox.conversations.unassigned.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'respects conversation priority order' do
|
||||
# Create conversations with different timestamps
|
||||
old_conversation = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
|
||||
new_conversation = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.minute.ago)
|
||||
|
||||
assignment_policy.update!(conversation_priority: :earliest_created)
|
||||
|
||||
service.assign_conversations(limit: 1)
|
||||
|
||||
expect(old_conversation.reload.assignee).not_to be_nil
|
||||
expect(new_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'handles longest_waiting priority' do
|
||||
# Create conversations with different last activity
|
||||
inactive_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 2.hours.ago)
|
||||
active_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 5.minutes.ago)
|
||||
|
||||
assignment_policy.update!(conversation_priority: :longest_waiting)
|
||||
|
||||
service.assign_conversations(limit: 1)
|
||||
|
||||
expect(inactive_conversation.reload.assignee).not_to be_nil
|
||||
expect(active_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'returns 0 when no conversations to assign' do
|
||||
Conversation.update_all(assignee_id: agent1.id)
|
||||
|
||||
expect(service.assign_conversations).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when policy is disabled' do
|
||||
before { assignment_policy.update!(enabled: false) }
|
||||
|
||||
it 'does not assign any conversations' do
|
||||
expect(service.assign_conversations).to eq(0)
|
||||
expect(inbox.conversations.unassigned.count).to eq(5)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enterprise capacity features' do
|
||||
let(:capacity_policy) { create(:enterprise_agent_capacity_policy, account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
# Mock enterprise availability
|
||||
stub_const('Enterprise', Module.new)
|
||||
stub_const('Enterprise::AssignmentV2::CapacityManager', Class.new)
|
||||
|
||||
allow_any_instance_of(Enterprise::AssignmentV2::CapacityManager).to receive(:get_agent_capacity).and_return(
|
||||
{ available_capacity: 1 }
|
||||
)
|
||||
|
||||
allow(assignment_policy).to receive(:capacity_filtering_enabled?).and_return(true)
|
||||
allow(inbox.account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
end
|
||||
|
||||
it 'applies capacity filters when available' do
|
||||
# Mock capacity limits
|
||||
allow_any_instance_of(Enterprise::AssignmentV2::CapacityManager).to receive(:get_agent_capacity)
|
||||
.with(agent1, inbox).and_return({ available_capacity: 0 })
|
||||
allow_any_instance_of(Enterprise::AssignmentV2::CapacityManager).to receive(:get_agent_capacity)
|
||||
.with(agent2, inbox).and_return({ available_capacity: 5 })
|
||||
|
||||
expect(service.assign_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).to eq(agent2) # Only agent with capacity
|
||||
end
|
||||
|
||||
it 'skips capacity filtering when enterprise not available' do
|
||||
allow(assignment_policy).to receive(:capacity_filtering_enabled?).and_return(false)
|
||||
|
||||
expect(service.assign_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).to be_in([agent1, agent2])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'cache management' do
|
||||
it 'uses cache for round robin state' do
|
||||
assignment_policy.update!(assignment_order: :round_robin)
|
||||
cache_key = "assignment_v2:round_robin:#{inbox.id}"
|
||||
|
||||
# First assignment
|
||||
conversation1 = create(:conversation, inbox: inbox, assignee: nil)
|
||||
service.assign_conversation(conversation1)
|
||||
|
||||
# Check cache was written
|
||||
expect(Rails.cache.read(cache_key)).not_to be_nil
|
||||
|
||||
# Second assignment should use cached state
|
||||
conversation2 = create(:conversation, inbox: inbox, assignee: nil)
|
||||
expect(Rails.cache).to receive(:read).with(cache_key).and_call_original
|
||||
|
||||
service.assign_conversation(conversation2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'edge cases' do
|
||||
it 'handles inbox without policy gracefully' do
|
||||
inbox_assignment_policy.destroy!
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
expect(service.assign_conversation(conversation)).to be false
|
||||
end
|
||||
|
||||
it 'handles empty agent list' do
|
||||
InboxMember.destroy_all
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
expect(service.assign_conversation(conversation)).to be false
|
||||
end
|
||||
|
||||
it 'filters out agents without inbox membership' do
|
||||
non_member_agent = create(:user, account: account, role: :agent, availability: :online)
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
expect(service.assign_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).not_to eq(non_member_agent)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,260 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::AssignmentV2::CapacityManager do
|
||||
let(:manager) { described_class.new }
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:capacity_policy) { create(:enterprise_agent_capacity_policy, account: account) }
|
||||
let!(:account_user) { create(:account_user, account: account, user: agent, agent_capacity_policy: capacity_policy) }
|
||||
|
||||
before do
|
||||
# Clear any existing cache
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
describe '#get_agent_capacity' do
|
||||
context 'with capacity policy and limits' do
|
||||
let!(:inbox_limit) { create(:enterprise_inbox_capacity_limit, agent_capacity_policy: capacity_policy, inbox: inbox, conversation_limit: 10) }
|
||||
|
||||
it 'returns correct capacity data' do
|
||||
capacity = manager.get_agent_capacity(agent, inbox)
|
||||
|
||||
expect(capacity).to include(
|
||||
total_capacity: 10,
|
||||
current_assignments: 0,
|
||||
available_capacity: 10,
|
||||
policy_id: capacity_policy.id,
|
||||
has_policy: true
|
||||
)
|
||||
end
|
||||
|
||||
it 'counts only open conversations' do
|
||||
# Create conversations with different statuses
|
||||
create_list(:conversation, 3, inbox: inbox, assignee: agent, status: :open)
|
||||
create_list(:conversation, 2, inbox: inbox, assignee: agent, status: :resolved)
|
||||
create(:conversation, inbox: inbox, assignee: agent, status: :snoozed)
|
||||
|
||||
capacity = manager.get_agent_capacity(agent, inbox)
|
||||
|
||||
expect(capacity[:current_assignments]).to eq(3)
|
||||
expect(capacity[:available_capacity]).to eq(7)
|
||||
end
|
||||
|
||||
it 'caches capacity data' do
|
||||
# First call
|
||||
manager.get_agent_capacity(agent, inbox)
|
||||
|
||||
# Second call should use cache
|
||||
expect(Rails.cache).to receive(:fetch).and_call_original
|
||||
manager.get_agent_capacity(agent, inbox)
|
||||
end
|
||||
|
||||
it 'respects cache TTL' do
|
||||
cache_key = "assignment_v2:capacity:#{agent.accounts.first.id}:#{agent.id}:#{inbox.id}"
|
||||
|
||||
manager.get_agent_capacity(agent, inbox)
|
||||
|
||||
# Check cache exists with TTL
|
||||
expect(Rails.cache.exist?(cache_key)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'without capacity policy' do
|
||||
before { account_user.update!(agent_capacity_policy: nil) }
|
||||
|
||||
it 'returns unlimited capacity' do
|
||||
capacity = manager.get_agent_capacity(agent, inbox)
|
||||
|
||||
expect(capacity).to include(
|
||||
total_capacity: 999_999,
|
||||
current_assignments: 0,
|
||||
available_capacity: 999_999,
|
||||
policy_id: nil,
|
||||
has_policy: false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without inbox limit' do
|
||||
it 'returns unlimited capacity' do
|
||||
capacity = manager.get_agent_capacity(agent, inbox)
|
||||
|
||||
expect(capacity[:has_policy]).to be false
|
||||
expect(capacity[:available_capacity]).to eq(999_999)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with exclusion rules' do
|
||||
let(:excluded_label) { create(:label, account: account, title: 'vip') }
|
||||
let(:capacity_policy) do
|
||||
create(:enterprise_agent_capacity_policy,
|
||||
account: account,
|
||||
exclusion_rules: {
|
||||
'labels' => ['vip'],
|
||||
'hours_threshold' => 24
|
||||
})
|
||||
end
|
||||
let!(:inbox_limit) { create(:enterprise_inbox_capacity_limit, agent_capacity_policy: capacity_policy, inbox: inbox, conversation_limit: 10) }
|
||||
|
||||
it 'excludes conversations with specified labels' do
|
||||
# Create regular conversations
|
||||
create_list(:conversation, 3, inbox: inbox, assignee: agent, status: :open)
|
||||
|
||||
# Create VIP conversations (should be excluded)
|
||||
vip_conversations = create_list(:conversation, 2, inbox: inbox, assignee: agent, status: :open)
|
||||
vip_conversations.each { |conv| create(:conversation_label, conversation: conv, label: excluded_label) }
|
||||
|
||||
capacity = manager.get_agent_capacity(agent, inbox)
|
||||
|
||||
expect(capacity[:current_assignments]).to eq(3) # Only non-VIP conversations
|
||||
end
|
||||
|
||||
it 'excludes old conversations based on hours threshold' do
|
||||
# Create recent conversations
|
||||
create_list(:conversation, 2, inbox: inbox, assignee: agent, status: :open, created_at: 1.hour.ago)
|
||||
|
||||
# Create old conversations (should be excluded)
|
||||
create_list(:conversation, 3, inbox: inbox, assignee: agent, status: :open, created_at: 2.days.ago)
|
||||
|
||||
capacity = manager.get_agent_capacity(agent, inbox)
|
||||
|
||||
expect(capacity[:current_assignments]).to eq(2) # Only recent conversations
|
||||
end
|
||||
end
|
||||
|
||||
context 'error handling' do
|
||||
it 'returns unlimited capacity on error' do
|
||||
allow(Rails.cache).to receive(:fetch).and_raise(StandardError, 'Cache error')
|
||||
expect(Rails.logger).to receive(:error).with(/Capacity manager failed/)
|
||||
|
||||
capacity = manager.get_agent_capacity(agent, inbox)
|
||||
|
||||
expect(capacity[:has_policy]).to be false
|
||||
expect(capacity[:available_capacity]).to eq(999_999)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_agents_capacity_status' do
|
||||
let(:agent2) { create(:user, account: account, role: :agent) }
|
||||
let!(:account_user2) { create(:account_user, account: account, user: agent2) }
|
||||
|
||||
it 'returns capacity status for multiple agents' do
|
||||
agents = [agent, agent2]
|
||||
|
||||
statuses = manager.get_agents_capacity_status(agents, inbox)
|
||||
|
||||
expect(statuses.length).to eq(2)
|
||||
expect(statuses[0][:agent]).to eq(agent)
|
||||
expect(statuses[1][:agent]).to eq(agent2)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_has_capacity?' do
|
||||
let!(:inbox_limit) { create(:enterprise_inbox_capacity_limit, agent_capacity_policy: capacity_policy, inbox: inbox, conversation_limit: 2) }
|
||||
|
||||
context 'when agent has capacity' do
|
||||
it 'returns true' do
|
||||
create(:conversation, inbox: inbox, assignee: agent, status: :open)
|
||||
|
||||
expect(manager.agent_has_capacity?(agent, inbox)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent is at capacity' do
|
||||
it 'returns false' do
|
||||
create_list(:conversation, 2, inbox: inbox, assignee: agent, status: :open)
|
||||
|
||||
expect(manager.agent_has_capacity?(agent, inbox)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_remaining_capacity' do
|
||||
let!(:inbox_limit) { create(:enterprise_inbox_capacity_limit, agent_capacity_policy: capacity_policy, inbox: inbox, conversation_limit: 5) }
|
||||
|
||||
it 'returns correct remaining capacity' do
|
||||
create_list(:conversation, 3, inbox: inbox, assignee: agent, status: :open)
|
||||
|
||||
expect(manager.get_remaining_capacity(agent, inbox)).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns 0 when over capacity' do
|
||||
create_list(:conversation, 6, inbox: inbox, assignee: agent, status: :open)
|
||||
|
||||
expect(manager.get_remaining_capacity(agent, inbox)).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#invalidate_agent_capacity_cache' do
|
||||
it 'clears specific inbox cache when inbox provided' do
|
||||
cache_key = "assignment_v2:capacity:#{agent.accounts.first.id}:#{agent.id}:#{inbox.id}"
|
||||
Rails.cache.write(cache_key, 'test_data')
|
||||
|
||||
manager.invalidate_agent_capacity_cache(agent, inbox)
|
||||
|
||||
expect(Rails.cache.read(cache_key)).to be_nil
|
||||
end
|
||||
|
||||
it 'clears all agent caches when inbox not provided' do
|
||||
cache_key1 = "assignment_v2:capacity:#{agent.id}:inbox1"
|
||||
cache_key2 = "assignment_v2:capacity:#{agent.id}:inbox2"
|
||||
|
||||
Rails.cache.write(cache_key1, 'test_data')
|
||||
Rails.cache.write(cache_key2, 'test_data')
|
||||
|
||||
expect(Rails.cache).to receive(:delete_matched).with("assignment_v2:capacity:#{agent.id}:*")
|
||||
|
||||
manager.invalidate_agent_capacity_cache(agent)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#invalidate_inbox_capacity_cache' do
|
||||
it 'clears all capacity caches for inbox' do
|
||||
expect(Rails.cache).to receive(:delete_matched).with("assignment_v2:capacity:*:#{inbox.id}")
|
||||
|
||||
manager.invalidate_inbox_capacity_cache(inbox)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'edge cases' do
|
||||
it 'handles agent without account correctly' do
|
||||
agent_without_account = create(:user, role: :agent)
|
||||
|
||||
capacity = manager.get_agent_capacity(agent_without_account, inbox)
|
||||
|
||||
expect(capacity[:has_policy]).to be false
|
||||
end
|
||||
|
||||
it 'handles multiple capacity policies gracefully' do
|
||||
# Create another policy and try to assign it
|
||||
another_policy = create(:enterprise_agent_capacity_policy, account: account)
|
||||
|
||||
# Update account user
|
||||
account_user.update!(agent_capacity_policy: another_policy)
|
||||
|
||||
# Should use the new policy
|
||||
capacity = manager.get_agent_capacity(agent, inbox)
|
||||
expect(capacity[:policy_id]).to eq(another_policy.id)
|
||||
end
|
||||
|
||||
it 'handles conversations from different inboxes' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
inbox_limit = create(:enterprise_inbox_capacity_limit,
|
||||
agent_capacity_policy: capacity_policy,
|
||||
inbox: inbox,
|
||||
conversation_limit: 5)
|
||||
|
||||
# Create conversations in different inboxes
|
||||
create_list(:conversation, 3, inbox: inbox, assignee: agent, status: :open)
|
||||
create_list(:conversation, 10, inbox: other_inbox, assignee: agent, status: :open)
|
||||
|
||||
# Should only count conversations from specified inbox
|
||||
capacity = manager.get_agent_capacity(agent, inbox)
|
||||
expect(capacity[:current_assignments]).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,260 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentV2::RateLimiter, type: :service do
|
||||
let(:account) { create(:account) }
|
||||
let(:policy) { create(:assignment_policy, account: account, fair_distribution_limit: 5, fair_distribution_window: 3600) }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:rate_limiter) { described_class.new(policy) }
|
||||
|
||||
before do
|
||||
# Clear Redis state
|
||||
Redis::Alfred.flushdb
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
it 'sets up rate limiter with policy parameters' do
|
||||
expect(rate_limiter.instance_variable_get(:@limit)).to eq(5)
|
||||
expect(rate_limiter.instance_variable_get(:@window_size)).to eq(3600)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_within_limits?' do
|
||||
context 'when agent has no assignments in current window' do
|
||||
it 'returns true' do
|
||||
expect(rate_limiter.agent_within_limits?(agent)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent is below limit' do
|
||||
before do
|
||||
# Simulate 3 assignments in current window
|
||||
3.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(rate_limiter.agent_within_limits?(agent)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent reaches limit' do
|
||||
before do
|
||||
# Simulate reaching the limit (5 assignments)
|
||||
5.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(rate_limiter.agent_within_limits?(agent)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent exceeds limit' do
|
||||
before do
|
||||
# Simulate exceeding the limit
|
||||
6.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(rate_limiter.agent_within_limits?(agent)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#increment_agent_assignments' do
|
||||
it 'increments assignment count for agent' do
|
||||
expect { rate_limiter.increment_agent_assignments(agent) }
|
||||
.to change { rate_limiter.get_agent_assignment_count(agent) }.from(0).to(1)
|
||||
end
|
||||
|
||||
it 'sets expiration on the key' do
|
||||
rate_limiter.increment_agent_assignments(agent)
|
||||
|
||||
current_window = Time.current.to_i / 3600
|
||||
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
|
||||
|
||||
ttl = Redis::Alfred.ttl(key)
|
||||
expect(ttl).to be > 0
|
||||
expect(ttl).to be <= 3600
|
||||
end
|
||||
|
||||
context 'when Redis fails' do
|
||||
before do
|
||||
allow(Redis::Alfred).to receive(:multi).and_raise(Redis::ConnectionError)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'logs error and continues without raising' do
|
||||
expect { rate_limiter.increment_agent_assignments(agent) }.not_to raise_error
|
||||
expect(Rails.logger).to have_received(:error).with(/Rate limiter increment failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_agent_assignment_count' do
|
||||
it 'returns 0 for agent with no assignments' do
|
||||
expect(rate_limiter.get_agent_assignment_count(agent)).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns correct count after assignments' do
|
||||
3.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
expect(rate_limiter.get_agent_assignment_count(agent)).to eq(3)
|
||||
end
|
||||
|
||||
context 'when Redis fails' do
|
||||
before do
|
||||
allow(Redis::Alfred).to receive(:get).and_raise(Redis::ConnectionError)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'returns 0 and logs error' do
|
||||
expect(rate_limiter.get_agent_assignment_count(agent)).to eq(0)
|
||||
expect(Rails.logger).to have_received(:error).with(/Rate limiter get count failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_remaining_assignments' do
|
||||
it 'returns full limit when no assignments made' do
|
||||
expect(rate_limiter.get_remaining_assignments(agent)).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns correct remaining count' do
|
||||
2.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
expect(rate_limiter.get_remaining_assignments(agent)).to eq(3)
|
||||
end
|
||||
|
||||
it 'returns 0 when limit reached' do
|
||||
5.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
expect(rate_limiter.get_remaining_assignments(agent)).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns 0 when limit exceeded' do
|
||||
6.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
expect(rate_limiter.get_remaining_assignments(agent)).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_assign_to_agent?' do
|
||||
it 'returns true when agent has remaining capacity' do
|
||||
2.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
expect(rate_limiter.can_assign_to_agent?(agent)).to be true
|
||||
end
|
||||
|
||||
it 'returns false when agent has no capacity' do
|
||||
5.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
expect(rate_limiter.can_assign_to_agent?(agent)).to be false
|
||||
end
|
||||
|
||||
it 'checks for specific count requirement' do
|
||||
3.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
|
||||
expect(rate_limiter.can_assign_to_agent?(agent, 1)).to be true
|
||||
expect(rate_limiter.can_assign_to_agent?(agent, 2)).to be true
|
||||
expect(rate_limiter.can_assign_to_agent?(agent, 3)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_agents_assignment_status' do
|
||||
let(:agent2) { create(:user, account: account) }
|
||||
let(:agents) { [agent, agent2] }
|
||||
|
||||
before do
|
||||
2.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
4.times { rate_limiter.increment_agent_assignments(agent2) }
|
||||
end
|
||||
|
||||
it 'returns status for all agents' do
|
||||
status = rate_limiter.get_agents_assignment_status(agents)
|
||||
|
||||
expect(status).to be_an(Array)
|
||||
expect(status.size).to eq(2)
|
||||
|
||||
agent_status = status.find { |s| s[:agent] == agent }
|
||||
expect(agent_status).to include(
|
||||
agent: agent,
|
||||
current_assignments: 2,
|
||||
remaining_assignments: 3,
|
||||
within_limits: true
|
||||
)
|
||||
|
||||
agent2_status = status.find { |s| s[:agent] == agent2 }
|
||||
expect(agent2_status).to include(
|
||||
agent: agent2,
|
||||
current_assignments: 4,
|
||||
remaining_assignments: 1,
|
||||
within_limits: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reset_agent_limits' do
|
||||
before do
|
||||
3.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
end
|
||||
|
||||
it 'resets agent assignment count to 0' do
|
||||
expect { rate_limiter.reset_agent_limits(agent) }
|
||||
.to change { rate_limiter.get_agent_assignment_count(agent) }.from(3).to(0)
|
||||
end
|
||||
|
||||
context 'when Redis fails' do
|
||||
before do
|
||||
allow(Redis::Alfred).to receive(:del).and_raise(Redis::ConnectionError)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'logs error and continues' do
|
||||
expect { rate_limiter.reset_agent_limits(agent) }.not_to raise_error
|
||||
expect(Rails.logger).to have_received(:error).with(/Rate limiter reset failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#time_until_next_window' do
|
||||
it 'returns time until next window boundary' do
|
||||
# Mock current time to make test predictable
|
||||
travel_to(Time.zone.parse('2024-01-01 10:30:00')) do
|
||||
time_until = rate_limiter.time_until_next_window
|
||||
expect(time_until).to be > 0
|
||||
expect(time_until).to be <= 3600
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'window boundaries' do
|
||||
it 'resets count in new window' do
|
||||
# Set up assignment in current window
|
||||
2.times { rate_limiter.increment_agent_assignments(agent) }
|
||||
expect(rate_limiter.get_agent_assignment_count(agent)).to eq(2)
|
||||
|
||||
# Travel to next window (advance by window size)
|
||||
travel(3601.seconds) do
|
||||
expect(rate_limiter.get_agent_assignment_count(agent)).to eq(0)
|
||||
expect(rate_limiter.agent_within_limits?(agent)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'concurrent access' do
|
||||
it 'handles concurrent increments correctly' do
|
||||
threads = []
|
||||
results = []
|
||||
|
||||
# Simulate concurrent assignment requests
|
||||
5.times do
|
||||
threads << Thread.new do
|
||||
results << rate_limiter.agent_within_limits?(agent)
|
||||
rate_limiter.increment_agent_assignments(agent) if results.last
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
# Final count should not exceed the limit
|
||||
final_count = rate_limiter.get_agent_assignment_count(agent)
|
||||
expect(final_count).to be <= 5
|
||||
expect(results.count(true)).to eq(final_count)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,170 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentV2::RoundRobinSelector, type: :service do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:policy) { create(:assignment_policy, account: account) }
|
||||
let(:user1) { create(:user, account: account, availability: User::AVAILABILITY_STATUSES['online']) }
|
||||
let(:user2) { create(:user, account: account, availability: User::AVAILABILITY_STATUSES['online']) }
|
||||
let(:user3) { create(:user, account: account, availability: User::AVAILABILITY_STATUSES['offline']) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: user1)
|
||||
create(:inbox_member, inbox: inbox, user: user2)
|
||||
create(:inbox_member, inbox: inbox, user: user3)
|
||||
create(:account_user, account: account, user: user1, role: 'agent')
|
||||
create(:account_user, account: account, user: user2, role: 'agent')
|
||||
create(:account_user, account: account, user: user3, role: 'agent')
|
||||
end
|
||||
|
||||
describe '#select_agent' do
|
||||
let(:selector) { described_class.new(inbox, policy) }
|
||||
|
||||
context 'when Redis is available' do
|
||||
before do
|
||||
allow(Redis::Alfred).to receive(:set).and_return(true)
|
||||
allow(Redis::Alfred).to receive(:del)
|
||||
allow(Redis::Alfred).to receive(:lpop).and_return(user1.id.to_s)
|
||||
allow(Redis::Alfred).to receive(:rpush)
|
||||
allow(Redis::Alfred).to receive(:multi).and_yield(double(del: nil, rpush: nil, expire: nil))
|
||||
end
|
||||
|
||||
it 'returns an online agent' do
|
||||
result = selector.select_agent
|
||||
expect(result).to be_a(User)
|
||||
expect([user1.id, user2.id]).to include(result.id)
|
||||
end
|
||||
|
||||
it 'excludes offline agents' do
|
||||
allow(selector).to receive(:compute_eligible_agents).and_return([user1.id, user2.id])
|
||||
result = selector.select_agent
|
||||
expect(result&.id).not_to eq(user3.id)
|
||||
end
|
||||
|
||||
it 'handles Redis lock contention gracefully' do
|
||||
allow(Redis::Alfred).to receive(:set).and_return(false)
|
||||
result = selector.select_agent
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Redis fails' do
|
||||
before do
|
||||
allow(Redis::Alfred).to receive(:set).and_raise(Redis::CannotConnectError)
|
||||
end
|
||||
|
||||
it 'falls back to database selection' do
|
||||
result = selector.select_agent
|
||||
expect(result).to be_a(User)
|
||||
expect([user1.id, user2.id]).to include(result.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with rate limiting' do
|
||||
let(:rate_limiter) { instance_double(AssignmentV2::RateLimiter) }
|
||||
|
||||
before do
|
||||
allow(AssignmentV2::RateLimiter).to receive(:new).and_return(rate_limiter)
|
||||
end
|
||||
|
||||
it 'filters agents by rate limits' do
|
||||
allow(rate_limiter).to receive(:agent_within_limits?).with(user1).and_return(true)
|
||||
allow(rate_limiter).to receive(:agent_within_limits?).with(user2).and_return(false)
|
||||
|
||||
# Mock Redis operations
|
||||
allow(Redis::Alfred).to receive(:set).and_return(true)
|
||||
allow(Redis::Alfred).to receive(:del)
|
||||
allow(Redis::Alfred).to receive(:lpop).and_return(user1.id.to_s)
|
||||
allow(Redis::Alfred).to receive(:rpush)
|
||||
allow(Redis::Alfred).to receive(:multi).and_yield(double(del: nil, rpush: nil, expire: nil))
|
||||
|
||||
result = selector.select_agent
|
||||
expect(result&.id).to eq(user1.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with enterprise capacity' do
|
||||
before do
|
||||
stub_const('Enterprise', Module.new)
|
||||
allow(inbox.account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
end
|
||||
|
||||
it 'attempts to filter by capacity when enterprise is available' do
|
||||
capacity_manager = instance_double('Enterprise::AssignmentV2::CapacityManager')
|
||||
stub_const('Enterprise::AssignmentV2::CapacityManager', class_double('Enterprise::AssignmentV2::CapacityManager', new: capacity_manager))
|
||||
|
||||
allow(capacity_manager).to receive(:get_agent_capacity).and_return({ available_capacity: 5 })
|
||||
|
||||
# Mock Redis operations
|
||||
allow(Redis::Alfred).to receive(:set).and_return(true)
|
||||
allow(Redis::Alfred).to receive(:del)
|
||||
allow(Redis::Alfred).to receive(:lpop).and_return(user1.id.to_s)
|
||||
allow(Redis::Alfred).to receive(:rpush)
|
||||
allow(Redis::Alfred).to receive(:multi).and_yield(double(del: nil, rpush: nil, expire: nil))
|
||||
|
||||
result = selector.select_agent
|
||||
expect(result).to be_a(User)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#refresh_queue!' do
|
||||
let(:selector) { described_class.new(inbox, policy) }
|
||||
|
||||
it 'refreshes the Redis queue with eligible agents' do
|
||||
expect(Redis::Alfred).to receive(:multi).and_yield(double(del: nil, rpush: nil, expire: nil))
|
||||
selector.refresh_queue!
|
||||
end
|
||||
end
|
||||
|
||||
describe 'race condition safety' do
|
||||
let(:selector) { described_class.new(inbox, policy) }
|
||||
|
||||
it 'handles concurrent access with Redis locks' do
|
||||
# Simulate lock contention
|
||||
call_count = 0
|
||||
allow(Redis::Alfred).to receive(:set) do |key, value, options|
|
||||
call_count += 1
|
||||
call_count == 1 ? true : false # First call succeeds, second fails
|
||||
end
|
||||
|
||||
allow(Redis::Alfred).to receive(:del)
|
||||
allow(Redis::Alfred).to receive(:lpop).and_return(user1.id.to_s)
|
||||
allow(Redis::Alfred).to receive(:rpush)
|
||||
|
||||
# Multiple concurrent calls
|
||||
results = []
|
||||
threads = []
|
||||
|
||||
3.times do
|
||||
threads << Thread.new do
|
||||
results << selector.select_agent
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
# At least one should succeed, others should be nil due to lock contention
|
||||
expect(results.compact.length).to be >= 1
|
||||
end
|
||||
end
|
||||
|
||||
describe 'memory and performance' do
|
||||
let(:selector) { described_class.new(inbox, policy) }
|
||||
|
||||
it 'cleans up Redis keys with TTL' do
|
||||
expect(Redis::Alfred).to receive(:multi).and_yield(
|
||||
double(del: nil, expire: receive(:expire).with(anything, AssignmentV2::RoundRobinSelector::QUEUE_TTL.to_i))
|
||||
)
|
||||
|
||||
allow(Redis::Alfred).to receive(:set).and_return(true)
|
||||
allow(Redis::Alfred).to receive(:del)
|
||||
allow(Redis::Alfred).to receive(:lpop).and_return(user1.id.to_s)
|
||||
allow(Redis::Alfred).to receive(:rpush)
|
||||
|
||||
selector.select_agent
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,199 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Assignment V2 Security', type: :service do
|
||||
let(:account1) { create(:account) }
|
||||
let(:account2) { create(:account) }
|
||||
let(:inbox1) { create(:inbox, account: account1) }
|
||||
let(:inbox2) { create(:inbox, account: account2) }
|
||||
let(:user1) { create(:user, account: account1) }
|
||||
let(:user2) { create(:user, account: account2) }
|
||||
|
||||
describe 'Cross-account data access prevention' do
|
||||
let(:policy1) { create(:assignment_policy, account: account1) }
|
||||
let(:policy2) { create(:assignment_policy, account: account2) }
|
||||
|
||||
context 'AssignmentPolicy' do
|
||||
it 'prevents cross-account policy assignment' do
|
||||
expect {
|
||||
InboxAssignmentPolicy.create!(
|
||||
inbox: inbox1,
|
||||
assignment_policy: policy2 # Different account policy
|
||||
)
|
||||
}.to raise_error(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
|
||||
it 'validates policy names are unique within account only' do
|
||||
create(:assignment_policy, account: account1, name: 'Default')
|
||||
|
||||
# Same name in different account should be allowed
|
||||
expect {
|
||||
create(:assignment_policy, account: account2, name: 'Default')
|
||||
}.not_to raise_error
|
||||
|
||||
# Same name in same account should fail
|
||||
expect {
|
||||
create(:assignment_policy, account: account1, name: 'Default')
|
||||
}.to raise_error(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
end
|
||||
|
||||
context 'Enterprise capacity policies' do
|
||||
before do
|
||||
stub_const('Enterprise', Module.new)
|
||||
enterprise_policy_class = Class.new(ApplicationRecord) do
|
||||
include AccountCacheRevalidator
|
||||
|
||||
self.table_name = 'enterprise_agent_capacity_policies'
|
||||
|
||||
belongs_to :account
|
||||
validates :name, presence: true, uniqueness: { scope: :account_id }
|
||||
end
|
||||
|
||||
stub_const('Enterprise::AgentCapacityPolicy', enterprise_policy_class)
|
||||
end
|
||||
|
||||
it 'prevents cross-account agent assignment' do
|
||||
# This test would verify that users from one account
|
||||
# cannot be assigned to capacity policies from another account
|
||||
expect(true).to be true # Placeholder - would need actual enterprise models
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'SQL injection prevention' do
|
||||
let(:policy) { create(:assignment_policy, account: account1) }
|
||||
let(:orchestrator) { AssignmentV2::AssignmentOrchestrator.new(inbox1) }
|
||||
|
||||
it 'uses parameterized queries in conversation fetching' do
|
||||
# Test that malicious input in policy configuration doesn't lead to SQL injection
|
||||
malicious_input = "'; DROP TABLE users; --"
|
||||
|
||||
# Should not raise SQL errors or cause injection
|
||||
expect {
|
||||
# This would test priority ordering with potentially malicious data
|
||||
conversations = orchestrator.send(:fetch_prioritized_conversations, 10)
|
||||
}.not_to raise_error
|
||||
end
|
||||
|
||||
it 'safely handles Arel SQL in longest_waiting priority' do
|
||||
policy.update!(conversation_priority: 'longest_waiting')
|
||||
conversations = orchestrator.send(:fetch_prioritized_conversations, 10)
|
||||
|
||||
# Should use parameterized Arel queries, not string interpolation
|
||||
expect(conversations).to be_an(ActiveRecord::Relation)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Redis key isolation' do
|
||||
let(:policy) { create(:assignment_policy, account: account1) }
|
||||
let(:rate_limiter1) { AssignmentV2::RateLimiter.new(policy) }
|
||||
let(:rate_limiter2) { AssignmentV2::RateLimiter.new(policy) }
|
||||
|
||||
it 'uses agent-specific Redis keys to prevent data leaks' do
|
||||
key1 = rate_limiter1.send(:rate_limit_key, user1, 123456)
|
||||
key2 = rate_limiter1.send(:rate_limit_key, user2, 123456)
|
||||
|
||||
expect(key1).to include(user1.id.to_s)
|
||||
expect(key2).to include(user2.id.to_s)
|
||||
expect(key1).not_to eq(key2)
|
||||
end
|
||||
|
||||
it 'includes window in Redis keys for temporal isolation' do
|
||||
window1 = 123456
|
||||
window2 = 123457
|
||||
|
||||
key1 = rate_limiter1.send(:rate_limit_key, user1, window1)
|
||||
key2 = rate_limiter1.send(:rate_limit_key, user1, window2)
|
||||
|
||||
expect(key1).not_to eq(key2)
|
||||
expect(key1).to include(window1.to_s)
|
||||
expect(key2).to include(window2.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Authorization checks' do
|
||||
let(:policy) { create(:assignment_policy, account: account1) }
|
||||
let(:selector) { AssignmentV2::RoundRobinSelector.new(inbox1, policy) }
|
||||
|
||||
it 'only selects agents who are inbox members' do
|
||||
create(:account_user, account: account1, user: user1, role: 'agent')
|
||||
create(:account_user, account: account2, user: user2, role: 'agent')
|
||||
|
||||
# Only user1 should be eligible (user2 is not an inbox member)
|
||||
eligible_agents = selector.send(:compute_eligible_agents)
|
||||
expect(eligible_agents).not_to include(user2.id)
|
||||
end
|
||||
|
||||
it 'only selects agents from the same account' do
|
||||
create(:inbox_member, inbox: inbox1, user: user1)
|
||||
create(:inbox_member, inbox: inbox1, user: user2) # Cross-account member
|
||||
create(:account_user, account: account1, user: user1, role: 'agent')
|
||||
create(:account_user, account: account2, user: user2, role: 'agent')
|
||||
|
||||
eligible_agents = selector.send(:compute_eligible_agents)
|
||||
expect(eligible_agents).to include(user1.id)
|
||||
expect(eligible_agents).not_to include(user2.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Input validation and sanitization' do
|
||||
it 'validates assignment policy limits are reasonable' do
|
||||
expect {
|
||||
create(:assignment_policy,
|
||||
account: account1,
|
||||
fair_distribution_limit: 101 # Over limit
|
||||
)
|
||||
}.to raise_error(ActiveRecord::RecordInvalid)
|
||||
|
||||
expect {
|
||||
create(:assignment_policy,
|
||||
account: account1,
|
||||
fair_distribution_window: 30 # Under minimum
|
||||
)
|
||||
}.to raise_error(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
|
||||
it 'validates enterprise capacity limits are reasonable' do
|
||||
# Test would validate that conversation limits can't be set to extreme values
|
||||
# This prevents resource exhaustion attacks
|
||||
expect(true).to be true # Placeholder
|
||||
end
|
||||
|
||||
it 'sanitizes policy names and descriptions' do
|
||||
policy = create(:assignment_policy,
|
||||
account: account1,
|
||||
name: 'Test Policy',
|
||||
description: 'A test description'
|
||||
)
|
||||
|
||||
expect(policy.name).to eq('Test Policy')
|
||||
expect(policy.description).to eq('A test description')
|
||||
expect(policy.name.length).to be <= 255
|
||||
expect(policy.description.length).to be <= 1000
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Rate limiting protection' do
|
||||
let(:policy) { create(:assignment_policy, account: account1, fair_distribution_limit: 5) }
|
||||
let(:rate_limiter) { AssignmentV2::RateLimiter.new(policy) }
|
||||
|
||||
before do
|
||||
allow(Redis::Alfred).to receive(:get).and_return('10') # Over limit
|
||||
allow(Redis::Alfred).to receive(:multi)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'prevents assignment when agent is over rate limit' do
|
||||
expect(rate_limiter.agent_within_limits?(user1)).to be false
|
||||
end
|
||||
|
||||
it 'handles Redis failures gracefully without blocking assignments' do
|
||||
allow(Redis::Alfred).to receive(:get).and_raise(Redis::CannotConnectError)
|
||||
|
||||
# Should return 0 on Redis failure, effectively disabling rate limiting
|
||||
expect(rate_limiter.agent_within_limits?(user1)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user