Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
549 lines
23 KiB
Go
549 lines
23 KiB
Go
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
|
// 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.SupportCalendar{}, &models.AgentPresenceLease{}, &models.LoginSession{}, &models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{},
|
|
&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)
|
|
}
|
|
// A configured profile alone is not live staffing. This specific success
|
|
// fixture supplies an employee account, current permission, and login lease;
|
|
// other setup fixtures retain their original visitor/permission boundaries.
|
|
seedSetupSimulationPresence(t, db, principal, now)
|
|
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")
|
|
}
|
|
}
|
|
|
|
func seedSetupSimulationPresence(t *testing.T, db *gorm.DB, principal *dto.AuthPrincipal, now time.Time) {
|
|
t.Helper()
|
|
if err := db.Model(&models.User{}).Where("id = ?", principal.UserID).Update("user_type", enums.UserTypeEmployee).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
role := models.Role{Code: "setup-simulation-operator", Status: enums.StatusOk}
|
|
permission := models.Permission{Code: constants.PermissionConversationView.Code, Status: enums.StatusOk}
|
|
session := models.LoginSession{UserID: principal.UserID, Token: "ak_isolated_setup_simulation", ExpiredAt: now.Add(time.Hour)}
|
|
if err := db.Where("code = ?", permission.Code).FirstOrCreate(&permission).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, item := range []any{&role, &session} {
|
|
if err := db.Create(item).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, item := range []any{&models.UserRole{UserID: principal.UserID, RoleID: role.ID}, &models.RolePermission{RoleID: role.ID, PermissionID: permission.ID}, &models.AgentPresenceLease{ID: "setup-simulation-presence", UserID: principal.UserID, LoginSessionID: session.ID, ExpiresAt: now.Add(time.Minute)}} {
|
|
if err := db.Create(item).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
principal.UserType = enums.UserTypeEmployee
|
|
principal.LoginSessionID = session.ID
|
|
principal.ExpiresAt = session.ExpiredAt
|
|
principal.Permissions = append(principal.Permissions, permission.Code)
|
|
}
|