package autoassignment import ( "context" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" ) func setupCapacityAssignmentDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) require.NoError(t, db.AutoMigrate( &model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.InboxMember{}, &model.Contact{}, &model.Conversation{}, &model.AssignmentPolicy{}, &model.InboxAssignmentPolicy{}, &model.AgentCapacityPolicy{}, &model.InboxCapacityLimit{}, &model.Tag{}, &model.ConversationLabel{}, )) return db } func TestAssignmentService_SkipsAgentsAtInboxCapacity(t *testing.T) { _, rdb := setupTestRedis(t) db := setupCapacityAssignmentDB(t) svc := NewAssignmentService(db, rdb) ctx := context.Background() account := &model.Account{Name: "Capacity Account", FeatureFlags: `{"advanced_assignment":true}`} require.NoError(t, db.Create(account).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "Capacity Inbox", ChannelType: string(model.InboxChannelTypeWebWidget), EnableAutoAssignment: true} require.NoError(t, db.Create(inbox).Error) contact := &model.Contact{AccountID: account.ID, Name: "Capacity Contact"} require.NoError(t, db.Create(contact).Error) agentAtCapacity := createCapacityUser(t, db, account.ID, "full@test.com") agentWithCapacity := createCapacityUser(t, db, account.ID, "available@test.com") require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: agentAtCapacity.ID}).Error) require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: agentWithCapacity.ID}).Error) policy := &model.AgentCapacityPolicy{AccountID: account.ID, Name: "Capacity", AssignmentLogic: "round_robin", ExclusionRules: []byte(`{}`)} require.NoError(t, db.Create(policy).Error) require.NoError(t, db.Model(&model.AccountUser{}). Where("account_id = ? AND user_id = ?", account.ID, agentAtCapacity.ID). Update("agent_capacity_policy_id", policy.ID).Error) require.NoError(t, db.Create(&model.InboxCapacityLimit{AgentCapacityPolicyID: policy.ID, InboxID: inbox.ID, ConversationLimit: 1}).Error) now := time.Now().Unix() existing := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &agentAtCapacity.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", LastActivityAt: &now} unassigned := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", LastActivityAt: &now} require.NoError(t, db.Create(existing).Error) require.NoError(t, db.Create(unassigned).Error) agentID, err := svc.AssignConversation(ctx, unassigned.ID, inbox.ID, account.ID) require.NoError(t, err) assert.Equal(t, agentWithCapacity.ID, agentID) } func TestAssignmentServiceIgnoresCapacityWhenAdvancedAssignmentIsDisabled(t *testing.T) { _, rdb := setupTestRedis(t) db := setupCapacityAssignmentDB(t) account := &model.Account{Name: "Default Assignment"} require.NoError(t, db.Create(account).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "Inbox", ChannelType: string(model.InboxChannelTypeWebWidget), EnableAutoAssignment: true} require.NoError(t, db.Create(inbox).Error) agent := createCapacityUser(t, db, account.ID, "default@test.com") require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: agent.ID}).Error) policy := &model.AgentCapacityPolicy{AccountID: account.ID, Name: "Excluded", ExclusionRules: []byte(`{"exclude_older_than_hours":1}`)} require.NoError(t, db.Create(policy).Error) require.NoError(t, db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", account.ID, agent.ID).Update("agent_capacity_policy_id", policy.ID).Error) require.NoError(t, db.Create(&model.InboxCapacityLimit{AgentCapacityPolicyID: policy.ID, InboxID: inbox.ID, ConversationLimit: 0}).Error) contact := &model.Contact{AccountID: account.ID, Name: "Contact"} require.NoError(t, db.Create(contact).Error) old := time.Now().Add(-2 * time.Hour).Unix() conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), LastActivityAt: &old} require.NoError(t, db.Create(conversation).Error) assigned, err := NewAssignmentService(db, rdb).AssignConversation(context.Background(), conversation.ID, inbox.ID, account.ID) require.NoError(t, err) require.Equal(t, agent.ID, assigned) } func TestAssignmentServiceAppliesCapacityPolicyExclusions(t *testing.T) { _, rdb := setupTestRedis(t) db := setupCapacityAssignmentDB(t) account := &model.Account{Name: "Exclusions", FeatureFlags: `{"advanced_assignment":true}`} require.NoError(t, db.Create(account).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "Inbox", ChannelType: string(model.InboxChannelTypeWebWidget), EnableAutoAssignment: true} require.NoError(t, db.Create(inbox).Error) agent := createCapacityUser(t, db, account.ID, "exclusions@test.com") require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: agent.ID}).Error) capacityPolicy := &model.AgentCapacityPolicy{ AccountID: account.ID, Name: "Exclude stale spam", ExclusionRules: []byte(`{"excluded_labels":["spam"],"exclude_older_than_hours":24}`), } require.NoError(t, db.Create(capacityPolicy).Error) require.NoError(t, db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", account.ID, agent.ID).Update("agent_capacity_policy_id", capacityPolicy.ID).Error) require.NoError(t, db.Create(&model.InboxCapacityLimit{AgentCapacityPolicyID: capacityPolicy.ID, InboxID: inbox.ID, ConversationLimit: 10}).Error) contact := &model.Contact{AccountID: account.ID, Name: "Contact"} require.NoError(t, db.Create(contact).Error) now := time.Now().Unix() old := time.Now().Add(-25 * time.Hour).Unix() recent := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), LastActivityAt: &now} labeled := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), LastActivityAt: &now} stale := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), LastActivityAt: &old} unknownAge := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen)} require.NoError(t, db.Create([]*model.Conversation{recent, labeled, stale, unknownAge}).Error) tag := &model.Tag{AccountID: account.ID, Name: "spam"} require.NoError(t, db.Create(tag).Error) require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: labeled.ID, TagID: tag.ID}).Error) assigned, err := NewAssignmentService(db, rdb).AssignUnassignedConversations(context.Background(), inbox.ID, account.ID) require.NoError(t, err) require.Equal(t, []uint{recent.ID}, assigned) for _, excluded := range []*model.Conversation{labeled, stale, unknownAge} { require.NoError(t, db.First(excluded, excluded.ID).Error) require.Nil(t, excluded.AssigneeID) } } func TestAssignmentServiceAppliesEachAgentsCapacityPolicyExclusions(t *testing.T) { _, rdb := setupTestRedis(t) db := setupCapacityAssignmentDB(t) account := &model.Account{Name: "Per-agent exclusions", FeatureFlags: `{"advanced_assignment":true}`} require.NoError(t, db.Create(account).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "Inbox", ChannelType: string(model.InboxChannelTypeWebWidget), EnableAutoAssignment: true} require.NoError(t, db.Create(inbox).Error) spamAgent := createCapacityUser(t, db, account.ID, "spam-policy@test.com") vipAgent := createCapacityUser(t, db, account.ID, "vip-policy@test.com") require.NoError(t, db.Create([]*model.InboxMember{{InboxID: inbox.ID, UserID: spamAgent.ID}, {InboxID: inbox.ID, UserID: vipAgent.ID}}).Error) spamPolicy := &model.AgentCapacityPolicy{AccountID: account.ID, Name: "Exclude spam", ExclusionRules: []byte(`{"excluded_labels":["spam"]}`)} vipPolicy := &model.AgentCapacityPolicy{AccountID: account.ID, Name: "Exclude vip", ExclusionRules: []byte(`{"excluded_labels":["vip"]}`)} require.NoError(t, db.Create(spamPolicy).Error) require.NoError(t, db.Create(vipPolicy).Error) require.NoError(t, db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", account.ID, spamAgent.ID).Update("agent_capacity_policy_id", spamPolicy.ID).Error) require.NoError(t, db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", account.ID, vipAgent.ID).Update("agent_capacity_policy_id", vipPolicy.ID).Error) require.NoError(t, db.Create([]*model.InboxCapacityLimit{ {AgentCapacityPolicyID: spamPolicy.ID, InboxID: inbox.ID, ConversationLimit: 10}, {AgentCapacityPolicyID: vipPolicy.ID, InboxID: inbox.ID, ConversationLimit: 10}, }).Error) contact := &model.Contact{AccountID: account.ID, Name: "Contact"} require.NoError(t, db.Create(contact).Error) now := time.Now().Unix() spam := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), LastActivityAt: &now} vip := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), LastActivityAt: &now} require.NoError(t, db.Create([]*model.Conversation{spam, vip}).Error) spamTag := &model.Tag{AccountID: account.ID, Name: "spam"} vipTag := &model.Tag{AccountID: account.ID, Name: "vip"} require.NoError(t, db.Create([]*model.Tag{spamTag, vipTag}).Error) require.NoError(t, db.Create([]*model.ConversationLabel{ {AccountID: account.ID, ConversationID: spam.ID, TagID: spamTag.ID}, {AccountID: account.ID, ConversationID: vip.ID, TagID: vipTag.ID}, }).Error) assigned, err := NewAssignmentService(db, rdb).AssignUnassignedConversations(context.Background(), inbox.ID, account.ID) require.NoError(t, err) require.Equal(t, []uint{spam.ID, vip.ID}, assigned) require.NoError(t, db.First(spam, spam.ID).Error) require.NoError(t, db.First(vip, vip.ID).Error) require.Equal(t, vipAgent.ID, *spam.AssigneeID) require.Equal(t, spamAgent.ID, *vip.AssigneeID) } func TestAssignmentEligibilityRejectsSoftDeletedUserAndAccountUser(t *testing.T) { for _, deleted := range []string{"user", "account user"} { t.Run(deleted, func(t *testing.T) { db := setupCapacityAssignmentDB(t) account := &model.Account{Name: deleted} require.NoError(t, db.Create(account).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "Inbox", ChannelType: string(model.InboxChannelTypeWebWidget), EnableAutoAssignment: true} require.NoError(t, db.Create(inbox).Error) agent := createCapacityUser(t, db, account.ID, deleted+"@test.com") require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: agent.ID}).Error) contact := &model.Contact{AccountID: account.ID, Name: "Contact"} require.NoError(t, db.Create(contact).Error) conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen)} require.NoError(t, db.Create(conversation).Error) if deleted == "user" { require.NoError(t, db.Delete(agent).Error) } else { require.NoError(t, db.Where("account_id = ? AND user_id = ?", account.ID, agent.ID).Delete(&model.AccountUser{}).Error) } agents, err := NewAssignmentService(db, nil).getEligibleAgents(context.Background(), inbox.ID, account.ID, nil, false) require.NoError(t, err) require.Empty(t, agents) assigned, err := repository.AutoAssignConversation(context.Background(), db, account.ID, inbox.ID, conversation.ID, agent.ID) require.NoError(t, err) require.False(t, assigned) }) } } func TestLongestWaitingSortsUnknownActivityLast(t *testing.T) { testLongestWaitingSortsUnknownActivityLast(t, setupCapacityAssignmentDB(t)) } func testLongestWaitingSortsUnknownActivityLast(t *testing.T, db *gorm.DB) { t.Helper() account := &model.Account{Name: "Longest waiting"} require.NoError(t, db.Create(account).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "Inbox", ChannelType: string(model.InboxChannelTypeWebWidget)} require.NoError(t, db.Create(inbox).Error) contact := &model.Contact{AccountID: account.ID, Name: "Contact"} require.NoError(t, db.Create(contact).Error) olderActivity := time.Now().Add(-2 * time.Hour).Unix() newerActivity := time.Now().Add(-time.Hour).Unix() unknown := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen)} newer := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), LastActivityAt: &newerActivity} older := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), LastActivityAt: &olderActivity} require.NoError(t, db.Create([]*model.Conversation{unknown, newer, older}).Error) conversations, err := NewAssignmentService(db, nil).findUnassignedConversations( context.Background(), inbox.ID, account.ID, &model.AssignmentPolicy{ConversationPriority: 1}, ) require.NoError(t, err) require.Equal(t, []uint{older.ID, newer.ID, unknown.ID}, []uint{conversations[0].ID, conversations[1].ID, conversations[2].ID}) } func createCapacityUser(t *testing.T, db *gorm.DB, accountID uint, email string) *model.User { t.Helper() user := &model.User{AccountID: accountID, Name: email, Email: email, Password: "hashed", Active: true, Available: true} require.NoError(t, db.Create(user).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: accountID, UserID: user.ID, Role: "agent", Availability: "online"}).Error) return user }