From b8b376c53a5bf92d4cdeca741939f19681ecc22e Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:11:07 +0200 Subject: [PATCH] test(time-tracking): cover the time_entries model --- pkg/db/fixtures/time_entries.yml | 36 ++ pkg/models/models.go | 1 + pkg/models/setup_tests.go | 1 + pkg/models/time_tracking_test.go | 678 +++++++++++++++++++++++++++++++ 4 files changed, 716 insertions(+) create mode 100644 pkg/db/fixtures/time_entries.yml create mode 100644 pkg/models/time_tracking_test.go diff --git a/pkg/db/fixtures/time_entries.yml b/pkg/db/fixtures/time_entries.yml new file mode 100644 index 000000000..a3a0112fb --- /dev/null +++ b/pkg/db/fixtures/time_entries.yml @@ -0,0 +1,36 @@ +- id: 1 + user_id: 1 + task_id: 1 + project_id: 0 + start_time: 2018-12-01 10:00:00 + end_time: 2018-12-01 11:00:00 + comment: Time entry on task 1 + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 +- id: 2 + user_id: 1 + task_id: 0 + project_id: 1 + start_time: 2018-12-01 12:00:00 + end_time: 2018-12-01 13:00:00 + comment: Standalone entry on project 1 + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 +- id: 3 + user_id: 3 + task_id: 0 + project_id: 3 + start_time: 2018-12-01 12:00:00 + end_time: 2018-12-01 13:00:00 + comment: Standalone entry on project 3 by user3 + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 +# Running timer (no end_time) on task 1 by user1 +- id: 4 + user_id: 1 + task_id: 1 + project_id: 0 + start_time: 2018-12-01 14:00:00 + comment: Running timer + created: 2018-12-01 15:13:12 + updated: 2018-12-02 15:13:12 diff --git a/pkg/models/models.go b/pkg/models/models.go index df562ef90..88d98231c 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -71,6 +71,7 @@ func GetTables() []interface{} { &TaskUnreadStatus{}, &Session{}, &OAuthCode{}, + &TimeEntry{}, } } diff --git a/pkg/models/setup_tests.go b/pkg/models/setup_tests.go index 66948fc84..15b056faa 100644 --- a/pkg/models/setup_tests.go +++ b/pkg/models/setup_tests.go @@ -59,6 +59,7 @@ func SetupTests() { "task_relations", "task_reminders", "tasks", + "time_entries", "team_projects", "team_members", "teams", diff --git a/pkg/models/time_tracking_test.go b/pkg/models/time_tracking_test.go new file mode 100644 index 000000000..deb1892fa --- /dev/null +++ b/pkg/models/time_tracking_test.go @@ -0,0 +1,678 @@ +// 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 ( + "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_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(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(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(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(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(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(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") + }) +}