mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-11-09 00:22:38 -06:00
377 lines
12 KiB
Go
377 lines
12 KiB
Go
// 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 mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"code.vikunja.io/api/pkg/models"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/danielgtaylor/huma/v2/autopatch"
|
|
"github.com/danielgtaylor/huma/v2/humatest"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func specFor(t *testing.T, api huma.API, method, path string) *toolSpec {
|
|
t.Helper()
|
|
item := api.OpenAPI().Paths[path]
|
|
require.NotNil(t, item)
|
|
var op *huma.Operation
|
|
switch method {
|
|
case http.MethodGet:
|
|
op = item.Get
|
|
case http.MethodPost:
|
|
op = item.Post
|
|
case http.MethodPatch:
|
|
op = item.Patch
|
|
}
|
|
require.NotNil(t, op)
|
|
spec, err := buildToolSpec(api.OpenAPI(), op)
|
|
require.NoError(t, err)
|
|
return spec
|
|
}
|
|
func TestBuildToolSpec_Create(t *testing.T) {
|
|
spec := specFor(t, newTestAPI(t), http.MethodPost, "/things")
|
|
props := spec.schema.Properties
|
|
for _, name := range []string{
|
|
"title",
|
|
"description",
|
|
"reminders",
|
|
} {
|
|
assert.Contains(t, props, name)
|
|
}
|
|
assert.NotContains(t, props, "id")
|
|
assert.NotContains(t, props, "owner")
|
|
assert.Equal(t, []string{"title"}, spec.schema.Required)
|
|
assert.ElementsMatch(t, []string{
|
|
"array",
|
|
"null",
|
|
}, props["reminders"].Types)
|
|
require.NotNil(t, props["reminders"].Items)
|
|
assert.Equal(t, "object", props["reminders"].Items.Type)
|
|
assert.Contains(t, props["reminders"].Items.Properties, "at")
|
|
assert.True(t, spec.hasBody)
|
|
assert.Empty(t, spec.params)
|
|
assert.NotNil(t, spec.schema.AdditionalProperties)
|
|
}
|
|
func TestBuildToolSpec_ListParams(t *testing.T) {
|
|
spec := specFor(t, newTestAPI(t), http.MethodGet, "/things")
|
|
assert.Equal(t, "query", spec.params["q"].In)
|
|
assert.ElementsMatch(t, []string{
|
|
"array",
|
|
"null",
|
|
}, spec.schema.Properties["expand"].Types)
|
|
assert.NotContains(t, spec.schema.Properties, "Authorization")
|
|
assert.Equal(t, formatParamDescription, spec.schema.Properties["format"].Description)
|
|
assert.Equal(t, "Filter things. Full syntax: call filter_syntax.", spec.schema.Properties["filter"].Description)
|
|
assert.False(t, spec.hasBody)
|
|
}
|
|
func TestBuildToolSpec_Patch(t *testing.T) {
|
|
spec := specFor(t, newTestAPI(t), http.MethodPatch, "/things/{id}")
|
|
assert.Equal(t, "path", spec.params["id"].In)
|
|
assert.Equal(t, []string{"id"}, spec.schema.Required)
|
|
assert.Contains(t, spec.schema.Properties, "title")
|
|
assert.NotContains(t, spec.schema.Properties, "owner")
|
|
// minLength:1 on the PUT shape: a null would merge-patch the title away to "".
|
|
assert.Equal(t, "string", spec.schema.Properties["title"].Type)
|
|
assert.ElementsMatch(t, []string{
|
|
"string",
|
|
"null",
|
|
}, spec.schema.Properties["description"].Types)
|
|
assert.NotContains(t, spec.schema.Properties, "format")
|
|
reminders := spec.schema.Properties["reminders"]
|
|
require.NotNil(t, reminders)
|
|
require.NotNil(t, reminders.Items)
|
|
assert.Equal(t, "object", reminders.Items.Type)
|
|
assert.Contains(t, reminders.Items.Properties, "at")
|
|
}
|
|
func TestBuildToolSpec_PutKeepsRequiredBodyFields(t *testing.T) {
|
|
cfg := huma.DefaultConfig("test", "1")
|
|
cfg.FieldsOptionalByDefault = true
|
|
_, api := humatest.New(t, cfg)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "thing-position-update",
|
|
Method: http.MethodPut,
|
|
Path: "/things/{id}/position",
|
|
}, func(_ context.Context, _ *struct {
|
|
ID int64 `path:"id"`
|
|
Body struct {
|
|
Position float64 `json:"position" required:"true"`
|
|
}
|
|
}) (*struct{}, error) {
|
|
return nil, nil
|
|
})
|
|
spec, err := buildToolSpec(api.OpenAPI(), api.OpenAPI().Paths["/things/{id}/position"].Put)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{
|
|
"id",
|
|
"position",
|
|
}, spec.schema.Required)
|
|
}
|
|
|
|
type widgetBody struct {
|
|
ThingID int64 `json:"thing_id" param:"thing"`
|
|
Username string `json:"username" param:"user"`
|
|
Mode string `json:"mode_name" param:"mode"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
func TestBuildToolSpec_DropsBodyFieldSuppliedByPath(t *testing.T) {
|
|
cfg := huma.DefaultConfig("test", "1")
|
|
cfg.FieldsOptionalByDefault = true
|
|
_, api := humatest.New(t, cfg)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "widgets-create",
|
|
Method: http.MethodPost,
|
|
Path: "/things/{thing}/widgets/{user}",
|
|
}, func(_ context.Context, _ *struct {
|
|
Thing int64 `path:"thing"`
|
|
User string `path:"user"`
|
|
Mode string `query:"mode"`
|
|
Body widgetBody
|
|
}) (*struct{}, error) {
|
|
return nil, nil
|
|
})
|
|
spec, err := buildToolSpec(api.OpenAPI(), api.OpenAPI().Paths["/things/{thing}/widgets/{user}"].Post)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, spec.schema.Properties, "thing_id")
|
|
assert.NotContains(t, spec.schema.Properties, "username")
|
|
assert.Contains(t, spec.schema.Properties, "mode_name")
|
|
assert.Contains(t, spec.schema.Properties, "title")
|
|
}
|
|
func TestBuildToolSpec_KeepsBodyFieldNotInThePath(t *testing.T) {
|
|
cfg := huma.DefaultConfig("test", "1")
|
|
cfg.FieldsOptionalByDefault = true
|
|
_, api := humatest.New(t, cfg)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "tasks-create",
|
|
Method: http.MethodPost,
|
|
Path: "/projects/{project}/tasks",
|
|
}, func(_ context.Context, _ *struct {
|
|
Project int64 `path:"project"`
|
|
Body models.Task
|
|
}) (*struct{}, error) {
|
|
return nil, nil
|
|
})
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "tasks-update",
|
|
Method: http.MethodPut,
|
|
Path: "/tasks/{task}",
|
|
}, func(_ context.Context, _ *struct {
|
|
Task int64 `path:"task"`
|
|
Body models.Task
|
|
}) (*struct{}, error) {
|
|
return nil, nil
|
|
})
|
|
create, err := buildToolSpec(api.OpenAPI(), api.OpenAPI().Paths["/projects/{project}/tasks"].Post)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, create.schema.Properties, "project_id")
|
|
update, err := buildToolSpec(api.OpenAPI(), api.OpenAPI().Paths["/tasks/{task}"].Put)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, update.schema.Properties, "project_id")
|
|
}
|
|
func TestBuildToolSpec_ParamBodyCollision(t *testing.T) {
|
|
_, api := humatest.New(t)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "clash",
|
|
Method: http.MethodPost,
|
|
Path: "/clash/{title}",
|
|
}, func(_ context.Context, _ *struct {
|
|
Title string `path:"title"`
|
|
Body testThing
|
|
}) (*struct{}, error) {
|
|
return nil, nil
|
|
})
|
|
_, err := buildToolSpec(api.OpenAPI(), api.OpenAPI().Paths["/clash/{title}"].Post)
|
|
require.Error(t, err)
|
|
}
|
|
func TestInit_BuildsToolIndex(t *testing.T) {
|
|
m := newTestModule(t)
|
|
var names []string
|
|
for _, tl := range m.order {
|
|
names = append(names, tl.name)
|
|
}
|
|
assert.ElementsMatch(t, []string{
|
|
"things_list",
|
|
"things_read",
|
|
"things_create",
|
|
"things_update",
|
|
"things_delete",
|
|
}, names)
|
|
update, ok := m.index["things_update"]
|
|
require.True(t, ok)
|
|
assert.Equal(t, http.MethodPatch, update.op.Method)
|
|
assert.Equal(t, "application/merge-patch+json", update.contentType)
|
|
assert.Equal(t, "/api/v2/things/:id", update.echoPath)
|
|
assert.Contains(t, update.description, "Only fields present")
|
|
assert.False(t, m.index["things_read"].typed)
|
|
_, denied := m.index["tokens_create"]
|
|
assert.False(t, denied)
|
|
}
|
|
func TestInit_UpdateDescriptionComesFromThePut(t *testing.T) {
|
|
update, ok := newTestModule(t).index["things_update"]
|
|
require.True(t, ok)
|
|
assert.Equal(t, "Update a thing. Moving a thing needs write access to the target. Only fields present in the arguments are changed.", update.description)
|
|
assert.NotContains(t, update.description, "JSON Patch")
|
|
}
|
|
|
|
type recursiveInput struct {
|
|
Title string `json:"title"`
|
|
Children []*recursiveInput `json:"children"`
|
|
}
|
|
|
|
func TestBuildToolSpec_RecursiveSchemaIsSelfContained(t *testing.T) {
|
|
_, api := humatest.New(t)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "recursive-create",
|
|
Method: http.MethodPost,
|
|
Path: "/recursive",
|
|
},
|
|
func(_ context.Context, _ *struct{ Body recursiveInput }) (*struct{}, error) { return nil, nil })
|
|
before, err := json.Marshal(api.OpenAPI())
|
|
require.NoError(t, err)
|
|
spec := specFor(t, api, http.MethodPost, "/recursive")
|
|
raw, err := json.Marshal(spec.schema)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, string(raw), `"$ref"`)
|
|
after, err := json.Marshal(api.OpenAPI())
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, string(before), string(after))
|
|
}
|
|
|
|
func TestBuildToolSpec_TaskCreationRequiresTitle(t *testing.T) {
|
|
cfg := huma.DefaultConfig("test", "1")
|
|
cfg.FieldsOptionalByDefault = true
|
|
_, api := humatest.New(t, cfg)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "tasks-create",
|
|
Method: http.MethodPost,
|
|
Path: "/tasks",
|
|
},
|
|
func(_ context.Context, _ *struct{ Body models.Task }) (*struct{}, error) { return nil, nil })
|
|
spec := specFor(t, api, http.MethodPost, "/tasks")
|
|
assert.Equal(t, []string{"title"}, spec.schema.Required)
|
|
}
|
|
|
|
func TestBuildToolSpec_PatchKeepsRequiredFieldsNonNullable(t *testing.T) {
|
|
cfg := huma.DefaultConfig("test", "1")
|
|
cfg.FieldsOptionalByDefault = true
|
|
_, api := humatest.New(t, cfg)
|
|
type gadget struct {
|
|
Title string `json:"title" required:"true"`
|
|
Note string `json:"note"`
|
|
}
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "gadgets-read",
|
|
Method: http.MethodGet,
|
|
Path: "/gadgets/{id}",
|
|
}, func(_ context.Context, _ *struct {
|
|
ID int64 `path:"id"`
|
|
}) (*struct{ Body gadget }, error) {
|
|
return nil, nil
|
|
})
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "gadgets-update",
|
|
Method: http.MethodPut,
|
|
Path: "/gadgets/{id}",
|
|
}, func(_ context.Context, _ *struct {
|
|
ID int64 `path:"id"`
|
|
Body gadget
|
|
}) (*struct{}, error) {
|
|
return nil, nil
|
|
})
|
|
autopatch.AutoPatch(api)
|
|
spec := specFor(t, api, http.MethodPatch, "/gadgets/{id}")
|
|
assert.Equal(t, "string", spec.schema.Properties["title"].Type)
|
|
assert.ElementsMatch(t, []string{
|
|
"string",
|
|
"null",
|
|
}, spec.schema.Properties["note"].Types)
|
|
}
|
|
|
|
func newRichTextAPI(t *testing.T) huma.API {
|
|
t.Helper()
|
|
cfg := huma.DefaultConfig("test", "1")
|
|
cfg.FieldsOptionalByDefault = true
|
|
_, api := humatest.New(t, cfg)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "notes-read",
|
|
Method: http.MethodGet,
|
|
Path: "/notes/{id}",
|
|
}, func(_ context.Context, _ *struct {
|
|
ID int64 `path:"id"`
|
|
Format string `query:"format" enum:"html,markdown"`
|
|
}) (*struct{}, error) {
|
|
return nil, nil
|
|
})
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "notes-update",
|
|
Method: http.MethodPut,
|
|
Path: "/notes/{id}",
|
|
}, func(_ context.Context, _ *struct {
|
|
ID int64 `path:"id"`
|
|
Format string `query:"format" enum:"html,markdown"`
|
|
Body struct {
|
|
Description string `json:"description"`
|
|
}
|
|
}) (*struct{}, error) {
|
|
return nil, nil
|
|
})
|
|
autopatch.AutoPatch(api)
|
|
return api
|
|
}
|
|
|
|
func TestBuildToolSpec_PatchTakesFormatAsAnArgument(t *testing.T) {
|
|
spec := specFor(t, newRichTextAPI(t), http.MethodPatch, "/notes/{id}")
|
|
format := spec.schema.Properties["format"]
|
|
require.NotNil(t, format)
|
|
assert.Equal(t, formatParamDescription, format.Description)
|
|
assert.Equal(t, []any{
|
|
"html",
|
|
"markdown",
|
|
}, format.Enum)
|
|
// Sending it as a query param would not survive AutoPatch's re-dispatch.
|
|
assert.NotContains(t, spec.params, "format")
|
|
}
|
|
|
|
func describeFor(t *testing.T, api huma.API, method, path string) string {
|
|
t.Helper()
|
|
item := api.OpenAPI().Paths[path]
|
|
require.NotNil(t, item)
|
|
var op *huma.Operation
|
|
switch method {
|
|
case http.MethodGet:
|
|
op = item.Get
|
|
case http.MethodPatch:
|
|
op = item.Patch
|
|
}
|
|
require.NotNil(t, op)
|
|
return describe(api.OpenAPI(), op, specFor(t, api, method, path))
|
|
}
|
|
|
|
func TestDescribe_FormatHint(t *testing.T) {
|
|
api := newTestAPI(t)
|
|
assert.Contains(t, describeFor(t, api, http.MethodGet, "/things"), formatArgHint)
|
|
assert.NotContains(t, describeFor(t, api, http.MethodGet, "/things/{id}"), formatArgHint)
|
|
assert.Contains(t, describeFor(t, newRichTextAPI(t), http.MethodPatch, "/notes/{id}"), formatArgHint)
|
|
}
|