feat(api/v2): add kanban bucket CRUD endpoints
Port the standalone bucket list/create/update/delete from v1 to the
Huma-backed /api/v2, under /projects/{project}/views/{view}/buckets,
using v2 verb conventions (POST creates, PUT updates). The handlers
reuse the generic handler.Do* functions, so permissions are enforced
by the Bucket model's existing Can* methods.
Mirrors v1: no read-one route (the model has no ReadOne/CanRead), so
AutoPatch synthesises no PATCH. No model changes.
This commit is contained in:
parent
9390199ce0
commit
0d043e80e4
|
|
@ -0,0 +1,155 @@
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bucketListBody is the list-response envelope. models.Bucket.ReadAll returns
|
||||||
|
// []*models.Bucket, so that's the element type.
|
||||||
|
type bucketListBody struct {
|
||||||
|
Body Paginated[*models.Bucket]
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterBucketRoutes wires the nested kanban-bucket CRUD onto the Huma API.
|
||||||
|
// Buckets live under /projects/{project}/views/{view}/buckets; every operation
|
||||||
|
// binds {project} → ProjectID and {view} → ProjectViewID, the write operations
|
||||||
|
// additionally {bucket} → ID. There is intentionally no read-one route
|
||||||
|
// (mirroring v1: the Bucket model has no ReadOne/CanRead), so AutoPatch
|
||||||
|
// synthesises no PATCH either.
|
||||||
|
func RegisterBucketRoutes(api huma.API) {
|
||||||
|
tags := []string{"projects"}
|
||||||
|
|
||||||
|
Register(api, huma.Operation{
|
||||||
|
OperationID: "buckets-list",
|
||||||
|
Summary: "List the buckets of a kanban view",
|
||||||
|
Description: "Returns all kanban buckets of a project view, ordered by position. Requires read access to the project. The list is not paginated by the server but is returned in the standard list envelope. To get the buckets together with their tasks, use the buckets/tasks endpoint instead.",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/projects/{project}/views/{view}/buckets",
|
||||||
|
Tags: tags,
|
||||||
|
}, bucketsList)
|
||||||
|
|
||||||
|
Register(api, huma.Operation{
|
||||||
|
OperationID: "buckets-create",
|
||||||
|
Summary: "Create a bucket in a kanban view",
|
||||||
|
Description: "Creates a kanban bucket in the given project view. The project and view come from the URL, not the body. Requires write access to the project.",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/projects/{project}/views/{view}/buckets",
|
||||||
|
Tags: tags,
|
||||||
|
}, bucketsCreate)
|
||||||
|
|
||||||
|
Register(api, huma.Operation{
|
||||||
|
OperationID: "buckets-update",
|
||||||
|
Summary: "Update a bucket of a kanban view",
|
||||||
|
Description: "Replaces a kanban bucket's title, limit and position. The bucket is identified by the URL, which also scopes it to the project and view. Requires write access to the project.",
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Path: "/projects/{project}/views/{view}/buckets/{bucket}",
|
||||||
|
Tags: tags,
|
||||||
|
}, bucketsUpdate)
|
||||||
|
|
||||||
|
Register(api, huma.Operation{
|
||||||
|
OperationID: "buckets-delete",
|
||||||
|
Summary: "Delete a bucket of a kanban view",
|
||||||
|
Description: "Deletes a kanban bucket and moves its tasks to the view's default bucket; no tasks are deleted. You cannot delete the last bucket of a view (rejected with 412). Requires write access to the project.",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/projects/{project}/views/{view}/buckets/{bucket}",
|
||||||
|
Tags: tags,
|
||||||
|
}, bucketsDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { AddRouteRegistrar(RegisterBucketRoutes) }
|
||||||
|
|
||||||
|
func bucketsList(ctx context.Context, in *struct {
|
||||||
|
ProjectID int64 `path:"project"`
|
||||||
|
ViewID int64 `path:"view"`
|
||||||
|
ListParams
|
||||||
|
}) (*bucketListBody, error) {
|
||||||
|
a, err := authFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, _, total, err := handler.DoReadAll(ctx, &models.Bucket{ProjectID: in.ProjectID, ProjectViewID: in.ViewID}, a, in.Q, in.Page, in.PerPage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
buckets, ok := result.([]*models.Bucket)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("buckets.ReadAll returned unexpected type %T (expected []*models.Bucket)", result)
|
||||||
|
}
|
||||||
|
return &bucketListBody{Body: NewPaginated(buckets, total, in.Page, in.PerPage)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketsCreate(ctx context.Context, in *struct {
|
||||||
|
ProjectID int64 `path:"project"`
|
||||||
|
ViewID int64 `path:"view"`
|
||||||
|
Body models.Bucket
|
||||||
|
}) (*singleBody[models.Bucket], error) {
|
||||||
|
a, err := authFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b := &in.Body
|
||||||
|
b.ProjectID = in.ProjectID // URL wins over body
|
||||||
|
b.ProjectViewID = in.ViewID // URL wins over body
|
||||||
|
if err := handler.DoCreate(ctx, b, a); err != nil {
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
return &singleBody[models.Bucket]{Body: b}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketsUpdate(ctx context.Context, in *struct {
|
||||||
|
ProjectID int64 `path:"project"`
|
||||||
|
ViewID int64 `path:"view"`
|
||||||
|
BucketID int64 `path:"bucket"`
|
||||||
|
Body models.Bucket
|
||||||
|
}) (*singleBody[models.Bucket], error) {
|
||||||
|
a, err := authFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b := &in.Body
|
||||||
|
b.ID = in.BucketID // URL wins over body
|
||||||
|
b.ProjectID = in.ProjectID // URL wins over body
|
||||||
|
b.ProjectViewID = in.ViewID // URL wins over body
|
||||||
|
if err := handler.DoUpdate(ctx, b, a); err != nil {
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
return &singleBody[models.Bucket]{Body: b}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketsDelete(ctx context.Context, in *struct {
|
||||||
|
ProjectID int64 `path:"project"`
|
||||||
|
ViewID int64 `path:"view"`
|
||||||
|
BucketID int64 `path:"bucket"`
|
||||||
|
}) (*emptyBody, error) {
|
||||||
|
a, err := authFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := handler.DoDelete(ctx, &models.Bucket{ID: in.BucketID, ProjectID: in.ProjectID, ProjectViewID: in.ViewID}, a); err != nil {
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
return &emptyBody{}, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
// 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"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/db"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBucket covers the nested kanban-bucket CRUD on /api/v2. Buckets live under
|
||||||
|
// /projects/{project}/views/{view}/buckets, so the harness binds the project and
|
||||||
|
// view in basePath and idParam picks {bucket}.
|
||||||
|
//
|
||||||
|
// Permission model — Bucket.Can{Create,Update,Delete} all delegate to
|
||||||
|
// Project.CanUpdate, which resolves to write access (not admin). Bucket.ReadAll
|
||||||
|
// only needs the view's read access. So write is the boundary for mutation,
|
||||||
|
// unlike project views where admin is required.
|
||||||
|
//
|
||||||
|
// Fixture topology (see pkg/db/fixtures):
|
||||||
|
// - project 1 (owned by testuser1), kanban view 4: buckets 1, 2, 3.
|
||||||
|
// - project 2 (owned by user3, no share to testuser1), kanban view 8:
|
||||||
|
// buckets 4, 40 — the forbidden / non-member negatives.
|
||||||
|
// - projects 9/10/11 are owned by user6 and shared to testuser1 read/write/admin;
|
||||||
|
// their kanban views 36/40/44 carry buckets {9,25}/{10,26}/{11,27}. The same
|
||||||
|
// user exercises every rung by switching the parent path.
|
||||||
|
func TestHumaBucket(t *testing.T) {
|
||||||
|
// project 1 is owned by testuser1.
|
||||||
|
owned := webHandlerTestV2{
|
||||||
|
user: &testuser1,
|
||||||
|
basePath: "/api/v2/projects/1/views/4/buckets",
|
||||||
|
idParam: "bucket",
|
||||||
|
t: t,
|
||||||
|
}
|
||||||
|
require.NoError(t, owned.ensureEnv())
|
||||||
|
// project 2 is owned by user3; testuser1 has no access. Share owned's Echo
|
||||||
|
// instance: each setupTestEnv() regenerates the global JWT signing secret,
|
||||||
|
// so two independent harnesses would invalidate each other's tokens.
|
||||||
|
forbidden := webHandlerTestV2{
|
||||||
|
user: &testuser1,
|
||||||
|
basePath: "/api/v2/projects/2/views/8/buckets",
|
||||||
|
idParam: "bucket",
|
||||||
|
t: t,
|
||||||
|
e: owned.e,
|
||||||
|
}
|
||||||
|
// project 9 is shared to testuser1 read-only — enough to list, below the
|
||||||
|
// write bar mutation requires.
|
||||||
|
readShared := webHandlerTestV2{
|
||||||
|
user: &testuser1,
|
||||||
|
basePath: "/api/v2/projects/9/views/36/buckets",
|
||||||
|
idParam: "bucket",
|
||||||
|
t: t,
|
||||||
|
e: owned.e,
|
||||||
|
}
|
||||||
|
// project 10 is shared with write — the rung that clears Project.CanUpdate,
|
||||||
|
// so it can create/update/delete buckets.
|
||||||
|
writeShared := webHandlerTestV2{
|
||||||
|
user: &testuser1,
|
||||||
|
basePath: "/api/v2/projects/10/views/40/buckets",
|
||||||
|
idParam: "bucket",
|
||||||
|
t: t,
|
||||||
|
e: owned.e,
|
||||||
|
}
|
||||||
|
// project 11 is shared with admin — write access is a subset, so it can do
|
||||||
|
// everything too.
|
||||||
|
adminShared := webHandlerTestV2{
|
||||||
|
user: &testuser1,
|
||||||
|
basePath: "/api/v2/projects/11/views/44/buckets",
|
||||||
|
idParam: "bucket",
|
||||||
|
t: t,
|
||||||
|
e: owned.e,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("ReadAll", func(t *testing.T) {
|
||||||
|
t.Run("Normal", func(t *testing.T) {
|
||||||
|
rec, err := owned.testReadAllWithUser(nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// view 4 has exactly buckets 1, 2, 3 in position order.
|
||||||
|
ids, viewIDs := bucketsFromReadAll(t, rec.Body.Bytes())
|
||||||
|
assert.ElementsMatch(t, []int64{1, 2, 3}, ids)
|
||||||
|
for _, vid := range viewIDs {
|
||||||
|
assert.Equal(t, int64(4), vid, "every returned bucket must belong to view 4")
|
||||||
|
}
|
||||||
|
assert.Contains(t, rec.Body.String(), `"total":3`)
|
||||||
|
})
|
||||||
|
t.Run("Read-only share can list", func(t *testing.T) {
|
||||||
|
// ReadAll only needs the view's read access; a read share suffices.
|
||||||
|
rec, err := readShared.testReadAllWithUser(nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ids, _ := bucketsFromReadAll(t, rec.Body.Bytes())
|
||||||
|
assert.ElementsMatch(t, []int64{9, 25}, ids)
|
||||||
|
})
|
||||||
|
t.Run("Write share can list", func(t *testing.T) {
|
||||||
|
rec, err := writeShared.testReadAllWithUser(nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ids, _ := bucketsFromReadAll(t, rec.Body.Bytes())
|
||||||
|
assert.ElementsMatch(t, []int64{10, 26}, ids)
|
||||||
|
})
|
||||||
|
t.Run("Forbidden", func(t *testing.T) {
|
||||||
|
_, err := forbidden.testReadAllWithUser(nil, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Create", func(t *testing.T) {
|
||||||
|
t.Run("Normal", func(t *testing.T) {
|
||||||
|
rec, err := owned.testCreateWithUser(nil, nil, `{"title":"New bucket","limit":5}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusCreated, rec.Code)
|
||||||
|
assert.Contains(t, rec.Body.String(), `"title":"New bucket"`)
|
||||||
|
assert.Contains(t, rec.Body.String(), `"limit":5`)
|
||||||
|
// ownership: the view from the URL wins over the body.
|
||||||
|
assert.Contains(t, rec.Body.String(), `"project_view_id":4`)
|
||||||
|
})
|
||||||
|
t.Run("Write share can create", func(t *testing.T) {
|
||||||
|
// write access clears Project.CanUpdate → Bucket.CanCreate passes.
|
||||||
|
rec, err := writeShared.testCreateWithUser(nil, nil, `{"title":"Write made"}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusCreated, rec.Code)
|
||||||
|
assert.Contains(t, rec.Body.String(), `"title":"Write made"`)
|
||||||
|
assert.Contains(t, rec.Body.String(), `"project_view_id":40`)
|
||||||
|
})
|
||||||
|
t.Run("Admin share can create", func(t *testing.T) {
|
||||||
|
rec, err := adminShared.testCreateWithUser(nil, nil, `{"title":"Admin made"}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusCreated, rec.Code)
|
||||||
|
assert.Contains(t, rec.Body.String(), `"title":"Admin made"`)
|
||||||
|
assert.Contains(t, rec.Body.String(), `"project_view_id":44`)
|
||||||
|
})
|
||||||
|
t.Run("Read share cannot create", func(t *testing.T) {
|
||||||
|
// read share is below the write bar Bucket.CanCreate enforces.
|
||||||
|
_, err := readShared.testCreateWithUser(nil, nil, `{"title":"Nope"}`)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||||
|
})
|
||||||
|
t.Run("Forbidden", func(t *testing.T) {
|
||||||
|
_, err := forbidden.testCreateWithUser(nil, nil, `{"title":"Nope"}`)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||||
|
})
|
||||||
|
t.Run("Empty title", func(t *testing.T) {
|
||||||
|
// Title has valid:"required" / minLength:"1" → 422 before the model.
|
||||||
|
_, err := owned.testCreateWithUser(nil, nil, `{"title":""}`)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Update", func(t *testing.T) {
|
||||||
|
t.Run("Normal", func(t *testing.T) {
|
||||||
|
rec, err := owned.testUpdateWithUser(nil, map[string]string{"bucket": "1"}, `{"title":"Renamed bucket"}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, rec.Body.String(), `"title":"Renamed bucket"`)
|
||||||
|
assert.Contains(t, rec.Body.String(), `"id":1`)
|
||||||
|
// Only the sent fields are written: the server-managed creator and the
|
||||||
|
// view scoping from the URL are preserved, not clobbered to zero.
|
||||||
|
db.AssertExists(t, "buckets", map[string]interface{}{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Renamed bucket",
|
||||||
|
"project_view_id": 4,
|
||||||
|
"created_by_id": 1,
|
||||||
|
}, false)
|
||||||
|
})
|
||||||
|
t.Run("Write share can update", func(t *testing.T) {
|
||||||
|
// bucket 10 belongs to view 40 (project 10, write share).
|
||||||
|
rec, err := writeShared.testUpdateWithUser(nil, map[string]string{"bucket": "10"}, `{"title":"Write renamed"}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, rec.Body.String(), `"title":"Write renamed"`)
|
||||||
|
assert.Contains(t, rec.Body.String(), `"id":10`)
|
||||||
|
})
|
||||||
|
t.Run("Read share cannot update", func(t *testing.T) {
|
||||||
|
// bucket 9 belongs to view 36 (project 9, read share) → needs write.
|
||||||
|
_, err := readShared.testUpdateWithUser(nil, map[string]string{"bucket": "9"}, `{"title":"x"}`)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||||
|
})
|
||||||
|
t.Run("Nonexisting", func(t *testing.T) {
|
||||||
|
_, err := owned.testUpdateWithUser(nil, map[string]string{"bucket": "9999"}, `{"title":"x"}`)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
|
||||||
|
})
|
||||||
|
t.Run("Forbidden", func(t *testing.T) {
|
||||||
|
// bucket 4 belongs to view 8 (project 2) — testuser1 has no access.
|
||||||
|
_, err := forbidden.testUpdateWithUser(nil, map[string]string{"bucket": "4"}, `{"title":"x"}`)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete", func(t *testing.T) {
|
||||||
|
t.Run("Read share cannot delete", func(t *testing.T) {
|
||||||
|
// bucket 25 belongs to view 36 (project 9, read share) → needs write.
|
||||||
|
_, err := readShared.testDeleteWithUser(nil, map[string]string{"bucket": "25"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||||
|
})
|
||||||
|
t.Run("Write share can delete", func(t *testing.T) {
|
||||||
|
// bucket 26 belongs to view 40 (project 10, write share); view 40 still
|
||||||
|
// has bucket 10 (plus the one created above), so it isn't the last.
|
||||||
|
rec, err := writeShared.testDeleteWithUser(nil, map[string]string{"bucket": "26"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||||
|
assert.Empty(t, rec.Body.String())
|
||||||
|
})
|
||||||
|
t.Run("Forbidden", func(t *testing.T) {
|
||||||
|
_, err := forbidden.testDeleteWithUser(nil, map[string]string{"bucket": "40"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||||
|
})
|
||||||
|
t.Run("Normal", func(t *testing.T) {
|
||||||
|
// view 4 has buckets 1, 2, 3 (plus the one created above), so deleting
|
||||||
|
// bucket 2 leaves more than one behind.
|
||||||
|
rec, err := owned.testDeleteWithUser(nil, map[string]string{"bucket": "2"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||||
|
assert.Empty(t, rec.Body.String())
|
||||||
|
db.AssertMissing(t, "buckets", map[string]interface{}{"id": 2})
|
||||||
|
})
|
||||||
|
t.Run("Nonexisting", func(t *testing.T) {
|
||||||
|
_, err := owned.testDeleteWithUser(nil, map[string]string{"bucket": "9999"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketsFromReadAll extracts the bucket ids and their project_view_ids from a v2
|
||||||
|
// paginated list body so the visible set can be asserted exactly.
|
||||||
|
func bucketsFromReadAll(t *testing.T, body []byte) (ids []int64, viewIDs []int64) {
|
||||||
|
t.Helper()
|
||||||
|
var resp struct {
|
||||||
|
Items []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProjectViewID int64 `json:"project_view_id"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &resp), "ReadAll body must be a paginated envelope: %s", string(body))
|
||||||
|
ids = make([]int64, 0, len(resp.Items))
|
||||||
|
viewIDs = make([]int64, 0, len(resp.Items))
|
||||||
|
for _, it := range resp.Items {
|
||||||
|
ids = append(ids, it.ID)
|
||||||
|
viewIDs = append(viewIDs, it.ProjectViewID)
|
||||||
|
}
|
||||||
|
return ids, viewIDs
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue