diff --git a/pkg/routes/api/v1/task_by_index.go b/pkg/routes/api/v1/task_by_index.go new file mode 100644 index 000000000..49106a311 --- /dev/null +++ b/pkg/routes/api/v1/task_by_index.go @@ -0,0 +1,39 @@ +// 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 v1 + +// GetTaskByProjectIndex is a doc-only stub: swag allows one @Router per func +// and Task.ReadOne already owns /tasks/{id}, so the by-index route needs its +// own function to host the second annotation. The route is wired directly to +// taskHandler.ReadOneWeb in routes.go. +// +// @Summary Get one task by its per-project index +// @Description Returns a single task identified by its per-project index. Useful when resolving human-readable references like "PROJ-42" to a canonical task object. Note that task indexes are reassigned when a task is moved between projects, so long-lived references should use the returned task id instead. +// @tags task +// @Accept json +// @Produce json +// @Param project path int true "The project ID" +// @Param index path int true "The task's per-project index" +// @Param expand query string false "If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a second step, will fetch all of these subtasks. This may result in more tasks than the pagination limit being returned, but all subtasks will be present in the response. You can only set this to `subtasks`." +// @Security JWTKeyAuth +// @Success 200 {object} models.Task "The task" +// @Failure 400 {object} web.HTTPError "Invalid project ID or index" +// @Failure 403 {object} web.HTTPError "The user does not have access to the task" +// @Failure 404 {object} models.Message "Task not found" +// @Failure 500 {object} models.Message "Internal error" +// @Router /projects/{project}/tasks/by-index/{index} [get] +func GetTaskByProjectIndex() {} //nolint:unused diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 98bd687bc..5ac117a03 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -535,6 +535,7 @@ func registerAPIRoutes(a *echo.Group) { } a.PUT("/projects/:project/tasks", taskHandler.CreateWeb) a.GET("/tasks/:projecttask", taskHandler.ReadOneWeb) + a.GET("/projects/:project/tasks/by-index/:index", taskHandler.ReadOneWeb) a.GET("/tasks", taskCollectionHandler.ReadAllWeb) a.DELETE("/tasks/:projecttask", taskHandler.DeleteWeb) a.POST("/tasks/:projecttask", taskHandler.UpdateWeb) diff --git a/pkg/webtests/task_test.go b/pkg/webtests/task_test.go index 75336297e..d067703b7 100644 --- a/pkg/webtests/task_test.go +++ b/pkg/webtests/task_test.go @@ -17,6 +17,7 @@ package webtests import ( + "net/http" "testing" "code.vikunja.io/api/pkg/db" @@ -49,6 +50,49 @@ func TestTask(t *testing.T) { }, t: t, } + t.Run("ReadOneByIndex", func(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "1", "index": "1"}) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"title":"task #1"`) + assert.Contains(t, rec.Body.String(), `"index":1`) + assert.Equal(t, "2", rec.Header().Get("x-max-permission")) // admin = 2 for owner + }) + + t.Run("Nonexistent index", func(t *testing.T) { + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "1", "index": "99999"}) + require.Error(t, err) + assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist) + }) + + t.Run("Nonexistent project", func(t *testing.T) { + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "99999", "index": "1"}) + require.Error(t, err) + assertHandlerErrorCode(t, err, models.ErrCodeTaskDoesNotExist) + }) + + t.Run("No permission", func(t *testing.T) { + // testuser1 has no access to project 2. Must be 403, not 404 — + // a 404 here would be an existence oracle. + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "2", "index": "1"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + + t.Run("Invalid project param", func(t *testing.T) { + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "notanumber", "index": "1"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, getHTTPErrorCode(err)) + }) + + t.Run("Invalid index param", func(t *testing.T) { + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "1", "index": "notanumber"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, getHTTPErrorCode(err)) + }) + }) + // Only run specific nested tests: // ^TestTask$/^Update$/^Update_task_items$/^Removing_Assignees_null$ t.Run("Update", func(t *testing.T) {