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/unified_test.go | |
parent | 56e0af3b41742876b471332aeb943a5a2ca8dfbf (diff) |
timesheet and unified reports
Diffstat (limited to 'internal/reports/unified_test.go')
-rw-r--r-- | internal/reports/unified_test.go | 562 |
1 files changed, 562 insertions, 0 deletions
diff --git a/internal/reports/unified_test.go b/internal/reports/unified_test.go new file mode 100644 index 0000000..64d0b3f --- /dev/null +++ b/internal/reports/unified_test.go @@ -0,0 +1,562 @@ +package reports + +import ( + "database/sql" + "testing" + "time" + + "punchcard/internal/queries" +) + + +func TestGenerateUnifiedReportData(t *testing.T) { + tests := []struct { + name string + entries interface{} + clientID int64 + clientName string + projectName string + contractor queries.Contractor + invoiceNumber int64 + dateRange DateRange + timezone *time.Location + wantEntries int + wantHours float64 + wantTotalAmount float64 + wantError bool + }{ + { + name: "client entries with UTC timezone", + entries: []queries.GetInvoiceDataByClientRow{ + { + 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}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 11400, // 3:10 + EntryBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + RateSource: "entry", + }, + { + 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}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 4920, // 1:22 + EntryBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + RateSource: "entry", + }, + }, + clientID: 1, + clientName: "Test Client", + contractor: queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + }, + invoiceNumber: 123, + dateRange: DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + }, + timezone: time.UTC, + wantEntries: 1, // Both entries have same rate so grouped together + wantHours: 4.5333, // 16320 seconds / 3600 + wantTotalAmount: 6.80, // 4.5333 * 1.50 + }, + { + name: "project entries with local timezone", + entries: []queries.GetInvoiceDataByProjectRow{ + { + 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}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: 1, + ProjectName: "Test Project", + DurationSeconds: 16800, // 4:40 + EntryBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + RateSource: "entry", + }, + }, + clientID: 1, + clientName: "Test Client", + projectName: "Test Project", + contractor: queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + }, + invoiceNumber: 124, + 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 + wantTotalAmount: 5.83, // 4.6667 * 1.25 + }, + { + name: "entries with different timezone", + entries: []queries.GetInvoiceDataByClientRow{ + { + 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}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 240, // 4 minutes + EntryBillableRate: sql.NullInt64{Int64: 200, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 200, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 200, Valid: true}, + RateSource: "entry", + }, + }, + clientID: 1, + clientName: "Test Client", + contractor: queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + }, + invoiceNumber: 125, + 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 + wantTotalAmount: 0.13, // 0.0667 * 2.00 + }, + { + name: "unsupported entry type", + entries: "invalid", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GenerateUnifiedReportData( + tt.entries, + tt.clientID, + tt.clientName, + tt.projectName, + tt.contractor, + tt.invoiceNumber, + tt.dateRange, + tt.timezone, + ) + + if tt.wantError { + if err == nil { + t.Errorf("GenerateUnifiedReportData() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("GenerateUnifiedReportData() error = %v", err) + return + } + + if result == nil { + t.Errorf("GenerateUnifiedReportData() returned nil result") + return + } + + // Test invoice data + if result.InvoiceData == nil { + t.Errorf("GenerateUnifiedReportData() invoice data is nil") + return + } + + if len(result.InvoiceData.LineItems) != tt.wantEntries { + t.Errorf("GenerateUnifiedReportData() invoice entries count = %d, want %d", len(result.InvoiceData.LineItems), tt.wantEntries) + } + + // Check invoice total hours (with tolerance for floating point precision) + if abs(result.InvoiceData.TotalHours-tt.wantHours) > 0.001 { + t.Errorf("GenerateUnifiedReportData() invoice total hours = %f, want %f", result.InvoiceData.TotalHours, tt.wantHours) + } + + // Check invoice total amount (with tolerance for floating point precision) + if abs(result.InvoiceData.TotalAmount-tt.wantTotalAmount) > 0.01 { + t.Errorf("GenerateUnifiedReportData() invoice total amount = %f, want %f", result.InvoiceData.TotalAmount, tt.wantTotalAmount) + } + + // Check invoice basic fields + if result.InvoiceData.ClientName != tt.clientName { + t.Errorf("GenerateUnifiedReportData() invoice client name = %s, want %s", result.InvoiceData.ClientName, tt.clientName) + } + if result.InvoiceData.ProjectName != tt.projectName { + t.Errorf("GenerateUnifiedReportData() invoice project name = %s, want %s", result.InvoiceData.ProjectName, tt.projectName) + } + if result.InvoiceData.InvoiceNumber != tt.invoiceNumber { + t.Errorf("GenerateUnifiedReportData() invoice number = %d, want %d", result.InvoiceData.InvoiceNumber, tt.invoiceNumber) + } + + // Test timesheet data + if result.TimesheetData == nil { + t.Errorf("GenerateUnifiedReportData() timesheet data is nil") + return + } + + // For timesheet, we expect individual entries, not grouped like invoice + expectedTimesheetEntries := tt.wantEntries + if tt.name == "client entries with UTC timezone" { + expectedTimesheetEntries = 2 // Individual timesheet entries + } + if len(result.TimesheetData.Entries) != expectedTimesheetEntries { + t.Errorf("GenerateUnifiedReportData() timesheet entries count = %d, want %d", len(result.TimesheetData.Entries), expectedTimesheetEntries) + } + + // Check timesheet total hours (with tolerance for floating point precision) + if abs(result.TimesheetData.TotalHours-tt.wantHours) > 0.001 { + t.Errorf("GenerateUnifiedReportData() timesheet total hours = %f, want %f", result.TimesheetData.TotalHours, tt.wantHours) + } + + // Check timesheet basic fields + if result.TimesheetData.ClientID != tt.clientID { + t.Errorf("GenerateUnifiedReportData() timesheet client ID = %d, want %d", result.TimesheetData.ClientID, tt.clientID) + } + if result.TimesheetData.ClientName != tt.clientName { + t.Errorf("GenerateUnifiedReportData() timesheet client name = %s, want %s", result.TimesheetData.ClientName, tt.clientName) + } + if result.TimesheetData.ProjectName != tt.projectName { + t.Errorf("GenerateUnifiedReportData() timesheet project name = %s, want %s", result.TimesheetData.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.TimesheetData.Timezone != expectedTimezone { + t.Errorf("GenerateUnifiedReportData() timesheet timezone = %s, want %s", result.TimesheetData.Timezone, expectedTimezone) + } + } + + // Check contractor data consistency between invoice and timesheet + if result.InvoiceData.ContractorName != result.TimesheetData.ContractorName { + t.Errorf("GenerateUnifiedReportData() contractor name mismatch: invoice=%s, timesheet=%s", result.InvoiceData.ContractorName, result.TimesheetData.ContractorName) + } + if result.InvoiceData.ContractorLabel != result.TimesheetData.ContractorLabel { + t.Errorf("GenerateUnifiedReportData() contractor label mismatch: invoice=%s, timesheet=%s", result.InvoiceData.ContractorLabel, result.TimesheetData.ContractorLabel) + } + if result.InvoiceData.ContractorEmail != result.TimesheetData.ContractorEmail { + t.Errorf("GenerateUnifiedReportData() contractor email mismatch: invoice=%s, timesheet=%s", result.InvoiceData.ContractorEmail, result.TimesheetData.ContractorEmail) + } + }) + } +} + +func TestUnifiedReportDataConsistency(t *testing.T) { + // Test that unified report produces consistent data between invoice and timesheet components + entries := []queries.GetInvoiceDataByClientRow{ + { + 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}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 11400, // 3:10 + EntryBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + RateSource: "entry", + }, + { + 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: "Code review", Valid: true}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 4920, // 1:22 + EntryBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + RateSource: "entry", + }, + } + + 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"), + } + + result, err := GenerateUnifiedReportData( + entries, + 1, + "Test Client", + "Test Project", + contractor, + 123, + dateRange, + time.UTC, + ) + + if err != nil { + t.Fatalf("GenerateUnifiedReportData() error = %v", err) + } + + // Note: Invoice entries are grouped by rate, timesheet entries remain individual + // So we don't expect the counts to be equal - invoice will have 1 grouped item, timesheet will have 2 individual entries + if len(result.InvoiceData.LineItems) != 1 { + t.Errorf("Invoice line items count = %d, want 1 (grouped)", len(result.InvoiceData.LineItems)) + } + if len(result.TimesheetData.Entries) != 2 { + t.Errorf("Timesheet entries count = %d, want 2 (individual)", len(result.TimesheetData.Entries)) + } + + // Verify total hours consistency + if abs(result.InvoiceData.TotalHours-result.TimesheetData.TotalHours) > 0.001 { + t.Errorf("Total hours mismatch: invoice=%f, timesheet=%f", result.InvoiceData.TotalHours, result.TimesheetData.TotalHours) + } + + // Verify date range consistency + if !result.InvoiceData.DateRange.Start.Equal(result.TimesheetData.DateRange.Start) { + t.Errorf("Date range start mismatch: invoice=%v, timesheet=%v", result.InvoiceData.DateRange.Start, result.TimesheetData.DateRange.Start) + } + if !result.InvoiceData.DateRange.End.Equal(result.TimesheetData.DateRange.End) { + t.Errorf("Date range end mismatch: invoice=%v, timesheet=%v", result.InvoiceData.DateRange.End, result.TimesheetData.DateRange.End) + } + + // Verify client information consistency + if result.InvoiceData.ClientName != result.TimesheetData.ClientName { + t.Errorf("Client name mismatch: invoice=%s, timesheet=%s", result.InvoiceData.ClientName, result.TimesheetData.ClientName) + } + + // Verify project information consistency + if result.InvoiceData.ProjectName != result.TimesheetData.ProjectName { + t.Errorf("Project name mismatch: invoice=%s, timesheet=%s", result.InvoiceData.ProjectName, result.TimesheetData.ProjectName) + } + + // Verify generation date consistency (should be the same day, allowing for small time differences) + invoiceGenTime := result.InvoiceData.GeneratedDate + timesheetGenTime := result.TimesheetData.GeneratedDate + if invoiceGenTime.Format("2006-01-02") != timesheetGenTime.Format("2006-01-02") { + t.Errorf("Generation date mismatch: invoice=%s, timesheet=%s", invoiceGenTime.Format("2006-01-02"), timesheetGenTime.Format("2006-01-02")) + } +} + +func TestUnifiedReportEntryTypeConversion(t *testing.T) { + tests := []struct { + name string + entries interface{} + expectError bool + }{ + { + name: "client entries conversion", + entries: []queries.GetInvoiceDataByClientRow{ + { + 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: "Work", Valid: true}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 11400, + EntryBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + RateSource: "entry", + }, + }, + expectError: false, + }, + { + name: "project entries conversion", + entries: []queries.GetInvoiceDataByProjectRow{ + { + TimeEntryID: 2, + StartTime: mustParseTime("2025-07-11T13:55:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-11T18:35:00Z"), Valid: true}, + Description: sql.NullString{String: "Work", Valid: true}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: 1, + ProjectName: "Test Project", + DurationSeconds: 16800, + EntryBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + RateSource: "entry", + }, + }, + expectError: false, + }, + { + name: "unsupported entry type", + entries: []struct { + ID int + Name string + }{ + {ID: 1, Name: "Invalid"}, + }, + expectError: true, + }, + { + name: "nil entries", + entries: nil, + expectError: true, + }, + { + name: "string entries", + entries: "invalid", + expectError: true, + }, + } + + 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"), + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GenerateUnifiedReportData( + tt.entries, + 1, + "Test Client", + "Test Project", + contractor, + 123, + dateRange, + time.UTC, + ) + + if tt.expectError { + if err == nil { + t.Errorf("GenerateUnifiedReportData() expected error but got none") + } + if result != nil { + t.Errorf("GenerateUnifiedReportData() expected nil result on error, got %v", result) + } + } else { + if err != nil { + t.Errorf("GenerateUnifiedReportData() unexpected error = %v", err) + } + if result == nil { + t.Errorf("GenerateUnifiedReportData() expected result but got nil") + } + } + }) + } +} + +func TestUnifiedReportEmptyEntries(t *testing.T) { + 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"), + } + + tests := []struct { + name string + entries interface{} + }{ + { + name: "empty client entries", + entries: []queries.GetInvoiceDataByClientRow{}, + }, + { + name: "empty project entries", + entries: []queries.GetInvoiceDataByProjectRow{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GenerateUnifiedReportData( + tt.entries, + 1, + "Test Client", + "Test Project", + contractor, + 123, + dateRange, + time.UTC, + ) + + if err != nil { + t.Errorf("GenerateUnifiedReportData() error = %v", err) + return + } + + if result == nil { + t.Errorf("GenerateUnifiedReportData() returned nil result") + return + } + + // Both invoice and timesheet should have zero entries + if len(result.InvoiceData.LineItems) != 0 { + t.Errorf("GenerateUnifiedReportData() invoice entries count = %d, want 0", len(result.InvoiceData.LineItems)) + } + if len(result.TimesheetData.Entries) != 0 { + t.Errorf("GenerateUnifiedReportData() timesheet entries count = %d, want 0", len(result.TimesheetData.Entries)) + } + + // Both should have zero total hours + if result.InvoiceData.TotalHours != 0 { + t.Errorf("GenerateUnifiedReportData() invoice total hours = %f, want 0", result.InvoiceData.TotalHours) + } + if result.TimesheetData.TotalHours != 0 { + t.Errorf("GenerateUnifiedReportData() timesheet total hours = %f, want 0", result.TimesheetData.TotalHours) + } + + // Invoice should have zero total amount + if result.InvoiceData.TotalAmount != 0 { + t.Errorf("GenerateUnifiedReportData() invoice total amount = %f, want 0", result.InvoiceData.TotalAmount) + } + }) + } +}
\ No newline at end of file |