diff options
Diffstat (limited to 'internal/commands/billable_rate_test.go')
-rw-r--r-- | internal/commands/billable_rate_test.go | 225 |
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) + } +} + |