diff options
author | T <t@tjp.lol> | 2025-08-04 15:34:23 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-04 19:49:08 -0600 |
commit | dc895cec9d8a84af89ce2501db234dff33c757e2 (patch) | |
tree | 8c961466f0769616b3a82da91f4cde4d3a881b73 /internal/reports/timesheet_test.go | |
parent | 56e0af3b41742876b471332aeb943a5a2ca8dfbf (diff) |
timesheet and unified reports
Diffstat (limited to 'internal/reports/timesheet_test.go')
-rw-r--r-- | internal/reports/timesheet_test.go | 541 |
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 |