From 51e5c86f692d7e0bd304140701c0a7cf75bddafe Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:15:00 +0200 Subject: [PATCH] feat(api/v2): add kanban task-bucket moves on /api/v2 --- pkg/routes/api/v2/task_bucket.go | 69 ++++++++++++++ pkg/webtests/huma_task_bucket_test.go | 127 ++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 pkg/routes/api/v2/task_bucket.go create mode 100644 pkg/webtests/huma_task_bucket_test.go diff --git a/pkg/routes/api/v2/task_bucket.go b/pkg/routes/api/v2/task_bucket.go new file mode 100644 index 000000000..b07774b2f --- /dev/null +++ b/pkg/routes/api/v2/task_bucket.go @@ -0,0 +1,69 @@ +// 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" +) + +// RegisterTaskBucketRoutes wires the kanban task-bucket move onto the Huma API. +// +// TaskBucket exposes only Update, so the handler reuses handler.DoUpdate (its +// CanUpdate enforces write access on the bucket's project). The bucket and view +// come from the path; only the task id is read from the body. +func RegisterTaskBucketRoutes(api huma.API) { + tags := []string{"projects"} + + Register(api, huma.Operation{ + OperationID: "task-bucket-update", + Summary: "Place a task in a kanban bucket", + Description: "Moves a task into the given bucket of a project's kanban view. Requires write access to the project. " + + "Idempotent: re-sending the same bucket is a no-op. Side effects: moving a task into the view's done bucket marks it done (and out of it un-marks it); a repeating task moved into the done bucket is reopened and routed back to the default bucket instead. " + + "Moving a task into a bucket that is already at its task limit is rejected with 412. A bucket that does not resolve under the project and view in the path is rejected with 404.", + Method: http.MethodPut, + Path: "/projects/{project}/views/{view}/buckets/{bucket}/tasks", + Tags: tags, + }, taskBucketUpdate) +} + +func init() { AddRouteRegistrar(RegisterTaskBucketRoutes) } + +func taskBucketUpdate(ctx context.Context, in *struct { + ProjectID int64 `path:"project"` + ViewID int64 `path:"view"` + BucketID int64 `path:"bucket"` + Body models.TaskBucket +}) (*singleBody[models.TaskBucket], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + tb := &in.Body + tb.ProjectID = in.ProjectID // URL wins over body + tb.ProjectViewID = in.ViewID // URL wins over body + tb.BucketID = in.BucketID // URL wins over body + if err := handler.DoUpdate(ctx, tb, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TaskBucket]{Body: tb}, nil +} diff --git a/pkg/webtests/huma_task_bucket_test.go b/pkg/webtests/huma_task_bucket_test.go new file mode 100644 index 000000000..e1623bf67 --- /dev/null +++ b/pkg/webtests/huma_task_bucket_test.go @@ -0,0 +1,127 @@ +// 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 ( + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTaskBucketV2 covers PUT /projects/{project}/views/{view}/buckets/{bucket}/tasks. +// It drives the Echo+Huma stack directly (humaRequest/humaTokenFor) because the +// route is an action sub-path webHandlerTestV2's buildURL doesn't model. Fixtures +// (project 1, view 4): bucket 1 default, bucket 2 "Doing" limit 3 (full), bucket 3 done. +func TestTaskBucketV2(t *testing.T) { + const path = "/api/v2/projects/1/views/4/buckets/%d/tasks" + + t.Run("moves a task into a bucket", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 3 starts in bucket 2; move it into bucket 1 (neither full nor done). + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":3}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"task_id":3`) + + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 3, + "bucket_id": 1, + }, false) + }) + + t.Run("moving a task into the done bucket marks it done", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Bucket 3 is the done bucket on view 4; task 1 is not yet done. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 3), `{"task_id":1}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"done":true`) + + db.AssertExists(t, "tasks", map[string]interface{}{ + "id": 1, + "done": true, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 1, + "bucket_id": 3, + }, false) + }) + + t.Run("moving a task out of the done bucket un-marks it done", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 2 starts in bucket 3 (done) and is done; move it to bucket 1. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":2}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"done":false`) + + db.AssertExists(t, "tasks", map[string]interface{}{ + "id": 2, + "done": false, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 2, + "bucket_id": 1, + }, false) + }) + + t.Run("full bucket is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Bucket 2 already holds 3 tasks and has a limit of 3. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 2), `{"task_id":1}`, token, "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeBucketLimitExceeded)) + }) + + t.Run("bucket on another view is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Bucket 4 lives on view 8 (project 2), so under view 4 / project 1 the + // permission check resolves the bucket's own view scoped by the path + // project and finds none → 404 before the move's own 400 can fire. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 4), `{"task_id":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.ErrCodeProjectViewDoesNotExist)) + }) + + t.Run("no write access is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // testuser15 has no access to project 1. + token := humaTokenFor(t, &testuser15) + + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":1}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +}