diff options
author | T <t@tjp.lol> | 2025-08-02 17:25:59 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-04 09:34:14 -0600 |
commit | 8be5f93f5b2d4b6f438ca84094937a0f7101c59b (patch) | |
tree | 3cedb6379818a28179e269477c12ae06dd57ca36 /internal/commands/in_test.go |
Initial commit of punchcard.
Contains working time tracking commands, and the stub of a command to
generate reports.
Diffstat (limited to 'internal/commands/in_test.go')
-rw-r--r-- | internal/commands/in_test.go | 566 |
1 files changed, 566 insertions, 0 deletions
diff --git a/internal/commands/in_test.go b/internal/commands/in_test.go new file mode 100644 index 0000000..3832037 --- /dev/null +++ b/internal/commands/in_test.go @@ -0,0 +1,566 @@ +package commands + +import ( + "context" + "database/sql" + "strings" + "testing" + + "punchcard/internal/queries" +) + +func TestInCommand(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (clientID, projectID int64) + args []string + expectedOutputs []string // Multiple possible outputs to check + expectError bool + errorContains string + }{ + { + name: "punch in with client by name", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestCorp", + Email: sql.NullString{String: "test@testcorp.com", Valid: true}, + BillableRate: sql.NullInt64{}, + }) + return client.ID, 0 + }, + args: []string{"in", "-c", "TestCorp"}, + expectedOutputs: []string{"Started timer (ID: 1) for client: TestCorp"}, + expectError: false, + }, + { + name: "punch in with client by ID", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TechSolutions", + Email: sql.NullString{String: "contact@techsolutions.com", Valid: true}, + BillableRate: sql.NullInt64{}, + }) + return client.ID, 0 + }, + args: []string{"in", "--client", "1"}, + expectedOutputs: []string{"Started timer (ID: 1) for client: TechSolutions"}, + expectError: false, + }, + { + name: "punch in with client and project", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "StartupXYZ", + Email: sql.NullString{String: "hello@startupxyz.io", Valid: true}, + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "Website Redesign", + ClientID: client.ID, + BillableRate: sql.NullInt64{}, + }) + return client.ID, project.ID + }, + args: []string{"in", "-c", "StartupXYZ", "-p", "Website Redesign"}, + expectedOutputs: []string{"Started timer (ID: 1) for client: StartupXYZ, project: Website Redesign"}, + expectError: false, + }, + { + name: "punch in with description", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "BigCorp", + Email: sql.NullString{String: "contact@bigcorp.com", Valid: true}, + }) + return client.ID, 0 + }, + args: []string{"in", "-c", "BigCorp", "Working on frontend"}, + expectedOutputs: []string{"Started timer (ID: 1) for client: BigCorp, description: Working on frontend"}, + expectError: false, + }, + { + name: "punch in with client, project, and description", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "MegaCorp", + Email: sql.NullString{String: "info@megacorp.com", Valid: true}, + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "Mobile App", + ClientID: client.ID, + }) + return client.ID, project.ID + }, + args: []string{"in", "-c", "MegaCorp", "-p", "Mobile App", "Implementing login flow"}, + expectedOutputs: []string{"Started timer (ID: 1) for client: MegaCorp, project: Mobile App, description: Implementing login flow"}, + expectError: false, + }, + { + name: "punch in without client", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"in"}, + expectError: true, + errorContains: "client is required", + }, + { + name: "punch in with nonexistent client", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"in", "-c", "NonexistentClient"}, + expectError: true, + errorContains: "invalid client", + }, + { + name: "punch in with nonexistent project but no client", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"in", "-p", "SomeProject"}, + expectError: true, + errorContains: "invalid project: project not found", + }, + { + name: "punch in with project not belonging to client", + setupData: func(q *queries.Queries) (int64, int64) { + client1, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "Client1", + Email: sql.NullString{String: "test1@example.com", Valid: true}, + }) + client2, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "Client2", + Email: sql.NullString{String: "test2@example.com", Valid: true}, + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "Project1", + ClientID: client2.ID, + }) + return client1.ID, project.ID + }, + args: []string{"in", "-c", "Client1", "-p", "Project1"}, + expectError: true, + errorContains: "does not belong to client", + }, + { + name: "punch in with project but no client - uses project's client", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ProjectClient", + Email: sql.NullString{String: "project@client.com", Valid: true}, + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + }) + return client.ID, project.ID + }, + args: []string{"in", "-p", "TestProject"}, + expectedOutputs: []string{"Started timer (ID: 1) for client: ProjectClient, project: TestProject"}, + expectError: false, + }, + { + name: "punch in with no flags - no previous entries", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"in"}, + expectError: true, + errorContains: "no previous time entries found", + }, + { + name: "punch in with billable rate flag", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "BillableClient", + Email: sql.NullString{}, + BillableRate: sql.NullInt64{}, + }) + return client.ID, 0 + }, + args: []string{"in", "-c", "BillableClient", "--hourly-rate", "250.75", "Premium work"}, + expectedOutputs: []string{"Started timer (ID: 1) for client: BillableClient, description: Premium work"}, + 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() + + // Setup test data + tt.setupData(q) + + // Execute command + output, err := executeCommandWithDB(t, q, tt.args...) + + // Check error expectation + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error to contain %q, got %q", tt.errorContains, err.Error()) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + // Check output contains expected strings + found := false + for _, expectedOutput := range tt.expectedOutputs { + if strings.Contains(output, expectedOutput) { + found = true + break + } + } + + if !found { + t.Errorf("Expected output to contain one of %v, got %q", tt.expectedOutputs, output) + } + }) + } +} + +func TestInCommandActiveTimerBehaviors(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) error + firstArgs []string + secondArgs []string + expectFirstIn string + expectSecondIn string + }{ + { + name: "no-op when identical timer already active", + setupData: func(q *queries.Queries) error { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return err + } + + _, err = q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + }) + return err + }, + firstArgs: []string{"in", "-c", "TestClient", "-p", "TestProject", "Working on tests"}, + secondArgs: []string{"in", "-c", "TestClient", "-p", "TestProject", "Working on tests"}, // Identical + expectFirstIn: "Started timer (ID: 1)", + expectSecondIn: "Timer already active with same parameters (ID: 1)", + }, + { + name: "stop current and start new when different description", + setupData: func(q *queries.Queries) error { + _, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + return err + }, + firstArgs: []string{"in", "-c", "TestClient", "First task"}, + secondArgs: []string{"in", "-c", "TestClient", "Second task"}, // Different description + expectFirstIn: "Started timer (ID: 1)", + expectSecondIn: "Stopped previous timer (ID: 1)", + }, + { + name: "stop current and start new when different client", + setupData: func(q *queries.Queries) error { + _, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "Client1", + }) + if err != nil { + return err + } + + _, err = q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "Client2", + }) + return err + }, + firstArgs: []string{"in", "-c", "Client1"}, + secondArgs: []string{"in", "-c", "Client2"}, // Different client + expectFirstIn: "Started timer (ID: 1)", + expectSecondIn: "Stopped previous timer (ID: 1)", + }, + { + name: "copy most recent entry when no flags provided", + setupData: func(q *queries.Queries) error { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return err + } + + // Create and immediately stop a time entry + if _, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + Description: sql.NullString{String: "Previous work", Valid: true}, + ClientID: client.ID, + }); err != nil { + return err + } + + _, err = q.StopTimeEntry(context.Background()) + return err + }, + firstArgs: []string{"in"}, // No flags - should copy most recent + secondArgs: []string{"in"}, // Same - should be no-op + expectFirstIn: "Started timer (ID: 2) for client: TestClient, description: Previous work", + expectSecondIn: "Timer already active with same parameters (ID: 2)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup fresh database for each test + q, cleanup := setupTestDB(t) + defer cleanup() + + // Setup test data + if err := tt.setupData(q); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Execute first command + output1, err1 := executeCommandWithDB(t, q, tt.firstArgs...) + if err1 != nil { + t.Fatalf("First command failed: %v", err1) + } + + if !strings.Contains(output1, tt.expectFirstIn) { + t.Errorf("First command output should contain %q, got: %s", tt.expectFirstIn, output1) + } + + // Execute second command + output2, err2 := executeCommandWithDB(t, q, tt.secondArgs...) + if err2 != nil { + t.Fatalf("Second command failed: %v", err2) + } + + if !strings.Contains(output2, tt.expectSecondIn) { + t.Errorf("Second command output should contain %q, got: %s", tt.expectSecondIn, output2) + } + }) + } +} + +func TestInCommandBillableRateStorage(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (clientID, projectID int64) + args []string + expectedRate *int64 // nil means should use coalesced value, values in cents + expectExplicitNil bool // true means should be NULL despite coalescing options + }{ + { + name: "explicit billable rate overrides client rate", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ClientWithRate", + Email: sql.NullString{}, + BillableRate: sql.NullInt64{Int64: 10000, Valid: true}, // $100.00 + }) + return client.ID, 0 + }, + args: []string{"in", "-c", "ClientWithRate", "--hourly-rate", "175.25"}, + expectedRate: func() *int64 { f := int64(17525); return &f }(), // $175.25 + }, + { + name: "explicit billable rate overrides project rate", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + Email: sql.NullString{}, + BillableRate: sql.NullInt64{Int64: 10000, Valid: true}, // $100.00 + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ProjectWithRate", + ClientID: client.ID, + BillableRate: sql.NullInt64{Int64: 15000, Valid: true}, // $150.00 + }) + return client.ID, project.ID + }, + args: []string{"in", "-c", "TestClient", "-p", "ProjectWithRate", "--hourly-rate", "200.50"}, + expectedRate: func() *int64 { f := int64(20050); return &f }(), // $200.50 + }, + { + name: "no explicit rate uses project rate", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + Email: sql.NullString{}, + BillableRate: sql.NullInt64{Int64: 10000, Valid: true}, // $100.00 + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ProjectWithRate", + ClientID: client.ID, + BillableRate: sql.NullInt64{Int64: 12500, Valid: true}, // $125.00 + }) + return client.ID, project.ID + }, + args: []string{"in", "-c", "TestClient", "-p", "ProjectWithRate"}, + expectedRate: func() *int64 { f := int64(12500); return &f }(), // $125.00 + }, + { + name: "no explicit rate and no project uses client rate", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ClientOnly", + Email: sql.NullString{}, + BillableRate: sql.NullInt64{Int64: 9000, Valid: true}, // $90.00 + }) + return client.ID, 0 + }, + args: []string{"in", "-c", "ClientOnly"}, + expectedRate: func() *int64 { f := int64(9000); return &f }(), // $90.00 + }, + { + name: "no rates anywhere results in NULL", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "NoRateClient", + Email: sql.NullString{}, + BillableRate: sql.NullInt64{}, + }) + return client.ID, 0 + }, + args: []string{"in", "-c", "NoRateClient"}, + expectExplicitNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup fresh database for each test + q, cleanup := setupTestDB(t) + defer cleanup() + + // Setup test data + tt.setupData(q) + + // Execute command + _, err := executeCommandWithDB(t, q, tt.args...) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Query the created time entry to verify billable_rate + timeEntry, err := q.GetActiveTimeEntry(context.Background()) + if err != nil { + t.Fatalf("Failed to query created time entry: %v", err) + } + + if tt.expectExplicitNil { + if timeEntry.BillableRate.Valid { + t.Errorf("Expected NULL billable_rate, got %d", timeEntry.BillableRate.Int64) + } + } else if tt.expectedRate != nil { + 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) + } + } + }) + } +} + +// TestFindFunctions tests both findClient and findProject functions with consolidated test cases +func TestFindFunctions(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + // Create test client + testClient, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "Test Client", + Email: sql.NullString{String: "test@example.com", Valid: true}, + }) + if err != nil { + t.Fatalf("Failed to create test client: %v", err) + } + + // Create test project + testProject, err := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "Test Project", + ClientID: testClient.ID, + }) + if err != nil { + t.Fatalf("Failed to create test project: %v", err) + } + + // Create entities with name "1" to test ambiguous lookup + _, err = q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "1", + Email: sql.NullString{String: "one@example.com", Valid: true}, + }) + if err != nil { + t.Fatalf("Failed to create ambiguous test client: %v", err) + } + + _, err = q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "1", + ClientID: testClient.ID, + }) + if err != nil { + t.Fatalf("Failed to create ambiguous test project: %v", err) + } + + tests := []struct { + name string + entityType string // "client" or "project" + ref string + expectError bool + expectedID int64 + }{ + // Client tests + {"find client by name", "client", "Test Client", false, testClient.ID}, + {"find client by ID", "client", "2", false, 2}, // Client with name "1" gets ID 2 + {"find client - ambiguous (name matches ID)", "client", "1", true, 0}, + {"find client - nonexistent name", "client", "Nonexistent Client", true, 0}, + {"find client - nonexistent ID", "client", "999", true, 0}, + + // Project tests + {"find project by name", "project", "Test Project", false, testProject.ID}, + {"find project by ID", "project", "2", false, 2}, // Project with name "1" gets ID 2 + {"find project - ambiguous (name matches ID)", "project", "1", true, 0}, + {"find project - nonexistent name", "project", "Nonexistent Project", true, 0}, + {"find project - nonexistent ID", "project", "999", true, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + var actualID int64 + + if tt.entityType == "client" { + client, findErr := findClient(context.Background(), q, tt.ref) + err = findErr + if findErr == nil { + actualID = client.ID + } + } else { + project, findErr := findProject(context.Background(), q, tt.ref) + err = findErr + if findErr == nil { + actualID = project.ID + } + } + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if actualID != tt.expectedID { + t.Errorf("Expected %s ID %d, got %d", tt.entityType, tt.expectedID, actualID) + } + }) + } +} |