mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-08-22 12:12:18 -05:00
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.
379 lines
11 KiB
Go
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)
|
|
})
|
|
}
|