Files
agent-desk-ng/internal/services/conversation_human_dispatch_service_test.go

424 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services_test
import (
"strings"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/services"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestConversationHumanDispatchAIHandoffOffHoursQueuesAndSendsNotice(t *testing.T) {
db := setupConversationHumanDispatchTestDB(t)
aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1")
conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing)
result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工")
if err != nil {
t.Fatalf("HandoffByAI() error = %v", err)
}
if result == nil || result.Decision != services.HandoffDecisionOffHours {
t.Fatalf("expected off_hours decision, got %+v", result)
}
current := services.ConversationService.Get(conversation.ID)
if current.Status != enums.IMConversationStatusPending {
t.Fatalf("expected conversation to be queued in pending status, got status=%d", current.Status)
}
if current.HandoffAt == nil {
t.Fatalf("expected handoffAt to be set")
}
if current.CurrentTeamID != 1 || current.CurrentAssigneeID != 0 {
t.Fatalf("expected conversation in team pool 1, got team=%d assignee=%d", current.CurrentTeamID, current.CurrentAssigneeID)
}
message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id"))
if message == nil {
t.Fatalf("expected off-hours notice message")
}
expectedMsg := services.HandoffOffHoursMessageForLocale("zh-CN")
if message.SenderType != enums.IMSenderTypeAI || message.Content != expectedMsg {
t.Fatalf("unexpected off-hours message: %+v, want %q", message, expectedMsg)
}
}
func TestConversationHumanDispatchCustomOffHoursMessage(t *testing.T) {
db := setupConversationHumanDispatchTestDB(t)
aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1")
aiAgent.OffHoursMessage = "Операторы сейчас оффлайн. Мы ответим вам в рабочее время!"
conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing)
result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "нужен оператор")
if err != nil {
t.Fatalf("HandoffByAI() error = %v", err)
}
if result == nil || result.Decision != services.HandoffDecisionOffHours {
t.Fatalf("expected off_hours decision, got %+v", result)
}
if result.Message != aiAgent.OffHoursMessage {
t.Fatalf("expected custom off-hours message %q, got %q", aiAgent.OffHoursMessage, result.Message)
}
message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id"))
if message == nil {
t.Fatalf("expected off-hours notice message")
}
if message.Content != aiAgent.OffHoursMessage {
t.Fatalf("expected custom notice %q, got %q", aiAgent.OffHoursMessage, message.Content)
}
}
func TestConversationHumanDispatchAIHandoffAssignsAvailableAgent(t *testing.T) {
db := setupConversationHumanDispatchTestDB(t)
aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1")
createHumanDispatchTeam(t, db, 1, "售后支持组")
createHumanDispatchActiveSchedule(t, db, 1)
createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk)
conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing)
result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工")
if err != nil {
t.Fatalf("HandoffByAI() error = %v", err)
}
if result == nil || result.Decision != services.HandoffDecisionAssigned {
t.Fatalf("expected assigned decision, got %+v", result)
}
current := services.ConversationService.Get(conversation.ID)
if current.Status != enums.IMConversationStatusActive {
t.Fatalf("expected active conversation, got status=%d", current.Status)
}
if current.CurrentAssigneeID != 101 || current.CurrentTeamID != 1 {
t.Fatalf("unexpected assignment: assignee=%d team=%d", current.CurrentAssigneeID, current.CurrentTeamID)
}
if current.HandoffAt == nil || current.HandoffReason != "用户要求转人工" {
t.Fatalf("expected handoff metadata, got at=%v reason=%q", current.HandoffAt, current.HandoffReason)
}
}
func TestConversationHumanDispatchAIHandoffFallsBackToFirstScheduledTeam(t *testing.T) {
db := setupConversationHumanDispatchTestDB(t)
aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "3,1,2")
createHumanDispatchTeam(t, db, 1, "售后支持组")
createHumanDispatchTeam(t, db, 2, "VIP支持组")
createHumanDispatchTeam(t, db, 3, "非值班组")
createHumanDispatchActiveSchedule(t, db, 1)
createHumanDispatchActiveSchedule(t, db, 2)
conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing)
result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工")
if err != nil {
t.Fatalf("HandoffByAI() error = %v", err)
}
if result == nil || result.Decision != services.HandoffDecisionTeamPool {
t.Fatalf("expected team_pool decision, got %+v", result)
}
current := services.ConversationService.Get(conversation.ID)
if current.Status != enums.IMConversationStatusPending {
t.Fatalf("expected pending conversation, got status=%d", current.Status)
}
if current.CurrentTeamID != 1 || current.CurrentAssigneeID != 0 {
t.Fatalf("expected fallback team 1 with no assignee, got team=%d assignee=%d", current.CurrentTeamID, current.CurrentAssigneeID)
}
}
func TestConversationHumanDispatchEmptyAgentTeamIDsFallsBackToActiveTeam(t *testing.T) {
db := setupConversationHumanDispatchTestDB(t)
aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "")
createHumanDispatchTeam(t, db, 1, "Отдел поддержки")
createHumanDispatchActiveSchedule(t, db, 1)
createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk)
conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing)
result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "AI execution failure")
if err != nil {
t.Fatalf("HandoffByAI() error = %v", err)
}
if result == nil || result.Decision != services.HandoffDecisionAssigned {
t.Fatalf("expected assigned decision when active team has schedule and idle agent even with empty aiAgent.TeamIDs, got %+v", result)
}
current := services.ConversationService.Get(conversation.ID)
if current.Status != enums.IMConversationStatusActive || current.CurrentAssigneeID != 101 || current.CurrentTeamID != 1 {
t.Fatalf("expected active assigned conversation (team=1, assignee=101), got status=%d team=%d assignee=%d", current.Status, current.CurrentTeamID, current.CurrentAssigneeID)
}
}
func TestConversationHumanDispatchHumanOnlyCreateOffHoursUsesGlobalPendingPool(t *testing.T) {
db := setupConversationHumanDispatchTestDB(t)
aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeHumanOnly, "1")
conversation, err := services.ConversationService.Create(openidentity.ExternalUser{
ExternalSource: enums.ExternalSourceGuest,
ExternalID: "guest-human-only-off-hours",
ExternalName: "非服务时间访客",
}, 1, aiAgent.ID)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if conversation.Status != enums.IMConversationStatusPending {
t.Fatalf("expected pending conversation, got status=%d", conversation.Status)
}
if conversation.CurrentTeamID != 0 || conversation.CurrentAssigneeID != 0 {
t.Fatalf("expected global pending pool, got team=%d assignee=%d", conversation.CurrentTeamID, conversation.CurrentAssigneeID)
}
message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id"))
expectedWaiting := services.HandoffWaitingMessageForLocale("zh-CN")
if message == nil || message.Content != expectedWaiting {
t.Fatalf("expected waiting message %q, got %+v", expectedWaiting, message)
}
}
func TestConversationHumanDispatchHumanOnlyCreateAssignsAvailableAgent(t *testing.T) {
db := setupConversationHumanDispatchTestDB(t)
aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeHumanOnly, "1")
createHumanDispatchTeam(t, db, 1, "售后支持组")
createHumanDispatchActiveSchedule(t, db, 1)
createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk)
conversation, err := services.ConversationService.Create(openidentity.ExternalUser{
ExternalSource: enums.ExternalSourceGuest,
ExternalID: "guest-human-only-assigned",
ExternalName: "服务时间访客",
}, 1, aiAgent.ID)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if conversation.Status != enums.IMConversationStatusActive {
t.Fatalf("expected active conversation, got status=%d", conversation.Status)
}
if conversation.CurrentAssigneeID != 101 || conversation.CurrentTeamID != 1 {
t.Fatalf("unexpected assignment: assignee=%d team=%d", conversation.CurrentAssigneeID, conversation.CurrentTeamID)
}
}
func TestConversationAutoAssignManualDispatchOffHoursReturnsBusinessMessage(t *testing.T) {
db := setupConversationHumanDispatchTestDB(t)
aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1")
conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusPending)
err := services.ConversationService.AutoAssignConversation(conversation.ID, testHumanDispatchOperator())
if err == nil {
t.Fatalf("expected off-hours manual dispatch to fail")
}
if !strings.Contains(err.Error(), "Human support is currently outside service hours") && !strings.Contains(err.Error(), "当前暂不在人工客服服务时间内") {
t.Fatalf("expected off-hours error, got %v", err)
}
}
func TestConversationAutoAssignManualDispatchFallsBackToTeamPool(t *testing.T) {
db := setupConversationHumanDispatchTestDB(t)
aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1")
createHumanDispatchTeam(t, db, 1, "售后支持组")
createHumanDispatchActiveSchedule(t, db, 1)
conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusPending)
err := services.ConversationService.AutoAssignConversation(conversation.ID, testHumanDispatchOperator())
if err != nil {
t.Fatalf("AutoAssignConversation() error = %v", err)
}
current := services.ConversationService.Get(conversation.ID)
if current.Status != enums.IMConversationStatusPending || current.CurrentTeamID != 1 || current.CurrentAssigneeID != 0 {
t.Fatalf("expected team-pool pending conversation, got %+v", current)
}
}
func setupConversationHumanDispatchTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "t_",
SingularTable: true,
},
})
if err != nil {
t.Fatalf("open sqlite error = %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(
&models.User{},
&models.Customer{},
&models.CustomerIdentity{},
&models.AIAgent{},
&models.AgentTeam{},
&models.AgentTeamSchedule{},
&models.AgentProfile{},
&models.Conversation{},
&models.ConversationParticipant{},
&models.ConversationAssignment{},
&models.ConversationEventLog{},
&models.ConversationReadState{},
&models.Message{},
&models.ChannelMessageOutbox{},
); err != nil {
t.Fatalf("auto migrate error = %v", err)
}
sqls.SetDB(db)
return db
}
func createHumanDispatchAIAgent(t *testing.T, db *gorm.DB, mode enums.IMConversationServiceMode, teamIDs string) models.AIAgent {
t.Helper()
item := models.AIAgent{
Name: "测试AI",
ServiceMode: mode,
TeamIDs: teamIDs,
Status: enums.StatusOk,
}
if err := db.Create(&item).Error; err != nil {
t.Fatalf("create ai agent error = %v", err)
}
return item
}
func createHumanDispatchTeam(t *testing.T, db *gorm.DB, id int64, name string) {
t.Helper()
if err := db.Create(&models.AgentTeam{ID: id, Name: name, Status: enums.StatusOk}).Error; err != nil {
t.Fatalf("create team error = %v", err)
}
}
func createHumanDispatchActiveSchedule(t *testing.T, db *gorm.DB, teamID int64) {
t.Helper()
now := time.Now()
if err := db.Create(&models.AgentTeamSchedule{
TeamID: teamID,
StartAt: now.Add(-time.Hour),
EndAt: now.Add(time.Hour),
Status: enums.StatusOk,
}).Error; err != nil {
t.Fatalf("create schedule error = %v", err)
}
}
func createHumanDispatchAgentProfile(t *testing.T, db *gorm.DB, userID, teamID int64, serviceStatus enums.ServiceStatus, maxConcurrent int, autoAssign bool, status enums.Status) {
t.Helper()
if err := db.Create(&models.User{
ID: userID,
Username: "agent",
Nickname: "客服",
Status: enums.StatusOk,
}).Error; err != nil {
t.Fatalf("create user error = %v", err)
}
if err := db.Create(&models.AgentProfile{
UserID: userID,
TeamID: teamID,
AgentCode: "A001",
DisplayName: "客服",
ServiceStatus: serviceStatus,
MaxConcurrentCount: maxConcurrent,
AutoAssignEnabled: autoAssign,
Status: status,
}).Error; err != nil {
t.Fatalf("create profile error = %v", err)
}
}
func createHumanDispatchConversation(t *testing.T, db *gorm.DB, aiAgentID int64, status enums.IMConversationStatus) models.Conversation {
t.Helper()
now := time.Now()
item := models.Conversation{
AIAgentID: aiAgentID,
ChannelID: 1,
CustomerID: 1,
CustomerName: "测试访客",
Status: status,
ServiceMode: enums.IMConversationServiceModeAIFirst,
LastMessageAt: now,
LastActiveAt: now,
}
if err := db.Create(&item).Error; err != nil {
t.Fatalf("create conversation error = %v", err)
}
return item
}
func testHumanDispatchOperator() *dto.AuthPrincipal {
return &dto.AuthPrincipal{UserID: 9, Username: "dispatcher", Nickname: "调度员"}
}
func TestConversationHumanDispatchCustomHandoffMessage(t *testing.T) {
db := setupConversationHumanDispatchTestDB(t)
aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1")
aiAgent.HandoffMessage = "客服专员马上为您服务,请稍候..."
db.Save(&aiAgent)
createHumanDispatchTeam(t, db, 1, "售后支持组")
createHumanDispatchActiveSchedule(t, db, 1)
createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk)
conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing)
result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "测试自定义转接语")
if err != nil {
t.Fatalf("HandoffByAI() error = %v", err)
}
if result == nil || result.Message != "客服专员马上为您服务,请稍候..." {
t.Fatalf("expected custom handoff message in result, got %+v", result)
}
message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id"))
if message == nil {
t.Fatalf("expected message to be sent")
}
if message.Content != "客服专员马上为您服务,请稍候..." {
t.Fatalf("expected custom message content, got %q", message.Content)
}
}
func TestHandoffByAIUsesConversationLanguageForWaitingMessage(t *testing.T) {
db := setupConversationHumanDispatchTestDB(t)
aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1")
createHumanDispatchTeam(t, db, 1, "Отдел поддержки")
createHumanDispatchActiveSchedule(t, db, 1)
createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk)
// 1. Russian customer message in conversation
convRu := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing)
services.MessageService.Create(&models.Message{
ConversationID: convRu.ID,
SenderType: enums.IMSenderTypeCustomer,
Content: "Здравствуйте, соедините со специалистом, пожалуйста.",
})
resultRu, err := services.ConversationHumanDispatchService.HandoffByAI(convRu.ID, aiAgent, "клиент запросил оператора")
if err != nil {
t.Fatalf("HandoffByAI() error = %v", err)
}
if resultRu == nil || resultRu.Message != "Мы соединяем вас с оператором поддержки. Пожалуйста, подождите." {
t.Fatalf("expected Russian handoff waiting message, got %+v", resultRu)
}
// 2. English customer message in conversation
convEn := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing)
services.MessageService.Create(&models.Message{
ConversationID: convEn.ID,
SenderType: enums.IMSenderTypeCustomer,
Content: "Can I speak with a human support agent please?",
})
resultEn, err := services.ConversationHumanDispatchService.HandoffByAI(convEn.ID, aiAgent, "customer requested human support")
if err != nil {
t.Fatalf("HandoffByAI() error = %v", err)
}
if resultEn == nil || resultEn.Message != "We are connecting you to a human support agent. Please wait." {
t.Fatalf("expected English handoff waiting message, got %+v", resultEn)
}
}