// 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 models import ( "context" "encoding/json" "testing" "time" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/license" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/web" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func timePtr(t time.Time) *time.Time { return &t } // Fixture access graph (pkg/db/fixtures): project 1 is owned by user1 only // (everyone else a stranger); task 1 lives in project 1. Project 3 is owned by // user3, with user1 and user2 granted read. user4 has access to neither. // Entries: 1 = user1 on task 1, 2 = user1 on project 1, 3 = user3 on project 3. func TestTimeEntry_CanRead(t *testing.T) { tests := []struct { name string entryID int64 auth web.Auth wantCan bool wantErr func(error) bool }{ {"owner reads task entry", 1, &user.User{ID: 1}, true, nil}, {"owner reads project entry", 2, &user.User{ID: 1}, true, nil}, {"reader reads other user's entry on a shared project", 3, &user.User{ID: 1}, true, nil}, {"stranger denied on owned project", 1, &user.User{ID: 4}, false, nil}, {"stranger denied on shared project", 3, &user.User{ID: 4}, false, nil}, {"link share denied", 1, &LinkSharing{ID: 1, ProjectID: 1}, false, nil}, {"missing entry is a 404", 999, &user.User{ID: 1}, false, IsErrTimeEntryDoesNotExist}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() can, _, err := (&TimeEntry{ID: tt.entryID}).CanRead(s, tt.auth) if tt.wantErr != nil { require.Error(t, err) assert.True(t, tt.wantErr(err), "unexpected error type: %v", err) return } require.NoError(t, err) assert.Equal(t, tt.wantCan, can) }) } } func TestTimeEntry_CanCreate(t *testing.T) { tests := []struct { name string entry *TimeEntry auth web.Auth wantCan bool wantErr func(error) bool }{ {"on a task in an owned project", &TimeEntry{TaskID: 1}, &user.User{ID: 1}, true, nil}, {"on an owned project", &TimeEntry{ProjectID: 1}, &user.User{ID: 1}, true, nil}, {"on a readable project", &TimeEntry{ProjectID: 3}, &user.User{ID: 1}, true, nil}, {"stranger denied", &TimeEntry{ProjectID: 1}, &user.User{ID: 4}, false, nil}, {"both task and project is invalid", &TimeEntry{TaskID: 1, ProjectID: 1}, &user.User{ID: 1}, false, IsErrTimeEntryInvalidContainer}, {"neither task nor project is invalid", &TimeEntry{}, &user.User{ID: 1}, false, IsErrTimeEntryInvalidContainer}, {"link share denied", &TimeEntry{ProjectID: 1}, &LinkSharing{ID: 1, ProjectID: 1}, false, nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() can, err := tt.entry.CanCreate(s, tt.auth) if tt.wantErr != nil { require.Error(t, err) assert.True(t, tt.wantErr(err), "unexpected error type: %v", err) return } require.NoError(t, err) assert.Equal(t, tt.wantCan, can) }) } } // Entry 3 is authored by user3; user1 can read project 3 but is not the author, // so it can read but not modify. func TestTimeEntry_CanModify(t *testing.T) { tests := []struct { name string entryID int64 auth web.Auth wantCan bool }{ {"author modifies own entry", 1, &user.User{ID: 1}, true}, {"author modifies own entry on shared project", 3, &user.User{ID: 3}, true}, {"reader who is not author cannot modify", 3, &user.User{ID: 1}, false}, {"stranger cannot modify", 3, &user.User{ID: 4}, false}, {"link share cannot modify", 1, &LinkSharing{ID: 1, ProjectID: 1}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() canUpdate, err := (&TimeEntry{ID: tt.entryID}).CanUpdate(s, tt.auth) require.NoError(t, err) assert.Equal(t, tt.wantCan, canUpdate, "CanUpdate") canDelete, err := (&TimeEntry{ID: tt.entryID}).CanDelete(s, tt.auth) require.NoError(t, err) assert.Equal(t, tt.wantCan, canDelete, "CanDelete") }) } } // Guards the data leak: ReadAll must return only entries on tasks/projects the // caller can read, since DoReadAll runs no permission check. func TestTimeEntry_ReadAll(t *testing.T) { tests := []struct { name string auth web.Auth wantIDs []int64 }{ {"user sees every readable entry", &user.User{ID: 1}, []int64{1, 2, 3, 4}}, {"user sees only entries on projects they can read", &user.User{ID: 2}, []int64{3}}, {"stranger sees nothing", &user.User{ID: 4}, []int64{}}, {"link share sees nothing", &LinkSharing{ID: 1, ProjectID: 1}, []int64{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() result, count, total, err := (&TimeEntry{}).ReadAll(s, tt.auth, "", 1, 50) require.NoError(t, err) entries, ok := result.([]*TimeEntry) require.True(t, ok) gotIDs := make([]int64, 0, len(entries)) for _, e := range entries { gotIDs = append(gotIDs, e.ID) } assert.ElementsMatch(t, tt.wantIDs, gotIDs) assert.Equal(t, len(tt.wantIDs), count) assert.Equal(t, int64(len(tt.wantIDs)), total) }) } } // Filtering reuses the task filter grammar. user1 can read entries 1,2,4 // (project 1) and 3 (project 3, shared) — the filter only narrows that set. func TestTimeEntry_ReadAll_Filter(t *testing.T) { tests := []struct { name string filter string wantIDs []int64 wantErr bool }{ {"by user", "user_id = 3", []int64{3}, false}, {"by task", "task_id = 1", []int64{1, 4}, false}, {"by project unions task-attached entries", "project_id = 1", []int64{1, 2, 4}, false}, {"by project negated", "project_id != 1", []int64{3}, false}, {"by start time", "start_time > '2018-12-01T11:00:00+00:00'", []int64{2, 3, 4}, false}, {"running timers via null end_time", "end_time = null", []int64{4}, false}, {"compound and", "user_id = 1 && end_time = null", []int64{4}, false}, {"compound or", "user_id = 3 || task_id = 1", []int64{1, 3, 4}, false}, {"in list", "user_id in 1,3", []int64{1, 2, 3, 4}, false}, {"comment is not filterable", "comment = whatever", nil, true}, {"unknown field errors", "bogus = 1", nil, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() te := &TimeEntry{Filter: tt.filter} result, _, _, err := te.ReadAll(s, &user.User{ID: 1}, "", 1, 50) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) entries, ok := result.([]*TimeEntry) require.True(t, ok) gotIDs := make([]int64, 0, len(entries)) for _, e := range entries { gotIDs = append(gotIDs, e.ID) } assert.ElementsMatch(t, tt.wantIDs, gotIDs) }) } } // Search matches the entry comment. Comments: 1="Time entry on task 1", // 2/3 contain "Standalone", 4="Running timer". func TestTimeEntry_ReadAll_Search(t *testing.T) { tests := []struct { name string search string wantIDs []int64 }{ {"matches a comment", "Running", []int64{4}}, {"is case-insensitive", "running", []int64{4}}, {"matches several", "Standalone", []int64{2, 3}}, {"no match", "nothing matches this", []int64{}}, {"empty search returns all readable", "", []int64{1, 2, 3, 4}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() result, _, _, err := (&TimeEntry{}).ReadAll(s, &user.User{ID: 1}, tt.search, 1, 50) require.NoError(t, err) entries, ok := result.([]*TimeEntry) require.True(t, ok) gotIDs := make([]int64, 0, len(entries)) for _, e := range entries { gotIDs = append(gotIDs, e.ID) } assert.ElementsMatch(t, tt.wantIDs, gotIDs) }) } } func TestTimeEntry_Create(t *testing.T) { t.Run("manual entry keeps its start time and is owned by the caller", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() start := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) end := time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC) te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: &end, Comment: "work"} require.NoError(t, te.Create(s, &user.User{ID: 1})) require.NoError(t, s.Commit()) assert.Equal(t, int64(1), te.UserID) assert.True(t, te.StartTime.Equal(start)) db.AssertExists(t, "time_entries", map[string]interface{}{ "id": te.ID, "user_id": 1, "task_id": 1, "comment": "work", }, false) }) t.Run("defaults the start time to now when none is given", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() te := &TimeEntry{TaskID: 1} require.NoError(t, te.Create(s, &user.User{ID: 1})) assert.False(t, te.StartTime.IsZero()) }) t.Run("a completed manual entry leaves a running timer alone", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() // entry 4 is user1's running timer manual := &TimeEntry{ TaskID: 1, StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC), EndTime: timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)), } require.NoError(t, manual.Create(s, &user.User{ID: 1})) require.NoError(t, s.Commit()) running := &TimeEntry{} exists, err := s.Where("id = ?", 4).Get(running) require.NoError(t, err) require.True(t, exists) assert.Nil(t, running.EndTime, "a manual entry must not stop the running timer") }) t.Run("auto-stops the caller's running timer", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() a := &user.User{ID: 1} first := &TimeEntry{TaskID: 1} require.NoError(t, first.Create(s, a)) require.Nil(t, first.EndTime, "first timer should be running") second := &TimeEntry{TaskID: 1} require.NoError(t, second.Create(s, a)) require.NoError(t, s.Commit()) reloaded := &TimeEntry{} exists, err := s.Where("id = ?", first.ID).Get(reloaded) require.NoError(t, err) require.True(t, exists) assert.NotNil(t, reloaded.EndTime, "first timer should have been auto-stopped") assert.Nil(t, second.EndTime, "second timer should still be running") }) } // A running timer (no end) must round-trip as a NULL end_time: found by the // null filter and serialized as JSON null, never the 0001-01-01 zero sentinel. func TestTimeEntry_RunningTimerEndTimeIsNull(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() a := &user.User{ID: 1} te := &TimeEntry{TaskID: 1, StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)} require.NoError(t, te.Create(s, a)) require.NoError(t, s.Commit()) reloaded, err := getTimeEntryByID(s, te.ID) require.NoError(t, err) marshalled, err := json.Marshal(reloaded) require.NoError(t, err) assert.Contains(t, string(marshalled), `"end_time":null`) assert.NotContains(t, string(marshalled), "0001-01-01") // Stored as NULL, so the null filter matches it (not just the fixtures). found := &TimeEntry{Filter: "end_time = null"} result, _, _, err := found.ReadAll(s, a, "", 1, 50) require.NoError(t, err) ids := []int64{} for _, e := range result.([]*TimeEntry) { ids = append(ids, e.ID) } assert.Contains(t, ids, te.ID) } // Regression guard: the permission check must not clobber the update payload. func TestTimeEntry_Update(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() a := &user.User{ID: 1} te := &TimeEntry{ ID: 1, TaskID: 1, StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC), EndTime: timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)), Comment: "updated comment", } can, err := te.CanUpdate(s, a) // the handler calls this before Update require.NoError(t, err) require.True(t, can) require.NoError(t, te.Update(s, a)) require.NoError(t, s.Commit()) assert.Equal(t, "updated comment", te.Comment) db.AssertExists(t, "time_entries", map[string]interface{}{ "id": 1, "comment": "updated comment", }, false) } func TestTimeEntry_UpdateReassignsContainer(t *testing.T) { validTimes := func(te *TimeEntry) { te.StartTime = time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) te.EndTime = timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)) } t.Run("moves an entry from a task to a project", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() a := &user.User{ID: 1} // Entry 1 is on task 1; move it onto project 1 directly. te := &TimeEntry{ID: 1, ProjectID: 1} validTimes(te) can, err := te.CanUpdate(s, a) require.NoError(t, err) require.True(t, can) require.NoError(t, te.Update(s, a)) require.NoError(t, s.Commit()) db.AssertExists(t, "time_entries", map[string]interface{}{ "id": 1, "task_id": 0, "project_id": 1, }, false) }) t.Run("rejects an update that sets both task and project", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() _, err := (&TimeEntry{ID: 1, TaskID: 1, ProjectID: 1}).CanUpdate(s, &user.User{ID: 1}) require.Error(t, err) assert.True(t, IsErrTimeEntryInvalidContainer(err)) }) t.Run("an omitted container keeps the existing one", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() a := &user.User{ID: 1} // Entry 1 is on task 1; update only the comment, no container set. te := &TimeEntry{ID: 1, Comment: "kept on task"} validTimes(te) can, err := te.CanUpdate(s, a) require.NoError(t, err) require.True(t, can) require.NoError(t, te.Update(s, a)) require.NoError(t, s.Commit()) db.AssertExists(t, "time_entries", map[string]interface{}{ "id": 1, "task_id": 1, "project_id": 0, "comment": "kept on task", }, false) }) } func TestTimeEntry_UpdateReopenGuard(t *testing.T) { a := &user.User{ID: 1} someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) t.Run("rejects clearing the end of a completed entry", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() // Entry 1 is completed; a nil end would reopen it as a running timer. te := &TimeEntry{ID: 1, TaskID: 1, StartTime: someStart} // EndTime nil can, err := te.CanUpdate(s, a) require.NoError(t, err) require.True(t, can) err = te.Update(s, a) require.Error(t, err) assert.True(t, IsErrTimeEntryAlreadyEnded(err), "unexpected error type: %v", err) }) t.Run("allows editing a running entry while it stays running", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() // Entry 4 is user1's running timer; keeping it running (nil end) is fine. te := &TimeEntry{ID: 4, TaskID: 1, StartTime: someStart, Comment: "edited"} // EndTime nil can, err := te.CanUpdate(s, a) require.NoError(t, err) require.True(t, can) require.NoError(t, te.Update(s, a)) }) } func TestTimeEntry_RejectsInvertedInterval(t *testing.T) { a := &user.User{ID: 1} start := time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC) before := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) t.Run("create rejects an end before the start", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: timePtr(before)} err := te.Create(s, a) require.Error(t, err) assert.True(t, IsErrTimeEntryEndBeforeStart(err), "unexpected error type: %v", err) }) t.Run("create allows an end equal to the start", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: timePtr(start)} require.NoError(t, te.Create(s, a)) }) t.Run("create allows a running timer with no end", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() te := &TimeEntry{TaskID: 1, StartTime: start} // EndTime nil require.NoError(t, te.Create(s, a)) }) t.Run("update rejects an end before the start", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() // Entry 1 is user1's completed entry. te := &TimeEntry{ID: 1, TaskID: 1, StartTime: start, EndTime: timePtr(before)} can, err := te.CanUpdate(s, a) require.NoError(t, err) require.True(t, can) err = te.Update(s, a) require.Error(t, err) assert.True(t, IsErrTimeEntryEndBeforeStart(err), "unexpected error type: %v", err) }) } func TestTimeEntry_StopRunningTimer(t *testing.T) { t.Run("stops the caller's running timer and returns it", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() entry, err := StopRunningTimer(s, &user.User{ID: 1}) // entry 4 require.NoError(t, err) require.NoError(t, s.Commit()) assert.Equal(t, int64(4), entry.ID) assert.NotNil(t, entry.EndTime) reloaded := &TimeEntry{} _, err = s.Where("id = ?", 4).Get(reloaded) require.NoError(t, err) assert.NotNil(t, reloaded.EndTime, "end time should be persisted") }) t.Run("errors when no timer is running", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() _, err := StopRunningTimer(s, &user.User{ID: 2}) // user2 has no entries require.Error(t, err) assert.True(t, IsErrNoRunningTimer(err), "unexpected error type: %v", err) }) t.Run("denies a link share and leaves the matching user's timer running", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() // Share id 1 collides with user 1, whose entry 4 is a running timer. _, err := StopRunningTimer(s, &LinkSharing{ID: 1, ProjectID: 1}) require.Error(t, err) assert.True(t, IsErrGenericForbidden(err), "unexpected error type: %v", err) running := &TimeEntry{} exists, err := s.Where("id = ?", 4).Get(running) require.NoError(t, err) require.True(t, exists) assert.Nil(t, running.EndTime, "the user's timer must not have been stopped by a link share") }) } func TestTimeEntry_Events(t *testing.T) { u := &user.User{ID: 1} someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC) someEnd := timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)) t.Run("create dispatches created", func(t *testing.T) { db.LoadAndAssertFixtures(t) events.ClearDispatchedEvents() s := db.NewSession() defer s.Close() te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd} require.NoError(t, te.Create(s, u)) require.NoError(t, s.Commit()) events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryCreatedEvent{}) }) t.Run("update dispatches updated", func(t *testing.T) { db.LoadAndAssertFixtures(t) events.ClearDispatchedEvents() s := db.NewSession() defer s.Close() te := &TimeEntry{ID: 1, TaskID: 1, StartTime: someStart, EndTime: someEnd, Comment: "edited"} can, err := te.CanUpdate(s, u) require.NoError(t, err) require.True(t, can) require.NoError(t, te.Update(s, u)) require.NoError(t, s.Commit()) events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) }) t.Run("delete dispatches deleted", func(t *testing.T) { db.LoadAndAssertFixtures(t) events.ClearDispatchedEvents() s := db.NewSession() defer s.Close() require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, u)) require.NoError(t, s.Commit()) events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryDeletedEvent{}) }) t.Run("starting a timer dispatches created plus updated for the auto-stopped entry", func(t *testing.T) { db.LoadAndAssertFixtures(t) events.ClearDispatchedEvents() s := db.NewSession() defer s.Close() // entry 4 is user1's running timer; a new running timer auto-stops it require.NoError(t, (&TimeEntry{TaskID: 1}).Create(s, u)) require.NoError(t, s.Commit()) events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryCreatedEvent{}) events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) }) t.Run("a completed manual entry dispatches only created", func(t *testing.T) { db.LoadAndAssertFixtures(t) events.ClearDispatchedEvents() s := db.NewSession() defer s.Close() te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd} require.NoError(t, te.Create(s, u)) require.NoError(t, s.Commit()) events.DispatchPending(context.Background(), s) assert.Equal(t, 1, events.CountDispatchedEvents((&TimeEntryCreatedEvent{}).Name())) assert.Equal(t, 0, events.CountDispatchedEvents((&TimeEntryUpdatedEvent{}).Name()), "a completed manual entry must not auto-stop") }) t.Run("StopRunningTimer dispatches updated", func(t *testing.T) { db.LoadAndAssertFixtures(t) events.ClearDispatchedEvents() s := db.NewSession() defer s.Close() _, err := StopRunningTimer(s, u) require.NoError(t, err) require.NoError(t, s.Commit()) events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) }) } func TestTimeEntry_Delete(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, &user.User{ID: 1})) require.NoError(t, s.Commit()) db.AssertMissing(t, "time_entries", map[string]interface{}{"id": 1}) } func TestTimeEntry_TaskCount(t *testing.T) { u := &user.User{ID: 1} t.Run("attaches counts for a licensed, non-share caller", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() license.SetForTests([]license.Feature{license.FeatureTimeTracking}) defer license.ResetForTests() task1 := &Task{ID: 1} // fixtures: time entries 1 and 4 are attached to task 1 task2 := &Task{ID: 2} // no time entries taskMap := map[int64]*Task{1: task1, 2: task2} require.NoError(t, addTimeEntriesCountToTasks(s, u, []int64{1, 2}, taskMap)) require.NotNil(t, task1.TimeEntriesCount) assert.Equal(t, int64(2), *task1.TimeEntriesCount) require.NotNil(t, task2.TimeEntriesCount) assert.Equal(t, int64(0), *task2.TimeEntriesCount) }) t.Run("leaves the count unset for a link share", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() license.SetForTests([]license.Feature{license.FeatureTimeTracking}) defer license.ResetForTests() task1 := &Task{ID: 1} taskMap := map[int64]*Task{1: task1} require.NoError(t, addTimeEntriesCountToTasks(s, &LinkSharing{ID: 1}, []int64{1}, taskMap)) assert.Nil(t, task1.TimeEntriesCount, "link shares must not learn time-entry counts") }) t.Run("leaves the count unset when the feature is unlicensed", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() license.ResetForTests() // feature disabled task1 := &Task{ID: 1} taskMap := map[int64]*Task{1: task1} require.NoError(t, addTimeEntriesCountToTasks(s, u, []int64{1}, taskMap)) assert.Nil(t, task1.TimeEntriesCount, "an unlicensed instance must not expose counts") }) }