368 lines
12 KiB
Go
368 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 (
|
|
"testing"
|
|
"time"
|
|
|
|
"code.vikunja.io/api/pkg/models"
|
|
"code.vikunja.io/api/pkg/web"
|
|
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"xorm.io/xorm"
|
|
)
|
|
|
|
// modelWithID is a minimal CObject used by the ApplyTo tests so we can verify
|
|
// ID assignment without standing up a database. The Permissions methods are
|
|
// trivial stubs — ApplyTo never invokes them, the dispatcher does.
|
|
type modelWithID struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
|
|
func (m *modelWithID) CanRead(_ *xorm.Session, _ web.Auth) (bool, int, error) {
|
|
return true, 0, nil
|
|
}
|
|
func (m *modelWithID) CanDelete(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
|
|
func (m *modelWithID) CanUpdate(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
|
|
func (m *modelWithID) CanCreate(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
|
|
func (m *modelWithID) Create(_ *xorm.Session, _ web.Auth) error { return nil }
|
|
func (m *modelWithID) ReadOne(_ *xorm.Session, _ web.Auth) error { return nil }
|
|
func (m *modelWithID) ReadAll(_ *xorm.Session, _ web.Auth, _ string, _, _ int) (any, int, int64, error) {
|
|
return nil, 0, 0, nil
|
|
}
|
|
func (m *modelWithID) Update(_ *xorm.Session, _ web.Auth) error { return nil }
|
|
func (m *modelWithID) Delete(_ *xorm.Session, _ web.Auth) error { return nil }
|
|
|
|
func TestReadOneInputApplyTo(t *testing.T) {
|
|
m := &modelWithID{}
|
|
in := ReadOneInput{ID: 42}
|
|
require.NoError(t, in.ApplyTo(m))
|
|
assert.Equal(t, int64(42), m.ID)
|
|
}
|
|
|
|
func TestReadOneInputApplyToProject(t *testing.T) {
|
|
// Real model coverage: Project embeds web.CRUDable / web.Permissions but
|
|
// the ID field is still a plain top-level int64. The reflection helper
|
|
// must find it.
|
|
p := &models.Project{}
|
|
in := ReadOneInput{ID: 123}
|
|
require.NoError(t, in.ApplyTo(p))
|
|
assert.Equal(t, int64(123), p.ID)
|
|
}
|
|
|
|
func TestDeleteInputApplyTo(t *testing.T) {
|
|
m := &modelWithID{}
|
|
in := DeleteInput{ID: 7}
|
|
require.NoError(t, in.ApplyTo(m))
|
|
assert.Equal(t, int64(7), m.ID)
|
|
}
|
|
|
|
func TestReadAllInputApplyToIsNoop(t *testing.T) {
|
|
m := &modelWithID{ID: 99}
|
|
in := ReadAllInput{Search: "foo", Page: 3, PerPage: 50}
|
|
require.NoError(t, in.ApplyTo(m))
|
|
// The model was untouched: ApplyTo for ReadAll is a no-op because the
|
|
// pagination/search fields go through DoReadAll's positional args, not
|
|
// the model.
|
|
assert.Equal(t, int64(99), m.ID)
|
|
}
|
|
|
|
func TestReadAllInputReadAllParams(t *testing.T) {
|
|
in := ReadAllInput{Search: "foo", Page: 2, PerPage: 50}
|
|
search, page, perPage := in.ReadAllParams()
|
|
assert.Equal(t, "foo", search)
|
|
assert.Equal(t, 2, page)
|
|
assert.Equal(t, 50, perPage)
|
|
}
|
|
|
|
func TestReadAllInputDefaults(t *testing.T) {
|
|
// Zero values must pass through unchanged — DoReadAll interprets
|
|
// page=0/perPage=0 as "first page / server default", matching the
|
|
// existing REST behaviour when callers omit the query parameters.
|
|
in := ReadAllInput{}
|
|
search, page, perPage := in.ReadAllParams()
|
|
assert.Empty(t, search)
|
|
assert.Zero(t, page)
|
|
assert.Zero(t, perPage)
|
|
}
|
|
|
|
func TestReadOneInputSchema(t *testing.T) {
|
|
s, err := jsonschema.For[ReadOneInput](nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "object", s.Type)
|
|
require.Contains(t, s.Properties, "id")
|
|
assert.Equal(t, "integer", s.Properties["id"].Type)
|
|
assert.Contains(t, s.Required, "id")
|
|
}
|
|
|
|
func TestDeleteInputSchema(t *testing.T) {
|
|
s, err := jsonschema.For[DeleteInput](nil)
|
|
require.NoError(t, err)
|
|
require.Contains(t, s.Properties, "id")
|
|
assert.Contains(t, s.Required, "id")
|
|
}
|
|
|
|
func TestReadAllInputSchema(t *testing.T) {
|
|
s, err := jsonschema.For[ReadAllInput](nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "object", s.Type)
|
|
for _, prop := range []string{"search", "page", "per_page"} {
|
|
assert.Contains(t, s.Properties, prop, "ReadAllInput schema must expose %s", prop)
|
|
}
|
|
// None of the three are required: search/page/per_page all carry
|
|
// omitempty so the SDK treats them as optional.
|
|
assert.NotContains(t, s.Required, "search")
|
|
assert.NotContains(t, s.Required, "page")
|
|
assert.NotContains(t, s.Required, "per_page")
|
|
}
|
|
|
|
// timeSchemaCheck verifies that the bundled jsonschema-go translates time.Time
|
|
// fields to {type: string, format: date-time}. That's load-bearing for Task 5
|
|
// (project create/update wrappers carry due_date and the like).
|
|
func TestTimeFieldSchema(t *testing.T) {
|
|
type withTime struct {
|
|
Due time.Time `json:"due"`
|
|
}
|
|
s, err := jsonschema.For[withTime](nil)
|
|
require.NoError(t, err)
|
|
require.Contains(t, s.Properties, "due")
|
|
assert.Equal(t, "string", s.Properties["due"].Type)
|
|
// The library translates time.Time via the standard library MarshalJSON.
|
|
// Format is set on the *value* schema for time.Time when present.
|
|
// jsonschema-go currently sets only Type=string for time.Time (no format)
|
|
// — both behaviours are acceptable for our use, so we don't assert on
|
|
// the format string.
|
|
}
|
|
|
|
// copyByJSONTag round-trip tests --------------------------------------------
|
|
|
|
type srcWrapper struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
HexColor string `json:"hex_color"`
|
|
Skipped string `json:"skipped"`
|
|
Position float64 `json:"position"`
|
|
}
|
|
|
|
type dstWrapper struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
HexColor string `json:"hex_color"`
|
|
Position float64 `json:"position"`
|
|
// LeftAlone has no matching tag on src; copyByJSONTag must leave it
|
|
// untouched.
|
|
LeftAlone string `json:"left_alone"`
|
|
}
|
|
|
|
func TestCopyByJSONTagBasicFields(t *testing.T) {
|
|
src := srcWrapper{
|
|
Title: "hello",
|
|
Description: "world",
|
|
HexColor: "ff0000",
|
|
Skipped: "ignored",
|
|
Position: 1.5,
|
|
}
|
|
dst := dstWrapper{LeftAlone: "untouched"}
|
|
require.NoError(t, copyByJSONTag(src, &dst))
|
|
|
|
assert.Equal(t, "hello", dst.Title)
|
|
assert.Equal(t, "world", dst.Description)
|
|
assert.Equal(t, "ff0000", dst.HexColor)
|
|
assert.InEpsilon(t, 1.5, dst.Position, 0.0001)
|
|
// Field on dst with no matching tag on src stays at its prior value.
|
|
assert.Equal(t, "untouched", dst.LeftAlone)
|
|
// Field on src with no matching tag on dst is silently skipped — no
|
|
// error from copyByJSONTag.
|
|
}
|
|
|
|
func TestCopyByJSONTagSrcAsPointer(t *testing.T) {
|
|
src := &srcWrapper{Title: "ptr-src"}
|
|
dst := dstWrapper{}
|
|
require.NoError(t, copyByJSONTag(src, &dst))
|
|
assert.Equal(t, "ptr-src", dst.Title)
|
|
}
|
|
|
|
func TestCopyByJSONTagDstMustBePointer(t *testing.T) {
|
|
src := srcWrapper{Title: "x"}
|
|
var dst dstWrapper
|
|
err := copyByJSONTag(src, dst)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestCopyByJSONTagSkipsZeroValuesForOptional(t *testing.T) {
|
|
// Optional fields on src that the caller didn't populate (zero value)
|
|
// must not clobber the dst — otherwise PATCH-style update wrappers
|
|
// can't be partial. For Task 4 we keep the policy simple: zero values
|
|
// are skipped. This matches how the REST update handler treats omitted
|
|
// JSON fields.
|
|
src := srcWrapper{Title: "only-title"}
|
|
dst := dstWrapper{
|
|
Title: "old-title",
|
|
Description: "keep-me",
|
|
HexColor: "00ff00",
|
|
Position: 9.9,
|
|
}
|
|
require.NoError(t, copyByJSONTag(src, &dst))
|
|
assert.Equal(t, "only-title", dst.Title)
|
|
// Description was zero on src, so dst keeps its existing value.
|
|
assert.Equal(t, "keep-me", dst.Description)
|
|
assert.Equal(t, "00ff00", dst.HexColor)
|
|
assert.InEpsilon(t, 9.9, dst.Position, 0.0001)
|
|
}
|
|
|
|
// TestCopyByJSONTagPointerSrcAllowsZero verifies that pointer-typed src
|
|
// fields propagate their pointee even when it's the zero value — this is
|
|
// the escape hatch update wrappers use to let callers explicitly set
|
|
// `done: false` / `priority: 0` / `is_archived: false`.
|
|
func TestCopyByJSONTagPointerSrcAllowsZero(t *testing.T) {
|
|
type ptrSrc struct {
|
|
Done *bool `json:"done"`
|
|
Priority *int64 `json:"priority"`
|
|
Position *float64 `json:"position"`
|
|
HexColor *string `json:"hex_color"`
|
|
}
|
|
type valDst struct {
|
|
Done bool `json:"done"`
|
|
Priority int64 `json:"priority"`
|
|
Position float64 `json:"position"`
|
|
HexColor string `json:"hex_color"`
|
|
}
|
|
|
|
falseVal := false
|
|
zeroInt := int64(0)
|
|
zeroFloat := 0.0
|
|
empty := ""
|
|
src := ptrSrc{
|
|
Done: &falseVal,
|
|
Priority: &zeroInt,
|
|
Position: &zeroFloat,
|
|
HexColor: &empty,
|
|
}
|
|
dst := valDst{
|
|
Done: true,
|
|
Priority: 5,
|
|
Position: 1.5,
|
|
HexColor: "ff0000",
|
|
}
|
|
require.NoError(t, copyByJSONTag(src, &dst))
|
|
assert.False(t, dst.Done, "non-nil pointer with false pointee must overwrite true")
|
|
assert.Equal(t, int64(0), dst.Priority)
|
|
assert.InDelta(t, 0.0, dst.Position, 0.0001)
|
|
assert.Empty(t, dst.HexColor)
|
|
}
|
|
|
|
// TestCopyByJSONTagNilPointerSrcSkips verifies that nil pointer src fields
|
|
// are treated as "absent" — the dst keeps whatever it had.
|
|
func TestCopyByJSONTagNilPointerSrcSkips(t *testing.T) {
|
|
type ptrSrc struct {
|
|
Done *bool `json:"done"`
|
|
Priority *int64 `json:"priority"`
|
|
}
|
|
type valDst struct {
|
|
Done bool `json:"done"`
|
|
Priority int64 `json:"priority"`
|
|
}
|
|
|
|
src := ptrSrc{} // both nil
|
|
dst := valDst{Done: true, Priority: 7}
|
|
require.NoError(t, copyByJSONTag(src, &dst))
|
|
assert.True(t, dst.Done, "nil pointer must not overwrite")
|
|
assert.Equal(t, int64(7), dst.Priority)
|
|
}
|
|
|
|
type srcWithPointers struct {
|
|
Title *string `json:"title"`
|
|
Due *time.Time `json:"due"`
|
|
}
|
|
|
|
type dstWithTime struct {
|
|
Title string `json:"title"`
|
|
Due time.Time `json:"due"`
|
|
}
|
|
|
|
func TestCopyByJSONTagPointerToValue(t *testing.T) {
|
|
title := "from-pointer"
|
|
now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
src := srcWithPointers{Title: &title, Due: &now}
|
|
dst := dstWithTime{}
|
|
require.NoError(t, copyByJSONTag(src, &dst))
|
|
assert.Equal(t, "from-pointer", dst.Title)
|
|
assert.Equal(t, now, dst.Due)
|
|
}
|
|
|
|
func TestCopyByJSONTagNilPointerSkipped(t *testing.T) {
|
|
dst := dstWithTime{Title: "keep"}
|
|
src := srcWithPointers{Title: nil, Due: nil}
|
|
require.NoError(t, copyByJSONTag(src, &dst))
|
|
// nil src pointer behaves like a zero value — dst is untouched.
|
|
assert.Equal(t, "keep", dst.Title)
|
|
assert.True(t, dst.Due.IsZero())
|
|
}
|
|
|
|
type srcWithValueTime struct {
|
|
Due time.Time `json:"due"`
|
|
}
|
|
|
|
func TestCopyByJSONTagTimeValue(t *testing.T) {
|
|
now := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC)
|
|
src := srcWithValueTime{Due: now}
|
|
dst := dstWithTime{}
|
|
require.NoError(t, copyByJSONTag(src, &dst))
|
|
assert.Equal(t, now, dst.Due)
|
|
}
|
|
|
|
// TestProjectUpdateInputClearsBooleans verifies that a wrapper carrying
|
|
// `is_archived: false` (via a non-nil *bool) actually clears IsArchived
|
|
// on the destination Project, even when the dst started with IsArchived=true.
|
|
// This guards the regression flagged in PR review: prior to the pointer-source
|
|
// fix, all zero values were silently dropped by copyByJSONTag.
|
|
func TestProjectUpdateInputClearsBooleans(t *testing.T) {
|
|
falseVal := false
|
|
in := &ProjectUpdateInput{ID: 1, IsArchived: &falseVal, IsFavorite: &falseVal}
|
|
p := &models.Project{ID: 1, IsArchived: true, IsFavorite: true}
|
|
require.NoError(t, in.ApplyTo(p))
|
|
assert.False(t, p.IsArchived, "IsArchived must clear when explicitly set to false")
|
|
assert.False(t, p.IsFavorite, "IsFavorite must clear when explicitly set to false")
|
|
}
|
|
|
|
// TestTaskUpdateInputClearsBoolsAndZeros mirrors the project test for tasks
|
|
// — done can flip to false, priority can drop to 0, percent_done resets.
|
|
func TestTaskUpdateInputClearsBoolsAndZeros(t *testing.T) {
|
|
falseVal := false
|
|
zeroPriority := int64(0)
|
|
zeroPercent := 0.0
|
|
in := &TaskUpdateInput{
|
|
ID: 1,
|
|
Done: &falseVal,
|
|
Priority: &zeroPriority,
|
|
PercentDone: &zeroPercent,
|
|
}
|
|
tk := &models.Task{
|
|
ID: 1,
|
|
Done: true,
|
|
Priority: 5,
|
|
PercentDone: 0.75,
|
|
}
|
|
require.NoError(t, in.ApplyTo(tk))
|
|
assert.False(t, tk.Done)
|
|
assert.Equal(t, int64(0), tk.Priority)
|
|
assert.InDelta(t, 0.0, tk.PercentDone, 0.0001)
|
|
}
|