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, archived INTEGER DEFAULT 0, 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, archived INTEGER DEFAULT 0, 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") } } }) } }