cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1package handlers
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/stormlightlabs/noteleaf/internal/models"
12)
13
14func setupTimeTrackingTestHandler(t *testing.T) (*TaskHandler, func()) {
15 tempDir := t.TempDir()
16 os.Setenv("NOTELEAF_CONFIG_DIR", tempDir)
17
18 handler, err := NewTaskHandler()
19 if err != nil {
20 t.Fatalf("Failed to create test handler: %v", err)
21 }
22
23 cleanup := func() {
24 handler.Close()
25 os.Unsetenv("NOTELEAF_CONFIG_DIR")
26 }
27
28 return handler, cleanup
29}
30
31func createTimeTrackingTestTask(t *testing.T, handler *TaskHandler) *models.Task {
32 ctx := context.Background()
33 task := &models.Task{
34 UUID: fmt.Sprintf("test-time-uuid-%d", time.Now().UnixNano()),
35 Description: "Test Time Tracking Task",
36 Status: "pending",
37 }
38
39 id, err := handler.repos.Tasks.Create(ctx, task)
40 if err != nil {
41 t.Fatalf("Failed to create test task: %v", err)
42 }
43 task.ID = id
44 return task
45}
46
47func TestTimeTracking(t *testing.T) {
48 t.Run("Start", func(t *testing.T) {
49 handler, cleanup := setupTimeTrackingTestHandler(t)
50 defer cleanup()
51
52 ctx := context.Background()
53 task := createTimeTrackingTestTask(t, handler)
54
55 t.Run("starts time tracking by ID", func(t *testing.T) {
56 err := handler.Start(ctx, fmt.Sprintf("%d", task.ID), "Working on tests")
57
58 if err != nil {
59 t.Fatalf("Failed to start time tracking: %v", err)
60 }
61
62 active, err := handler.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID)
63 if err != nil {
64 t.Fatalf("Failed to get active time entry: %v", err)
65 }
66
67 if active.Description != "Working on tests" {
68 t.Errorf("Expected description 'Working on tests', got %q", active.Description)
69 }
70 if !active.IsActive() {
71 t.Error("Expected time entry to be active")
72 }
73 })
74
75 t.Run("starts time tracking by UUID", func(t *testing.T) {
76 err := handler.Stop(ctx, task.UUID)
77 if err != nil {
78 t.Fatalf("Failed to stop previous tracking: %v", err)
79 }
80
81 err = handler.Start(ctx, task.UUID, "Working via UUID")
82
83 if err != nil {
84 t.Fatalf("Failed to start time tracking by UUID: %v", err)
85 }
86
87 active, err := handler.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID)
88 if err != nil {
89 t.Fatalf("Failed to get active time entry: %v", err)
90 }
91
92 if active.Description != "Working via UUID" {
93 t.Errorf("Expected description 'Working via UUID', got %q", active.Description)
94 }
95 })
96
97 t.Run("handles already started task gracefully", func(t *testing.T) {
98 err := handler.Start(ctx, fmt.Sprintf("%d", task.ID), "Another attempt")
99
100 if err != nil {
101 t.Fatalf("Expected graceful handling of already started task, got error: %v", err)
102 }
103 })
104
105 t.Run("fails with non-existent task", func(t *testing.T) {
106 err := handler.Start(ctx, "99999", "Non-existent task")
107
108 if err == nil {
109 t.Error("Expected error for non-existent task")
110 }
111 if !strings.Contains(err.Error(), "failed to find task") {
112 t.Errorf("Expected 'failed to find task' error, got: %v", err)
113 }
114 })
115 })
116
117 t.Run("Stop", func(t *testing.T) {
118 handler, cleanup := setupTimeTrackingTestHandler(t)
119 defer cleanup()
120
121 ctx := context.Background()
122 task := createTimeTrackingTestTask(t, handler)
123
124 t.Run("stops active time tracking", func(t *testing.T) {
125 err := handler.Start(ctx, fmt.Sprintf("%d", task.ID), "Test work")
126 if err != nil {
127 t.Fatalf("Failed to start time tracking: %v", err)
128 }
129
130 time.Sleep(1010 * time.Millisecond)
131
132 err = handler.Stop(ctx, fmt.Sprintf("%d", task.ID))
133
134 if err != nil {
135 t.Fatalf("Failed to stop time tracking: %v", err)
136 }
137
138 _, err = handler.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID)
139 if err.Error() != "sql: no rows in result set" {
140 t.Errorf("Expected no active time entry after stopping, got: %v", err)
141 }
142 })
143
144 t.Run("handles no active tracking gracefully", func(t *testing.T) {
145 err := handler.Stop(ctx, fmt.Sprintf("%d", task.ID))
146
147 if err != nil {
148 t.Fatalf("Expected graceful handling of no active tracking, got error: %v", err)
149 }
150 })
151
152 t.Run("stops by UUID", func(t *testing.T) {
153 err := handler.Start(ctx, task.UUID, "UUID test")
154 if err != nil {
155 t.Fatalf("Failed to start time tracking: %v", err)
156 }
157
158 time.Sleep(1010 * time.Millisecond)
159
160 err = handler.Stop(ctx, task.UUID)
161
162 if err != nil {
163 t.Fatalf("Failed to stop time tracking by UUID: %v", err)
164 }
165 })
166
167 t.Run("fails with non-existent task", func(t *testing.T) {
168 err := handler.Stop(ctx, "99999")
169
170 if err == nil {
171 t.Error("Expected error for non-existent task")
172 }
173 if !strings.Contains(err.Error(), "failed to find task") {
174 t.Errorf("Expected 'failed to find task' error, got: %v", err)
175 }
176 })
177 })
178
179 t.Run("Timesheet", func(t *testing.T) {
180 handler, cleanup := setupTimeTrackingTestHandler(t)
181 defer cleanup()
182
183 ctx := context.Background()
184 task1 := createTimeTrackingTestTask(t, handler)
185
186 task2 := &models.Task{
187 UUID: fmt.Sprintf("test-time-uuid-2-%d", time.Now().UnixNano()),
188 Description: "Second Time Tracking Task",
189 Status: "pending",
190 }
191 id2, err := handler.repos.Tasks.Create(ctx, task2)
192 if err != nil {
193 t.Fatalf("Failed to create second test task: %v", err)
194 }
195 task2.ID = id2
196
197 setupTimeEntries := func() {
198 entry1, _ := handler.repos.TimeEntries.Start(ctx, task1.ID, "First session")
199 time.Sleep(1010 * time.Millisecond)
200 handler.repos.TimeEntries.Stop(ctx, entry1.ID)
201
202 entry2, _ := handler.repos.TimeEntries.Start(ctx, task2.ID, "Second task work")
203 time.Sleep(1010 * time.Millisecond)
204 handler.repos.TimeEntries.Stop(ctx, entry2.ID)
205
206 handler.repos.TimeEntries.Start(ctx, task1.ID, "Active work")
207 }
208
209 t.Run("shows general timesheet", func(t *testing.T) {
210 setupTimeEntries()
211
212 err := handler.Timesheet(ctx, 7, "")
213
214 if err != nil {
215 t.Fatalf("Failed to generate timesheet: %v", err)
216 }
217 })
218
219 t.Run("shows task-specific timesheet", func(t *testing.T) {
220 err := handler.Timesheet(ctx, 7, fmt.Sprintf("%d", task1.ID))
221
222 if err != nil {
223 t.Fatalf("Failed to generate task timesheet: %v", err)
224 }
225 })
226
227 t.Run("shows task-specific timesheet by UUID", func(t *testing.T) {
228 err := handler.Timesheet(ctx, 7, task1.UUID)
229
230 if err != nil {
231 t.Fatalf("Failed to generate task timesheet by UUID: %v", err)
232 }
233 })
234
235 t.Run("handles empty timesheet gracefully", func(t *testing.T) {
236 task3 := &models.Task{
237 UUID: fmt.Sprintf("test-empty-uuid-%d", time.Now().UnixNano()),
238 Description: "Empty Task",
239 Status: "pending",
240 }
241 id3, err := handler.repos.Tasks.Create(ctx, task3)
242 if err != nil {
243 t.Fatalf("Failed to create empty test task: %v", err)
244 }
245
246 err = handler.Timesheet(ctx, 7, fmt.Sprintf("%d", id3))
247
248 if err != nil {
249 t.Fatalf("Failed to handle empty timesheet: %v", err)
250 }
251 })
252
253 t.Run("fails with non-existent task", func(t *testing.T) {
254 err := handler.Timesheet(ctx, 7, "99999")
255
256 if err == nil {
257 t.Error("Expected error for non-existent task")
258 }
259 if !strings.Contains(err.Error(), "failed to find task") {
260 t.Errorf("Expected 'failed to find task' error, got: %v", err)
261 }
262 })
263 })
264
265 t.Run("TestFormatDuration", func(t *testing.T) {
266 tests := []struct {
267 duration time.Duration
268 expected string
269 }{
270 {30 * time.Second, "30s"},
271 {90 * time.Second, "2m"},
272 {30 * time.Minute, "30m"},
273 {90 * time.Minute, "1.5h"},
274 {2 * time.Hour, "2.0h"},
275 {25 * time.Hour, "1d 1.0h"},
276 {48 * time.Hour, "2d"},
277 {72 * time.Hour, "3d"},
278 }
279
280 for _, test := range tests {
281 result := formatDuration(test.duration)
282 if result != test.expected {
283 t.Errorf("formatDuration(%v) = %q, expected %q", test.duration, result, test.expected)
284 }
285 }
286 })
287
288 t.Run("TestTimeEntryMethods", func(t *testing.T) {
289 now := time.Now()
290
291 t.Run("IsActive returns true for entry without end time", func(t *testing.T) {
292 entry := &models.TimeEntry{
293 StartTime: now,
294 EndTime: nil,
295 }
296
297 if !entry.IsActive() {
298 t.Error("Expected entry to be active")
299 }
300 })
301
302 t.Run("IsActive returns false for entry with end time", func(t *testing.T) {
303 endTime := now.Add(time.Hour)
304 entry := &models.TimeEntry{
305 StartTime: now,
306 EndTime: &endTime,
307 }
308
309 if entry.IsActive() {
310 t.Error("Expected entry to not be active")
311 }
312 })
313
314 t.Run("Stop sets end time and calculates duration", func(t *testing.T) {
315 entry := &models.TimeEntry{
316 StartTime: now.Add(-time.Second), // Start 1 second ago
317 EndTime: nil,
318 }
319
320 entry.Stop()
321
322 if entry.EndTime == nil {
323 t.Error("Expected EndTime to be set after stopping")
324 }
325 if entry.DurationSeconds <= 0 {
326 t.Error("Expected duration to be calculated and greater than 0")
327 }
328 if entry.IsActive() {
329 t.Error("Expected entry to not be active after stopping")
330 }
331 })
332
333 t.Run("GetDuration returns calculated duration for completed entry", func(t *testing.T) {
334 start := now
335 end := now.Add(2 * time.Hour)
336 entry := &models.TimeEntry{
337 StartTime: start,
338 EndTime: &end,
339 DurationSeconds: int64((2 * time.Hour).Seconds()),
340 }
341
342 duration := entry.GetDuration()
343 expected := 2 * time.Hour
344
345 if duration != expected {
346 t.Errorf("Expected duration %v, got %v", expected, duration)
347 }
348 })
349
350 t.Run("GetDuration returns live duration for active entry", func(t *testing.T) {
351 start := time.Now().Add(-time.Minute)
352 entry := &models.TimeEntry{
353 StartTime: start,
354 EndTime: nil,
355 }
356
357 duration := entry.GetDuration()
358
359 if duration < 59*time.Second || duration > 61*time.Second {
360 t.Errorf("Expected duration around 1 minute, got %v", duration)
361 }
362 })
363 })
364}