summaryrefslogtreecommitdiff
path: root/internal/tui/shared_test.go
blob: 1df3eb9e6234358f1d50a0a89a5b92be461b85d9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
package tui

import (
	"context"
	"database/sql"
	"testing"
	"time"

	"git.tjp.lol/punchcard/internal/queries"
	_ "modernc.org/sqlite"
)

// setupTestDB creates an in-memory SQLite database for testing
func setupTestDB(t *testing.T) (*queries.Queries, *sql.DB, func()) {
	db, err := sql.Open("sqlite", ":memory:")
	if err != nil {
		t.Fatalf("Failed to open in-memory sqlite db: %v", err)
	}

	// Simple but complete schema setup for testing
	schema := `
	CREATE TABLE time_entry (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		start_time DATETIME NOT NULL,
		end_time DATETIME,
		description TEXT,
		client_id INTEGER NOT NULL,
		project_id INTEGER,
		billable_rate INTEGER,
		created_at DATETIME DEFAULT CURRENT_TIMESTAMP
	);
	CREATE TABLE client (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		name TEXT NOT NULL UNIQUE,
		email TEXT,
		billable_rate INTEGER,
		created_at DATETIME DEFAULT CURRENT_TIMESTAMP
	);
	CREATE TABLE project (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		name TEXT NOT NULL,
		client_id INTEGER NOT NULL,
		billable_rate INTEGER,
		created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
		FOREIGN KEY (client_id) REFERENCES client (id)
	);
	CREATE TABLE contractor (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		name TEXT NOT NULL,
		label TEXT,
		email TEXT,
		created_at DATETIME DEFAULT CURRENT_TIMESTAMP
	);
	`
	_, err = db.Exec(schema)
	if err != nil {
		t.Fatalf("Failed to create test schema: %v", err)
	}

	q := queries.New(db)

	cleanup := func() {
		if err := db.Close(); err != nil {
			t.Logf("error closing database: %v", err)
		}
	}

	return q, db, cleanup
}

func TestMostRecentMonday(t *testing.T) {
	tests := []struct {
		name     string
		fromTime time.Time
		wantDay  time.Weekday
	}{
		{
			name:     "from wednesday gets previous monday",
			fromTime: time.Date(2024, time.August, 21, 15, 30, 0, 0, time.UTC), // Wednesday
			wantDay:  time.Monday,
		},
		{
			name:     "from monday gets same monday",
			fromTime: time.Date(2024, time.August, 19, 10, 0, 0, 0, time.UTC), // Monday
			wantDay:  time.Monday,
		},
		{
			name:     "from sunday gets next monday (current week)",
			fromTime: time.Date(2024, time.August, 18, 20, 0, 0, 0, time.UTC), // Sunday
			wantDay:  time.Monday,
		},
		{
			name:     "timezone boundary - UTC time vs local time",
			fromTime: time.Date(2024, time.August, 19, 2, 0, 0, 0, time.UTC), // Monday 2 AM UTC
			wantDay:  time.Monday,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := mostRecentMonday(tt.fromTime)

			if result.Weekday() != tt.wantDay {
				t.Errorf("mostRecentMonday(%v) weekday = %v, want %v", tt.fromTime, result.Weekday(), tt.wantDay)
			}

			// The function always converts to local time, so compare appropriately
			localFromTime := tt.fromTime.Local()

			// For Sunday, the function returns Monday of the same week (which is after Sunday)
			// For other days, it should return a Monday that's not after the input day
			if localFromTime.Weekday() != time.Sunday {
				if result.After(localFromTime) {
					t.Errorf("mostRecentMonday(%v) = %v, should not be after local input time %v", tt.fromTime, result, localFromTime)
				}
			}

			// Verify result is within reasonable range (allowing Monday after Sunday)
			daysDiff := localFromTime.Sub(result).Hours() / 24
			if daysDiff > 7 || daysDiff < -1.5 { // Allow for Monday after Sunday
				t.Errorf("mostRecentMonday(%v) = %v, should be within reasonable range of local input %v, got %v days", tt.fromTime, result, localFromTime, daysDiff)
			}
		})
	}
}

