// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics. package services_test import ( "encoding/json" "errors" "slices" "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/dto/response" "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 setupRepairPrincipal(t *testing.T, db *gorm.DB) *dto.AuthPrincipal { t.Helper() user := models.User{Username: "setup-admin", Nickname: "Setup Admin", Status: enums.StatusOk} if err := db.Create(&user).Error; err != nil { t.Fatal(err) } return &dto.AuthPrincipal{ UserID: user.ID, Username: user.Username, Nickname: user.Nickname, Roles: []string{constants.RoleCodeAdmin}, Permissions: []string{ constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code, constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleUpdate.Code, constants.PermissionAgentCreate.Code, constants.PermissionAgentUpdate.Code, constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdateStatus.Code, constants.PermissionAIAgentCreate.Code, constants.PermissionAIAgentUpdate.Code, constants.PermissionChannelCreate.Code, constants.PermissionChannelUpdate.Code, }, } } func applyReviewedSetupFix(t *testing.T, req request.SetupQuickFixRequest, principal *dto.AuthPrincipal) *response.SetupQuickFixResponse { t.Helper() preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal) if err != nil { t.Fatalf("preview: %v", err) } if preview.Applied || preview.PreviewToken == "" { t.Fatalf("expected read-only preview: %+v", preview) } req.Apply, req.PreviewToken = true, preview.PreviewToken applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal) if err != nil { t.Fatalf("apply: %v", err) } if !applied.Applied { t.Fatal("reviewed changes were not applied") } return applied } func assertSetupCount(t *testing.T, db *gorm.DB, model any, want int64) { t.Helper() var count int64 if err := db.Model(model).Count(&count).Error; err != nil { t.Fatal(err) } if count != want { t.Fatalf("%T count = %d, want %d", model, count, want) } } func TestSetupReadiness_RequiresReviewedExplicitApply(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll} preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal) if err != nil { t.Fatal(err) } if preview.Applied || len(preview.Changes) != 3 { t.Fatalf("expected three proposed draft setup records: %+v", preview) } for _, change := range preview.Changes { if change.EntityName == "" || len(change.After) == 0 { t.Fatalf("unnamed or unspecified change: %+v", change) } } for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}, &models.AgentTeamSchedule{}} { assertSetupCount(t, db, model, 0) } req.Apply = true if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("apply without preview must fail") } req.PreviewToken = preview.PreviewToken applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal) if err != nil { t.Fatal(err) } if !applied.Applied || applied.Readiness == nil || applied.Readiness.CanReceiveRequestsNow { t.Fatalf("configuration must not claim shift coverage: %+v", applied) } for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} { assertSetupCount(t, db, model, 1) } assertSetupCount(t, db, &models.Channel{}, 0) assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0) second := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal) if len(second.Changes) != 0 { t.Fatalf("existing configuration must be preserved: %+v", second.Changes) } } func TestSetupReadiness_PreviewInvalidatedByConfigurationChange(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll} preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal) if err != nil { t.Fatal(err) } if err := db.Create(&models.AgentTeam{Name: "Deliberately chosen team", Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) } req.Apply, req.PreviewToken = true, preview.PreviewToken if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("stale preview must fail") } assertSetupCount(t, db, &models.AgentTeam{}, 1) assertSetupCount(t, db, &models.AgentProfile{}, 0) assertSetupCount(t, db, &models.AIAgent{}, 0) assertSetupCount(t, db, &models.Channel{}, 0) } func TestSetupReadiness_RepairTransactionRollsBackAllWrites(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll} preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal) if err != nil { t.Fatal(err) } if err := db.Callback().Create().Before("gorm:create").Register("fail_setup_agent", func(tx *gorm.DB) { if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "AIAgent" { tx.AddError(errors.New("injected agent write failure")) } }); err != nil { t.Fatal(err) } req.Apply, req.PreviewToken = true, preview.PreviewToken if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("expected injected transaction failure") } for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}} { assertSetupCount(t, db, model, 0) } } func TestSetupReadiness_PreservesDeletedDisabledAndZeroRollout(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) team := models.AgentTeam{Name: "Deleted team", Status: enums.StatusDeleted} disabledTeam := models.AgentTeam{Name: "Disabled team", Status: enums.StatusDisabled} profile := models.AgentProfile{UserID: principal.UserID, AgentCode: "deleted-profile", Status: enums.StatusDeleted} deletedAgent := models.AIAgent{Name: "Deleted agent", Status: enums.StatusDeleted} disabledAgent := models.AIAgent{Name: "Disabled agent", Status: enums.StatusDisabled, RolloutPercent: 100} zeroAgent := models.AIAgent{Name: "Zero rollout", Status: enums.StatusOk, RolloutPercent: 100} deletedChannel := models.Channel{Name: "Deleted channel", ChannelID: "deleted-channel", Status: enums.StatusDeleted} disabledChannel := models.Channel{Name: "Disabled channel", ChannelID: "disabled-channel", Status: enums.StatusDisabled, AIAgentRolloutPercent: 100} for _, model := range []any{&team, &disabledTeam, &profile, &deletedAgent, &disabledAgent, &zeroAgent, &deletedChannel, &disabledChannel} { if err := db.Create(model).Error; err != nil { t.Fatal(err) } } if err := db.Model(&zeroAgent).Update("rollout_percent", 0).Error; err != nil { t.Fatal(err) } if err := db.Model(&disabledChannel).Update("ai_agent_rollout_percent", 0).Error; err != nil { t.Fatal(err) } result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal) if len(result.Changes) != 0 { t.Fatalf("broad repair must preserve intentional configurations: %+v", result.Changes) } for _, req := range []request.SetupQuickFixRequest{ {Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID}, {Action: services.QuickFixActionFixAIAgentHandoff, EntityID: deletedAgent.ID}, {Action: services.QuickFixActionEnsureDefaultChannel, EntityID: deletedChannel.ID}, {Action: services.QuickFixActionEnableMyProfile}, } { if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatalf("deleted target must not be restored: %+v", req) } } for _, id := range []int64{disabledAgent.ID, zeroAgent.ID} { res := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: id}, principal) if len(res.Changes) != 0 { t.Fatalf("disabled/zero rollout changed: %+v", res) } } if err := db.First(&zeroAgent, zeroAgent.ID).Error; err != nil { t.Fatal(err) } if zeroAgent.RolloutPercent != 0 { t.Fatal("zero rollout was overwritten") } if err := db.First(&disabledChannel, disabledChannel.ID).Error; err != nil { t.Fatal(err) } if disabledChannel.Status != enums.StatusDisabled || disabledChannel.AIAgentRolloutPercent != 0 { t.Fatal("disabled channel was overwritten") } assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0) } func TestSetupReadiness_TargetedHandoffDoesNotRewriteOtherAgents(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) first := models.AIAgent{Name: "Selected", Status: enums.StatusOk, RolloutPercent: 25, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "12"} second := models.AIAgent{Name: "Unrelated", Status: enums.StatusOk, RolloutPercent: 10, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "23"} for _, agent := range []*models.AIAgent{&first, &second} { if err := db.Create(agent).Error; err != nil { t.Fatal(err) } if err := db.Model(agent).Update("auto_handoff_on_failure", false).Error; err != nil { t.Fatal(err) } } if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff}, principal); err == nil { t.Fatal("existing agents require a named target") } result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: first.ID}, principal) if len(result.Changes) != 1 || result.Changes[0].EntityID != first.ID { t.Fatalf("wrong scope: %+v", result.Changes) } if err := db.First(&first, first.ID).Error; err != nil { t.Fatal(err) } if err := db.First(&second, second.ID).Error; err != nil { t.Fatal(err) } if first.ServiceMode != enums.IMConversationServiceModeAIFirst || first.RolloutPercent != 25 || first.TeamIDs != "12" { t.Fatal("selected agent was not minimally updated") } if second.ServiceMode != enums.IMConversationServiceModeAIOnly || second.AutoHandoffOnFailure || second.RolloutPercent != 10 || second.TeamIDs != "23" { t.Fatal("unrelated agent was changed") } } func TestSetupReadiness_OffHoursIsNotMissingSchedule(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) now := time.Now() team := models.AgentTeam{Name: "Day shift", Status: enums.StatusOk} other := models.AgentTeam{Name: "Other team", Status: enums.StatusOk} for _, model := range []any{&team, &other} { if err := db.Create(model).Error; err != nil { t.Fatal(err) } } schedule := models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(24*time.Hour), EndAt: now.Add(32*time.Hour), Status: enums.StatusOk} if err := db.Create(&schedule).Error; err != nil { t.Fatal(err) } result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID}, principal) if len(result.Changes) != 0 { t.Fatal("off-hours team must not be mutated") } assertSetupCount(t, db, &models.AgentTeamSchedule{}, 1) if result.Readiness == nil { t.Fatal("missing readiness") } foundOffHours, foundMissing := false, false for _, step := range result.Readiness.Steps { if step.Key != "schedules" { continue } for _, issue := range step.Issues { if issue.EntityID == team.ID && issue.Code == "team_off_hours_now" { foundOffHours = true } if issue.EntityID == other.ID && issue.Code == "team_no_schedule_configured" { foundMissing = true } if issue.QuickFixAction != "" { t.Fatal("shift changes require explicit scheduling") } } } if !foundOffHours || !foundMissing { t.Fatal("missing and off-hours schedules must be distinguished") } } func TestSetupReadiness_AdminRoleDoesNotOverridePermissionDenial(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) principal.Permissions = nil for _, role := range []string{constants.RoleCodeAdmin, constants.RoleCodeSuperAdmin} { principal.Roles = []string{role} for _, action := range []string{services.QuickFixActionQuickSetupAll, services.QuickFixActionEnsureTeamSchedule, services.QuickFixActionEnableMyProfile, services.QuickFixActionFixAIAgentHandoff, services.QuickFixActionEnsureDefaultChannel} { if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: action}, principal); err == nil { t.Fatalf("role %s bypassed explicit permission list for %s", role, action) } } } assertSetupCount(t, db, &models.AgentTeam{}, 0) } func TestSetupReadiness_StatusOnlyPermissionRemainsScoped(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) team := models.AgentTeam{Name: "Existing", Status: enums.StatusOk} if err := db.Create(&team).Error; err != nil { t.Fatal(err) } profile := models.AgentProfile{UserID: principal.UserID, TeamID: team.ID, AgentCode: "status-only", Status: enums.StatusOk, ServiceStatus: enums.ServiceStatusBusy, AutoAssignEnabled: true, MaxConcurrentCount: 5} if err := db.Create(&profile).Error; err != nil { t.Fatal(err) } principal.Permissions = []string{constants.PermissionAgentUpdateStatus.Code} result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal) if len(result.Changes) != 1 || len(result.Changes[0].After) != 1 { t.Fatalf("status-only permission exceeded: %+v", result.Changes) } assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0) if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil { t.Fatal(err) } if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal); err == nil { t.Fatal("status permission must not authorize config edits") } } func TestSetupReadiness_SimulationEmptyArraysAndHonestCoverage(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) for _, channelID := range []int64{0, 9999} { result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channelID}, principal) if err != nil { t.Fatal(err) } encoded, err := json.Marshal(result) if err != nil { t.Fatal(err) } if !strings.Contains(string(encoded), `"candidate_operators":[]`) { t.Fatalf("null candidates: %s", encoded) } } applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal) var team models.AgentTeam var agent models.AIAgent if err := db.First(&team).Error; err != nil { t.Fatal(err) } if err := db.First(&agent).Error; err != nil { t.Fatal(err) } if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil { t.Fatal(err) } channel := models.Channel{Name: "Published human-only test route", ChannelID: "human-only-test", ChannelType: "web", AIAgentID: agent.ID, AIAgentRolloutPercent: 100, Status: enums.StatusOk} if err := db.Create(&channel).Error; err != nil { t.Fatal(err) } now := time.Now() if err := db.Create(&models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour), Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) } result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal) if err != nil { t.Fatal(err) } if !result.WillReachOperator || result.MatchedOperator == nil || result.MatchedOperator.UserID != principal.UserID { t.Fatalf("human-only check failed: %+v", result) } if _, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID, BypassAI: true}, principal); err == nil { t.Fatal("unsupported bypass must be explicitly rejected") } if err := db.Model(&models.AIAgent{}).Where("id = ?", channel.AIAgentID).Update("service_mode", enums.IMConversationServiceModeAIFirst).Error; err != nil { t.Fatal(err) } result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal) if err != nil { t.Fatal(err) } if result.WillReachOperator || result.FinalOutcomeCode != "ai_execution_not_simulated" { t.Fatalf("AI route must not promise assignment: %+v", result) } if err := db.Model(&channel).Update("ai_agent_rollout_percent", 25).Error; err != nil { t.Fatal(err) } result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal) if err != nil { t.Fatal(err) } if result.WillReachOperator || result.FinalOutcomeCode != "partial_rollout_not_simulated" { t.Fatalf("partial rollout must not promise assignment: %+v", result) } } func TestSetupReadiness_PostCommitFailuresAreVisible(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll} preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal) if err != nil { t.Fatal(err) } if err := db.Callback().Query().Before("gorm:query").Register("fail_post_commit_dispatch", func(tx *gorm.DB) { if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "Conversation" { tx.AddError(errors.New("injected dispatch read failure")) } }); err != nil { t.Fatal(err) } req.Apply, req.PreviewToken = true, preview.PreviewToken result, err := services.SetupReadinessService.ApplyQuickFix(req, principal) if err != nil { t.Fatalf("committed repair must not appear rolled back: %v", err) } if !result.Applied || !slices.Contains(result.WarningCodes, "dispatch_failed") { t.Fatalf("dispatch failure must be visible: %+v", result) } for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} { assertSetupCount(t, db, model, 1) } assertSetupCount(t, db, &models.Channel{}, 0) } func TestSetupReadiness_DefaultChannelRequiresPublishedAgent(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal) result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal) if len(result.Changes) != 0 || !slices.Contains(result.WarningCodes, "agent_publication_required") { t.Fatalf("draft agent must not be bound implicitly: %+v", result) } assertSetupCount(t, db, &models.Channel{}, 0) var agent models.AIAgent if err := db.First(&agent).Error; err != nil { t.Fatal(err) } // This unit fixture supplies the publication marker; revision publication itself // belongs to the existing agent publishing service's tests. if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil { t.Fatal(err) } result = applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal) if len(result.Changes) != 1 || result.Changes[0].EntityType != "channel" { t.Fatalf("expected reviewed channel create: %+v", result.Changes) } assertSetupCount(t, db, &models.Channel{}, 1) assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0) } func TestSetupReadiness_MyProfileRejectsAnotherNamedTarget(t *testing.T) { db := setupSetupReadinessTestDB(t) principal := setupRepairPrincipal(t, db) applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal) var profile models.AgentProfile if err := db.Where("user_id = ?", principal.UserID).First(&profile).Error; err != nil { t.Fatal(err) } if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile, EntityID: profile.ID + 1}, principal); err == nil { t.Fatal("my-profile action must not silently substitute a different named target") } }