package model import ( "errors" "os" "sync" "testing" "github.com/QuantumNous/new-api/common" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/schema" ) func TestChannelPriorityPlan(t *testing.T) { for _, tc := range []struct { name string values []int64 id, target int position string want map[int]int64 }{ {"midpoint", []int64{100, 80, 0}, 3, 2, "before", map[int]int64{3: 90}}, {"top preserves peers", []int64{0, 0, 0}, 3, 1, "before", map[int]int64{3: 10}}, {"bottom", []int64{0, 0, 0}, 1, 3, "after", map[int]int64{1: -10}}, {"same tier", []int64{100, 80, 0}, 3, 2, "same", map[int]int64{3: 80}}, {"same slot", []int64{100, 80, 0}, 2, 1, "after", map[int]int64{}}, {"same tier no op", []int64{0, 0}, 1, 2, "same", map[int]int64{}}, {"shift smaller upper tier", []int64{100, 99, 99, 0}, 4, 2, "before", map[int]int64{1: 101, 4: 100}}, {"shift smaller lower tier", []int64{100, 100, 99, 0}, 4, 3, "before", map[int]int64{3: 98, 4: 99}}, {"shift complete adjacent tiers", []int64{102, 101, 100, 99, 0}, 5, 3, "after", map[int]int64{4: 98, 5: 99}}, {"upper safe bound", []int64{maxSortablePriority, 0}, 2, 1, "before", map[int]int64{1: maxSortablePriority - 1, 2: maxSortablePriority}}, {"lower safe bound", []int64{-maxSortablePriority, 0}, 2, 1, "after", map[int]int64{1: -maxSortablePriority + 1, 2: -maxSortablePriority}}, } { t.Run(tc.name, func(t *testing.T) { items := make([]ChannelPriority, len(tc.values)) for i, value := range tc.values { items[i] = ChannelPriority{ID: i + 1, Priority: &value} } changes, err := planChannelPriorityMove(items, ChannelPriorityMove{ID: tc.id, TargetID: tc.target, Position: tc.position}) require.NoError(t, err) got := map[int]int64{} for _, change := range changes { require.NotNil(t, change.Priority) got[change.ID] = *change.Priority } assert.Equal(t, tc.want, got) }) } _, err := planChannelPriorityMove([]ChannelPriority{{ID: 1}, {ID: 2}}, ChannelPriorityMove{ID: 1, TargetID: 2, Position: "invalid"}) assert.ErrorIs(t, err, ErrChannelPriorityInvalid) } func TestChannelPriorityDatabaseMatrix(t *testing.T) { for _, dialect := range []string{"sqlite", "mysql", "postgres"} { t.Run(dialect, func(t *testing.T) { var driver gorm.Dialector switch dialect { case "sqlite": driver = sqlite.Open(":memory:") case "mysql": dsn := os.Getenv("TEST_MYSQL_DSN") if dsn == "" { t.Skip("TEST_MYSQL_DSN not configured") } driver = mysql.Open(dsn) case "postgres": dsn := os.Getenv("TEST_POSTGRES_DSN") if dsn == "" { t.Skip("TEST_POSTGRES_DSN not configured") } driver = postgres.Open(dsn) } db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "priority_test_"}}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) sqlDB.SetMaxOpenConns(1) previousDB, previousType := DB, common.MainDatabaseType() DB = db common.SetMainDatabaseType(common.DatabaseType(dialect)) initCol() t.Cleanup(func() { require.NoError(t, db.Migrator().DropTable(&Ability{}, &Channel{})) DB = previousDB common.SetMainDatabaseType(previousType) initCol() require.NoError(t, sqlDB.Close()) }) require.NoError(t, db.AutoMigrate(&Channel{}, &Ability{})) var version string query := "SELECT version()" if dialect == "sqlite" { query = "SELECT sqlite_version()" } require.NoError(t, db.Raw(query).Scan(&version).Error) t.Logf("database version: %s", version) high, low, zero := int64(100), int64(99), int64(0) w1, w2, w3, w4 := uint(11), uint(22), uint(33), uint(44) channels := []Channel{ {Name: "high", Priority: &high, Weight: &w1, Key: "preserved", Status: 1}, {Name: "low", Priority: &low, Weight: &w2, Status: 1}, {Name: "peer", Priority: &low, Weight: &w3, Status: 1}, {Name: "moving", Priority: &zero, Weight: &w4, Status: 1}, } require.NoError(t, db.Create(&channels).Error) for _, channel := range channels { for _, group := range []string{"default", "second"} { require.NoError(t, db.Create(&Ability{Group: group, Model: "test", ChannelId: channel.Id, Priority: channel.Priority, Weight: uint(*channel.Weight), Enabled: true}).Error) } } initial, err := GetChannelPriorityOrder() require.NoError(t, err) move := ChannelPriorityMove{Revision: initial.Revision, ID: channels[3].Id, TargetID: channels[1].Id, Position: "before", Preview: true} preview, err := ReorderChannelPriority(move) require.NoError(t, err) require.Len(t, preview.Changes, 2) unchanged, err := GetChannelPriorityOrder() require.NoError(t, err) assert.Equal(t, initial, unchanged, "preview does not persist") move.Preview = false applied, err := ReorderChannelPriority(move) require.NoError(t, err) assert.Equal(t, preview, applied) for _, change := range applied.Changes { var abilities []Ability require.NoError(t, db.Where("channel_id = ?", change.ID).Find(&abilities).Error) require.Len(t, abilities, 2) for _, ability := range abilities { assert.Equal(t, change.Priority, ability.Priority) assert.True(t, ability.Enabled) } } var saved Channel require.NoError(t, db.First(&saved, channels[0].Id).Error) assert.Equal(t, "preserved", saved.Key) assert.Equal(t, channels[0].Weight, saved.Weight) _, err = ReorderChannelPriority(move) assert.ErrorIs(t, err, ErrChannelPriorityConflict) undo := ChannelPriorityMove{Revision: applied.Revision} for _, change := range applied.Changes { undo.Restore = append(undo.Restore, ChannelPriorityChange{ID: change.ID, Previous: change.Priority, Priority: change.Previous}) } restored, err := ReorderChannelPriority(undo) require.NoError(t, err) assert.Equal(t, initial.Items, restored.Items) assert.Equal(t, initial.Revision, restored.Revision) if dialect != "sqlite" { sqlDB.SetMaxOpenConns(2) } start := make(chan struct{}) errorsCh := make(chan error, 2) var workers sync.WaitGroup for range 2 { workers.Go(func() { <-start; _, moveErr := ReorderChannelPriority(move); errorsCh <- moveErr }) } close(start) workers.Wait() close(errorsCh) succeeded, conflicted := 0, 0 for moveErr := range errorsCh { if moveErr == nil { succeeded++ } else { require.ErrorIs(t, moveErr, ErrChannelPriorityConflict) conflicted++ } } assert.Equal(t, 1, succeeded) assert.Equal(t, 1, conflicted) _, err = ReorderChannelPriority(undo) require.NoError(t, err) sqlDB.SetMaxOpenConns(1) // An ability write failure must roll back every channel change too. require.NoError(t, db.Callback().Update().Before("gorm:update").Register("priority_test_failure", func(tx *gorm.DB) { if tx.Statement.Table == "priority_test_abilities" { tx.AddError(errors.New("forced routing write failure")) } })) _, err = ReorderChannelPriority(move) require.ErrorContains(t, err, "forced routing write failure") require.NoError(t, db.Callback().Update().Remove("priority_test_failure")) unchanged, err = GetChannelPriorityOrder() require.NoError(t, err) assert.Equal(t, initial, unchanged) // Null priority is an effective zero and undo must restore the null itself. require.NoError(t, db.Model(&Channel{}).Where("id = ?", channels[3].Id).Update("priority", nil).Error) nullable, err := GetChannelPriorityOrder() require.NoError(t, err) var sorted []ChannelPriority require.NoError(t, NewChannelSortOptions("priority", "asc", false).Apply(db.Model(&Channel{}).Select("id", "priority")).Find(&sorted).Error) assert.Equal(t, []int{channels[3].Id, channels[2].Id, channels[1].Id, channels[0].Id}, []int{sorted[0].ID, sorted[1].ID, sorted[2].ID, sorted[3].ID}) move.Revision = nullable.Revision applied, err = ReorderChannelPriority(move) require.NoError(t, err) undo = ChannelPriorityMove{Revision: applied.Revision} for _, change := range applied.Changes { undo.Restore = append(undo.Restore, ChannelPriorityChange{ID: change.ID, Previous: change.Priority, Priority: change.Previous}) } restored, err = ReorderChannelPriority(undo) require.NoError(t, err) assert.Equal(t, nullable.Items, restored.Items) }) } }