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) } }