Files
gochat/backend/internal/autoassignment/coverage9_test.go
T
2026-08-21 14:41:25 +08:00

399 lines
17 KiB
Go

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{},
&AssignmentPolicy{}, &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_ActiveInactive_Cov9(t *testing.T) {
activeAccount := &AssignmentPolicy{Policy: PolicyLongestWaiting, FairDistributionLimit: 11, FairDistributionWindow: 111, Active: true}
inactiveAccount := &AssignmentPolicy{Policy: PolicyLowestLoad, FairDistributionLimit: 22, FairDistributionWindow: 222, Active: false}
activeInbox := &InboxAssignmentPolicy{Policy: PolicyLowestLoad, FairDistributionLimit: 33, FairDistributionWindow: 333, Active: true}
inactiveInbox := &InboxAssignmentPolicy{Policy: PolicyLowestLoad, FairDistributionLimit: 44, FairDistributionWindow: 444, Active: false}
require.Equal(t, PolicyRoundRobin, EffectivePolicy(nil, nil))
require.Equal(t, PolicyLongestWaiting, EffectivePolicy(activeAccount, nil))
require.Equal(t, PolicyRoundRobin, EffectivePolicy(inactiveAccount, nil))
require.Equal(t, PolicyLowestLoad, EffectivePolicy(activeAccount, activeInbox))
require.Equal(t, PolicyLongestWaiting, EffectivePolicy(activeAccount, inactiveInbox))
require.Equal(t, 5, EffectiveLimit(nil, nil))
require.Equal(t, 11, EffectiveLimit(activeAccount, nil))
require.Equal(t, 22, EffectiveLimit(inactiveAccount, nil))
require.Equal(t, 33, EffectiveLimit(activeAccount, activeInbox))
require.Equal(t, 11, EffectiveLimit(activeAccount, inactiveInbox))
require.Equal(t, 300, EffectiveWindow(nil, nil))
require.Equal(t, 111, EffectiveWindow(activeAccount, nil))
require.Equal(t, 222, EffectiveWindow(inactiveAccount, nil))
require.Equal(t, 333, EffectiveWindow(activeAccount, activeInbox))
require.Equal(t, 111, EffectiveWindow(activeAccount, inactiveInbox))
}
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)
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 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)
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 := &AssignmentPolicy{AccountID: acc.ID, Policy: PolicyLowestLoad, FairDistributionLimit: 7, FairDistributionWindow: 77, Active: true}
require.NoError(t, db.Create(policy).Error)
inboxPolicy := &InboxAssignmentPolicy{AccountID: acc.ID, InboxID: inbox.ID, Policy: PolicyLongestWaiting, FairDistributionLimit: 8, FairDistributionWindow: 88, Active: true}
require.NoError(t, db.Create(inboxPolicy).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)
require.NoError(t, err)
require.NotEmpty(t, convs)
_ = conv
require.Equal(t, PolicyLowestLoad, svc.getAccountPolicy(context.Background(), acc.ID).Policy)
require.Equal(t, PolicyLongestWaiting, svc.getInboxPolicy(context.Background(), inbox.ID).Policy)
}
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())
}