package reports import ( "database/sql" "os" "os/exec" "path/filepath" "testing" "time" "punchcard/internal/queries" "punchcard/templates" ) func TestGenerateTimesheetData(t *testing.T) { tests := []struct { name string entries interface{} clientID int64 clientName string projectName string contractor queries.Contractor dateRange DateRange timezone *time.Location wantEntries int wantHours float64 wantError bool }{ { name: "client entries with UTC timezone", entries: []queries.GetTimesheetDataByClientRow{ { TimeEntryID: 1, StartTime: mustParseTime("2025-07-10T14:55:00Z"), EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T18:05:00Z"), Valid: true}, Description: sql.NullString{String: "GL closing", Valid: true}, DurationSeconds: 11400, // 3:10 }, { TimeEntryID: 2, StartTime: mustParseTime("2025-07-10T18:42:00Z"), EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T20:04:00Z"), Valid: true}, Description: sql.NullString{String: "GL closing", Valid: true}, DurationSeconds: 4920, // 1:22 }, }, clientID: 1, clientName: "Test Client", contractor: queries.Contractor{ Name: "Travis Parker", Label: "Software Development", Email: "travis@example.com", }, dateRange: DateRange{ Start: mustParseTime("2025-07-01T00:00:00Z"), End: mustParseTime("2025-07-31T23:59:59Z"), }, timezone: time.UTC, wantEntries: 2, wantHours: 4.5333, // 16320 seconds / 3600 }, { name: "project entries with local timezone", entries: []queries.GetTimesheetDataByProjectRow{ { TimeEntryID: 3, StartTime: mustParseTime("2025-07-11T13:55:00Z"), EndTime: sql.NullTime{Time: mustParseTime("2025-07-11T18:35:00Z"), Valid: true}, Description: sql.NullString{String: "Development work", Valid: true}, ProjectID: 1, ProjectName: "Test Project", DurationSeconds: 16800, // 4:40 }, }, clientID: 1, clientName: "Test Client", projectName: "Test Project", contractor: queries.Contractor{ Name: "Travis Parker", Label: "Software Development", Email: "travis@example.com", }, dateRange: DateRange{ Start: mustParseTime("2025-07-01T00:00:00Z"), End: mustParseTime("2025-07-31T23:59:59Z"), }, timezone: time.Local, wantEntries: 1, wantHours: 4.6667, // 16800 seconds / 3600 }, { name: "entries with different timezone", entries: []queries.GetTimesheetDataByClientRow{ { TimeEntryID: 4, StartTime: mustParseTime("2025-07-15T00:09:00Z"), EndTime: sql.NullTime{Time: mustParseTime("2025-07-15T00:13:00Z"), Valid: true}, Description: sql.NullString{String: "Quick fix", Valid: true}, DurationSeconds: 240, // 4 minutes }, }, clientID: 1, clientName: "Test Client", contractor: queries.Contractor{ Name: "Travis Parker", Label: "Software Development", Email: "travis@example.com", }, dateRange: DateRange{ Start: mustParseTime("2025-07-01T00:00:00Z"), End: mustParseTime("2025-07-31T23:59:59Z"), }, timezone: mustLoadLocation("America/New_York"), wantEntries: 1, wantHours: 0.0667, // 240 seconds / 3600 }, { name: "unsupported entry type", entries: "invalid", wantError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := GenerateTimesheetData( tt.entries, tt.clientID, tt.clientName, tt.projectName, tt.contractor, tt.dateRange, tt.timezone, ) if tt.wantError { if err == nil { t.Errorf("GenerateTimesheetData() expected error but got none") } return } if err != nil { t.Errorf("GenerateTimesheetData() error = %v", err) return } if len(result.Entries) != tt.wantEntries { t.Errorf("GenerateTimesheetData() entries count = %d, want %d", len(result.Entries), tt.wantEntries) } // Check total hours (with tolerance for floating point precision) if abs(result.TotalHours-tt.wantHours) > 0.001 { t.Errorf("GenerateTimesheetData() total hours = %f, want %f", result.TotalHours, tt.wantHours) } // Check basic fields if result.ClientID != tt.clientID { t.Errorf("GenerateTimesheetData() client ID = %d, want %d", result.ClientID, tt.clientID) } if result.ClientName != tt.clientName { t.Errorf("GenerateTimesheetData() client name = %s, want %s", result.ClientName, tt.clientName) } if result.ProjectName != tt.projectName { t.Errorf("GenerateTimesheetData() project name = %s, want %s", result.ProjectName, tt.projectName) } // Check timezone handling if tt.timezone != nil { expectedTimezone := tt.timezone.String() if expectedTimezone == "Local" { zone, _ := time.Now().Zone() expectedTimezone = zone } if result.Timezone != expectedTimezone { t.Errorf("GenerateTimesheetData() timezone = %s, want %s", result.Timezone, expectedTimezone) } } }) } } func TestConvertToTimesheetEntries(t *testing.T) { tests := []struct { name string entries []timesheetEntryData timezone *time.Location want []TimesheetEntry }{ { name: "UTC timezone conversion", entries: []timesheetEntryData{ { TimeEntryID: 1, StartTime: mustParseTime("2025-07-10T14:55:00Z"), EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T18:05:00Z"), Valid: true}, Description: sql.NullString{String: "Development work", Valid: true}, ProjectName: sql.NullString{String: "Test Project", Valid: true}, DurationSeconds: 11400, // 3:10 }, }, timezone: time.UTC, want: []TimesheetEntry{ { Date: "2025-07-10", StartTime: "14:55", EndTime: "18:05", Duration: "3:10", Hours: 3.1667, // Rounded to nearest minute ProjectName: "Test Project", Description: "Development work", }, }, }, { name: "timezone conversion to EST", entries: []timesheetEntryData{ { TimeEntryID: 2, StartTime: mustParseTime("2025-07-10T18:00:00Z"), // 6:00 PM UTC EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T19:00:00Z"), Valid: true}, // 7:00 PM UTC Description: sql.NullString{String: "Meeting", Valid: true}, DurationSeconds: 3600, // 1 hour }, }, timezone: mustLoadLocation("America/New_York"), // UTC-4 in July (EDT) want: []TimesheetEntry{ { Date: "2025-07-10", StartTime: "14:00", // 2:00 PM EDT EndTime: "15:00", // 3:00 PM EDT Duration: "1:00", Hours: 1.0, ProjectName: "", Description: "Meeting", }, }, }, { name: "skip entry without end time", entries: []timesheetEntryData{ { TimeEntryID: 3, StartTime: mustParseTime("2025-07-10T14:55:00Z"), EndTime: sql.NullTime{Valid: false}, // No end time Description: sql.NullString{String: "Active entry", Valid: true}, DurationSeconds: 0, }, }, timezone: time.UTC, want: []TimesheetEntry{}, // Should be empty }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := convertToTimesheetEntries(tt.entries, tt.timezone) if len(result) != len(tt.want) { t.Errorf("convertToTimesheetEntries() length = %d, want %d", len(result), len(tt.want)) return } for i, entry := range result { want := tt.want[i] if entry.Date != want.Date { t.Errorf("entry[%d].Date = %s, want %s", i, entry.Date, want.Date) } if entry.StartTime != want.StartTime { t.Errorf("entry[%d].StartTime = %s, want %s", i, entry.StartTime, want.StartTime) } if entry.EndTime != want.EndTime { t.Errorf("entry[%d].EndTime = %s, want %s", i, entry.EndTime, want.EndTime) } if entry.Duration != want.Duration { t.Errorf("entry[%d].Duration = %s, want %s", i, entry.Duration, want.Duration) } if abs(entry.Hours-want.Hours) > 0.001 { t.Errorf("entry[%d].Hours = %f, want %f", i, entry.Hours, want.Hours) } if entry.ProjectName != want.ProjectName { t.Errorf("entry[%d].ProjectName = %s, want %s", i, entry.ProjectName, want.ProjectName) } if entry.Description != want.Description { t.Errorf("entry[%d].Description = %s, want %s", i, entry.Description, want.Description) } } }) } } func TestFormatDuration(t *testing.T) { tests := []struct { name string seconds int64 want string }{ {"zero", 0, "0:00"}, {"30 seconds (rounds down)", 30, "0:01"}, // 30 seconds rounds to 1 minute {"29 seconds (rounds down)", 29, "0:00"}, // 29 seconds rounds to 0 minutes {"90 seconds", 90, "0:02"}, // 90 seconds rounds to 2 minutes {"1 hour", 3600, "1:00"}, {"1 hour 30 minutes", 5400, "1:30"}, {"1 hour 29 minutes 59 seconds", 5399, "1:30"}, // Rounds to 1:30 {"3 hours 10 minutes", 11400, "3:10"}, {"large duration", 50000, "13:53"}, // 13 hours 53 minutes (rounded) } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := formatDuration(tt.seconds) if result != tt.want { t.Errorf("formatDuration(%d) = %s, want %s", tt.seconds, result, tt.want) } }) } } func TestTimesheetTypstTemplateCompilation(t *testing.T) { // Check if Typst is installed if err := checkTypstInstalled(); err != nil { t.Skip("Typst is not installed, skipping template compilation test") } // Create temporary directory tempDir, err := os.MkdirTemp("", "punchcard-timesheet-test") if err != nil { t.Fatalf("Failed to create temp directory: %v", err) } defer func() { _ = os.RemoveAll(tempDir) }() // Create test data file testData := `{ "client_name": "Test Client", "project_name": "Test Project", "date_range_start": "2025-07-01", "date_range_end": "2025-07-31", "generated_date": "2025-08-04", "contractor_name": "Travis Parker", "contractor_label": "Software Development", "contractor_email": "travis@example.com", "timezone": "UTC", "entries": [ { "date": "2025-07-10", "start_time": "14:55", "end_time": "18:05", "duration": "3:10", "hours": 3.1667, "project_name": "Test Project", "description": "Development work" }, { "date": "2025-07-10", "start_time": "18:42", "end_time": "20:04", "duration": "1:22", "hours": 1.3667, "project_name": "Test Project", "description": "Code review" }, { "date": "2025-07-11", "start_time": "13:55", "end_time": "18:35", "duration": "4:40", "hours": 4.6667, "project_name": "Test Project", "description": "Feature implementation" } ], "total_hours": 9.2 }` dataFile := filepath.Join(tempDir, "data.json") if err := os.WriteFile(dataFile, []byte(testData), 0644); err != nil { t.Fatalf("Failed to write test data file: %v", err) } // Write Typst template to temp directory typstFile := filepath.Join(tempDir, "timesheet.typ") if err := os.WriteFile(typstFile, []byte(templates.TimesheetTemplate), 0644); err != nil { t.Fatalf("Failed to write Typst template: %v", err) } // Compile with Typst outputPDF := filepath.Join(tempDir, "test-timesheet.pdf") cmd := exec.Command("typst", "compile", typstFile, outputPDF) cmd.Dir = tempDir if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("Typst compilation failed: %v\nOutput: %s", err, string(output)) } // Verify PDF was created if _, err := os.Stat(outputPDF); os.IsNotExist(err) { t.Fatalf("PDF file was not created") } t.Logf("Successfully compiled timesheet Typst template to PDF") } func TestGenerateTimesheetPDF(t *testing.T) { // Check if Typst is installed if err := checkTypstInstalled(); err != nil { t.Skip("Typst is not installed, skipping PDF generation test") } // Create test timesheet data timesheetData := &TimesheetData{ ClientName: "Test Client Co.", ProjectName: "Test Project", DateRange: DateRange{ Start: mustParseTime("2025-07-01T00:00:00Z"), End: mustParseTime("2025-07-31T23:59:59Z"), }, Entries: []TimesheetEntry{ { Date: "2025-07-10", StartTime: "14:55", EndTime: "18:05", Duration: "3:10", Hours: 3.1667, ProjectName: "Test Project", Description: "Development work", }, { Date: "2025-07-10", StartTime: "18:42", EndTime: "20:04", Duration: "1:22", Hours: 1.3667, ProjectName: "Test Project", Description: "Code review", }, { Date: "2025-07-11", StartTime: "13:55", EndTime: "18:35", Duration: "4:40", Hours: 4.6667, ProjectName: "Test Project", Description: "Feature implementation", }, }, TotalHours: 9.2, GeneratedDate: mustParseTime("2025-08-04T00:00:00Z"), ContractorName: "Travis Parker", ContractorLabel: "Software Development", ContractorEmail: "travis@example.com", Timezone: "UTC", } // Create temporary output file tempDir, err := os.MkdirTemp("", "punchcard-timesheet-pdf-test") if err != nil { t.Fatalf("Failed to create temp directory: %v", err) } defer func() { _ = os.RemoveAll(tempDir) }() outputPath := filepath.Join(tempDir, "test-timesheet.pdf") // Generate PDF if err := GenerateTimesheetPDF(timesheetData, outputPath); err != nil { t.Fatalf("Failed to generate timesheet PDF: %v", err) } // Verify PDF was created if _, err := os.Stat(outputPath); os.IsNotExist(err) { t.Fatalf("PDF file was not created at %s", outputPath) } t.Logf("Successfully generated timesheet PDF at %s", outputPath) } func TestGenerateDefaultTimesheetFilename(t *testing.T) { dateRange := DateRange{ Start: mustParseTime("2025-07-01T00:00:00Z"), End: mustParseTime("2025-07-31T23:59:59Z"), } tests := []struct { name string clientName string projectName string want string }{ { name: "client only", clientName: "Test Client", projectName: "", want: "timesheet_Test Client_2025-07_", }, { name: "client and project", clientName: "Test Client", projectName: "Test Project", want: "timesheet_Test Client_Test Project_2025-07_", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := GenerateDefaultTimesheetFilename(tt.clientName, tt.projectName, dateRange) // Check that the filename starts with the expected pattern if len(result) < len(tt.want) || result[:len(tt.want)] != tt.want { t.Errorf("GenerateDefaultTimesheetFilename() prefix = %s, want prefix %s", result, tt.want) } // Check that it ends with .pdf if result[len(result)-4:] != ".pdf" { t.Errorf("GenerateDefaultTimesheetFilename() should end with .pdf, got %s", result) } }) } } // Helper functions for tests func mustParseTime(timeStr string) time.Time { t, err := time.Parse(time.RFC3339, timeStr) if err != nil { panic(err) } return t } func mustLoadLocation(name string) *time.Location { loc, err := time.LoadLocation(name) if err != nil { panic(err) } return loc } func abs(x float64) float64 { if x < 0 { return -x } return x }