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: "no previous time entries found", }, { 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 results in NULL (even with 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"}, expectExplicitNil: true, // NULL because no explicit rate override }, { name: "no explicit rate results in NULL (even with 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"}, expectExplicitNil: true, // NULL because no explicit rate override }, { 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) } }) } }