summaryrefslogtreecommitdiff
path: root/internal/reports/timesheet_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/reports/timesheet_test.go')
-rw-r--r--internal/reports/timesheet_test.go541
1 files changed, 541 insertions, 0 deletions
diff --git a/internal/reports/timesheet_test.go b/internal/reports/timesheet_test.go
new file mode 100644
index 0000000..8c0ac52
--- /dev/null
+++ b/internal/reports/timesheet_test.go
@@ -0,0 +1,541 @@
+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
+} \ No newline at end of file