package services_test import ( "strings" "testing" "time" "agent-desk/internal/models" "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/enums" "agent-desk/internal/services" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" "gorm.io/gorm/schema" ) func setupSetupReadinessTestDB(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.Channel{}, &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 TestSetupReadiness_EmptyDBAndQuickSetupAll(t *testing.T) { db := setupSetupReadinessTestDB(t) admin := models.User{ Username: "admin", Nickname: "Admin Operator", Status: enums.StatusOk, } if err := db.Create(&admin).Error; err != nil { t.Fatalf("create admin error = %v", err) } principal := &dto.AuthPrincipal{ UserID: admin.ID, Username: admin.Username, Nickname: admin.Nickname, Roles: []string{constants.RoleCodeSuperAdmin}, } readiness, err := services.SetupReadinessService.GetReadiness(principal) if err != nil { t.Fatalf("GetReadiness error = %v", err) } if readiness.CanReceiveRequestsNow { t.Fatalf("expected CanReceiveRequestsNow = false on empty DB") } if readiness.ReadySteps != 0 { t.Fatalf("expected ReadySteps = 0 on empty DB, got %d", readiness.ReadySteps) } if readiness.TotalSteps != 6 { t.Fatalf("expected TotalSteps = 6, got %d", readiness.TotalSteps) } if readiness.CurrentOperator.CanReceiveNow { t.Fatalf("expected CurrentOperator.CanReceiveNow = false on empty DB") } fixResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ Action: "quick_setup_all", }, principal) if err != nil { t.Fatalf("ApplyQuickFix(quick_setup_all) error = %v", err) } if !fixResp.Readiness.CanReceiveRequestsNow { t.Fatalf("expected CanReceiveRequestsNow = true after quick_setup_all, steps = %+v", fixResp.Readiness.Steps) } if fixResp.Readiness.ReadySteps != 6 { t.Fatalf("expected ReadySteps = 6 after quick_setup_all, got %d", fixResp.Readiness.ReadySteps) } if !fixResp.Readiness.CurrentOperator.CanReceiveNow { t.Fatalf("expected CurrentOperator.CanReceiveNow = true after quick_setup_all, blockers = %v", fixResp.Readiness.CurrentOperator.BlockerCodes) } // Second call must be idempotent fixResp2, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ Action: "quick_setup_all", }, principal) if err != nil { t.Fatalf("second ApplyQuickFix(quick_setup_all) error = %v", err) } if !fixResp2.Readiness.CanReceiveRequestsNow { t.Fatalf("expected CanReceiveRequestsNow = true after second quick_setup_all") } var teamCount int64 db.Model(&models.AgentTeam{}).Count(&teamCount) if teamCount != 1 { t.Fatalf("expected idempotent 1 team, got %d", teamCount) } } func TestSetupReadiness_OffHoursAndBusyOperator(t *testing.T) { db := setupSetupReadinessTestDB(t) now := time.Now() opUser := models.User{ Username: "operator1", Nickname: "Operator One", Status: enums.StatusOk, } if err := db.Create(&opUser).Error; err != nil { t.Fatalf("create user error = %v", err) } team := models.AgentTeam{ Name: "Support Team", Status: enums.StatusOk, } if err := db.Create(&team).Error; err != nil { t.Fatalf("create team error = %v", err) } // Schedule in the past (off-hours now) pastSchedule := models.AgentTeamSchedule{ TeamID: team.ID, StartAt: now.Add(-48 * time.Hour), EndAt: now.Add(-24 * time.Hour), Status: enums.StatusOk, } if err := db.Create(&pastSchedule).Error; err != nil { t.Fatalf("create past schedule error = %v", err) } // Operator is Busy and AutoAssignEnabled = false profile := models.AgentProfile{ UserID: opUser.ID, TeamID: team.ID, AgentCode: "OP-001", DisplayName: "Operator One", ServiceStatus: enums.ServiceStatusBusy, MaxConcurrentCount: 5, AutoAssignEnabled: false, Status: enums.StatusOk, } if err := db.Create(&profile).Error; err != nil { t.Fatalf("create profile error = %v", err) } if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil { t.Fatalf("update auto_assign_enabled error = %v", err) } principal := &dto.AuthPrincipal{ UserID: opUser.ID, Username: opUser.Username, Nickname: opUser.Nickname, Roles: []string{constants.RoleCodeAdmin}, } readiness, err := services.SetupReadinessService.GetReadiness(principal) if err != nil { t.Fatalf("GetReadiness error = %v", err) } if readiness.CurrentOperator.TeamHasScheduleNow { t.Fatalf("expected TeamHasScheduleNow = false for past schedule") } if readiness.CurrentOperator.CanReceiveNow { t.Fatalf("expected CurrentOperator.CanReceiveNow = false when busy and off-hours") } // Fix operator profile and schedule _, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ Action: "enable_my_operator_profile", }, principal) if err != nil { t.Fatalf("enable_my_operator_profile error = %v", err) } readinessAfter, err := services.SetupReadinessService.GetReadiness(principal) if err != nil { t.Fatalf("GetReadiness after fix error = %v", err) } if !readinessAfter.CurrentOperator.CanReceiveNow { t.Fatalf("expected CurrentOperator.CanReceiveNow = true after enable_my_operator_profile, blockers = %v", readinessAfter.CurrentOperator.BlockerCodes) } } func TestSetupReadiness_SimulateRoute_AIOnlyAndMissingTeams(t *testing.T) { db := setupSetupReadinessTestDB(t) opUser := models.User{ Username: "op2", Nickname: "Operator Two", Status: enums.StatusOk, } db.Create(&opUser) principal := &dto.AuthPrincipal{ UserID: opUser.ID, Username: opUser.Username, Nickname: opUser.Nickname, Roles: []string{constants.RoleCodeAdmin}, } // Setup team + active schedule + idle operator _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ Action: "enable_my_operator_profile", }, principal) if err != nil { t.Fatalf("enable_my_operator_profile error = %v", err) } // Create AI Agent in AIOnly mode and empty TeamIDs agent := models.AIAgent{ Name: "AI Bot", Status: enums.StatusOk, ServiceMode: enums.IMConversationServiceModeAIOnly, RolloutPercent: 100, TeamIDs: "", } db.Create(&agent) channel := models.Channel{ Name: "Web Widget", ChannelType: "web", ChannelID: "web-test-1", AIAgentID: agent.ID, AIAgentRolloutPercent: 100, Status: enums.StatusOk, } db.Create(&channel) sim1, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ ChannelID: channel.ID, BypassAI: true, }, principal) if err != nil { t.Fatalf("SimulateRoute 1 error = %v", err) } if sim1.WillReachOperator { t.Fatalf("expected WillReachOperator = false when AIOnly") } if sim1.FinalOutcomeCode != "blocked_ai_only_mode" { t.Fatalf("expected blocked_ai_only_mode, got %s", sim1.FinalOutcomeCode) } // Switch to HumanOnly but still empty TeamIDs db.Model(&agent).Update("service_mode", enums.IMConversationServiceModeHumanOnly) sim2, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ ChannelID: channel.ID, BypassAI: true, }, principal) if err != nil { t.Fatalf("SimulateRoute 2 error = %v", err) } if sim2.WillReachOperator { t.Fatalf("expected WillReachOperator = false when TeamIDs is empty") } if sim2.FinalOutcomeCode != "blocked_agent_no_teams" { t.Fatalf("expected blocked_agent_no_teams, got %s", sim2.FinalOutcomeCode) } // Fix AI Agent handoff via quick fix _, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ Action: "fix_ai_agent_handoff", EntityID: agent.ID, }, principal) if err != nil { t.Fatalf("fix_ai_agent_handoff error = %v", err) } sim3, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ ChannelID: channel.ID, BypassAI: true, }, principal) if err != nil { t.Fatalf("SimulateRoute 3 error = %v", err) } if !sim3.WillReachOperator { t.Fatalf("expected WillReachOperator = true after fix_ai_agent_handoff, got outcome %s", sim3.FinalOutcomeCode) } if sim3.FinalOutcomeCode != "assigned_to_operator" { t.Fatalf("expected assigned_to_operator, got %s", sim3.FinalOutcomeCode) } if sim3.MatchedOperator == nil || sim3.MatchedOperator.UserID != opUser.ID { t.Fatalf("expected MatchedOperator UserID = %d, got %+v", opUser.ID, sim3.MatchedOperator) } } func TestSetupReadiness_QuickFixPermissionEnforcement(t *testing.T) { db := setupSetupReadinessTestDB(t) csUser := models.User{ Username: "cs_agent", Nickname: "Support Agent", Status: enums.StatusOk, } if err := db.Create(&csUser).Error; err != nil { t.Fatalf("create csUser error = %v", err) } // Regular cs_user only has view permissions csPrincipal := &dto.AuthPrincipal{ UserID: csUser.ID, Username: csUser.Username, Nickname: csUser.Nickname, Roles: []string{constants.RoleCodeCsUser}, Permissions: []string{ constants.PermissionAgentView.Code, constants.PermissionAgentTeamView.Code, constants.PermissionAgentTeamScheduleView.Code, constants.PermissionAIAgentView.Code, constants.PermissionChannelView.Code, }, } // 1. GetReadiness for cs_user without mutation permissions must not expose quick-fix actions readiness, err := services.SetupReadinessService.GetReadiness(csPrincipal) if err != nil { t.Fatalf("GetReadiness error = %v", err) } for _, step := range readiness.Steps { if len(step.QuickFixActions) != 0 { t.Fatalf("expected step %s QuickFixActions to be empty for cs_user without write permissions, got %v", step.Key, step.QuickFixActions) } for _, issue := range step.Issues { if issue.QuickFixAction != "" { t.Fatalf("expected issue %s QuickFixAction to be empty for cs_user without write permissions, got %s", issue.Code, issue.QuickFixAction) } } } // 2. Direct API calls to ApplyQuickFix must fail with Forbidden (error.e0225) and leave DB untouched for _, action := range []string{ services.QuickFixActionQuickSetupAll, services.QuickFixActionEnsureTeamSchedule, services.QuickFixActionEnableMyProfile, services.QuickFixActionFixAIAgentHandoff, services.QuickFixActionEnsureDefaultChannel, } { _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ Action: action, }, csPrincipal) if err == nil { t.Fatalf("expected Forbidden error when cs_user executes %s without permissions, got nil", action) } } var teamCount, agentCount, channelCount, profileCount int64 db.Model(&models.AgentTeam{}).Count(&teamCount) db.Model(&models.AIAgent{}).Count(&agentCount) db.Model(&models.Channel{}).Count(&channelCount) db.Model(&models.AgentProfile{}).Count(&profileCount) if teamCount != 0 || agentCount != 0 || channelCount != 0 || profileCount != 0 { t.Fatalf("expected zero entities created after forbidden quick fixes, got teams=%d aiAgents=%d channels=%d profiles=%d", teamCount, agentCount, channelCount, profileCount) } // 3. Seed an active team + active schedule + busy profile for csUser now := time.Now() team := models.AgentTeam{Name: "Main Team", Status: enums.StatusOk} db.Create(&team) db.Create(&models.AgentTeamSchedule{ TeamID: team.ID, StartAt: now.Add(-1 * time.Hour), EndAt: now.Add(24 * time.Hour), Status: enums.StatusOk, }) db.Create(&models.AgentProfile{ UserID: csUser.ID, TeamID: team.ID, AgentCode: "AG-CS1", DisplayName: "Support Agent", ServiceStatus: enums.ServiceStatusBusy, MaxConcurrentCount: 5, AutoAssignEnabled: true, Status: enums.StatusOk, }) // Grant only agent.updateStatus to csPrincipal: can switch own existing profile to Idle, // but still cannot modify AI agents or channels! statusOnlyPrincipal := &dto.AuthPrincipal{ UserID: csUser.ID, Username: csUser.Username, Nickname: csUser.Nickname, Roles: []string{constants.RoleCodeCsUser}, Permissions: []string{ constants.PermissionAgentView.Code, constants.PermissionAgentUpdateStatus.Code, }, } if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ Action: services.QuickFixActionFixAIAgentHandoff, }, statusOnlyPrincipal); err == nil { t.Fatalf("expected Forbidden when statusOnlyPrincipal calls fix_ai_agent_handoff") } if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ Action: services.QuickFixActionEnsureDefaultChannel, }, statusOnlyPrincipal); err == nil { t.Fatalf("expected Forbidden when statusOnlyPrincipal calls ensure_default_channel") } fixProfileResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ Action: services.QuickFixActionEnableMyProfile, }, statusOnlyPrincipal) if err != nil { t.Fatalf("expected statusOnlyPrincipal to succeed on enable_my_operator_profile when team+schedule+profile exist, got %v", err) } if !fixProfileResp.Readiness.CurrentOperator.CanReceiveNow { t.Fatalf("expected CurrentOperator.CanReceiveNow = true after status update, blockers = %v", fixProfileResp.Readiness.CurrentOperator.BlockerCodes) } }