feat(api/v2): add project view routes
Add ProjectView CRUD on /api/v2 under the nested path
/projects/{project}/views[/{view}], establishing the two-path-param
binding pattern for sub-resources. Mirrors the labels.go handler shape
and reuses handler.Do* so permission checks stay at the model layer.
Both {project} and {view} are bound on every operation; {project} is
threaded onto ProjectView.ProjectID (ReadOne resolves via
GetProjectViewByIDAndProject, which needs the parent id). List wraps the
[]*models.ProjectView slice in the shared Paginated envelope, read sends
an ETag for If-None-Match/304, and AutoPatch synthesises PATCH.
Also:
- Tag exposed ProjectView / ProjectViewBucketConfiguration / nested
TaskCollection fields with doc: descriptions; mark server-controlled
fields (id, project_id, created, updated) readOnly. Safe for v1.
- Give ProjectViewKind and BucketConfigurationModeKind a huma.SchemaProvider
so the string-serialised enums reflect as string schemas instead of
Huma's default integer schema (which rejected the string form with 422).
Routes registered in registerAPIRoutesV2 before EnableAutoPatch.
This commit is contained in:
parent
c7e7f8dca3
commit
5ddc9d8ff0
|
|
@ -23,6 +23,7 @@ import (
|
|||
|
||||
"code.vikunja.io/api/pkg/web"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
|
|
@ -66,6 +67,17 @@ func (p *ProjectViewKind) UnmarshalJSON(bytes []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Schema lets Huma (/api/v2) reflect this type as a string enum. The custom
|
||||
// Marshal/UnmarshalJSON above serialize it as a string, but the underlying Go
|
||||
// type is an int — without this, Huma would generate an integer schema and
|
||||
// reject the string form clients actually send.
|
||||
func (*ProjectViewKind) Schema(_ huma.Registry) *huma.Schema {
|
||||
return &huma.Schema{
|
||||
Type: "string",
|
||||
Enum: []any{"list", "gantt", "table", "kanban"},
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: When adding or changing enum values for ProjectViewKind,
|
||||
// make sure to update the corresponding `enums` tag in the ProjectView struct
|
||||
// to keep the OpenAPI documentation in sync.
|
||||
|
|
@ -123,39 +135,48 @@ func (p *BucketConfigurationModeKind) UnmarshalJSON(bytes []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Schema lets Huma (/api/v2) reflect this type as a string enum; see the note
|
||||
// on ProjectViewKind.Schema for why this is needed.
|
||||
func (*BucketConfigurationModeKind) Schema(_ huma.Registry) *huma.Schema {
|
||||
return &huma.Schema{
|
||||
Type: "string",
|
||||
Enum: []any{"none", "manual", "filter"},
|
||||
}
|
||||
}
|
||||
|
||||
type ProjectViewBucketConfiguration struct {
|
||||
Title string `json:"title"`
|
||||
Filter *TaskCollection `json:"filter"`
|
||||
Title string `json:"title" doc:"The title of the bucket this configuration creates."`
|
||||
Filter *TaskCollection `json:"filter" doc:"The filter query that decides which tasks land in this bucket. See https://vikunja.io/docs/filters."`
|
||||
}
|
||||
|
||||
type ProjectView struct {
|
||||
// The unique numeric id of this view
|
||||
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"`
|
||||
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view" readOnly:"true" doc:"The unique, numeric id of this view. Set by the server."`
|
||||
// The title of this view
|
||||
Title string `xorm:"varchar(255) not null" json:"title" valid:"required,runelength(1|250)"`
|
||||
Title string `xorm:"varchar(255) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250" doc:"The title of this view."`
|
||||
// The project this view belongs to
|
||||
ProjectID int64 `xorm:"not null index" json:"project_id" param:"project"`
|
||||
ProjectID int64 `xorm:"not null index" json:"project_id" param:"project" readOnly:"true" doc:"The project this view belongs to. Taken from the URL path; ignored on write."`
|
||||
// The kind of this view. Can be `list`, `gantt`, `table` or `kanban`.
|
||||
ViewKind ProjectViewKind `xorm:"not null" json:"view_kind" swaggertype:"string" enums:"list,gantt,table,kanban"`
|
||||
ViewKind ProjectViewKind `xorm:"not null" json:"view_kind" swaggertype:"string" enums:"list,gantt,table,kanban" doc:"The kind of this view. One of list, gantt, table or kanban."`
|
||||
|
||||
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
|
||||
Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter"`
|
||||
Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter" doc:"The filter query used to match tasks shown in this view. See https://vikunja.io/docs/filters."`
|
||||
// The position of this view in the list. The list of all views will be sorted by this parameter.
|
||||
Position float64 `xorm:"double null" json:"position"`
|
||||
Position float64 `xorm:"double null" json:"position" doc:"The position of this view in the project's list of views. Views are sorted ascending by this value."`
|
||||
|
||||
// The bucket configuration mode. Can be `none`, `manual` or `filter`. `manual` allows to move tasks between buckets as you normally would. `filter` creates buckets based on a filter for each bucket.
|
||||
BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter,manual"`
|
||||
BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter,manual" doc:"The bucket configuration mode. One of none, manual or filter. manual lets you move tasks between buckets; filter creates a bucket per filter."`
|
||||
// When the bucket configuration mode is not `manual`, this field holds the options of that configuration.
|
||||
BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration"`
|
||||
BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration" doc:"When the bucket configuration mode is filter, holds the title and filter of each bucket."`
|
||||
// The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a view.
|
||||
DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"`
|
||||
DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id" doc:"The id of the bucket new tasks without a bucket are added to. Defaults to the leftmost bucket."`
|
||||
// If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.
|
||||
DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"`
|
||||
DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id" doc:"The id of the done bucket. Tasks moved here are marked done, and tasks marked done are moved here."`
|
||||
|
||||
// A timestamp when this view was updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated not null" json:"updated"`
|
||||
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this view was last updated. You cannot change this value."`
|
||||
// A timestamp when this reaction was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this view was created. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -32,20 +32,20 @@ type TaskCollection struct {
|
|||
ProjectID int64 `param:"project" json:"-"`
|
||||
ProjectViewID int64 `param:"view" json:"-"`
|
||||
|
||||
Search string `query:"s" json:"s"`
|
||||
Search string `query:"s" json:"s" doc:"A search term to match tasks by their title."`
|
||||
|
||||
// The query parameter to sort by. This is for ex. done, priority, etc.
|
||||
SortBy []string `query:"sort_by" json:"sort_by"`
|
||||
SortBy []string `query:"sort_by" json:"sort_by" doc:"The fields to sort by, for example done or priority."`
|
||||
// The query parameter to order the items by. This can be either asc or desc, with asc being the default.
|
||||
OrderBy []string `query:"order_by" json:"order_by"`
|
||||
OrderBy []string `query:"order_by" json:"order_by" doc:"The order for each sort_by field, either asc or desc. Defaults to asc."`
|
||||
|
||||
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
|
||||
Filter string `query:"filter" json:"filter"`
|
||||
Filter string `query:"filter" json:"filter" doc:"The filter query to match tasks by. See https://vikunja.io/docs/filters."`
|
||||
// The time zone which should be used for date match (statements like "now" resolve to different actual times)
|
||||
FilterTimezone string `query:"filter_timezone" json:"-"`
|
||||
|
||||
// If set to true, the result will also include null values
|
||||
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
|
||||
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls" doc:"If true, the result also includes tasks whose filtered field is null."`
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
// 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"
|
||||
"github.com/danielgtaylor/huma/v2/conditional"
|
||||
)
|
||||
|
||||
// projectViewListBody is the list-response envelope. models.ProjectView.ReadAll
|
||||
// returns []*models.ProjectView, so that's the element type.
|
||||
type projectViewListBody struct {
|
||||
Body Paginated[*models.ProjectView]
|
||||
}
|
||||
|
||||
// RegisterProjectViewRoutes wires the nested ProjectView CRUD onto the Huma API.
|
||||
// Every operation binds two path params: {project} → ProjectID and {view} → ID.
|
||||
// This is the reference shape every nested sub-resource copies.
|
||||
func RegisterProjectViewRoutes(api huma.API) {
|
||||
tags := []string{"project_views"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "project-views-list",
|
||||
Summary: "List the views of a project",
|
||||
Description: "Returns all views of the given project. Requires read access to the project; the list is not paginated by the server but is returned in the standard list envelope.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/projects/{project}/views",
|
||||
Tags: tags,
|
||||
}, projectViewsList)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "project-views-read",
|
||||
Summary: "Get a single view of a project",
|
||||
Description: "Returns one view of a project. The view must belong to the project in the path. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/projects/{project}/views/{view}",
|
||||
Tags: tags,
|
||||
}, projectViewsRead)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "project-views-create",
|
||||
Summary: "Create a view in a project",
|
||||
Description: "Creates a view in the given project. The parent project is taken from the URL, not the body. Only project admins may create a view.",
|
||||
Method: http.MethodPost,
|
||||
Path: "/projects/{project}/views",
|
||||
Tags: tags,
|
||||
}, projectViewsCreate)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "project-views-update",
|
||||
Summary: "Update a view of a project",
|
||||
Description: "Replaces a project view's fields. The view must belong to the project in the path, and only project admins may update it. Use PATCH for a partial update.",
|
||||
Method: http.MethodPut,
|
||||
Path: "/projects/{project}/views/{view}",
|
||||
Tags: tags,
|
||||
}, projectViewsUpdate)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "project-views-delete",
|
||||
Summary: "Delete a view of a project",
|
||||
Description: "Deletes a project view along with its buckets and task positions. Only project admins may delete it.",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/projects/{project}/views/{view}",
|
||||
Tags: tags,
|
||||
}, projectViewsDelete)
|
||||
}
|
||||
|
||||
func projectViewsList(ctx context.Context, in *struct {
|
||||
ProjectID int64 `path:"project"`
|
||||
ListParams
|
||||
}) (*projectViewListBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, _, total, err := handler.DoReadAll(ctx, &models.ProjectView{ProjectID: in.ProjectID}, a, in.Q, in.Page, in.PerPage)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
items, ok := result.([]*models.ProjectView)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("projectViews.ReadAll returned unexpected type %T (expected []*models.ProjectView)", result)
|
||||
}
|
||||
return &projectViewListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
|
||||
}
|
||||
|
||||
func projectViewsRead(ctx context.Context, in *struct {
|
||||
ProjectID int64 `path:"project"`
|
||||
ID int64 `path:"view"`
|
||||
conditional.Params
|
||||
}) (*singleReadBody[models.ProjectView], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ReadOne resolves the view via GetProjectViewByIDAndProject, which needs
|
||||
// both ids — the parent project scopes the lookup.
|
||||
view := &models.ProjectView{ID: in.ID, ProjectID: in.ProjectID}
|
||||
if _, err := handler.DoReadOne(ctx, view, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
// PreconditionFailed wants the unquoted etag; response header uses RFC 9110 quoted form.
|
||||
etag := fmt.Sprintf("%d-%d", view.ID, view.Updated.UnixNano())
|
||||
if in.HasConditionalParams() {
|
||||
if err := in.PreconditionFailed(etag, view.Updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &singleReadBody[models.ProjectView]{ETag: `"` + etag + `"`, Body: view}, nil
|
||||
}
|
||||
|
||||
func projectViewsCreate(ctx context.Context, in *struct {
|
||||
ProjectID int64 `path:"project"`
|
||||
Body models.ProjectView
|
||||
}) (*singleBody[models.ProjectView], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Body.ProjectID = in.ProjectID // URL wins over body
|
||||
if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.ProjectView]{Body: &in.Body}, nil
|
||||
}
|
||||
|
||||
func projectViewsUpdate(ctx context.Context, in *struct {
|
||||
ProjectID int64 `path:"project"`
|
||||
ID int64 `path:"view"`
|
||||
Body models.ProjectView
|
||||
}) (*singleBody[models.ProjectView], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Body.ID = in.ID // URL wins over body
|
||||
in.Body.ProjectID = in.ProjectID // parent from the path scopes the update
|
||||
if err := handler.DoUpdate(ctx, &in.Body, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.ProjectView]{Body: &in.Body}, nil
|
||||
}
|
||||
|
||||
func projectViewsDelete(ctx context.Context, in *struct {
|
||||
ProjectID int64 `path:"project"`
|
||||
ID int64 `path:"view"`
|
||||
}) (*emptyBody, error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := handler.DoDelete(ctx, &models.ProjectView{ID: in.ID, ProjectID: in.ProjectID}, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &emptyBody{}, nil
|
||||
}
|
||||
|
|
@ -396,6 +396,7 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) {
|
|||
// Resource registrations.
|
||||
apiv2.RegisterLabelRoutes(api)
|
||||
apiv2.RegisterTaskDuplicateRoutes(api)
|
||||
apiv2.RegisterProjectViewRoutes(api)
|
||||
|
||||
// AutoPatch must run AFTER all GET/PUT pairs are registered so it can
|
||||
// synthesize their PATCH counterparts.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
// 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"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestProjectView is the nested-path reference test for /api/v2. Views live
|
||||
// under /projects/{project}/views/{view}, so the harness binds two path params:
|
||||
// basePath carries the literal {project} and idParam picks {view}.
|
||||
//
|
||||
// Fixtures: project 1 (owned by testuser1) has views 1-4; project 2 (owned by
|
||||
// user3, no share to testuser1) has views 5-8 — used for the forbidden and
|
||||
// wrong-parent negatives.
|
||||
func TestProjectView(t *testing.T) {
|
||||
// project 1 is owned by testuser1.
|
||||
owned := webHandlerTestV2{
|
||||
user: &testuser1,
|
||||
basePath: "/api/v2/projects/1/views",
|
||||
idParam: "view",
|
||||
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",
|
||||
idParam: "view",
|
||||
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)
|
||||
// project 1's four default views, none from project 2.
|
||||
assert.Contains(t, rec.Body.String(), `"title":"List"`)
|
||||
assert.Contains(t, rec.Body.String(), `"title":"Gantt"`)
|
||||
assert.Contains(t, rec.Body.String(), `"title":"Table"`)
|
||||
assert.Contains(t, rec.Body.String(), `"title":"Kanban"`)
|
||||
assert.Contains(t, rec.Body.String(), `"project_id":1`)
|
||||
assert.NotContains(t, rec.Body.String(), `"project_id":2`)
|
||||
})
|
||||
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("ReadOne", func(t *testing.T) {
|
||||
t.Run("Normal", func(t *testing.T) {
|
||||
rec, err := owned.testReadOneWithUser(nil, map[string]string{"view": "1"})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `"title":"List"`)
|
||||
assert.Contains(t, rec.Body.String(), `"id":1`)
|
||||
assert.NotEmpty(t, rec.Result().Header.Get("ETag"))
|
||||
})
|
||||
t.Run("Nonexisting", func(t *testing.T) {
|
||||
_, err := owned.testReadOneWithUser(nil, map[string]string{"view": "9999"})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
|
||||
})
|
||||
t.Run("View from another project", func(t *testing.T) {
|
||||
// view 5 belongs to project 2; reading it under project 1 must 404.
|
||||
_, err := owned.testReadOneWithUser(nil, map[string]string{"view": "5"})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
|
||||
})
|
||||
t.Run("Forbidden", func(t *testing.T) {
|
||||
// view 5 read under its real parent (project 2) — no access.
|
||||
_, err := forbidden.testReadOneWithUser(nil, map[string]string{"view": "5"})
|
||||
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 view","view_kind":"list"}`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusCreated, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), `"title":"New view"`)
|
||||
// ownership: the parent project from the URL wins.
|
||||
assert.Contains(t, rec.Body.String(), `"project_id":1`)
|
||||
})
|
||||
t.Run("Forbidden", func(t *testing.T) {
|
||||
_, err := forbidden.testCreateWithUser(nil, nil, `{"title":"Nope","view_kind":"list"}`)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
t.Run("Normal", func(t *testing.T) {
|
||||
rec, err := owned.testUpdateWithUser(nil, map[string]string{"view": "1"}, `{"title":"Renamed list","view_kind":"list"}`)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `"title":"Renamed list"`)
|
||||
assert.Contains(t, rec.Body.String(), `"id":1`)
|
||||
})
|
||||
t.Run("View from another project", func(t *testing.T) {
|
||||
_, err := owned.testUpdateWithUser(nil, map[string]string{"view": "5"}, `{"title":"x","view_kind":"list"}`)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
|
||||
})
|
||||
t.Run("Forbidden", func(t *testing.T) {
|
||||
_, err := forbidden.testUpdateWithUser(nil, map[string]string{"view": "5"}, `{"title":"x","view_kind":"list"}`)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
t.Run("Normal", func(t *testing.T) {
|
||||
rec, err := owned.testDeleteWithUser(nil, map[string]string{"view": "2"})
|
||||
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{"view": "5"})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestProjectView_ETagReturns304 covers v2-only conditional-request behaviour.
|
||||
func TestProjectView_ETagReturns304(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/1/views/1", "", token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
etag := rec.Header().Get("ETag")
|
||||
require.NotEmpty(t, etag, "GET must return an ETag header")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/projects/1/views/1", strings.NewReader(""))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
rec = httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusNotModified, rec.Code, "body: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestProjectView_PATCHMergePatch confirms AutoPatch synthesises a PATCH that
|
||||
// only touches supplied fields.
|
||||
func TestProjectView_PATCHMergePatch(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Create a fresh view so we don't stomp fixtures.
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/views",
|
||||
`{"title":"before","view_kind":"table"}`, token, "")
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
var created struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
|
||||
|
||||
// PATCH only title; view_kind must survive.
|
||||
rec = humaRequest(t, e, http.MethodPatch, fmt.Sprintf("/api/v2/projects/1/views/%d", created.ID),
|
||||
`{"title":"after"}`, token, "application/merge-patch+json")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/projects/1/views/%d", created.ID), "", token, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
var after struct {
|
||||
Title string `json:"title"`
|
||||
ViewKind string `json:"view_kind"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
|
||||
assert.Equal(t, "after", after.Title)
|
||||
assert.Equal(t, "table", after.ViewKind, "view_kind must survive the PATCH")
|
||||
}
|
||||
Loading…
Reference in New Issue