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) } }