summaryrefslogtreecommitdiff
path: root/internal/commands/billable_rate_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/commands/billable_rate_test.go')
-rw-r--r--internal/commands/billable_rate_test.go225
1 files changed, 225 insertions, 0 deletions
diff --git a/internal/commands/billable_rate_test.go b/internal/commands/billable_rate_test.go
new file mode 100644
index 0000000..f9ce621
--- /dev/null
+++ b/internal/commands/billable_rate_test.go
@@ -0,0 +1,225 @@
+package commands
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+ "time"
+
+ "punchcard/internal/queries"
+)
+
+func TestTimeEntryBillableRateCoalescing(t *testing.T) {
+ tests := []struct {
+ name string
+ clientRate *int64 // nil means NULL, values in cents
+ projectRate *int64 // nil means NULL, values in cents
+ explicitRate *int64 // nil means NULL, values in cents
+ expectedRate *int64 // nil means NULL, values in cents
+ expectError bool
+ }{
+ {
+ name: "no rates anywhere - should be NULL",
+ clientRate: nil,
+ projectRate: nil,
+ explicitRate: nil,
+ expectedRate: nil,
+ expectError: false,
+ },
+ {
+ name: "only client rate - should use client rate",
+ clientRate: func() *int64 { f := int64(10000); return &f }(), // $100.00
+ projectRate: nil,
+ explicitRate: nil,
+ expectedRate: func() *int64 { f := int64(10000); return &f }(),
+ expectError: false,
+ },
+ {
+ name: "client and project rates - should use project rate",
+ clientRate: func() *int64 { f := int64(10000); return &f }(), // $100.00
+ projectRate: func() *int64 { f := int64(15000); return &f }(), // $150.00
+ explicitRate: nil,
+ expectedRate: func() *int64 { f := int64(15000); return &f }(),
+ expectError: false,
+ },
+ {
+ name: "all rates provided - should use explicit rate",
+ clientRate: func() *int64 { f := int64(10000); return &f }(), // $100.00
+ projectRate: func() *int64 { f := int64(15000); return &f }(), // $150.00
+ explicitRate: func() *int64 { f := int64(20000); return &f }(), // $200.00
+ expectedRate: func() *int64 { f := int64(20000); return &f }(),
+ expectError: false,
+ },
+ {
+ name: "explicit 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
+ explicitRate: func() *int64 { f := int64(0); return &f }(), // $0.00
+ expectedRate: func() *int64 { f := int64(0); return &f }(),
+ expectError: false,
+ },
+ {
+ name: "only project rate with no client rate - should use project rate",
+ clientRate: nil,
+ projectRate: func() *int64 { f := int64(12500); return &f }(), // $125.00
+ explicitRate: nil,
+ expectedRate: func() *int64 { f := int64(12500); return &f }(),
+ expectError: false,
+ },
+ }
+
+ 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 explicitBillableRate sql.NullInt64
+ if tt.explicitRate != nil {
+ explicitBillableRate = sql.NullInt64{Int64: *tt.explicitRate, Valid: true}
+ }
+
+ timeEntry, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{
+ Description: sql.NullString{String: "Test work", Valid: true},
+ ClientID: client.ID,
+ ProjectID: sql.NullInt64{Int64: project.ID, Valid: true},
+ BillableRate: explicitBillableRate,
+ })
+
+ // Check error expectation
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ // Verify the coalesced billable rate
+ if tt.expectedRate == nil {
+ if timeEntry.BillableRate.Valid {
+ t.Errorf("Expected NULL billable_rate, got %d", timeEntry.BillableRate.Int64)
+ }
+ } else {
+ if !timeEntry.BillableRate.Valid {
+ t.Errorf("Expected billable_rate %d, got NULL", *tt.expectedRate)
+ } else if timeEntry.BillableRate.Int64 != *tt.expectedRate {
+ t.Errorf("Expected billable_rate %d, got %d", *tt.expectedRate, timeEntry.BillableRate.Int64)
+ }
+ }
+ })
+ }
+}
+
+func TestTimeEntryWithTimesCoalescing(t *testing.T) {
+ // Test CreateTimeEntryWithTimes also applies coalescing
+ q, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ // Create client with rate $100.00 (10000 cents)
+ client, err := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "RateClient",
+ Email: sql.NullString{},
+ BillableRate: sql.NullInt64{Int64: 10000, Valid: true},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create client: %v", err)
+ }
+
+ // Create project with rate $150.00 (15000 cents)
+ project, err := q.CreateProject(context.Background(), queries.CreateProjectParams{
+ Name: "RateProject",
+ ClientID: client.ID,
+ BillableRate: sql.NullInt64{Int64: 15000, Valid: true},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create project: %v", err)
+ }
+
+ // Create time entry with times but no explicit rate - should use project rate
+ now := time.Now().UTC()
+ timeEntry, err := q.CreateTimeEntryWithTimes(context.Background(), queries.CreateTimeEntryWithTimesParams{
+ StartTime: now,
+ EndTime: sql.NullTime{Time: now.Add(time.Hour), Valid: true},
+ Description: sql.NullString{String: "Test work", Valid: true},
+ ClientID: client.ID,
+ ProjectID: sql.NullInt64{Int64: project.ID, Valid: true},
+ BillableRate: sql.NullInt64{}, // No explicit rate
+ })
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+
+ // Should use project rate (15000 cents = $150.00)
+ if !timeEntry.BillableRate.Valid || timeEntry.BillableRate.Int64 != 15000 {
+ t.Errorf("Expected billable_rate 15000, got %v", timeEntry.BillableRate)
+ }
+}
+
+func TestTimeEntryCoalescingWithoutProject(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
+ timeEntry, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{
+ 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)
+ }
+
+ // Should use client rate (7550 cents = $75.50)
+ if !timeEntry.BillableRate.Valid || timeEntry.BillableRate.Int64 != 7550 {
+ t.Errorf("Expected billable_rate 7550, got %v", timeEntry.BillableRate)
+ }
+}
+