func TestMostRecentMondayTimezoneConsistency(t *testing.T) {
	// Test that mostRecentMonday works consistently across timezones
	utcTime := time.Date(2024, time.August, 20, 1, 0, 0, 0, time.UTC) // Tuesday 1 AM UTC

	tests := []struct {
		name     string
		timezone string
		offset   int // hours from UTC
	}{
		{"Pacific", "America/Los_Angeles", -8},
		{"Eastern", "America/New_York", -5},
		{"UTC", "UTC", 0},
		{"Tokyo", "Asia/Tokyo", 9},
		{"Sydney", "Australia/Sydney", 10},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			loc, err := time.LoadLocation(tt.timezone)
			if err != nil {
				t.Skipf("Timezone %s not available: %v", tt.timezone, err)
			}

			localTime := utcTime.In(loc)
			monday := mostRecentMonday(localTime)

			if monday.Weekday() != time.Monday {
				t.Errorf("Expected Monday, got %v for timezone %s", monday.Weekday(), tt.timezone)
			}

			// The function always returns local time, not the input timezone
			if monday.Location() != time.Local {
				t.Errorf("Expected Local timezone, got %v", monday.Location())
			}
		})
	}
}

func TestDateOnly(t *testing.T) {
	tests := []struct {
		name     string
		input    time.Time
		wantHour int
		wantMin  int
		wantSec  int
		wantLoc  *time.Location
	}{
		{
			name:     "UTC time to date only",
			input:    time.Date(2024, time.August, 21, 15, 30, 45, 123456789, time.UTC),
			wantHour: 0,
			wantMin:  0,
			wantSec:  0,
			wantLoc:  time.UTC,
		},
		{
			name:     "Local time preserves timezone",
			input:    time.Date(2024, time.August, 21, 23, 59, 59, 0, time.Local),
			wantHour: 0,
			wantMin:  0,
			wantSec:  0,
			wantLoc:  time.Local,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := dateOnly(tt.input)

			if result.Hour() != tt.wantHour {
				t.Errorf("dateOnly(%v).Hour() = %d, want %d", tt.input, result.Hour(), tt.wantHour)
			}
			if result.Minute() != tt.wantMin {
				t.Errorf("dateOnly(%v).Minute() = %d, want %d", tt.input, result.Minute(), tt.wantMin)
			}
			if result.Second() != tt.wantSec {
				t.Errorf("dateOnly(%v).Second() = %d, want %d", tt.input, result.Second(), tt.wantSec)
			}
			if result.Location() != tt.wantLoc {
				t.Errorf("dateOnly(%v).Location() = %v, want %v", tt.input, result.Location(), tt.wantLoc)
			}

			// Verify same date
			if result.Year() != tt.input.Year() || result.Month() != tt.input.Month() || result.Day() != tt.input.Day() {
				t.Errorf("dateOnly(%v) changed date: got %v", tt.input, result)
			}
		})
	}
}

