summaryrefslogtreecommitdiff
path: root/internal/reports
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-09-29 15:04:44 -0600
committerT <t@tjp.lol>2025-09-30 11:40:45 -0600
commit7ba68d333bc20b5795ccfd3870546a05eee60470 (patch)
tree12dc4b017803b7d01844fd42b9e3be281cbbd986 /internal/reports
parentbce8dbb58165e443902d9dae3909225ef42630c4 (diff)
Support for archiving clients and projects.HEADmain
Diffstat (limited to 'internal/reports')
-rw-r--r--internal/reports/api.go31
-rw-r--r--internal/reports/archive_reports_test.go612
2 files changed, 637 insertions, 6 deletions
diff --git a/internal/reports/api.go b/internal/reports/api.go
index 90b066b..b7bd3c2 100644
--- a/internal/reports/api.go
+++ b/internal/reports/api.go
@@ -4,7 +4,9 @@ import (
"context"
"database/sql"
"fmt"
+ "os"
"path/filepath"
+ "strings"
"time"
"git.tjp.lol/punchcard/internal/queries"
@@ -25,6 +27,23 @@ type ReportResult struct {
TotalEntries int
}
+func expandPath(path string) (string, error) {
+ if strings.HasPrefix(path, "~/") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to get home directory: %w", err)
+ }
+ path = filepath.Join(home, path[2:])
+ } else if path == "~" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to get home directory: %w", err)
+ }
+ path = home
+ }
+ return filepath.Abs(path)
+}
+
func GenerateInvoice(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) {
if params.ProjectName == "" {
return GenerateClientInvoice(ctx, q, params)
@@ -91,7 +110,7 @@ func GenerateClientInvoice(ctx context.Context, q *queries.Queries, params Repor
outputPath = GenerateDefaultInvoiceFilename(invoiceData.ClientName, invoiceData.ProjectName, params.DateRange)
}
- outputPath, err = filepath.Abs(outputPath)
+ outputPath, err = expandPath(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve output path: %w", err)
}
@@ -172,7 +191,7 @@ func GenerateProjectInvoice(ctx context.Context, q *queries.Queries, params Repo
outputPath = GenerateDefaultInvoiceFilename(invoiceData.ClientName, invoiceData.ProjectName, params.DateRange)
}
- outputPath, err = filepath.Abs(outputPath)
+ outputPath, err = expandPath(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve output path: %w", err)
}
@@ -236,7 +255,7 @@ func GenerateClientTimesheet(ctx context.Context, q *queries.Queries, params Rep
outputPath = GenerateDefaultTimesheetFilename(timesheetData.ClientName, timesheetData.ProjectName, params.DateRange)
}
- outputPath, err = filepath.Abs(outputPath)
+ outputPath, err = expandPath(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve output path: %w", err)
}
@@ -299,7 +318,7 @@ func GenerateProjectTimesheet(ctx context.Context, q *queries.Queries, params Re
outputPath = GenerateDefaultTimesheetFilename(timesheetData.ClientName, timesheetData.ProjectName, params.DateRange)
}
- outputPath, err = filepath.Abs(outputPath)
+ outputPath, err = expandPath(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve output path: %w", err)
}
@@ -361,7 +380,7 @@ func GenerateClientUnifiedReport(ctx context.Context, q *queries.Queries, params
outputPath = GenerateDefaultUnifiedFilename(unifiedData.InvoiceData.ClientName, unifiedData.InvoiceData.ProjectName, params.DateRange)
}
- outputPath, err = filepath.Abs(outputPath)
+ outputPath, err = expandPath(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve output path: %w", err)
}
@@ -442,7 +461,7 @@ func GenerateProjectUnifiedReport(ctx context.Context, q *queries.Queries, param
outputPath = GenerateDefaultUnifiedFilename(unifiedData.InvoiceData.ClientName, unifiedData.InvoiceData.ProjectName, params.DateRange)
}
- outputPath, err = filepath.Abs(outputPath)
+ outputPath, err = expandPath(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve output path: %w", err)
}
diff --git a/internal/reports/archive_reports_test.go b/internal/reports/archive_reports_test.go
new file mode 100644
index 0000000..d300031
--- /dev/null
+++ b/internal/reports/archive_reports_test.go
@@ -0,0 +1,612 @@
+package reports
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+ "time"
+
+ "git.tjp.lol/punchcard/internal/database"
+ "git.tjp.lol/punchcard/internal/queries"
+)
+
+func setupTestDB(t *testing.T) (*queries.Queries, func()) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatalf("Failed to open in-memory sqlite db: %v", err)
+ }
+ if err := database.InitializeDB(db); err != nil {
+ t.Fatalf("Failed to initialize in-memory sqlite db: %v", err)
+ }
+ q := queries.New(db)
+
+ cleanup := func() {
+ if err := q.DBTX().(*sql.DB).Close(); err != nil {
+ t.Logf("error closing database: %v", err)
+ }
+ }
+
+ return q, cleanup
+}
+
+func TestGetInvoiceDataByClientWithArchivedClient(t *testing.T) {
+ tests := []struct {
+ name string
+ setupData func(*queries.Queries) (clientID int64)
+ expectEntries int
+ expectTotalAmount float64
+ }{
+ {
+ name: "archived client time entries appear in invoice",
+ setupData: func(q *queries.Queries) int64 {
+ client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "ArchivedClient",
+ BillableRate: sql.NullInt64{Int64: 10000, Valid: true},
+ })
+
+ startTime := time.Now().UTC().Add(-2 * time.Hour)
+ endTime := time.Now().UTC()
+
+ _, err := q.DBTX().(*sql.DB).Exec(
+ "INSERT INTO time_entry (client_id, start_time, end_time) VALUES (?, ?, ?)",
+ client.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+
+ _ = q.ArchiveClient(context.Background(), client.ID)
+
+ return client.ID
+ },
+ expectEntries: 1,
+ expectTotalAmount: 200.0,
+ },
+ {
+ name: "multiple time entries for archived client all appear",
+ setupData: func(q *queries.Queries) int64 {
+ client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "MultiEntryClient",
+ BillableRate: sql.NullInt64{Int64: 15000, Valid: true},
+ })
+
+ for i := 0; i < 3; i++ {
+ startTime := time.Now().UTC().Add(-time.Duration(i+2) * time.Hour)
+ endTime := time.Now().UTC().Add(-time.Duration(i+1) * time.Hour)
+
+ _, err := q.DBTX().(*sql.DB).Exec(
+ "INSERT INTO time_entry (client_id, start_time, end_time) VALUES (?, ?, ?)",
+ client.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+ }
+
+ _ = q.ArchiveClient(context.Background(), client.ID)
+
+ return client.ID
+ },
+ expectEntries: 3,
+ expectTotalAmount: 450.0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ q, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ clientID := tt.setupData(q)
+
+ startTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
+ endTime := time.Now().UTC()
+
+ entries, err := q.GetInvoiceDataByClient(context.Background(), queries.GetInvoiceDataByClientParams{
+ ClientID: clientID,
+ StartTime: startTime,
+ EndTime: endTime,
+ })
+
+ if err != nil {
+ t.Fatalf("Failed to get invoice data: %v", err)
+ }
+
+ if len(entries) != tt.expectEntries {
+ t.Errorf("Expected %d entries, got %d", tt.expectEntries, len(entries))
+ }
+
+ if len(entries) > 0 {
+ contractor := queries.Contractor{
+ Name: "Test Contractor",
+ Label: "Contractor",
+ Email: "test@example.com",
+ }
+
+ invoiceData, err := GenerateInvoiceData(
+ entries,
+ clientID,
+ "TestClient",
+ "",
+ contractor,
+ 1,
+ DateRange{Start: startTime, End: endTime},
+ )
+
+ if err != nil {
+ t.Fatalf("Failed to generate invoice data: %v", err)
+ }
+
+ if invoiceData.TotalAmount != tt.expectTotalAmount {
+ t.Errorf("Expected total amount %.2f, got %.2f", tt.expectTotalAmount, invoiceData.TotalAmount)
+ }
+ }
+ })
+ }
+}
+
+func TestGetInvoiceDataByProjectWithArchivedProject(t *testing.T) {
+ tests := []struct {
+ name string
+ setupData func(*queries.Queries) (projectID int64)
+ expectEntries int
+ expectTotalAmount float64
+ }{
+ {
+ name: "archived project time entries appear in invoice",
+ setupData: func(q *queries.Queries) int64 {
+ client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "TestClient",
+ })
+ project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{
+ Name: "ArchivedProject",
+ ClientID: client.ID,
+ BillableRate: sql.NullInt64{Int64: 12500, Valid: true},
+ })
+
+ baseTime := time.Now().UTC()
+ startTime := baseTime.Add(-2 * time.Hour)
+ endTime := baseTime
+
+ _, err := q.DBTX().(*sql.DB).Exec(
+ "INSERT INTO time_entry (client_id, project_id, start_time, end_time) VALUES (?, ?, ?, ?)",
+ client.ID, project.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+
+ _ = q.ArchiveProject(context.Background(), project.ID)
+
+ return project.ID
+ },
+ expectEntries: 1,
+ expectTotalAmount: 250.0,
+ },
+ {
+ name: "multiple time entries for archived project all appear",
+ setupData: func(q *queries.Queries) int64 {
+ client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "TestClient",
+ })
+ project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{
+ Name: "MultiEntryProject",
+ ClientID: client.ID,
+ BillableRate: sql.NullInt64{Int64: 20000, Valid: true},
+ })
+
+ baseTime := time.Now().UTC()
+ for i := 0; i < 2; i++ {
+ startTime := baseTime.Add(-time.Duration(i+2) * time.Hour)
+ endTime := baseTime.Add(-time.Duration(i+1) * time.Hour)
+
+ _, err := q.DBTX().(*sql.DB).Exec(
+ "INSERT INTO time_entry (client_id, project_id, start_time, end_time) VALUES (?, ?, ?, ?)",
+ client.ID, project.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+ }
+
+ _ = q.ArchiveProject(context.Background(), project.ID)
+
+ return project.ID
+ },
+ expectEntries: 2,
+ expectTotalAmount: 396.66,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ q, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ projectID := tt.setupData(q)
+
+ startTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
+ endTime := time.Now().UTC()
+
+ entries, err := q.GetInvoiceDataByProject(context.Background(), queries.GetInvoiceDataByProjectParams{
+ ProjectID: projectID,
+ StartTime: startTime,
+ EndTime: endTime,
+ })
+
+ if err != nil {
+ t.Fatalf("Failed to get invoice data: %v", err)
+ }
+
+ if len(entries) != tt.expectEntries {
+ t.Errorf("Expected %d entries, got %d", tt.expectEntries, len(entries))
+ }
+
+ if len(entries) > 0 {
+ contractor := queries.Contractor{
+ Name: "Test Contractor",
+ Label: "Contractor",
+ Email: "test@example.com",
+ }
+
+ invoiceData, err := GenerateInvoiceData(
+ entries,
+ entries[0].ClientID,
+ "TestClient",
+ "ArchivedProject",
+ contractor,
+ 1,
+ DateRange{Start: startTime, End: endTime},
+ )
+
+ if err != nil {
+ t.Fatalf("Failed to generate invoice data: %v", err)
+ }
+
+ if invoiceData.TotalAmount != tt.expectTotalAmount {
+ t.Errorf("Expected total amount %.2f, got %.2f", tt.expectTotalAmount, invoiceData.TotalAmount)
+ }
+ }
+ })
+ }
+}
+
+func TestGetTimesheetDataByClientWithArchivedClient(t *testing.T) {
+ tests := []struct {
+ name string
+ setupData func(*queries.Queries) (clientID int64)
+ expectEntries int
+ expectTotalHrs float64
+ }{
+ {
+ name: "archived client time entries appear in timesheet",
+ setupData: func(q *queries.Queries) int64 {
+ client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "ArchivedTimesheetClient",
+ })
+
+ startTime := time.Now().UTC().Add(-2 * time.Hour)
+ endTime := time.Now().UTC()
+
+ _, err := q.DBTX().(*sql.DB).Exec(
+ "INSERT INTO time_entry (client_id, start_time, end_time) VALUES (?, ?, ?)",
+ client.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+
+ _ = q.ArchiveClient(context.Background(), client.ID)
+
+ return client.ID
+ },
+ expectEntries: 1,
+ expectTotalHrs: 2.0,
+ },
+ {
+ name: "multiple time entries for archived client all appear in timesheet",
+ setupData: func(q *queries.Queries) int64 {
+ client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "MultiEntryTimesheetClient",
+ })
+
+ for i := 0; i < 3; i++ {
+ startTime := time.Now().UTC().Add(-time.Duration(i+2) * time.Hour)
+ endTime := time.Now().UTC().Add(-time.Duration(i+1) * time.Hour)
+
+ _, err := q.DBTX().(*sql.DB).Exec(
+ "INSERT INTO time_entry (client_id, start_time, end_time) VALUES (?, ?, ?)",
+ client.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+ }
+
+ _ = q.ArchiveClient(context.Background(), client.ID)
+
+ return client.ID
+ },
+ expectEntries: 3,
+ expectTotalHrs: 3.0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ q, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ clientID := tt.setupData(q)
+
+ startTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
+ endTime := time.Now().UTC()
+
+ entries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{
+ ClientID: clientID,
+ StartTime: startTime,
+ EndTime: endTime,
+ })
+
+ if err != nil {
+ t.Fatalf("Failed to get timesheet data: %v", err)
+ }
+
+ if len(entries) != tt.expectEntries {
+ t.Errorf("Expected %d entries, got %d", tt.expectEntries, len(entries))
+ }
+
+ if len(entries) > 0 {
+ contractor := queries.Contractor{
+ Name: "Test Contractor",
+ Label: "Contractor",
+ Email: "test@example.com",
+ }
+
+ timesheetData, err := GenerateTimesheetData(
+ entries,
+ clientID,
+ "TestClient",
+ "",
+ contractor,
+ DateRange{Start: startTime, End: endTime},
+ time.UTC,
+ )
+
+ if err != nil {
+ t.Fatalf("Failed to generate timesheet data: %v", err)
+ }
+
+ if timesheetData.TotalHours != tt.expectTotalHrs {
+ t.Errorf("Expected total hours %.2f, got %.2f", tt.expectTotalHrs, timesheetData.TotalHours)
+ }
+ }
+ })
+ }
+}
+
+func TestGetTimesheetDataByProjectWithArchivedProject(t *testing.T) {
+ tests := []struct {
+ name string
+ setupData func(*queries.Queries) (projectID int64)
+ expectEntries int
+ expectTotalHrs float64
+ }{
+ {
+ name: "archived project time entries appear in timesheet",
+ setupData: func(q *queries.Queries) int64 {
+ client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "TestClient",
+ })
+ project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{
+ Name: "ArchivedTimesheetProject",
+ ClientID: client.ID,
+ })
+
+ startTime := time.Now().UTC().Add(-2 * time.Hour)
+ endTime := time.Now().UTC()
+
+ _, err := q.DBTX().(*sql.DB).Exec(
+ "INSERT INTO time_entry (client_id, project_id, start_time, end_time) VALUES (?, ?, ?, ?)",
+ client.ID, project.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+
+ _ = q.ArchiveProject(context.Background(), project.ID)
+
+ return project.ID
+ },
+ expectEntries: 1,
+ expectTotalHrs: 2.0,
+ },
+ {
+ name: "multiple time entries for archived project all appear in timesheet",
+ setupData: func(q *queries.Queries) int64 {
+ client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "TestClient",
+ })
+ project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{
+ Name: "MultiEntryTimesheetProject",
+ ClientID: client.ID,
+ })
+
+ for i := 0; i < 2; i++ {
+ startTime := time.Now().UTC().Add(-time.Duration(i+2) * time.Hour)
+ endTime := time.Now().UTC().Add(-time.Duration(i+1) * time.Hour)
+
+ _, err := q.DBTX().(*sql.DB).Exec(
+ "INSERT INTO time_entry (client_id, project_id, start_time, end_time) VALUES (?, ?, ?, ?)",
+ client.ID, project.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+ }
+
+ _ = q.ArchiveProject(context.Background(), project.ID)
+
+ return project.ID
+ },
+ expectEntries: 2,
+ expectTotalHrs: 2.0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ q, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ projectID := tt.setupData(q)
+
+ startTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
+ endTime := time.Now().UTC()
+
+ entries, err := q.GetTimesheetDataByProject(context.Background(), queries.GetTimesheetDataByProjectParams{
+ ProjectID: projectID,
+ StartTime: startTime,
+ EndTime: endTime,
+ })
+
+ if err != nil {
+ t.Fatalf("Failed to get timesheet data: %v", err)
+ }
+
+ t.Logf("Got %d entries, expected %d", len(entries), tt.expectEntries)
+ if len(entries) != tt.expectEntries {
+ t.Errorf("Expected %d entries, got %d", tt.expectEntries, len(entries))
+ return
+ }
+
+ if len(entries) > 0 {
+ contractor := queries.Contractor{
+ Name: "Test Contractor",
+ Label: "Contractor",
+ Email: "test@example.com",
+ }
+
+ timesheetData, err := GenerateTimesheetData(
+ entries,
+ entries[0].ClientID,
+ "TestClient",
+ "ArchivedTimesheetProject",
+ contractor,
+ DateRange{Start: startTime, End: endTime},
+ time.UTC,
+ )
+
+ if err != nil {
+ t.Fatalf("Failed to generate timesheet data: %v", err)
+ }
+
+ // Allow for small floating point rounding errors
+ diff := timesheetData.TotalHours - tt.expectTotalHrs
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > 0.01 {
+ t.Errorf("Expected total hours %.2f, got %.2f", tt.expectTotalHrs, timesheetData.TotalHours)
+ return
+ }
+ }
+ })
+ }
+}
+
+func TestArchivedClientProjectRevenueIntegrity(t *testing.T) {
+ tests := []struct {
+ name string
+ setupData func(*queries.Queries) (clientID, projectID int64)
+ verifyInvoice func(*testing.T, []queries.GetInvoiceDataByClientRow)
+ }{
+ {
+ name: "archived client and project both appear in combined invoice",
+ setupData: func(q *queries.Queries) (int64, int64) {
+ client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{
+ Name: "FullyArchivedClient",
+ BillableRate: sql.NullInt64{Int64: 10000, Valid: true},
+ })
+ project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{
+ Name: "FullyArchivedProject",
+ ClientID: client.ID,
+ })
+
+ for i := 0; i < 2; i++ {
+ startTime := time.Now().UTC().Add(-time.Duration(i+2) * time.Hour)
+ endTime := time.Now().UTC().Add(-time.Duration(i+1) * time.Hour)
+
+ if i == 1 {
+ _, err := q.DBTX().(*sql.DB).Exec(
+ "INSERT INTO time_entry (client_id, project_id, start_time, end_time) VALUES (?, ?, ?, ?)",
+ client.ID, project.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+ } else {
+ _, err := q.DBTX().(*sql.DB).Exec(
+ "INSERT INTO time_entry (client_id, start_time, end_time) VALUES (?, ?, ?)",
+ client.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create time entry: %v", err)
+ }
+ }
+ }
+
+ _ = q.ArchiveClient(context.Background(), client.ID)
+ _ = q.ArchiveProject(context.Background(), project.ID)
+
+ return client.ID, project.ID
+ },
+ verifyInvoice: func(t *testing.T, entries []queries.GetInvoiceDataByClientRow) {
+ if len(entries) != 2 {
+ t.Errorf("Expected 2 entries, got %d", len(entries))
+ }
+
+ hasProjectEntry := false
+ hasClientEntry := false
+ for _, entry := range entries {
+ if entry.ProjectID.Valid {
+ hasProjectEntry = true
+ } else {
+ hasClientEntry = true
+ }
+ }
+
+ if !hasProjectEntry {
+ t.Errorf("Expected at least one project entry")
+ }
+ if !hasClientEntry {
+ t.Errorf("Expected at least one client-only entry")
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ q, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ clientID, _ := tt.setupData(q)
+
+ startTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
+ endTime := time.Now().UTC()
+
+ entries, err := q.GetInvoiceDataByClient(context.Background(), queries.GetInvoiceDataByClientParams{
+ ClientID: clientID,
+ StartTime: startTime,
+ EndTime: endTime,
+ })
+
+ if err != nil {
+ t.Fatalf("Failed to get invoice data: %v", err)
+ }
+
+ tt.verifyInvoice(t, entries)
+ })
+ }
+}