From 25a294d7bc21729ac189d38313428f1abd55a689 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:06:50 +0200 Subject: [PATCH] feat(api/v2): add task position updates on /api/v2 --- pkg/routes/api/v2/task_position.go | 63 +++++++++++++++++ pkg/webtests/huma_task_position_test.go | 94 +++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 pkg/routes/api/v2/task_position.go create mode 100644 pkg/webtests/huma_task_position_test.go diff --git a/pkg/routes/api/v2/task_position.go b/pkg/routes/api/v2/task_position.go new file mode 100644 index 000000000..13a7e3af8 --- /dev/null +++ b/pkg/routes/api/v2/task_position.go @@ -0,0 +1,63 @@ +// 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 . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterTaskPositionRoutes wires the task-position update onto the Huma API. +// +// Setting a position is a plain CRUDable Update, so the handler reuses +// handler.DoUpdate (its CanUpdate delegates to the task's CanUpdate); the only +// custom part is taking TaskID from the path rather than the request body. +func RegisterTaskPositionRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-position-update", + Summary: "Set a task's position in a view", + Description: "Sets where a task sorts within one of its project's views. The position is per view, so this only affects the view named by project_view_id. Requires write access to the task. Positions below the minimum spacing make the server recalculate every position in the view, so the returned value may differ from the one sent.", + Method: http.MethodPut, + Path: "/tasks/{task}/position", + Tags: tags, + }, tasksPositionUpdate) +} + +func init() { AddRouteRegistrar(RegisterTaskPositionRoutes) } + +func tasksPositionUpdate(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The numeric id of the task whose position to set."` + Body models.TaskPosition +}) (*singleBody[models.TaskPosition], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + tp := &in.Body + tp.TaskID = in.TaskID // URL wins over body + if err := handler.DoUpdate(ctx, tp, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TaskPosition]{Body: tp}, nil +} diff --git a/pkg/webtests/huma_task_position_test.go b/pkg/webtests/huma_task_position_test.go new file mode 100644 index 000000000..da10768e4 --- /dev/null +++ b/pkg/webtests/huma_task_position_test.go @@ -0,0 +1,94 @@ +// 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 . + +package webtests + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTaskPositionV2 covers PUT /tasks/{task}/position. It drives the Echo+Huma +// stack directly (humaRequest/humaTokenFor) because webHandlerTestV2's buildURL +// only models base[/{id}] paths, not action sub-paths. +func TestTaskPositionV2(t *testing.T) { + t.Run("updates the position of a writable task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 1 lives in project 1, which testuser1 owns; view 1 belongs to project 1. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":256}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp models.TaskPosition + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, int64(1), resp.TaskID, "task id is taken from the URL") + assert.Equal(t, int64(1), resp.ProjectViewID) + assert.InDelta(t, 256.0, resp.Position, 0) + }) + + t.Run("path task id wins over the body", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Body names task 2, URL names task 1; the URL must win. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"task_id":2,"project_view_id":1,"position":300}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp models.TaskPosition + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, int64(1), resp.TaskID) + }) + + t.Run("nonexistent task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskDoesNotExist), "body must surface ErrCodeTaskDoesNotExist; body: %s", rec.Body.String()) + }) + + t.Run("no access to the task is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // testuser15 cannot access task 1 (project 1, owned by testuser1). + token := humaTokenFor(t, &testuser15) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("read but no write on the task is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // Task 32 lives in project 3, on which testuser1 has read-only access. + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/32/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +}