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