Files
vikunja/pkg/models/bulk_task_create_test.go
T
kolaente e86c17faa9 feat(tasks): add BulkTaskCreation model
Creates up to 100 tasks in one project atomically. One project write
check covers the batch; any invalid task rolls the whole batch back
with error 4031 naming the offending payload index, and an
invalid batch size returns 4030. The 1..100 bound is declared as
minItems/maxItems in the schema (pinned to the constant by a test) with
the model check as backstop for non-HTTP callers. Payload positions are
zeroed — positions are always calculated server-side.
2026-08-02 16:21:50 +02:00

379 lines
11 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"context"
"reflect"
"strconv"
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
// positionsForTasks returns taskID → position for one view.
func positionsForTasks(t *testing.T, s *xorm.Session, viewID int64, taskIDs []int64) map[int64]float64 {
t.Helper()
positions := []*TaskPosition{}
err := s.Where("project_view_id = ?", viewID).In("task_id", taskIDs).Find(&positions)
require.NoError(t, err)
result := make(map[int64]float64, len(positions))
for _, p := range positions {
result[p.TaskID] = p.Position
}
return result
}
func TestBulkTaskCreation_Create(t *testing.T) {
usr := &user.User{ID: 1}
t.Run("happy path", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
btc := &BulkTaskCreation{
ProjectID: 1,
Tasks: []*Task{
{Title: "bulk one"},
{Title: "bulk two"},
{Title: "bulk three"},
},
}
err := btc.Create(s, usr)
require.NoError(t, err)
require.NoError(t, s.Commit())
taskIDs := make([]int64, 0, len(btc.Tasks))
for i, task := range btc.Tasks {
assert.NotZero(t, task.ID)
assert.NotEmpty(t, task.UID)
assert.NotEmpty(t, task.Identifier)
// Project 1's highest index is 34, held by the soft-deleted task 51.
assert.Equal(t, int64(35+i), task.Index)
taskIDs = append(taskIDs, task.ID)
}
for _, task := range btc.Tasks {
db.AssertExists(t, "tasks", map[string]interface{}{
"id": task.ID,
"title": task.Title,
"project_id": 1,
"created_by_id": 1,
}, false)
}
// The kanban view 4 puts new tasks into its default bucket 1.
for _, task := range btc.Tasks {
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": task.ID,
"bucket_id": 1,
}, false)
}
// Distinct positions in payload order in every view of project 1.
for _, viewID := range []int64{1, 2, 3, 4} {
positions := positionsForTasks(t, s, viewID, taskIDs)
require.Len(t, positions, len(taskIDs), "view %d", viewID)
assert.Less(t, positions[taskIDs[0]], positions[taskIDs[1]], "view %d", viewID)
assert.Less(t, positions[taskIDs[1]], positions[taskIDs[2]], "view %d", viewID)
}
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TaskCreatedEvent{})
events.AssertDispatched(t, &TasksBatchCreatedEvent{})
})
t.Run("batch lands on top of a view with existing positions", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
btc := &BulkTaskCreation{
ProjectID: 1,
Tasks: []*Task{{Title: "top one"}, {Title: "top two"}},
}
require.NoError(t, btc.Create(s, usr))
require.NoError(t, s.Commit())
// View 1's lowest fixture position is 2 → step 2/3, new tasks below it.
positions := positionsForTasks(t, s, 1, []int64{btc.Tasks[0].ID, btc.Tasks[1].ID})
assert.Less(t, positions[btc.Tasks[0].ID], positions[btc.Tasks[1].ID])
assert.Less(t, positions[btc.Tasks[1].ID], 2.0)
})
t.Run("batch lands on top of a crowded view forcing a recalculation", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, err := s.Where("project_view_id = ? AND task_id = ?", 1, 1).Delete(&TaskPosition{})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 1, ProjectViewID: 1, Position: MinPositionSpacing / 2})
require.NoError(t, err)
btc := &BulkTaskCreation{
ProjectID: 1,
Tasks: []*Task{{Title: "crowded one"}, {Title: "crowded two"}},
}
require.NoError(t, btc.Create(s, usr))
require.NoError(t, s.Commit())
newIDs := []int64{btc.Tasks[0].ID, btc.Tasks[1].ID}
positions := positionsForTasks(t, s, 1, newIDs)
require.Len(t, positions, 2)
assert.Less(t, positions[newIDs[0]], positions[newIDs[1]])
others := []*TaskPosition{}
err = s.Where("project_view_id = ?", 1).NotIn("task_id", newIDs).Find(&others)
require.NoError(t, err)
require.NotEmpty(t, others)
for _, p := range others {
assert.Less(t, positions[newIDs[1]], p.Position)
}
})
t.Run("task flipped to done keeps its recalculated position", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Force view 4 into a recalculation during the batch's position calculation.
_, err := s.Insert(&TaskPosition{TaskID: 1, ProjectViewID: 4, Position: MinPositionSpacing / 2})
require.NoError(t, err)
// Bucket 3 is view 4's done bucket, so the task gets a position there before the recalculation runs.
btc := &BulkTaskCreation{
ProjectID: 1,
Tasks: []*Task{{Title: "into done bucket", BucketID: 3}},
}
require.NoError(t, btc.Create(s, usr))
require.NoError(t, s.Commit())
assert.True(t, btc.Tasks[0].Done)
positions := positionsForTasks(t, s, 4, []int64{btc.Tasks[0].ID, 1})
require.Len(t, positions, 2)
// The done task keeps the slot the recalculation gave it instead of moving back to the top.
assert.Less(t, positions[1], positions[btc.Tasks[0].ID])
})
t.Run("preset indexes", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
btc := &BulkTaskCreation{
ProjectID: 1,
Tasks: []*Task{
{Title: "free preset", Index: 100}, // free → kept
{Title: "taken preset", Index: 1}, // taken by task 1 → reassigned
{Title: "duplicate preset", Index: 100}, // taken earlier in the batch → reassigned
{Title: "no preset"},
},
}
require.NoError(t, btc.Create(s, usr))
require.NoError(t, s.Commit())
assert.Equal(t, int64(100), btc.Tasks[0].Index)
assert.Equal(t, int64(35), btc.Tasks[1].Index)
assert.Equal(t, int64(36), btc.Tasks[2].Index)
assert.Equal(t, int64(37), btc.Tasks[3].Index)
})
t.Run("explicit bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
bucket := &Bucket{Title: "bulk target", ProjectViewID: 4, CreatedByID: 1}
_, err := s.Insert(bucket)
require.NoError(t, err)
btc := &BulkTaskCreation{
ProjectID: 1,
Tasks: []*Task{{Title: "into bucket", BucketID: bucket.ID}},
}
require.NoError(t, btc.Create(s, usr))
require.NoError(t, s.Commit())
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": btc.Tasks[0].ID,
"bucket_id": bucket.ID,
}, false)
})
t.Run("bucket limit reached mid-batch fails the whole batch", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
bucket := &Bucket{Title: "tiny", ProjectViewID: 4, CreatedByID: 1, Limit: 1}
_, err := s.Insert(bucket)
require.NoError(t, err)
btc := &BulkTaskCreation{
ProjectID: 1,
Tasks: []*Task{
{Title: "fits", BucketID: bucket.ID},
{Title: "does not fit", BucketID: bucket.ID},
},
}
err = btc.Create(s, usr)
require.Error(t, err)
require.True(t, IsErrInvalidTaskInBulkCreation(err))
assert.Equal(t, 1, err.(ErrInvalidTaskInBulkCreation).Index)
var limitErr ErrBucketLimitExceeded
require.ErrorAs(t, err, &limitErr)
assert.Equal(t, bucket.ID, limitErr.BucketID)
assert.Equal(t, btc.Tasks[1].ID, limitErr.TaskID)
})
t.Run("bucket already full", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Bucket 2 has limit 3 and already holds 3 tasks.
btc := &BulkTaskCreation{
ProjectID: 1,
Tasks: []*Task{{Title: "overflow", BucketID: 2}},
}
err := btc.Create(s, usr)
require.Error(t, err)
require.True(t, IsErrInvalidTaskInBulkCreation(err))
assert.Equal(t, 0, err.(ErrInvalidTaskInBulkCreation).Index)
var limitErr ErrBucketLimitExceeded
require.ErrorAs(t, err, &limitErr)
assert.Equal(t, int64(2), limitErr.BucketID)
})
t.Run("empty batch", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
btc := &BulkTaskCreation{ProjectID: 1, Tasks: []*Task{}}
err := btc.Create(s, usr)
require.Error(t, err)
assert.True(t, IsErrInvalidBulkTaskCreationCount(err))
})
t.Run("oversized batch", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tasks := make([]*Task, MaxTasksPerBulkCreation+1)
for i := range tasks {
tasks[i] = &Task{Title: "too many"}
}
btc := &BulkTaskCreation{ProjectID: 1, Tasks: tasks}
err := btc.Create(s, usr)
require.Error(t, err)
assert.True(t, IsErrInvalidBulkTaskCreationCount(err))
})
t.Run("invalid task carries its index", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
btc := &BulkTaskCreation{
ProjectID: 1,
Tasks: []*Task{{Title: "valid"}, {Title: ""}},
}
err := btc.Create(s, usr)
require.Error(t, err)
require.True(t, IsErrInvalidTaskInBulkCreation(err))
assert.Equal(t, 1, err.(ErrInvalidTaskInBulkCreation).Index)
require.NoError(t, s.Rollback())
db.AssertMissing(t, "tasks", map[string]interface{}{
"title": "valid",
"project_id": 1,
})
})
t.Run("nonexistent project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
btc := &BulkTaskCreation{ProjectID: 9999999, Tasks: []*Task{{Title: "nope"}}}
err := btc.Create(s, usr)
require.Error(t, err)
assert.True(t, IsErrProjectDoesNotExist(err))
})
}
// The struct tags cannot reference the constant, so pin them here.
func TestBulkTaskCreation_TaskLimitTag(t *testing.T) {
field, has := reflect.TypeOf(BulkTaskCreation{}).FieldByName("Tasks")
require.True(t, has)
maxItems, err := strconv.Atoi(field.Tag.Get("maxItems"))
require.NoError(t, err)
assert.Equal(t, MaxTasksPerBulkCreation, maxItems)
minItems, err := strconv.Atoi(field.Tag.Get("minItems"))
require.NoError(t, err)
assert.Equal(t, 1, minItems)
}
func TestBulkTaskCreation_CanCreate(t *testing.T) {
t.Run("write access", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
btc := &BulkTaskCreation{ProjectID: 1}
can, err := btc.CanCreate(s, &user.User{ID: 1})
require.NoError(t, err)
assert.True(t, can)
})
t.Run("read-only access", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// User 2 has read-only access to project 3.
btc := &BulkTaskCreation{ProjectID: 3}
can, err := btc.CanCreate(s, &user.User{ID: 2})
require.NoError(t, err)
assert.False(t, can)
})
t.Run("no access", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
btc := &BulkTaskCreation{ProjectID: 1}
can, err := btc.CanCreate(s, &user.User{ID: 6})
require.NoError(t, err)
assert.False(t, can)
})
}