diff --git a/pkg/routes/api/v2/task_collection.go b/pkg/routes/api/v2/task_collection.go
new file mode 100644
index 000000000..a0cdda758
--- /dev/null
+++ b/pkg/routes/api/v2/task_collection.go
@@ -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 .
+
+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
+}
diff --git a/pkg/webtests/huma_task_collection_test.go b/pkg/webtests/huma_task_collection_test.go
new file mode 100644
index 000000000..110b04b61
--- /dev/null
+++ b/pkg/webtests/huma_task_collection_test.go
@@ -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 .
+
+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`)
+ })
+}