diff --git a/pkg/models/kanban_task_bucket_test.go b/pkg/models/kanban_task_bucket_test.go index 6d1eb2f24..7bde2ade4 100644 --- a/pkg/models/kanban_task_bucket_test.go +++ b/pkg/models/kanban_task_bucket_test.go @@ -226,6 +226,68 @@ func TestTaskBucket_Update(t *testing.T) { }) }) + t.Run("saved filter: first task into empty limited bucket is allowed", func(t *testing.T) { + // Regression test for #2672: on a saved-filter kanban view the bucket + // limit was checked against the total number of tasks matching the + // filter instead of the number of tasks actually in the target bucket, + // so adding the first task to an empty limited bucket was wrongly + // rejected with ErrBucketLimitExceeded. + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // A saved filter matching many tasks; the filter total is well above + // the bucket limit we set below. + sf := &SavedFilter{ + Title: "limit-filter", + Filters: &TaskCollection{Filter: "done = false"}, + } + err := sf.Create(s, u) + require.NoError(t, err) + + filterProjectID := getProjectIDFromSavedFilterID(sf.ID) + + view := &ProjectView{} + exists, err := s.Where("project_id = ? AND view_kind = ?", filterProjectID, ProjectViewKindKanban).Get(view) + require.NoError(t, err) + require.True(t, exists) + + // All matching tasks are placed in the default bucket on creation; + // pick three of them to move into a fresh, empty bucket. + var defaultTasks []*TaskBucket + err = s.Where("project_view_id = ?", view.ID).Find(&defaultTasks) + require.NoError(t, err) + require.GreaterOrEqual(t, len(defaultTasks), 3, "filter must match enough tasks to exceed the bucket limit") + + limitedBucket := &Bucket{ + Title: "limited", + ProjectViewID: view.ID, + ProjectID: filterProjectID, + Limit: 2, + } + err = limitedBucket.Create(s, u) + require.NoError(t, err) + + moveTaskToBucket := func(taskID int64) error { + tb := &TaskBucket{ + TaskID: taskID, + BucketID: limitedBucket.ID, + ProjectViewID: view.ID, + ProjectID: filterProjectID, + } + return tb.Update(s, u) + } + + // Moving the FIRST task into the empty bucket must succeed (0/2 -> 1/2). + require.NoError(t, moveTaskToBucket(defaultTasks[0].TaskID)) + // The second one fills the bucket up to the limit (1/2 -> 2/2). + require.NoError(t, moveTaskToBucket(defaultTasks[1].TaskID)) + // The third one would exceed the limit and must be rejected. + err = moveTaskToBucket(defaultTasks[2].TaskID) + require.Error(t, err) + assert.True(t, IsErrBucketLimitExceeded(err)) + }) + t.Run("keep done timestamp when moving task between projects", func(t *testing.T) { db.LoadAndAssertFixtures(t) u := &user.User{ID: 1} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index ee5eda824..eb2989694 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -830,9 +830,15 @@ func checkBucketLimit(s *xorm.Session, a web.Auth, t *Task, bucket *Bucket) (tas } if view.ProjectID < 0 || (view.Filter != nil && view.Filter.Filter != "") { + // For saved filters or views with a filter, the count must be scoped to + // this bucket *and* the filter: raw task_buckets rows can include tasks + // that no longer match the filter (#355), while the unscoped filter total + // counts tasks across all buckets, not just this one (#2672). ReadAll + // combines the bucket_id condition with the saved-filter / view filter. tc := &TaskCollection{ ProjectID: view.ProjectID, ProjectViewID: bucket.ProjectViewID, + Filter: "bucket_id = " + strconv.FormatInt(bucket.ID, 10), } _, _, taskCount, err = tc.ReadAll(s, a, "", 1, 1)