From 5ddc9d8ff0c956c5838f25b7cdcd2c32611781fd Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 31 May 2026 17:54:49 +0200 Subject: [PATCH] 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. --- pkg/models/project_view.go | 49 ++++-- pkg/models/task_collection.go | 10 +- pkg/routes/api/v2/project_views.go | 177 +++++++++++++++++++++ pkg/routes/routes.go | 1 + pkg/webtests/huma_project_view_test.go | 204 +++++++++++++++++++++++++ 5 files changed, 422 insertions(+), 19 deletions(-) create mode 100644 pkg/routes/api/v2/project_views.go create mode 100644 pkg/webtests/huma_project_view_test.go diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go index fce6798de..8b337ff90 100644 --- a/pkg/models/project_view.go +++ b/pkg/models/project_view.go @@ -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:"-"` diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 227caaa46..22f048564 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -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 diff --git a/pkg/routes/api/v2/project_views.go b/pkg/routes/api/v2/project_views.go new file mode 100644 index 000000000..907d305c9 --- /dev/null +++ b/pkg/routes/api/v2/project_views.go @@ -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 . + +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 +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 5b00b1362..92ef73efc 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -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. diff --git a/pkg/webtests/huma_project_view_test.go b/pkg/webtests/huma_project_view_test.go new file mode 100644 index 000000000..0b1537576 --- /dev/null +++ b/pkg/webtests/huma_project_view_test.go @@ -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 . + +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") +}