Files

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)
}