399 lines
17 KiB
Go
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())
|
|
}
|