feat(api/v2): add task collection (task lists) on /api/v2
Ports v1's task-list surface to /api/v2 as four endpoints. v1 served a
single polymorphic endpoint; v2 makes it monomorphic:
GET /tasks flat []*Task, all projects
GET /projects/{project}/tasks flat []*Task
GET /projects/{project}/views/{view}/tasks flat []*Task (even kanban)
GET /projects/{project}/views/{view}/buckets/tasks []*Bucket with tasks
The three task endpoints force flat tasks via TaskCollection so a kanban
view path no longer returns buckets; the dedicated buckets endpoint keeps
the polymorphic kanban branch and is not paginated (bounded by the view's
bucket config). Search is exposed as q; multi-value sort_by/order_by/expand
use ,explode. Hitting the buckets endpoint with a non-kanban view is a 400
rather than a type-mismatch 500.
This commit is contained in:
parent
3a84c491ae
commit
3bd75acabf
|
|
@ -0,0 +1,243 @@
|
|||
// 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 apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
const taskListFilterDoc = "Filtering, sorting and search apply to every variant. See https://vikunja.io/docs/filters for the filter language."
|
||||
|
||||
type taskListBody struct {
|
||||
Body Paginated[*models.Task]
|
||||
}
|
||||
|
||||
// bucketsWithTasksBody is the buckets-with-tasks response. It is not paginated:
|
||||
// the view's bucket configuration bounds how many tasks each bucket carries, so
|
||||
// page/per_page don't apply and total is simply the number of buckets.
|
||||
type bucketsWithTasksBody struct {
|
||||
Body struct {
|
||||
Items []*models.Bucket `json:"items"`
|
||||
Total int64 `json:"total" doc:"The number of buckets returned."`
|
||||
}
|
||||
}
|
||||
|
||||
// The three task-list input structs below each repeat ListParams + the six
|
||||
// query fields INLINE. They can't share that set through an embed: in this Huma
|
||||
// version a second/nested anonymous embed alongside ListParams is silently
|
||||
// dropped from request binding and the OpenAPI spec (verified against the
|
||||
// generated spec). A single shared input struct doesn't work either — Huma
|
||||
// lists every path:"" field regardless of the route template, so a shared
|
||||
// project/view field leaks onto a narrower route as a phantom path param. The
|
||||
// structs differ only in their path params; taskListViewInput is shared by both
|
||||
// view-scoped endpoints.
|
||||
|
||||
type taskListAllInput struct {
|
||||
ListParams
|
||||
Filter string `query:"filter" doc:"Filter query to match tasks by. See https://vikunja.io/docs/filters."`
|
||||
FilterTimezone string `query:"filter_timezone" doc:"Timezone used to resolve relative date filters like \"now\"."`
|
||||
FilterIncludeNulls bool `query:"filter_include_nulls" doc:"If true, also include tasks whose filtered field is null."`
|
||||
SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."`
|
||||
OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."`
|
||||
Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."`
|
||||
}
|
||||
|
||||
type taskListProjectInput struct {
|
||||
ProjectID int64 `path:"project" doc:"The numeric id of the project."`
|
||||
ListParams
|
||||
Filter string `query:"filter" doc:"Filter query to match tasks by. See https://vikunja.io/docs/filters."`
|
||||
FilterTimezone string `query:"filter_timezone" doc:"Timezone used to resolve relative date filters like \"now\"."`
|
||||
FilterIncludeNulls bool `query:"filter_include_nulls" doc:"If true, also include tasks whose filtered field is null."`
|
||||
SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."`
|
||||
OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."`
|
||||
Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."`
|
||||
}
|
||||
|
||||
type taskListViewInput struct {
|
||||
ProjectID int64 `path:"project" doc:"The numeric id of the project."`
|
||||
ViewID int64 `path:"view" doc:"The numeric id of the project view."`
|
||||
ListParams
|
||||
Filter string `query:"filter" doc:"Filter query to match tasks by. See https://vikunja.io/docs/filters."`
|
||||
FilterTimezone string `query:"filter_timezone" doc:"Timezone used to resolve relative date filters like \"now\"."`
|
||||
FilterIncludeNulls bool `query:"filter_include_nulls" doc:"If true, also include tasks whose filtered field is null."`
|
||||
SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."`
|
||||
OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."`
|
||||
Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."`
|
||||
}
|
||||
|
||||
// taskListFilters is the bound query carried into the shared collection builder.
|
||||
// The three input structs convert into it so the collection logic lives once.
|
||||
type taskListFilters struct {
|
||||
Q string
|
||||
Filter string
|
||||
FilterTimezone string
|
||||
FilterIncludeNulls bool
|
||||
SortBy []string
|
||||
OrderBy []string
|
||||
Expand []string
|
||||
}
|
||||
|
||||
func (in taskListAllInput) filters() taskListFilters {
|
||||
return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand}
|
||||
}
|
||||
|
||||
func (in taskListProjectInput) filters() taskListFilters {
|
||||
return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand}
|
||||
}
|
||||
|
||||
func (in taskListViewInput) filters() taskListFilters {
|
||||
return taskListFilters{in.Q, in.Filter, in.FilterTimezone, in.FilterIncludeNulls, in.SortBy, in.OrderBy, in.Expand}
|
||||
}
|
||||
|
||||
// collection turns the bound query into a TaskCollection. The search term
|
||||
// arrives as `q` but reaches the model through DoReadAll's search argument, not
|
||||
// the collection's Search field. forceFlat keeps a kanban view path returning
|
||||
// flat tasks; the buckets endpoint leaves it false for the polymorphic shape.
|
||||
func (f taskListFilters) collection(projectID, viewID int64, forceFlat bool) (*models.TaskCollection, error) {
|
||||
expand, err := parseTaskExpand(f.Expand)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
tc := &models.TaskCollection{
|
||||
ProjectID: projectID,
|
||||
ProjectViewID: viewID,
|
||||
Filter: f.Filter,
|
||||
FilterTimezone: f.FilterTimezone,
|
||||
FilterIncludeNulls: f.FilterIncludeNulls,
|
||||
SortBy: f.SortBy,
|
||||
OrderBy: f.OrderBy,
|
||||
Expand: expand,
|
||||
}
|
||||
if forceFlat {
|
||||
tc.SetForceFlatTasks()
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
|
||||
func RegisterTaskCollectionRoutes(api huma.API) {
|
||||
tags := []string{"tasks"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "tasks-list",
|
||||
Summary: "List tasks across all projects",
|
||||
Description: "Returns the tasks the authenticated user can see across every project they have access to, paginated and flat. " + taskListFilterDoc,
|
||||
Method: http.MethodGet,
|
||||
Path: "/tasks",
|
||||
Tags: tags,
|
||||
}, tasksListAll)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "project-tasks-list",
|
||||
Summary: "List tasks in a project",
|
||||
Description: "Returns the tasks in a project, paginated and flat. Requires read access to the project. " + taskListFilterDoc,
|
||||
Method: http.MethodGet,
|
||||
Path: "/projects/{project}/tasks",
|
||||
Tags: tags,
|
||||
}, projectTasksList)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "project-view-tasks-list",
|
||||
Summary: "List tasks in a project view",
|
||||
Description: "Returns the tasks in a project view, paginated and flat. The view's own filter, sort and search are applied on top of the query. Always returns flat tasks, even for a kanban view — use the buckets endpoint to get tasks grouped by bucket. " + taskListFilterDoc,
|
||||
Method: http.MethodGet,
|
||||
Path: "/projects/{project}/views/{view}/tasks",
|
||||
Tags: tags,
|
||||
}, projectViewTasksList)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "project-view-buckets-tasks-list",
|
||||
Summary: "List a kanban view's buckets with their tasks",
|
||||
Description: "Returns the buckets of a project's kanban view, each populated with the tasks in it. Requires read access to the project. Not paginated: the number and size of buckets follow the view's bucket configuration, so page/per_page do not apply. " + taskListFilterDoc,
|
||||
Method: http.MethodGet,
|
||||
Path: "/projects/{project}/views/{view}/buckets/tasks",
|
||||
Tags: tags,
|
||||
}, projectViewBucketsTasksList)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterTaskCollectionRoutes) }
|
||||
|
||||
func tasksListAll(ctx context.Context, in *taskListAllInput) (*taskListBody, error) {
|
||||
return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, 0, 0)
|
||||
}
|
||||
|
||||
func projectTasksList(ctx context.Context, in *taskListProjectInput) (*taskListBody, error) {
|
||||
return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, in.ProjectID, 0)
|
||||
}
|
||||
|
||||
func projectViewTasksList(ctx context.Context, in *taskListViewInput) (*taskListBody, error) {
|
||||
return readFlatTasks(ctx, in.filters(), in.Page, in.PerPage, in.ProjectID, in.ViewID)
|
||||
}
|
||||
|
||||
// readFlatTasks runs DoReadAll for a flat-task endpoint and unwraps the result.
|
||||
// The model authorizes (project/view CanRead) inside ReadAll, so there's no
|
||||
// Can* call here.
|
||||
func readFlatTasks(ctx context.Context, f taskListFilters, page, perPage int, projectID, viewID int64) (*taskListBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc, err := f.collection(projectID, viewID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, _, total, err := handler.DoReadAll(ctx, tc, a, f.Q, page, perPage)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
tasks, ok := result.([]*models.Task)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Task)", result)
|
||||
}
|
||||
return &taskListBody{Body: NewPaginated(tasks, total, page, perPage)}, nil
|
||||
}
|
||||
|
||||
func projectViewBucketsTasksList(ctx context.Context, in *taskListViewInput) (*bucketsWithTasksBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := in.filters()
|
||||
tc, err := f.collection(in.ProjectID, in.ViewID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, _, total, err := handler.DoReadAll(ctx, tc, a, f.Q, in.Page, in.PerPage)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
buckets, ok := result.([]*models.Bucket)
|
||||
if !ok {
|
||||
// ReadAll only yields []*Bucket from the kanban branch; a flat []*Task
|
||||
// here means the view has no bucket configuration, so there are no
|
||||
// buckets to return. That's a client error, not a 500.
|
||||
if _, isTasks := result.([]*models.Task); isTasks {
|
||||
return nil, huma.Error400BadRequest("this view has no buckets; use the tasks endpoint for non-kanban views")
|
||||
}
|
||||
return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Bucket)", result)
|
||||
}
|
||||
out := &bucketsWithTasksBody{}
|
||||
out.Body.Items = buckets
|
||||
out.Body.Total = total
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
// 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 webtests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// decodePaginatedTaskItems pulls the items slice out of a Paginated[*Task]
|
||||
// response so length assertions don't have to regex over nested task JSON.
|
||||
func decodePaginatedTaskItems(t *testing.T, rec *httptest.ResponseRecorder) []json.RawMessage {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
return body.Items
|
||||
}
|
||||
|
||||
// TestHumaTaskCollection covers the v2 task-list endpoints. v2 splits v1's
|
||||
// single polymorphic /tasks endpoint into flat-task endpoints (always []*Task,
|
||||
// paginated) and a dedicated buckets-with-tasks endpoint (always []*Bucket).
|
||||
// Mirrors v1's TestTaskCollection where the surface overlaps.
|
||||
func TestHumaTaskCollection(t *testing.T) {
|
||||
h := webHandlerTestV2{user: &testuser1, t: t}
|
||||
require.NoError(t, h.ensureEnv())
|
||||
tok := humaTokenFor(t, &testuser1)
|
||||
|
||||
get := func(path string) *httptest.ResponseRecorder {
|
||||
return humaRequest(t, h.e, http.MethodGet, path, "", tok, "")
|
||||
}
|
||||
|
||||
t.Run("project-scoped", func(t *testing.T) {
|
||||
t.Run("returns the project's tasks", func(t *testing.T) {
|
||||
rec := get("/api/v2/projects/1/tasks")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, `"items":[`)
|
||||
assert.Contains(t, body, `task #1`)
|
||||
assert.Contains(t, body, `task #12`)
|
||||
assert.NotContains(t, body, `task #13`) // other project
|
||||
assert.NotContains(t, body, `task #14`)
|
||||
})
|
||||
t.Run("forbidden project", func(t *testing.T) {
|
||||
// Project 2 is inaccessible to user1.
|
||||
rec := get("/api/v2/projects/2/tasks")
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
t.Run("nonexistent project", func(t *testing.T) {
|
||||
rec := get("/api/v2/projects/99999/tasks")
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
t.Run("pagination", func(t *testing.T) {
|
||||
rec := get("/api/v2/projects/1/tasks?page=1&per_page=2")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Len(t, decodePaginatedTaskItems(t, rec), 2, "per_page caps the page to two tasks")
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, `"page":1`)
|
||||
assert.Contains(t, body, `"per_page":2`)
|
||||
})
|
||||
t.Run("filter", func(t *testing.T) {
|
||||
rec := get("/api/v2/projects/1/tasks?filter=" +
|
||||
"start_date%20%3E%20%272018-12-11T03%3A46%3A40%2B00%3A00%27%20%7C%7C%20" +
|
||||
"end_date%20%3C%20%272018-12-13T11%3A20%3A01%2B00%3A00%27%20%7C%7C%20" +
|
||||
"due_date%20%3E%20%272018-11-29T14%3A00%3A00%2B00%3A00%27")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
body := rec.Body.String()
|
||||
assert.NotContains(t, body, `task #1`)
|
||||
assert.Contains(t, body, `task #5 `)
|
||||
assert.Contains(t, body, `task #6 `)
|
||||
assert.NotContains(t, body, `task #10`)
|
||||
})
|
||||
t.Run("invalid filter value", func(t *testing.T) {
|
||||
// ErrInvalidTaskFilterValue carries an explicit 400; only govalidator
|
||||
// failures map to 422 in v2.
|
||||
rec := get("/api/v2/projects/1/tasks?filter=due_date%20%3E%20invalid")
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("search via q", func(t *testing.T) {
|
||||
// Only task #6 has the word "unique" in its description.
|
||||
rec := get("/api/v2/projects/1/tasks?q=unique")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, `task #6 `)
|
||||
assert.NotContains(t, body, `task #1`)
|
||||
assert.NotContains(t, body, `task #2 `)
|
||||
})
|
||||
|
||||
t.Run("sort by repeated params", func(t *testing.T) {
|
||||
// Two sort_by + two order_by prove ,explode binds every value.
|
||||
rec := get("/api/v2/projects/1/tasks?sort_by=priority&sort_by=id&order_by=desc&order_by=asc")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
// task #3 has priority 100, the highest; desc puts it first.
|
||||
assert.Regexp(t, `"items":\[\{"id":3,`, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("invalid sort field", func(t *testing.T) {
|
||||
// A 400 (not 200) proves sort_by binds: the model validated the field
|
||||
// and rejected it. ErrInvalidTaskField carries an explicit 400.
|
||||
rec := get("/api/v2/projects/1/tasks?sort_by=loremipsum")
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("cross-project", func(t *testing.T) {
|
||||
// /tasks returns tasks from every project the user can see, including
|
||||
// shared ones, but not tasks in projects they have no access to.
|
||||
rec := get("/api/v2/tasks")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, `task #1`) // own project
|
||||
assert.Contains(t, body, `task #15`) // shared via team readonly
|
||||
assert.Contains(t, body, `task #21`) // shared via parent project team
|
||||
assert.NotContains(t, body, `task #13`) // no access
|
||||
assert.NotContains(t, body, `task #14`)
|
||||
})
|
||||
|
||||
t.Run("view-scoped", func(t *testing.T) {
|
||||
t.Run("list view returns flat tasks", func(t *testing.T) {
|
||||
rec := get("/api/v2/projects/1/views/1/tasks")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, `task #1`)
|
||||
assert.NotContains(t, body, `testbucket`) // not buckets
|
||||
})
|
||||
t.Run("kanban view still returns flat tasks", func(t *testing.T) {
|
||||
// View 4 is project 1's kanban view. v1 would return buckets here;
|
||||
// v2's tasks endpoint forces flat tasks.
|
||||
rec := get("/api/v2/projects/1/views/4/tasks")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, `"items":[`)
|
||||
assert.Contains(t, body, `task #1`)
|
||||
assert.NotContains(t, body, `testbucket`)
|
||||
})
|
||||
t.Run("forbidden view", func(t *testing.T) {
|
||||
// Project 2 (and its view 8) is inaccessible to user1.
|
||||
rec := get("/api/v2/projects/2/views/8/tasks")
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("saved filter project", func(t *testing.T) {
|
||||
// Project -2 maps to saved filter #1, whose stored filter matches the
|
||||
// date-range tasks. Recurses inside the model.
|
||||
rec := get("/api/v2/projects/-2/tasks")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, `task #5 `)
|
||||
assert.Contains(t, body, `task #6 `)
|
||||
assert.NotContains(t, body, `task #1`)
|
||||
assert.NotContains(t, body, `task #10`)
|
||||
})
|
||||
}
|
||||
|
||||
// TestHumaTaskCollection_Expand proves expand binds every repeated value
|
||||
// (,explode) and routes through parseTaskExpand.
|
||||
func TestHumaTaskCollection_Expand(t *testing.T) {
|
||||
h := webHandlerTestV2{user: &testuser1, t: t}
|
||||
require.NoError(t, h.ensureEnv())
|
||||
tok := humaTokenFor(t, &testuser1)
|
||||
|
||||
get := func(path string) *httptest.ResponseRecorder {
|
||||
return humaRequest(t, h.e, http.MethodGet, path, "", tok, "")
|
||||
}
|
||||
|
||||
t.Run("repeated expand applies every value", func(t *testing.T) {
|
||||
rec := get("/api/v2/projects/1/tasks?expand=comment_count&expand=reactions")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, `"comment_count":`)
|
||||
assert.Contains(t, body, `"reactions":`)
|
||||
})
|
||||
t.Run("invalid expand rejected", func(t *testing.T) {
|
||||
rec := get("/api/v2/projects/1/tasks?expand=bogus")
|
||||
// enum on the query param makes Huma reject before the handler.
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
// TestHumaTaskCollection_Buckets covers the dedicated buckets-with-tasks
|
||||
// endpoint: a kanban view returns []*Bucket with each bucket's tasks populated,
|
||||
// not paginated.
|
||||
func TestHumaTaskCollection_Buckets(t *testing.T) {
|
||||
h := webHandlerTestV2{user: &testuser1, t: t}
|
||||
require.NoError(t, h.ensureEnv())
|
||||
tok := humaTokenFor(t, &testuser1)
|
||||
|
||||
get := func(path string) *httptest.ResponseRecorder {
|
||||
return humaRequest(t, h.e, http.MethodGet, path, "", tok, "")
|
||||
}
|
||||
|
||||
t.Run("kanban view returns buckets with tasks", func(t *testing.T) {
|
||||
rec := get("/api/v2/projects/1/views/4/buckets/tasks")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, `testbucket1`)
|
||||
assert.Contains(t, body, `testbucket2`)
|
||||
assert.Contains(t, body, `testbucket3`)
|
||||
assert.NotContains(t, body, `testbucket4`) // belongs to project 2's view
|
||||
// Tasks are nested under their bucket, not at the top level.
|
||||
assert.Contains(t, body, `"tasks":[`)
|
||||
assert.Contains(t, body, `task #1`)
|
||||
// total counts buckets, not tasks.
|
||||
assert.Contains(t, body, `"total":3`)
|
||||
})
|
||||
|
||||
t.Run("forbidden project", func(t *testing.T) {
|
||||
rec := get("/api/v2/projects/2/views/8/buckets/tasks")
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("non-kanban view is a 400, not a 500", func(t *testing.T) {
|
||||
// View 1 is project 1's list view; it has no bucket configuration, so
|
||||
// the model returns flat tasks and the handler refuses cleanly.
|
||||
rec := get("/api/v2/projects/1/views/1/buckets/tasks")
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("static tasks segment does not collide with the bucket-update route", func(t *testing.T) {
|
||||
// PUT .../buckets/{bucket}/tasks exists; GET .../buckets/tasks must hit
|
||||
// this handler, not parse "tasks" as a bucket id.
|
||||
rec := get("/api/v2/projects/1/views/4/buckets/tasks")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), `testbucket1`)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue