Files
vikunja/pkg/models/task_position_test.go
kolaente 2014343557 fix: catch ErrNeedsFullRecalculation in task creation position conflict resolution
resolvePositionConflictsAfterInsert now falls back to a full position
recalculation when resolveTaskPositionConflicts returns
ErrNeedsFullRecalculation, instead of bubbling the error up as HTTP 500.
This mirrors the existing fallback logic in the CLI repair command.

Ref: #2550
2026-04-09 07:25:57 +00:00

596 lines
20 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 (
"testing"
"code.vikunja.io/api/pkg/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFindPositionConflicts(t *testing.T) {
t.Run("no conflicts", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project view 1 has tasks at positions 2 and 4 - no conflicts
conflicts, err := findPositionConflicts(s, 1, 2)
require.NoError(t, err)
assert.Len(t, conflicts, 1) // Only one task at position 2
})
t.Run("finds conflicts", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Insert two tasks with the same position
_, err := s.Insert(&TaskPosition{TaskID: 100, ProjectViewID: 1, Position: 999})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 101, ProjectViewID: 1, Position: 999})
require.NoError(t, err)
conflicts, err := findPositionConflicts(s, 1, 999)
require.NoError(t, err)
assert.Len(t, conflicts, 2)
})
t.Run("no conflicts at nonexistent position", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
conflicts, err := findPositionConflicts(s, 1, 12345)
require.NoError(t, err)
assert.Empty(t, conflicts)
})
}
func TestResolveTaskPositionConflicts(t *testing.T) {
t.Run("no conflict to resolve", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Single task - no conflict
conflicts := []*TaskPosition{
{TaskID: 1, ProjectViewID: 1, Position: 100},
}
err := resolveTaskPositionConflicts(s, 1, conflicts)
require.NoError(t, err)
})
t.Run("resolves conflicts with neighbors", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Set up: Create positions at 100, 200 (conflict), 200 (conflict), 300
_, err := s.Insert(&TaskPosition{TaskID: 100, ProjectViewID: 1, Position: 100})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 101, ProjectViewID: 1, Position: 200})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 102, ProjectViewID: 1, Position: 200})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 103, ProjectViewID: 1, Position: 300})
require.NoError(t, err)
conflicts := []*TaskPosition{
{TaskID: 101, ProjectViewID: 1, Position: 200},
{TaskID: 102, ProjectViewID: 1, Position: 200},
}
err = resolveTaskPositionConflicts(s, 1, conflicts)
require.NoError(t, err)
// Check that the positions are now different
var pos1, pos2 TaskPosition
_, err = s.Where("task_id = ? AND project_view_id = ?", 101, 1).Get(&pos1)
require.NoError(t, err)
_, err = s.Where("task_id = ? AND project_view_id = ?", 102, 1).Get(&pos2)
require.NoError(t, err)
assert.NotEqual(t, pos1.Position, pos2.Position)
// Both should be between 100 and 300
assert.Greater(t, pos1.Position, 100.0)
assert.Less(t, pos1.Position, 300.0)
assert.Greater(t, pos2.Position, 100.0)
assert.Less(t, pos2.Position, 300.0)
})
t.Run("resolves conflicts at start (no left neighbor)", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Clear existing positions for this view to control test data
_, err := s.Where("project_view_id = ?", 99).Delete(&TaskPosition{})
require.NoError(t, err)
// Set up: positions at 50 (conflict), 50 (conflict), 100
_, err = s.Insert(&TaskPosition{TaskID: 200, ProjectViewID: 99, Position: 50})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 201, ProjectViewID: 99, Position: 50})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 202, ProjectViewID: 99, Position: 100})
require.NoError(t, err)
conflicts := []*TaskPosition{
{TaskID: 200, ProjectViewID: 99, Position: 50},
{TaskID: 201, ProjectViewID: 99, Position: 50},
}
err = resolveTaskPositionConflicts(s, 99, conflicts)
require.NoError(t, err)
// Check positions are unique and between 0 and 100
var pos1, pos2 TaskPosition
_, err = s.Where("task_id = ? AND project_view_id = ?", 200, 99).Get(&pos1)
require.NoError(t, err)
_, err = s.Where("task_id = ? AND project_view_id = ?", 201, 99).Get(&pos2)
require.NoError(t, err)
assert.NotEqual(t, pos1.Position, pos2.Position)
assert.GreaterOrEqual(t, pos1.Position, 0.0)
assert.Less(t, pos1.Position, 100.0)
assert.GreaterOrEqual(t, pos2.Position, 0.0)
assert.Less(t, pos2.Position, 100.0)
})
t.Run("resolves conflicts at end (no right neighbor)", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Clear existing positions for this view
_, err := s.Where("project_view_id = ?", 98).Delete(&TaskPosition{})
require.NoError(t, err)
// Set up: positions at 100, 200 (conflict), 200 (conflict) - no right neighbor
_, err = s.Insert(&TaskPosition{TaskID: 300, ProjectViewID: 98, Position: 100})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 301, ProjectViewID: 98, Position: 200})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 302, ProjectViewID: 98, Position: 200})
require.NoError(t, err)
conflicts := []*TaskPosition{
{TaskID: 301, ProjectViewID: 98, Position: 200},
{TaskID: 302, ProjectViewID: 98, Position: 200},
}
err = resolveTaskPositionConflicts(s, 98, conflicts)
require.NoError(t, err)
// Check positions are unique and > 100
var pos1, pos2 TaskPosition
_, err = s.Where("task_id = ? AND project_view_id = ?", 301, 98).Get(&pos1)
require.NoError(t, err)
_, err = s.Where("task_id = ? AND project_view_id = ?", 302, 98).Get(&pos2)
require.NoError(t, err)
assert.NotEqual(t, pos1.Position, pos2.Position)
assert.Greater(t, pos1.Position, 100.0)
assert.Greater(t, pos2.Position, 100.0)
})
t.Run("returns error when spacing exhausted", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Clear existing positions for this view
_, err := s.Where("project_view_id = ?", 97).Delete(&TaskPosition{})
require.NoError(t, err)
// Set up: extremely tight spacing that can't accommodate multiple tasks
// Gap of 2e-9 with 2 conflicts means spacing of ~6.67e-10 < MinPositionSpacing (1e-9)
basePos := 100.0
tinyGap := 1e-9
_, err = s.Insert(&TaskPosition{TaskID: 400, ProjectViewID: 97, Position: basePos})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 401, ProjectViewID: 97, Position: basePos + tinyGap})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 402, ProjectViewID: 97, Position: basePos + tinyGap})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 403, ProjectViewID: 97, Position: basePos + 2*tinyGap})
require.NoError(t, err)
conflicts := []*TaskPosition{
{TaskID: 401, ProjectViewID: 97, Position: basePos + tinyGap},
{TaskID: 402, ProjectViewID: 97, Position: basePos + tinyGap},
}
err = resolveTaskPositionConflicts(s, 97, conflicts)
assert.True(t, IsErrNeedsFullRecalculation(err))
})
t.Run("handles multiple conflicts deterministically", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Clear existing positions for this view
_, err := s.Where("project_view_id = ?", 96).Delete(&TaskPosition{})
require.NoError(t, err)
// Set up: 4 tasks at the same position
_, err = s.Insert(&TaskPosition{TaskID: 504, ProjectViewID: 96, Position: 0})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 501, ProjectViewID: 96, Position: 500})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 503, ProjectViewID: 96, Position: 500})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 502, ProjectViewID: 96, Position: 500})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 500, ProjectViewID: 96, Position: 500})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 505, ProjectViewID: 96, Position: 1000})
require.NoError(t, err)
conflicts := []*TaskPosition{
{TaskID: 501, ProjectViewID: 96, Position: 500},
{TaskID: 503, ProjectViewID: 96, Position: 500},
{TaskID: 502, ProjectViewID: 96, Position: 500},
{TaskID: 500, ProjectViewID: 96, Position: 500},
}
err = resolveTaskPositionConflicts(s, 96, conflicts)
require.NoError(t, err)
// Fetch all positions and verify they are unique and ordered by task ID
var positions []*TaskPosition
err = s.Where("project_view_id = ? AND task_id IN (500, 501, 502, 503)", 96).
OrderBy("task_id ASC").
Find(&positions)
require.NoError(t, err)
require.Len(t, positions, 4)
// Positions should be strictly increasing (sorted by task_id)
for i := 1; i < len(positions); i++ {
assert.Greater(t, positions[i].Position, positions[i-1].Position,
"Position for task %d should be greater than task %d",
positions[i].TaskID, positions[i-1].TaskID)
}
// All should be between 0 and 1000
for _, p := range positions {
assert.Greater(t, p.Position, 0.0)
assert.Less(t, p.Position, 1000.0)
}
})
}
func TestUpdateTaskPositionWithConflictResolution(t *testing.T) {
t.Run("resolves conflict on update", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Clear existing positions for this view
_, err := s.Where("project_view_id = ?", 95).Delete(&TaskPosition{})
require.NoError(t, err)
// Set up: two tasks with different positions
_, err = s.Insert(&TaskPosition{TaskID: 600, ProjectViewID: 95, Position: 100})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 601, ProjectViewID: 95, Position: 200})
require.NoError(t, err)
// Update task 600 to have the same position as task 601
tp := &TaskPosition{
TaskID: 600,
ProjectViewID: 95,
Position: 200,
}
err = updateTaskPosition(s, nil, tp)
require.NoError(t, err)
// Verify both tasks now have unique positions
var pos1, pos2 TaskPosition
_, err = s.Where("task_id = ? AND project_view_id = ?", 600, 95).Get(&pos1)
require.NoError(t, err)
_, err = s.Where("task_id = ? AND project_view_id = ?", 601, 95).Get(&pos2)
require.NoError(t, err)
assert.NotEqual(t, pos1.Position, pos2.Position)
})
}
func TestRepairTaskPositions(t *testing.T) {
t.Run("no duplicates to repair", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Clear all positions and set up clean data with no duplicates
_, err := s.Where("project_view_id = ?", 94).Delete(&TaskPosition{})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 700, ProjectViewID: 94, Position: 100})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 701, ProjectViewID: 94, Position: 200})
require.NoError(t, err)
result, err := RepairTaskPositions(s, false)
require.NoError(t, err)
// View 94 should be scanned but not repaired (no duplicates)
assert.GreaterOrEqual(t, result.ViewsScanned, 1)
assert.Empty(t, result.Errors)
})
t.Run("repairs duplicates in view", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Clear and set up duplicates
_, err := s.Where("project_view_id = ?", 93).Delete(&TaskPosition{})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 800, ProjectViewID: 93, Position: 100})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 801, ProjectViewID: 93, Position: 200})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 802, ProjectViewID: 93, Position: 200}) // Duplicate!
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 803, ProjectViewID: 93, Position: 300})
require.NoError(t, err)
result, err := RepairTaskPositions(s, false)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.ViewsRepaired, 1)
assert.GreaterOrEqual(t, result.TasksAffected, 2)
assert.Empty(t, result.Errors)
// Verify positions are now unique
var pos1, pos2 TaskPosition
_, err = s.Where("task_id = ? AND project_view_id = ?", 801, 93).Get(&pos1)
require.NoError(t, err)
_, err = s.Where("task_id = ? AND project_view_id = ?", 802, 93).Get(&pos2)
require.NoError(t, err)
assert.NotEqual(t, pos1.Position, pos2.Position)
})
t.Run("dry run reports without changes - view 92", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Clear and set up duplicates
_, err := s.Where("project_view_id = ?", 92).Delete(&TaskPosition{})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 900, ProjectViewID: 92, Position: 500})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 901, ProjectViewID: 92, Position: 500}) // Duplicate!
require.NoError(t, err)
result, err := RepairTaskPositions(s, true) // dry run
require.NoError(t, err)
assert.GreaterOrEqual(t, result.ViewsRepaired, 1)
assert.GreaterOrEqual(t, result.TasksAffected, 2)
// Verify positions are still duplicates (dry run shouldn't change them)
var pos1, pos2 TaskPosition
_, err = s.Where("task_id = ? AND project_view_id = ?", 900, 92).Get(&pos1)
require.NoError(t, err)
_, err = s.Where("task_id = ? AND project_view_id = ?", 901, 92).Get(&pos2)
require.NoError(t, err)
assert.InDelta(t, pos1.Position, pos2.Position, 0) // Still duplicates
})
t.Run("handles multiple views", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Set up duplicates in two different views
_, err := s.Where("project_view_id IN (90, 91)").Delete(&TaskPosition{})
require.NoError(t, err)
// View 90: duplicates
_, err = s.Insert(&TaskPosition{TaskID: 1000, ProjectViewID: 90, Position: 100})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 1001, ProjectViewID: 90, Position: 100})
require.NoError(t, err)
// View 91: duplicates
_, err = s.Insert(&TaskPosition{TaskID: 1002, ProjectViewID: 91, Position: 200})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 1003, ProjectViewID: 91, Position: 200})
require.NoError(t, err)
result, err := RepairTaskPositions(s, false)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.ViewsRepaired, 2)
assert.GreaterOrEqual(t, result.TasksAffected, 4)
assert.Empty(t, result.Errors)
})
}
func TestCreateTaskPositionConflictResolution(t *testing.T) {
t.Run("resolves conflicts after position insert", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Insert two positions with the same value for the same view
pos1 := &TaskPosition{TaskID: 600, ProjectViewID: 1, Position: 12345}
pos2 := &TaskPosition{TaskID: 601, ProjectViewID: 1, Position: 12345}
_, err := s.Insert(pos1)
require.NoError(t, err)
_, err = s.Insert(pos2)
require.NoError(t, err)
// Call the new function that should detect and resolve conflicts
err = resolvePositionConflictsAfterInsert(s, []*TaskPosition{pos1, pos2})
require.NoError(t, err)
// Verify positions are now unique
var updated1, updated2 TaskPosition
_, err = s.Where("task_id = ? AND project_view_id = ?", 600, 1).Get(&updated1)
require.NoError(t, err)
_, err = s.Where("task_id = ? AND project_view_id = ?", 601, 1).Get(&updated2)
require.NoError(t, err)
assert.NotEqual(t, updated1.Position, updated2.Position)
})
t.Run("no-op when no conflicts", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
pos1 := &TaskPosition{TaskID: 700, ProjectViewID: 1, Position: 11111}
pos2 := &TaskPosition{TaskID: 701, ProjectViewID: 1, Position: 22222}
_, err := s.Insert(pos1)
require.NoError(t, err)
_, err = s.Insert(pos2)
require.NoError(t, err)
err = resolvePositionConflictsAfterInsert(s, []*TaskPosition{pos1, pos2})
require.NoError(t, err)
// Positions should remain unchanged
var updated1, updated2 TaskPosition
_, err = s.Where("task_id = ? AND project_view_id = ?", 700, 1).Get(&updated1)
require.NoError(t, err)
_, err = s.Where("task_id = ? AND project_view_id = ?", 701, 1).Get(&updated2)
require.NoError(t, err)
assert.InDelta(t, 11111.0, updated1.Position, 0)
assert.InDelta(t, 22222.0, updated2.Position, 0)
})
}
func TestSetTaskInBucketInViewsResolvesConflicts(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Pre-insert a position that will conflict with the one calculateNewPositionForTask produces.
views, err := getViewsForProject(s, 1)
require.NoError(t, err)
require.NotEmpty(t, views)
// Pick the first view
view := views[0]
// Get the current lowest position to predict the new task's position
lowestPosition := &TaskPosition{}
exists, err := s.Where("project_view_id = ?", view.ID).
OrderBy("position asc").
Get(lowestPosition)
require.NoError(t, err)
require.True(t, exists)
require.GreaterOrEqual(t, lowestPosition.Position, MinPositionSpacing)
predictedPosition := lowestPosition.Position / 2
// Insert a conflicting position at the predicted value
_, err = s.Insert(&TaskPosition{
TaskID: 999,
ProjectViewID: view.ID,
Position: predictedPosition,
})
require.NoError(t, err)
// Now create positions as task creation would
newPos := &TaskPosition{
TaskID: 998,
ProjectViewID: view.ID,
Position: predictedPosition,
}
_, err = s.Insert(newPos)
require.NoError(t, err)
// Resolve conflicts
err = resolvePositionConflictsAfterInsert(s, []*TaskPosition{newPos})
require.NoError(t, err)
// Verify they have different positions
var p1, p2 TaskPosition
_, err = s.Where("task_id = ? AND project_view_id = ?", 999, view.ID).Get(&p1)
require.NoError(t, err)
_, err = s.Where("task_id = ? AND project_view_id = ?", 998, view.ID).Get(&p2)
require.NoError(t, err)
assert.NotEqual(t, p1.Position, p2.Position,
"Positions should be unique after conflict resolution")
}
func TestResolvePositionConflictsAfterInsertFallsBackToRecalculation(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
viewID := int64(1)
// Clear existing positions for this view
_, err := s.Where("project_view_id = ?", viewID).Delete(&TaskPosition{})
require.NoError(t, err)
// Set up extremely tight spacing that forces ErrNeedsFullRecalculation:
// Two existing positions with a gap smaller than MinPositionSpacing * (conflicts+1)
basePos := 100.0
tinyGap := MinPositionSpacing * 0.1 // Much smaller than needed
_, err = s.Insert(&TaskPosition{TaskID: 800, ProjectViewID: viewID, Position: basePos})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 801, ProjectViewID: viewID, Position: basePos + tinyGap})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 802, ProjectViewID: viewID, Position: basePos + tinyGap})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 803, ProjectViewID: viewID, Position: basePos + 2*tinyGap})
require.NoError(t, err)
// The conflicting positions that would trigger ErrNeedsFullRecalculation
conflictPositions := []*TaskPosition{
{TaskID: 801, ProjectViewID: viewID, Position: basePos + tinyGap},
{TaskID: 802, ProjectViewID: viewID, Position: basePos + tinyGap},
}
// This should NOT return an error -- it should fall back to full recalculation
err = resolvePositionConflictsAfterInsert(s, conflictPositions)
require.NoError(t, err)
// Verify all positions are now unique
var positions []*TaskPosition
err = s.Where("project_view_id = ?", viewID).OrderBy("position ASC").Find(&positions)
require.NoError(t, err)
seen := make(map[float64]bool)
for _, p := range positions {
assert.False(t, seen[p.Position], "duplicate position found: %f for task %d", p.Position, p.TaskID)
seen[p.Position] = true
}
}