func TestGetTimerInfoTimezoneHandling(t *testing.T) {
	tests := []struct {
		name           string
		setupData      func(*queries.Queries, *sql.DB) error
		expectActive   bool
		expectDuration bool // whether duration should be > 0
	}{
		{
			name: "active timer duration calculation",
			setupData: func(q *queries.Queries, db *sql.DB) error {
				// Create client
				client, err := q.CreateClient(context.Background(), queries.CreateClientParams{
					Name: "TestClient",
				})
				if err != nil {
					return err
				}

				// Create active time entry (start_time in UTC)
				_, err = db.Exec(`
					INSERT INTO time_entry (start_time, client_id, description) 
					VALUES (datetime('now', 'utc', '-1 hour'), ?, 'Test work')
				`, client.ID)
				return err
			},
			expectActive:   true,
			expectDuration: true,
		},
		{
			name: "no active timer falls back to most recent",
			setupData: func(q *queries.Queries, db *sql.DB) error {
				// Create client
				client, err := q.CreateClient(context.Background(), queries.CreateClientParams{
					Name: "TestClient",
				})
				if err != nil {
					return err
				}

				// Create completed time entry
				_, err = db.Exec(`
					INSERT INTO time_entry (start_time, end_time, client_id, description) 
					VALUES (
						datetime('now', 'utc', '-2 hours'), 
						datetime('now', 'utc', '-1 hour'), 
						?, 'Completed work'
					)
				`, client.ID)
				return err
			},
			expectActive:   false,
			expectDuration: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			q, db, cleanup := setupTestDB(t)
			defer cleanup()

			// Setup test data
			if err := tt.setupData(q, db); err != nil {
				t.Fatalf("Failed to setup test data: %v", err)
			}

			// Test getTimerInfo
			info, err := getTimerInfo(context.Background(), q)
			if err != nil {
				t.Fatalf("getTimerInfo failed: %v", err)
			}

			if info.IsActive != tt.expectActive {
				t.Errorf("Expected IsActive=%v, got %v", tt.expectActive, info.IsActive)
			}

			if tt.expectDuration {
				if info.Duration <= 0 {
					t.Errorf("Expected positive duration, got %v", info.Duration)
				}
			}

			// Verify StartTime timezone handling
			if info.StartTime.IsZero() {
				t.Error("Expected non-zero StartTime")
			}

			// For active timers, verify the duration calculation makes sense
			if info.IsActive && tt.expectDuration {
				// The duration should be reasonable (we inserted 1 hour ago)
				expectedMin := 55 * time.Minute // Allow some margin for test execution time
				expectedMax := 65 * time.Minute

				if info.Duration < expectedMin || info.Duration > expectedMax {
					t.Errorf("Expected duration between %v and %v, got %v", expectedMin, expectedMax, info.Duration)
				}
			}
		})
	}
}

func TestGetAppDataTimezoneFiltering(t *testing.T) {
	// Test that getAppData correctly filters "Today" and "Week" totals using local timezone
	tests := []struct {
		name      string
		setupData func(*queries.Queries, *sql.DB) error
	}{
		{
			name: "today and week filtering uses local timezone",
			setupData: func(q *queries.Queries, db *sql.DB) error {
				// Create client
				client, err := q.CreateClient(context.Background(), queries.CreateClientParams{
					Name: "FilterTestClient",
				})
				if err != nil {
					return err
				}

				now := time.Now()
				today := now.Format("2006-01-02")
				yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")

				// Insert entries for today and yesterday in UTC
				_, err = db.Exec(`
					INSERT INTO time_entry (start_time, end_time, client_id, description) VALUES 
					(?, ?, ?, 'Today work'),
					(?, ?, ?, 'Yesterday work')
				`,
					today+" 10:00:00", today+" 11:00:00", client.ID,
					yesterday+" 10:00:00", yesterday+" 11:00:00", client.ID,
				)
				return err
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			q, db, cleanup := setupTestDB(t)
			defer cleanup()

			// Create a default contractor first
			_, err := q.CreateContractor(context.Background(), queries.CreateContractorParams{
				Name:  "Default Contractor",
				Label: "Testing",
				Email: "test@example.com",
			})
			if err != nil {
				t.Fatalf("Failed to create contractor: %v", err)
			}

			if err := tt.setupData(q, db); err != nil {
				t.Fatalf("Failed to setup test data: %v", err)
			}

			// Test with empty filter to get all data
			filter := HistoryFilter{
				StartDate: time.Now().AddDate(0, 0, -7), // Last 7 days
			}

			_, info, stats, _, _, _, err := getAppData(context.Background(), q, filter)
			if err != nil {
				t.Fatalf("getAppData failed: %v", err)
			}

			// Verify that we got some time stats
			// The exact values depend on the current time and setup, so we just verify they're reasonable
			if stats.TodayTotal < 0 {
				t.Errorf("Expected non-negative today total, got %v", stats.TodayTotal)
			}

			if stats.WeekTotal < stats.TodayTotal {
				t.Errorf("Expected week total >= today total, got week=%v, today=%v", stats.WeekTotal, stats.TodayTotal)
			}

			// If there's an active timer, verify it contributes to both today and week
			if info.IsActive {
				expectedContribution := info.Duration
				// The stats should include active time, but exact verification is complex
				// due to timing differences, so we just verify structure
				if stats.TodayTotal == 0 && expectedContribution > 0 {
					t.Log("Note: Active timer contribution might not be reflected in test due to timing")
				}
			}
		})
	}
}