diff options
Diffstat (limited to 'internal/commands/report_coalescing_test.go')
-rw-r--r-- | internal/commands/report_coalescing_test.go | 251 |
1 files changed, 251 insertions, 0 deletions
diff --git a/internal/commands/report_coalescing_test.go b/internal/commands/report_coalescing_test.go new file mode 100644 index 0000000..8f4d221 --- /dev/null +++ b/internal/commands/report_coalescing_test.go @@ -0,0 +1,251 @@ +package commands + +import ( + "context" + "database/sql" + "testing" + "time" + + "punchcard/internal/queries" +) + +func TestReportRateCoalescing(t *testing.T) { + tests := []struct { + name string + clientRate *int64 // nil means NULL, values in cents + projectRate *int64 // nil means NULL, values in cents + entryRate *int64 // nil means NULL, values in cents + expectedRateSource string + }{ + { + name: "no rates anywhere - should use client source", + clientRate: nil, + projectRate: nil, + entryRate: nil, + expectedRateSource: "client", + }, + { + name: "only client rate - should use client source", + clientRate: func() *int64 { f := int64(10000); return &f }(), // $100.00 + projectRate: nil, + entryRate: nil, + expectedRateSource: "client", + }, + { + name: "client and project rates - should use project source", + clientRate: func() *int64 { f := int64(10000); return &f }(), // $100.00 + projectRate: func() *int64 { f := int64(15000); return &f }(), // $150.00 + entryRate: nil, + expectedRateSource: "project", + }, + { + name: "all rates provided - should use entry source", + clientRate: func() *int64 { f := int64(10000); return &f }(), // $100.00 + projectRate: func() *int64 { f := int64(15000); return &f }(), // $150.00 + entryRate: func() *int64 { f := int64(20000); return &f }(), // $200.00 + expectedRateSource: "entry", + }, + { + name: "entry rate overrides even when zero", + clientRate: func() *int64 { f := int64(10000); return &f }(), // $100.00 + projectRate: func() *int64 { f := int64(15000); return &f }(), // $150.00 + entryRate: func() *int64 { f := int64(0); return &f }(), // $0.00 + expectedRateSource: "entry", + }, + { + name: "only project rate with no client rate - should use project source", + clientRate: nil, + projectRate: func() *int64 { f := int64(12500); return &f }(), // $125.00 + entryRate: nil, + expectedRateSource: "project", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup fresh database for each test + q, cleanup := setupTestDB(t) + defer cleanup() + + // Create client with optional billable rate + var clientBillableRate sql.NullInt64 + if tt.clientRate != nil { + clientBillableRate = sql.NullInt64{Int64: *tt.clientRate, Valid: true} + } + + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + Email: sql.NullString{}, + BillableRate: clientBillableRate, + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + // Create project with optional billable rate + var projectBillableRate sql.NullInt64 + if tt.projectRate != nil { + projectBillableRate = sql.NullInt64{Int64: *tt.projectRate, Valid: true} + } + + project, err := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + BillableRate: projectBillableRate, + }) + if err != nil { + t.Fatalf("Failed to create project: %v", err) + } + + // Create time entry with optional explicit billable rate + var entryBillableRate sql.NullInt64 + if tt.entryRate != nil { + entryBillableRate = sql.NullInt64{Int64: *tt.entryRate, Valid: true} + } + + // Create time entry with explicit start and end times + startTime := time.Now().UTC() + endTime := startTime.Add(2 * time.Hour) // 2 hours of work + + _, err = q.CreateTimeEntryWithTimes(context.Background(), queries.CreateTimeEntryWithTimesParams{ + StartTime: startTime.Format(time.DateTime), + EndTime: endTime.Format(time.DateTime), + Description: sql.NullString{String: "Test work", Valid: true}, + ClientID: client.ID, + ProjectID: sql.NullInt64{Int64: project.ID, Valid: true}, + BillableRate: entryBillableRate, + }) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + + // Test GetInvoiceDataByClient query (report coalescing logic) + clientData, err := q.GetInvoiceDataByClient(context.Background(), queries.GetInvoiceDataByClientParams{ + ClientID: client.ID, + StartTime: startTime.Add(-time.Hour), + EndTime: endTime.Add(time.Hour), + }) + if err != nil { + t.Fatalf("Failed to get invoice data by client: %v", err) + } + + if len(clientData) != 1 { + t.Fatalf("Expected 1 client data entry, got %d", len(clientData)) + } + + entry := clientData[0] + if entry.RateSource != tt.expectedRateSource { + t.Errorf("Expected rate source %s, got %s", tt.expectedRateSource, entry.RateSource) + } + + // Test GetInvoiceDataByProject query (report coalescing logic) + projectData, err := q.GetInvoiceDataByProject(context.Background(), queries.GetInvoiceDataByProjectParams{ + ProjectID: project.ID, + StartTime: startTime.Add(-time.Hour), + EndTime: endTime.Add(time.Hour), + }) + if err != nil { + t.Fatalf("Failed to get invoice data by project: %v", err) + } + + if len(projectData) != 1 { + t.Fatalf("Expected 1 project data entry, got %d", len(projectData)) + } + + projectEntry := projectData[0] + if projectEntry.RateSource != tt.expectedRateSource { + t.Errorf("Expected rate source %s, got %s (project query)", tt.expectedRateSource, projectEntry.RateSource) + } + + // Verify that the appropriate rate values are present + switch tt.expectedRateSource { + case "entry": + if !entry.EntryBillableRate.Valid { + t.Errorf("Expected entry billable rate to be valid when rate source is 'entry'") + } else if tt.entryRate != nil && entry.EntryBillableRate.Int64 != *tt.entryRate { + t.Errorf("Expected entry billable rate %d, got %d", *tt.entryRate, entry.EntryBillableRate.Int64) + } + case "project": + if !entry.ProjectBillableRate.Valid { + t.Errorf("Expected project billable rate to be valid when rate source is 'project'") + } else if tt.projectRate != nil && entry.ProjectBillableRate.Int64 != *tt.projectRate { + t.Errorf("Expected project billable rate %d, got %d", *tt.projectRate, entry.ProjectBillableRate.Int64) + } + case "client": + // Client rate can be NULL and still be the source (fallback case) + if tt.clientRate != nil { + if !entry.ClientBillableRate.Valid { + t.Errorf("Expected client billable rate to be valid when rate source is 'client' and client has rate") + } else if entry.ClientBillableRate.Int64 != *tt.clientRate { + t.Errorf("Expected client billable rate %d, got %d", *tt.clientRate, entry.ClientBillableRate.Int64) + } + } + } + }) + } +} + +func TestReportRateCoalescingWithoutProject(t *testing.T) { + // Test coalescing when no project is specified + q, cleanup := setupTestDB(t) + defer cleanup() + + // Create client with rate $75.50 (7550 cents) + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "NoProjectClient", + Email: sql.NullString{}, + BillableRate: sql.NullInt64{Int64: 7550, Valid: true}, + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + // Create time entry without project - should use client rate + startTime := time.Now().UTC() + endTime := startTime.Add(time.Hour) + + _, err = q.CreateTimeEntryWithTimes(context.Background(), queries.CreateTimeEntryWithTimesParams{ + StartTime: startTime.Format(time.DateTime), + EndTime: endTime.Format(time.DateTime), + Description: sql.NullString{String: "Client work", Valid: true}, + ClientID: client.ID, + ProjectID: sql.NullInt64{}, // No project + BillableRate: sql.NullInt64{}, // No explicit rate + }) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + + // Test GetInvoiceDataByClient query + clientData, err := q.GetInvoiceDataByClient(context.Background(), queries.GetInvoiceDataByClientParams{ + ClientID: client.ID, + StartTime: startTime.Add(-time.Hour), + EndTime: endTime.Add(time.Hour), + }) + if err != nil { + t.Fatalf("Failed to get invoice data by client: %v", err) + } + + if len(clientData) != 1 { + t.Fatalf("Expected 1 client data entry, got %d", len(clientData)) + } + + entry := clientData[0] + if entry.RateSource != "client" { + t.Errorf("Expected rate source 'client', got %s", entry.RateSource) + } + + if !entry.ClientBillableRate.Valid || entry.ClientBillableRate.Int64 != 7550 { + t.Errorf("Expected client billable rate 7550, got %v", entry.ClientBillableRate) + } + + // Project rate should be NULL since no project + if entry.ProjectBillableRate.Valid { + t.Errorf("Expected project billable rate to be NULL when no project, got %d", entry.ProjectBillableRate.Int64) + } + + // Entry rate should be NULL since no explicit override + if entry.EntryBillableRate.Valid { + t.Errorf("Expected entry billable rate to be NULL when no explicit override, got %d", entry.EntryBillableRate.Int64) + } +}
\ No newline at end of file |