package commands import ( "context" "database/sql" "errors" "strings" "testing" "time" "git.tjp.lol/punchcard/internal/queries" ) func TestOutCommand(t *testing.T) { tests := []struct { name string setupTimeEntry bool args []string expectError bool expectedOutput string }{ { name: "stop active timer", setupTimeEntry: true, args: []string{"out"}, expectError: false, expectedOutput: "Timer stopped. Session duration:", }, { name: "no active timer", setupTimeEntry: false, args: []string{"out"}, expectError: true, }, { name: "out command with arguments should fail", setupTimeEntry: false, args: []string{"out", "extra"}, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Setup fresh database for each test q, cleanup := setupTestDB(t) defer cleanup() // Setup time entry if needed if tt.setupTimeEntry { // Create a test client first client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "TestClient", Email: sql.NullString{String: "test@example.com", Valid: true}, }) if err != nil { t.Fatalf("Failed to setup test client: %v", err) } // Create active time entry _, err = q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ Description: sql.NullString{String: "Test work", Valid: true}, ClientID: client.ID, ProjectID: sql.NullInt64{}, }) if err != nil { t.Fatalf("Failed to setup test time entry: %v", err) } } // Execute command output, err := executeCommandWithDB(t, q, tt.args...) // Check error expectation if tt.expectError { if err == nil { t.Errorf("Expected error but got none") } return } if err != nil { t.Errorf("Unexpected error: %v", err) return } // Check output contains expected text if tt.expectedOutput != "" { if len(output) == 0 || output[:len(tt.expectedOutput)] != tt.expectedOutput { t.Errorf("Expected output to start with %q, got %q", tt.expectedOutput, output) } } // If we set up a time entry, verify it was stopped if tt.setupTimeEntry { // Try to get active time entry - should return no rows _, err := q.GetActiveTimeEntry(context.Background()) if !errors.Is(err, sql.ErrNoRows) { t.Errorf("Expected no active time entry after stopping, but got: %v", err) } // Verify the time entry was updated with an end_time // We can't directly query by ID with the current queries, but we can check that no active entries exist } }) } } func TestOutCommandErrorType(t *testing.T) { // Test that the specific ErrNoActiveTimer error is returned q, cleanup := setupTestDB(t) defer cleanup() // Execute out command with no active timer _, err := executeCommandWithDB(t, q, "out") if err == nil { t.Fatal("Expected error but got none") } // Check that it's specifically the ErrNoActiveTimer error if !errors.Is(err, ErrNoActiveTimer) { t.Errorf("Expected ErrNoActiveTimer, got: %v", err) } } func TestOutCommandTimezoneHandling(t *testing.T) { // Test that punch out stores UTC timestamps and calculates durations correctly tests := []struct { name string setupData func(*queries.Queries) error }{ { name: "punch out stores UTC end timestamp", setupData: func(q *queries.Queries) error { client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "TimezoneOutClient", }) if err != nil { return err } _, err = q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ Description: sql.NullString{String: "Timezone test work", Valid: true}, ClientID: client.ID, }) return err }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { q, cleanup := setupTestDB(t) defer cleanup() if err := tt.setupData(q); err != nil { t.Fatalf("Failed to setup test data: %v", err) } // Get the active entry before stopping to compare beforeEntry, err := q.GetActiveTimeEntry(context.Background()) if err != nil { t.Fatalf("Failed to get active entry before stopping: %v", err) } // Execute out command output, err := executeCommandWithDB(t, q, "out") if err != nil { t.Fatalf("Unexpected error: %v", err) } // Verify output contains expected elements if !strings.Contains(output, "Timer stopped") { t.Errorf("Expected 'Timer stopped' in output, got: %s", output) } // Verify no active timer exists now _, err = q.GetActiveTimeEntry(context.Background()) if !errors.Is(err, sql.ErrNoRows) { t.Errorf("Expected no active timer after stopping, but got: %v", err) } // Get the stopped entry to verify timestamps stoppedEntry, err := q.GetTimeEntryById(context.Background(), beforeEntry.ID) if err != nil { t.Fatalf("Failed to get stopped entry: %v", err) } // Verify start time is still in UTC if stoppedEntry.StartTime.Location() != time.UTC { t.Errorf("Expected start time to remain in UTC, got: %v", stoppedEntry.StartTime.Location()) } // Verify end time is stored in UTC if !stoppedEntry.EndTime.Valid { t.Errorf("Expected end time to be set") } else if stoppedEntry.EndTime.Time.Location() != time.UTC { t.Errorf("Expected end time to be in UTC, got: %v", stoppedEntry.EndTime.Time.Location()) } // Verify end time is after or equal to start time (can be equal due to millisecond precision) if stoppedEntry.EndTime.Valid && stoppedEntry.EndTime.Time.Before(stoppedEntry.StartTime) { t.Errorf("Expected end time %v to be >= start time %v", stoppedEntry.EndTime.Time, stoppedEntry.StartTime) } }) } } func TestOutCommandDurationCalculation(t *testing.T) { // Test that duration calculations are timezone-independent tests := []struct { name string setupData func(*queries.Queries) error }{ { name: "duration calculation works across timezone boundaries", setupData: func(q *queries.Queries) error { client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ Name: "DurationTestClient", }) if err != nil { return err } _, err = q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ Description: sql.NullString{String: "Duration test work", Valid: true}, ClientID: client.ID, }) return err }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { q, cleanup := setupTestDB(t) defer cleanup() if err := tt.setupData(q); err != nil { t.Fatalf("Failed to setup test data: %v", err) } // Get start time activeEntry, err := q.GetActiveTimeEntry(context.Background()) if err != nil { t.Fatalf("Failed to get active entry: %v", err) } startTime := activeEntry.StartTime // Wait a tiny bit to ensure non-zero duration (this is a simple test) time.Sleep(time.Millisecond * 10) // Execute out command output, err := executeCommandWithDB(t, q, "out") if err != nil { t.Fatalf("Unexpected error: %v", err) } // Parse the duration from output (it should contain something like "Session duration: 10ms") if !strings.Contains(output, "Session duration:") { t.Errorf("Expected duration in output, got: %s", output) } // Get the stopped entry stoppedEntry, err := q.GetTimeEntryById(context.Background(), activeEntry.ID) if err != nil { t.Fatalf("Failed to get stopped entry: %v", err) } // Verify that duration can be calculated correctly from UTC times if !stoppedEntry.EndTime.Valid { t.Fatalf("Expected end time to be valid") } duration := stoppedEntry.EndTime.Time.Sub(stoppedEntry.StartTime) if duration < 0 { t.Errorf("Expected non-negative duration, got: %v", duration) } // Duration should be at least the sleep time we waited (with some tolerance) // Note: In fast tests, the duration might be very small or even 0 if duration < 0 { t.Errorf("Duration should not be negative, got: %v", duration) } // Verify times are in UTC for consistency if startTime.Location() != time.UTC || stoppedEntry.EndTime.Time.Location() != time.UTC { t.Errorf("Expected both times in UTC for consistent duration calculation") } }) } }