diff --git a/pkg/webtests/huma_time_entry_test.go b/pkg/webtests/huma_time_entry_test.go new file mode 100644 index 000000000..1b8b91ffb --- /dev/null +++ b/pkg/webtests/huma_time_entry_test.go @@ -0,0 +1,211 @@ +// 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" + "net/http" + "net/url" + "testing" + + "code.vikunja.io/api/pkg/license" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Fixture entries (pkg/db/fixtures/time_entries.yml): 1 = user1 on task 1, +// 2 = user1 on project 1, 3 = user3 on project 3 (user1 can read), 4 = user1's +// running timer on task 1. user1 (testuser1) can read all four. + +// The gate is the one v2-specific concern with no model-level equivalent: every +// time-tracking route 404s on an instance without the feature. +func TestHumaTimeEntry_LicenseGate(t *testing.T) { + t.Run("disabled feature 404s the list", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{}) // licensed, but not time tracking + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("disabled feature 404s timer/stop", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{}) + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("disabled feature 404s the task-scoped list", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{}) + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/time-entries", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("enabled feature serves the list", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + }) +} + +func TestHumaTimeEntry(t *testing.T) { + // SetForTests must come after setupTestEnv — the latter re-inits the license to free. + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + testHandler := webHandlerTestV2{ + user: &testuser1, + basePath: "/api/v2/time-entries", + idParam: "id", + t: t, + e: e, + } + + t.Run("ReadAll returns the readable set", func(t *testing.T) { + rec, err := testHandler.testReadAllWithUser(nil, nil) + require.NoError(t, err) + assert.ElementsMatch(t, []int64{1, 2, 3, 4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes()), + "body: %s", rec.Body.String()) + }) + + t.Run("ReadOne", func(t *testing.T) { + rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"id": "1"}) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"max_permission":`) + }) + + t.Run("ReadOne forbidden for a stranger", func(t *testing.T) { + // entry 1 is on project 1; user2 has no access to it. + stranger := webHandlerTestV2{user: &testuser2, basePath: "/api/v2/time-entries", idParam: "id", t: t, e: e} + _, err := stranger.testReadOneWithUser(nil, map[string]string{"id": "1"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + + t.Run("ReadOne of a missing entry is 404", func(t *testing.T) { + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"id": "9999"}) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err)) + }) +} + +// Create exercises the full handler path (DoCreate → CanCreate → Create → +// commit → DispatchPending) that the model-level tests bypass. +func TestHumaTimeEntry_Create(t *testing.T) { + t.Run("saving an entry with end_time", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + body := `{"task_id":1,"start_time":"2020-01-01T09:00:00Z","end_time":"2020-01-01T10:00:00Z","comment":"work"}` + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries", body, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"user_id":1`) + assert.Contains(t, rec.Body.String(), `"task_id":1`) + }) + + t.Run("starting a timer without end_time", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + body := `{"task_id":1,"start_time":"2020-01-01T09:00:00Z","comment":"timer"}` + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries", body, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"user_id":1`) + // A running timer's end_time is null on the wire, not the zero-time sentinel. + assert.Contains(t, rec.Body.String(), `"end_time":null`) + assert.NotContains(t, rec.Body.String(), "0001-01-01") + }) +} + +// The filter param must wire through the route into ReadAll. +func TestHumaTimeEntry_Filter(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + token := humaTokenFor(t, &testuser1) + + t.Run("by task", func(t *testing.T) { + q := url.Values{"filter": {"task_id = 1"}} + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries?"+q.Encode(), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.ElementsMatch(t, []int64{1, 4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes())) + }) + + t.Run("running timers via null end_time", func(t *testing.T) { + q := url.Values{"filter": {"end_time = null"}} + rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries?"+q.Encode(), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.ElementsMatch(t, []int64{4}, timeEntryIDsFromReadAll(t, rec.Body.Bytes())) + }) +} + +func TestHumaTimeEntry_TimerStop(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + t.Run("stops the caller's running timer", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":4`, "entry 4 is user1's running timer") + assert.NotContains(t, rec.Body.String(), `"end_time":"0001-01-01`, "end_time must now be set") + }) + + t.Run("404 when the caller has no running timer", func(t *testing.T) { + // user2 has no entries, so no running timer. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/time-entries/timer/stop", "", humaTokenFor(t, &testuser2), "") + assert.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String()) + }) +} + +func timeEntryIDsFromReadAll(t *testing.T, body []byte) []int64 { + t.Helper() + var resp struct { + Items []struct { + ID int64 `json:"id"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal(body, &resp), "ReadAll body must be a paginated envelope: %s", string(body)) + ids := make([]int64, 0, len(resp.Items)) + for _, it := range resp.Items { + ids = append(ids, it.ID) + } + return ids +}