summaryrefslogtreecommitdiff
path: root/internal/commands/in_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/commands/in_test.go')
-rw-r--r--internal/commands/in_test.go566
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)
+ }
+ })
+ }
+}