package autoassignment import ( "context" "fmt" "net/url" "os" "strings" "sync" "sync/atomic" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) func migrateFullAADB_Cov9(t *testing.T, db *gorm.DB) { t.Helper() require.NoError(t, db.AutoMigrate( &model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.InboxMember{}, &model.Contact{}, &model.ContactInbox{}, &model.Conversation{}, &model.Message{}, &model.Team{}, &model.TeamMember{}, &model.BackgroundJob{}, &model.AssignmentPolicy{}, &model.InboxAssignmentPolicy{}, &model.AgentCapacityPolicy{}, &model.InboxCapacityLimit{}, )) } func setupFullAADB_Cov9(t *testing.T) (*gorm.DB, *redis.Client) { t.Helper() db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared&_busy_timeout=5000", strings.ReplaceAll(t.Name(), "/", "_"))), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) t.Cleanup(func() { _ = sqlDB.Close() }) migrateFullAADB_Cov9(t, db) mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { _ = rdb.Close() }) return db, rdb } func setupFullAAPostgresDB_Cov9(t *testing.T) (*gorm.DB, *redis.Client) { t.Helper() dsn := os.Getenv("GOCHAT_TEST_DB_URL") if dsn == "" { dsn = "postgres://postgres:postgres@localhost:5432/gochat_test?sslmode=disable" } admin, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) adminDB, err := admin.DB() require.NoError(t, err) schema := fmt.Sprintf("autoassignment_%d", time.Now().UnixNano()) require.NoError(t, admin.Exec("CREATE SCHEMA "+schema).Error) t.Cleanup(func() { if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil { t.Errorf("drop PostgreSQL test schema: %v", err) } if err := adminDB.Close(); err != nil { t.Errorf("close PostgreSQL admin connection: %v", err) } }) if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { dsnURL, parseErr := url.Parse(dsn) require.NoError(t, parseErr) query := dsnURL.Query() query.Set("search_path", schema) dsnURL.RawQuery = query.Encode() dsn = dsnURL.String() } else { dsn += " search_path=" + schema } db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), DisableForeignKeyConstraintWhenMigrating: true, }) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) t.Cleanup(func() { _ = sqlDB.Close() }) migrateFullAADB_Cov9(t, db) mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { _ = rdb.Close() }) return db, rdb } func seedAssignableConversation_Cov9(t *testing.T, db *gorm.DB) (*model.Account, *model.User, *model.Inbox, *model.Conversation) { t.Helper() acc := &model.Account{Name: "cov9-account"} require.NoError(t, db.Create(acc).Error) agent := &model.User{AccountID: acc.ID, Name: "agent", Email: "agent-cov9@example.com", Password: "p", Active: true, Available: true} require.NoError(t, db.Create(agent).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: acc.ID, UserID: agent.ID, Role: "agent"}).Error) inbox := &model.Inbox{AccountID: acc.ID, Name: "inbox", ChannelType: "web_widget", EnableAutoAssignment: true} require.NoError(t, db.Create(inbox).Error) require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: agent.ID, Role: "agent", AvailabilityStatus: "online"}).Error) contact := &model.Contact{AccountID: acc.ID, Name: "contact", Email: "contact-cov9@example.com"} require.NoError(t, db.Create(contact).Error) conv := &model.Conversation{AccountID: acc.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen)} require.NoError(t, db.Create(conv).Error) return acc, agent, inbox, conv } func TestEffectivePolicyLimitWindow_Cov9(t *testing.T) { policy := &model.AssignmentPolicy{AssignmentOrder: 1, FairDistributionLimit: 11, FairDistributionWindow: 111, Enabled: true} require.Equal(t, PolicyRoundRobin, EffectivePolicy(nil, false)) require.Equal(t, PolicyRoundRobin, EffectivePolicy(policy, false)) require.Equal(t, PolicyLowestLoad, EffectivePolicy(policy, true)) require.Equal(t, 5, EffectiveLimit(nil)) require.Equal(t, 11, EffectiveLimit(policy)) require.Equal(t, 300, EffectiveWindow(nil)) require.Equal(t, 111, EffectiveWindow(policy)) } func TestAssignmentService_AssignConversation_Success_Cov9(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) acc, agent, inbox, conv := seedAssignableConversation_Cov9(t, db) svc := NewAssignmentService(db, rdb) agentID, err := svc.AssignConversation(context.Background(), conv.ID, inbox.ID, acc.ID) require.NoError(t, err) require.Equal(t, agent.ID, agentID) var updated model.Conversation require.NoError(t, db.First(&updated, conv.ID).Error) require.NotNil(t, updated.AssigneeID) require.Equal(t, agent.ID, *updated.AssigneeID) } func TestAssignmentService_AssignUnassignedConversations_Success_Cov9(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) acc, _, inbox, conv := seedAssignableConversation_Cov9(t, db) svc := NewAssignmentService(db, rdb) ids, err := svc.AssignUnassignedConversations(context.Background(), inbox.ID, acc.ID) require.NoError(t, err) require.Contains(t, ids, conv.ID) } func TestAssignmentService_AssignConversation_NoEligibleAgents_Cov9(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) acc, agent, inbox, conv := seedAssignableConversation_Cov9(t, db) require.NoError(t, db.Model(agent).Update("available", false).Error) svc := NewAssignmentService(db, rdb) agentID, err := svc.AssignConversation(context.Background(), conv.ID, inbox.ID, acc.ID) require.NoError(t, err) require.Zero(t, agentID) } func TestAssignmentServiceRejectsCandidateDeactivatedAfterSelection(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) account, agent, inbox, conversation := seedAssignableConversation_Cov9(t, db) svc := NewAssignmentService(db, rdb) agents, err := svc.getEligibleAgents(context.Background(), inbox.ID, account.ID, nil, false) require.NoError(t, err) require.Equal(t, []uint{agent.ID}, agents) require.NoError(t, db.Model(agent).Update("active", false).Error) assigned, err := svc.assignConversation(context.Background(), account.ID, inbox.ID, conversation.ID, agent.ID) require.NoError(t, err) require.False(t, assigned) require.NoError(t, db.First(conversation, conversation.ID).Error) require.Nil(t, conversation.AssigneeID) } func TestAssignmentServiceAutoAssignmentDoesNotOverwriteNewConversationState(t *testing.T) { tests := map[string]func(*testing.T, *gorm.DB, *model.Account, *model.Inbox, *model.Conversation) *uint{ "manual assignment": func(t *testing.T, db *gorm.DB, account *model.Account, _ *model.Inbox, conversation *model.Conversation) *uint { manualAgent := &model.User{AccountID: account.ID, Name: "manual", Email: "manual-cov9@example.com", Password: "p", Active: true} require.NoError(t, db.Create(manualAgent).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: manualAgent.ID, Role: "agent"}).Error) require.NoError(t, db.Model(conversation).Update("assignee_id", manualAgent.ID).Error) return &manualAgent.ID }, "resolved conversation": func(t *testing.T, db *gorm.DB, _ *model.Account, _ *model.Inbox, conversation *model.Conversation) *uint { require.NoError(t, db.Model(conversation).Update("status", model.ConversationStatusResolved).Error) return nil }, "changed inbox": func(t *testing.T, db *gorm.DB, account *model.Account, _ *model.Inbox, conversation *model.Conversation) *uint { otherInbox := &model.Inbox{AccountID: account.ID, Name: "other", ChannelType: "web_widget", EnableAutoAssignment: true} require.NoError(t, db.Create(otherInbox).Error) require.NoError(t, db.Model(conversation).Update("inbox_id", otherInbox.ID).Error) return nil }, } for name, mutate := range tests { t.Run(name, func(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) account, _, inbox, conversation := seedAssignableConversation_Cov9(t, db) expectedAssignee := mutate(t, db, account, inbox, conversation) agentID, err := NewAssignmentService(db, rdb).AssignConversation(context.Background(), conversation.ID, inbox.ID, account.ID) require.NoError(t, err) require.Zero(t, agentID) require.NoError(t, db.First(conversation, conversation.ID).Error) require.Equal(t, expectedAssignee, conversation.AssigneeID) }) } } func TestAssignmentServiceOnlyOneConcurrentWorkerWins(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) testAssignmentServiceOnlyOneConcurrentWorkerWins(t, db, rdb) } func TestAssignmentServiceOnlyOneConcurrentWorkerWinsPostgres(t *testing.T) { if os.Getenv("GOCHAT_TEST_DB") != "postgres" { t.Skip("PostgreSQL concurrency test") } db, rdb := setupFullAAPostgresDB_Cov9(t) testAssignmentServiceOnlyOneConcurrentWorkerWins(t, db, rdb) } func TestLongestWaitingSortsUnknownActivityLastPostgres(t *testing.T) { if os.Getenv("GOCHAT_TEST_DB") != "postgres" { t.Skip("PostgreSQL longest-waiting test") } db, _ := setupFullAAPostgresDB_Cov9(t) testLongestWaitingSortsUnknownActivityLast(t, db) } func testAssignmentServiceOnlyOneConcurrentWorkerWins(t *testing.T, db *gorm.DB, rdb *redis.Client) { t.Helper() account, agent, inbox, conversation := seedAssignableConversation_Cov9(t, db) db = db.Session(&gorm.Session{SkipDefaultTransaction: true}) sqlDB, err := db.DB() require.NoError(t, err) sqlDB.SetMaxOpenConns(2) sqlDB.SetMaxIdleConns(2) var arrivals atomic.Int32 readBarrier := make(chan struct{}) type barrierContextKey struct{} barrierContext := context.WithValue(context.Background(), barrierContextKey{}, true) require.NoError(t, db.Callback().Query().After("gorm:query").Register("test:concurrent_auto_assignment_barrier", func(tx *gorm.DB) { if tx.Statement.Table != "conversations" || tx.Statement.Context == nil || tx.Statement.Context.Value(barrierContextKey{}) == nil { return } if arrivals.Add(1) == 2 { close(readBarrier) } select { case <-readBarrier: case <-time.After(5 * time.Second): tx.AddError(fmt.Errorf("concurrent assignment barrier timed out after %d arrivals", arrivals.Load())) } })) t.Cleanup(func() { _ = db.Callback().Query().Remove("test:concurrent_auto_assignment_barrier") }) start := make(chan struct{}) type result struct { assignedIDs []uint err error } results := make(chan result, 2) var wg sync.WaitGroup for range 2 { wg.Add(1) go func() { defer wg.Done() <-start assignedIDs, err := NewAssignmentService(db, rdb).AssignUnassignedConversations(barrierContext, inbox.ID, account.ID) results <- result{assignedIDs: assignedIDs, err: err} }() } close(start) wg.Wait() close(results) assignedCount := 0 for result := range results { require.NoError(t, result.err) assignedCount += len(result.assignedIDs) for _, conversationID := range result.assignedIDs { require.Equal(t, conversation.ID, conversationID) } } require.Equal(t, int32(2), arrivals.Load(), "both workers must read the same unassigned conversation") require.Equal(t, 1, assignedCount) var updated model.Conversation require.NoError(t, db.First(&updated, conversation.ID).Error) require.NotNil(t, updated.AssigneeID, "最终会话必须已分配") require.Equal(t, agent.ID, *updated.AssigneeID) rateKeys, err := rdb.Keys(context.Background(), fmt.Sprintf("%s%d:%d:*", rateLimitKeyPrefix, inbox.ID, agent.ID)).Result() require.NoError(t, err) rateCount := 0 for _, key := range rateKeys { count, err := rdb.Get(context.Background(), key).Int() require.NoError(t, err) rateCount += count } require.Equal(t, 1, rateCount, "the CAS loser must not increment the rate-limit key") } func TestAssignmentServiceExcludesNonAgentInboxMembers(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) account, agent, inbox, _ := seedAssignableConversation_Cov9(t, db) require.NoError(t, db.Model(&model.AccountUser{}). Where("account_id = ? AND user_id = ?", account.ID, agent.ID). Update("role", "member").Error) agents, err := NewAssignmentService(db, rdb).getEligibleAgents(context.Background(), inbox.ID, account.ID, nil, false) require.NoError(t, err) require.Empty(t, agents) } func TestAssignmentService_GetAndPolicyHelpers_Cov9(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) acc, _, inbox, conv := seedAssignableConversation_Cov9(t, db) svc := NewAssignmentService(db, rdb) policy := &model.AssignmentPolicy{AccountID: acc.ID, Name: "Runtime", AssignmentOrder: 1, FairDistributionLimit: 7, FairDistributionWindow: 77, Enabled: true} require.NoError(t, db.Create(policy).Error) require.NoError(t, db.Create(&model.InboxAssignmentPolicy{InboxID: inbox.ID, AssignmentPolicyID: policy.ID}).Error) loadedInbox, err := svc.getInbox(context.Background(), inbox.ID) require.NoError(t, err) require.Equal(t, inbox.ID, loadedInbox.ID) convs, err := svc.findUnassignedConversations(context.Background(), inbox.ID, acc.ID, policy) require.NoError(t, err) require.NotEmpty(t, convs) _ = conv loadedPolicy, err := svc.getInboxPolicy(context.Background(), acc.ID, inbox.ID) require.NoError(t, err) require.Equal(t, policy.ID, loadedPolicy.ID) } func TestAssignmentService_AgentHasInboxCapacity_Limited_Cov9(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) acc, agent, inbox, conv := seedAssignableConversation_Cov9(t, db) policy := &model.AgentCapacityPolicy{AccountID: acc.ID, Name: "cap"} require.NoError(t, db.Create(policy).Error) require.NoError(t, db.Create(&model.InboxCapacityLimit{AgentCapacityPolicyID: policy.ID, InboxID: inbox.ID, ConversationLimit: 1}).Error) require.NoError(t, db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", acc.ID, agent.ID).Update("agent_capacity_policy_id", policy.ID).Error) agentID := agent.ID require.NoError(t, db.Model(&model.Conversation{}).Where("id = ?", conv.ID).Update("assignee_id", agentID).Error) svc := NewAssignmentService(db, rdb) ok, err := svc.agentHasInboxCapacity(context.Background(), acc.ID, inbox.ID, agent.ID, 0) require.NoError(t, err) require.False(t, ok) ok, err = svc.agentHasInboxCapacity(context.Background(), acc.ID, inbox.ID, agent.ID, conv.ID) require.NoError(t, err) require.True(t, ok) } func TestRoundRobinAndLowestLoad_Cov9(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) _, agent, inbox, _ := seedAssignableConversation_Cov9(t, db) rr := NewRoundRobinSelector(rdb) rr.SyncQueue(context.Background(), inbox.ID, []uint{agent.ID, agent.ID + 1}) next, err := rr.Next(context.Background(), inbox.ID) require.NoError(t, err) require.NotZero(t, next) ll := NewLowestLoadSelector(db) selected, err := ll.Select(context.Background(), inbox.ID, []uint{agent.ID, agent.ID + 1}) require.NoError(t, err) require.NotZero(t, selected) } func TestAutoAssignmentListener_OnEventPaths_Cov9(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) acc, _, inbox, conv := seedAssignableConversation_Cov9(t, db) listener := NewAutoAssignmentListener(db, rdb) data := map[string]any{"conversation_id": float64(conv.ID), "inbox_id": float64(inbox.ID), "account_id": float64(acc.ID)} require.NoError(t, listener.OnEvent(context.Background(), &channel.ChannelEvent{Type: channel.EventConversationCreated, Data: data})) // Reset assignee to exercise opened/unassigned paths. require.NoError(t, db.Model(&model.Conversation{}).Where("id = ?", conv.ID).Update("assignee_id", nil).Error) require.NoError(t, listener.OnEvent(context.Background(), &channel.ChannelEvent{Type: channel.EventConversationOpened, Data: data})) require.NoError(t, listener.OnEvent(context.Background(), &channel.ChannelEvent{Type: channel.EventConversationUnassigned, Data: data})) require.NoError(t, listener.OnEvent(context.Background(), &channel.ChannelEvent{Type: "ignored", Data: data})) } func TestEventNames_Cov9(t *testing.T) { names := EventNames() require.Contains(t, names, string(channel.EventConversationCreated)) require.Contains(t, names, string(channel.EventConversationOpened)) require.Contains(t, names, string(channel.EventConversationUnassigned)) } func TestConversationQueryModel_TableName_Cov9(t *testing.T) { require.Equal(t, "conversations", ConversationQueryModel{}.TableName()) }