diff --git a/internal/rpc/additional_coverage_test.go b/internal/rpc/additional_coverage_test.go new file mode 100644 index 00000000..c3f361f3 --- /dev/null +++ b/internal/rpc/additional_coverage_test.go @@ -0,0 +1,891 @@ +package rpc + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/steveyegge/beads/internal/types" +) + +// CountResult represents the response from handleCount +type CountResult struct { + Count int `json:"count"` +} + +// TestCount tests the Count operation via RPC +func TestCount(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create some issues first + for i := 0; i < 5; i++ { + args := &CreateArgs{ + Title: "Test Issue for Count", + Description: "Test description", + IssueType: "task", + Priority: 2, + } + if _, err := client.Create(args); err != nil { + t.Fatalf("Create failed: %v", err) + } + } + + // Create a closed issue + createResp, err := client.Create(&CreateArgs{ + Title: "Closed Issue", + Description: "Test description", + IssueType: "bug", + Priority: 1, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + var closedIssue types.Issue + json.Unmarshal(createResp.Data, &closedIssue) + if _, err := client.CloseIssue(&CloseArgs{ID: closedIssue.ID, Reason: "Done"}); err != nil { + t.Fatalf("CloseIssue failed: %v", err) + } + + tests := []struct { + name string + args *CountArgs + expectedCount int + }{ + { + name: "Count all issues", + args: &CountArgs{}, + expectedCount: 6, + }, + { + name: "Count open issues", + args: &CountArgs{Status: "open"}, + expectedCount: 5, + }, + { + name: "Count closed issues", + args: &CountArgs{Status: "closed"}, + expectedCount: 1, + }, + { + name: "Count by type task", + args: &CountArgs{IssueType: "task"}, + expectedCount: 5, + }, + { + name: "Count by type bug", + args: &CountArgs{IssueType: "bug"}, + expectedCount: 1, + }, + { + name: "Count by priority", + args: &CountArgs{Priority: intPtr(2)}, + expectedCount: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := client.Count(tt.args) + if err != nil { + t.Fatalf("Count failed: %v", err) + } + + if !resp.Success { + t.Fatalf("Expected success, got error: %s", resp.Error) + } + + var result CountResult + if err := json.Unmarshal(resp.Data, &result); err != nil { + t.Fatalf("Failed to unmarshal count result: %v", err) + } + + if result.Count != tt.expectedCount { + t.Errorf("Expected count %d, got %d", tt.expectedCount, result.Count) + } + }) + } +} + +// TestCountWithDateFilters tests Count with date range filters +func TestCountWithDateFilters(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create an issue + _, err := client.Create(&CreateArgs{ + Title: "Recent Issue", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + // Count with created_after in the past (should include our issue) + yesterday := time.Now().Add(-24 * time.Hour).Format(time.RFC3339) + resp, err := client.Count(&CountArgs{CreatedAfter: yesterday}) + if err != nil { + t.Fatalf("Count failed: %v", err) + } + + var result CountResult + json.Unmarshal(resp.Data, &result) + if result.Count < 1 { + t.Errorf("Expected at least 1 issue created after %s, got %d", yesterday, result.Count) + } + + // Count with created_before in the past (should not include our issue) + resp, err = client.Count(&CountArgs{CreatedBefore: yesterday}) + if err != nil { + t.Fatalf("Count failed: %v", err) + } + + json.Unmarshal(resp.Data, &result) + if result.Count != 0 { + t.Errorf("Expected 0 issues created before %s, got %d", yesterday, result.Count) + } +} + +// TestResolveID tests the ResolveID operation via RPC +func TestResolveID(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create an issue first + createResp, err := client.Create(&CreateArgs{ + Title: "Test Issue for Resolution", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + var issue types.Issue + json.Unmarshal(createResp.Data, &issue) + + // Test resolving the full ID + resp, err := client.ResolveID(&ResolveIDArgs{ID: issue.ID}) + if err != nil { + t.Fatalf("ResolveID failed: %v", err) + } + + if !resp.Success { + t.Fatalf("Expected success, got error: %s", resp.Error) + } + + var resolvedID string + if err := json.Unmarshal(resp.Data, &resolvedID); err != nil { + t.Fatalf("Failed to unmarshal resolved ID: %v", err) + } + + if resolvedID != issue.ID { + t.Errorf("Expected resolved ID %s, got %s", issue.ID, resolvedID) + } + + // Test resolving a partial ID (first few characters after prefix) + // Note: This depends on there being only one issue with this prefix + if len(issue.ID) > 3 { + partialID := issue.ID[:len(issue.ID)-2] // Remove last 2 chars + resp, err = client.ResolveID(&ResolveIDArgs{ID: partialID}) + if err != nil { + t.Fatalf("ResolveID with partial failed: %v", err) + } + + if !resp.Success { + // This might fail if partial is ambiguous, which is fine + t.Logf("Partial resolution returned: %s", resp.Error) + } + } +} + +// TestResolveID_NotFound tests ResolveID with non-existent ID +func TestResolveID_NotFound(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // ResolveID with non-existent ID should fail with an error + resp, err := client.ResolveID(&ResolveIDArgs{ID: "bd-nonexistent"}) + + // The error is returned through the Execute function + if err != nil { + // Expected - this is the correct behavior for non-existent ID + return + } + + // If we got here without error, check the response + if resp.Success { + t.Error("Expected failure for non-existent ID, got success") + } +} + +// TestDelete tests the Delete operation via RPC +func TestDelete(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create an issue to delete + createResp, err := client.Create(&CreateArgs{ + Title: "Issue to Delete", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + var issue types.Issue + json.Unmarshal(createResp.Data, &issue) + + // Delete the issue + deleteResp, err := client.Delete(&DeleteArgs{ + IDs: []string{issue.ID}, + Force: true, + Reason: "Testing deletion", + }) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + if !deleteResp.Success { + t.Fatalf("Expected success, got error: %s", deleteResp.Error) + } + + // Verify the delete result + var result map[string]interface{} + json.Unmarshal(deleteResp.Data, &result) + + if int(result["deleted_count"].(float64)) != 1 { + t.Errorf("Expected deleted_count=1, got %v", result["deleted_count"]) + } + + // Note: Deleted issues are tombstoned, not hard-deleted. + // They may still appear with status=closed, so we just verify + // the delete operation succeeded above. +} + +// TestDelete_DryRun tests Delete in dry-run mode +func TestDelete_DryRun(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create an issue + createResp, err := client.Create(&CreateArgs{ + Title: "Issue for DryRun Delete", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + var issue types.Issue + json.Unmarshal(createResp.Data, &issue) + + // Delete in dry-run mode + deleteResp, err := client.Delete(&DeleteArgs{ + IDs: []string{issue.ID}, + DryRun: true, + }) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + if !deleteResp.Success { + t.Fatalf("Expected success, got error: %s", deleteResp.Error) + } + + var result map[string]interface{} + json.Unmarshal(deleteResp.Data, &result) + if result["dry_run"] != true { + t.Error("Expected dry_run to be true in response") + } + + // Verify issue still exists + showResp, err := client.Show(&ShowArgs{ID: issue.ID}) + if err != nil { + t.Fatalf("Show failed: %v", err) + } + + if !showResp.Success { + t.Error("Issue should still exist after dry-run delete") + } +} + +// TestDelete_NoIDs tests Delete with no IDs +func TestDelete_NoIDs(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Delete with no IDs should fail + resp, err := client.Delete(&DeleteArgs{ + IDs: []string{}, + }) + + // The error may come through err or through resp.Success=false + if err != nil { + // Expected - this is the correct behavior + return + } + + if resp.Success { + t.Error("Expected failure when deleting with no IDs") + } +} + +// TestDelete_MultipleIssues tests deleting multiple issues at once +func TestDelete_MultipleIssues(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create multiple issues + var ids []string + for i := 0; i < 3; i++ { + createResp, err := client.Create(&CreateArgs{ + Title: "Issue for Batch Delete", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + var issue types.Issue + json.Unmarshal(createResp.Data, &issue) + ids = append(ids, issue.ID) + } + + // Delete all at once + deleteResp, err := client.Delete(&DeleteArgs{ + IDs: ids, + Force: true, + }) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + if !deleteResp.Success { + t.Fatalf("Expected success, got error: %s", deleteResp.Error) + } + + var result map[string]interface{} + json.Unmarshal(deleteResp.Data, &result) + if int(result["deleted_count"].(float64)) != 3 { + t.Errorf("Expected deleted_count=3, got %v", result["deleted_count"]) + } +} + +// TestStale tests the Stale operation via RPC +func TestStale(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create an issue (it won't be stale because it's just created) + _, err := client.Create(&CreateArgs{ + Title: "Fresh Issue", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + // Get stale issues (should be empty since issue is fresh) + resp, err := client.Stale(&StaleArgs{Days: 7}) + if err != nil { + t.Fatalf("Stale failed: %v", err) + } + + if !resp.Success { + t.Fatalf("Expected success, got error: %s", resp.Error) + } + + var staleIssues []types.Issue + if err := json.Unmarshal(resp.Data, &staleIssues); err != nil { + t.Fatalf("Failed to unmarshal stale issues: %v", err) + } + + // Should be empty since our issue was just created + if len(staleIssues) != 0 { + t.Errorf("Expected 0 stale issues, got %d", len(staleIssues)) + } +} + +// TestStale_WithStatusFilter tests Stale with status filter +func TestStale_WithStatusFilter(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create and close an issue + createResp, err := client.Create(&CreateArgs{ + Title: "Issue to Close", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + var issue types.Issue + json.Unmarshal(createResp.Data, &issue) + + if _, err := client.CloseIssue(&CloseArgs{ID: issue.ID}); err != nil { + t.Fatalf("CloseIssue failed: %v", err) + } + + // Get stale open issues (should not include the closed one) + resp, err := client.Stale(&StaleArgs{Days: 0, Status: "open"}) + if err != nil { + t.Fatalf("Stale failed: %v", err) + } + + if !resp.Success { + t.Fatalf("Expected success, got error: %s", resp.Error) + } + + var staleIssues []types.Issue + json.Unmarshal(resp.Data, &staleIssues) + + for _, si := range staleIssues { + if si.ID == issue.ID { + t.Error("Closed issue should not appear in open stale issues") + } + } +} + +// TestCommentList tests the ListComments operation via RPC +func TestCommentList(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create an issue first + createResp, err := client.Create(&CreateArgs{ + Title: "Issue for Comments", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + var issue types.Issue + json.Unmarshal(createResp.Data, &issue) + + // List comments on the issue (should be empty initially) + resp, err := client.ListComments(&CommentListArgs{ID: issue.ID}) + if err != nil { + t.Fatalf("ListComments failed: %v", err) + } + + if !resp.Success { + t.Fatalf("Expected success, got error: %s", resp.Error) + } + + var comments []types.Comment + if err := json.Unmarshal(resp.Data, &comments); err != nil { + t.Fatalf("Failed to unmarshal comments: %v", err) + } + + if len(comments) != 0 { + t.Errorf("Expected 0 comments initially, got %d", len(comments)) + } +} + +// TestCommentAdd tests the AddComment operation via RPC +func TestCommentAdd(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create an issue first + createResp, err := client.Create(&CreateArgs{ + Title: "Issue for Adding Comments", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + var issue types.Issue + json.Unmarshal(createResp.Data, &issue) + + // Add a comment + addResp, err := client.AddComment(&CommentAddArgs{ + ID: issue.ID, + Author: "testuser", + Text: "This is a test comment", + }) + if err != nil { + t.Fatalf("AddComment failed: %v", err) + } + + if !addResp.Success { + t.Fatalf("Expected success, got error: %s", addResp.Error) + } + + var comment types.Comment + if err := json.Unmarshal(addResp.Data, &comment); err != nil { + t.Fatalf("Failed to unmarshal comment: %v", err) + } + + if comment.Author != "testuser" { + t.Errorf("Expected author 'testuser', got '%s'", comment.Author) + } + if comment.Text != "This is a test comment" { + t.Errorf("Expected text 'This is a test comment', got '%s'", comment.Text) + } + + // Verify comment is listed + listResp, err := client.ListComments(&CommentListArgs{ID: issue.ID}) + if err != nil { + t.Fatalf("ListComments failed: %v", err) + } + + var comments []types.Comment + json.Unmarshal(listResp.Data, &comments) + + if len(comments) != 1 { + t.Errorf("Expected 1 comment, got %d", len(comments)) + } +} + +// TestCommentAdd_MultipleComments tests adding multiple comments +func TestCommentAdd_MultipleComments(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create an issue + createResp, err := client.Create(&CreateArgs{ + Title: "Issue for Multiple Comments", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + var issue types.Issue + json.Unmarshal(createResp.Data, &issue) + + // Add multiple comments + for i := 1; i <= 3; i++ { + _, err := client.AddComment(&CommentAddArgs{ + ID: issue.ID, + Author: "user", + Text: "Comment text", + }) + if err != nil { + t.Fatalf("AddComment %d failed: %v", i, err) + } + } + + // Verify all comments are listed + listResp, err := client.ListComments(&CommentListArgs{ID: issue.ID}) + if err != nil { + t.Fatalf("ListComments failed: %v", err) + } + + var comments []types.Comment + json.Unmarshal(listResp.Data, &comments) + + if len(comments) != 3 { + t.Errorf("Expected 3 comments, got %d", len(comments)) + } +} + +// TestMetrics tests the Metrics operation via RPC +func TestMetrics(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Make a few requests to generate some metrics + for i := 0; i < 3; i++ { + if err := client.Ping(); err != nil { + t.Fatalf("Ping failed: %v", err) + } + } + + // Get metrics + metrics, err := client.Metrics() + if err != nil { + t.Fatalf("Metrics failed: %v", err) + } + + if metrics == nil { + t.Fatal("Expected metrics, got nil") + } + + // Check that we have operations recorded + if len(metrics.Operations) == 0 { + t.Error("Expected at least some operations recorded in metrics") + } + + // Calculate total requests from operations + var totalRequests int64 + for _, op := range metrics.Operations { + totalRequests += op.TotalCount + } + + // We made 3 pings + 1 metrics request = at least 4 requests + // (metrics call itself is recorded after snapshot) + if totalRequests < 3 { + t.Errorf("Expected at least 3 total requests, got %d", totalRequests) + } + + // Check uptime is reasonable + if metrics.UptimeSeconds <= 0 { + t.Errorf("Expected positive uptime, got %f", metrics.UptimeSeconds) + } +} + +// TestCountWithGroupBy tests Count with GroupBy option +func TestCountWithGroupBy(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create issues with different statuses and types + _, err := client.Create(&CreateArgs{ + Title: "Task Issue", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + createResp, err := client.Create(&CreateArgs{ + Title: "Bug Issue", + Description: "Test description", + IssueType: "bug", + Priority: 1, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + var bugIssue types.Issue + json.Unmarshal(createResp.Data, &bugIssue) + + // Close the bug + if _, err := client.CloseIssue(&CloseArgs{ID: bugIssue.ID}); err != nil { + t.Fatalf("CloseIssue failed: %v", err) + } + + // Count grouped by status + resp, err := client.Count(&CountArgs{GroupBy: "status"}) + if err != nil { + t.Fatalf("Count with GroupBy failed: %v", err) + } + + if !resp.Success { + t.Fatalf("Expected success, got error: %s", resp.Error) + } + + // The response should contain grouped data + // (The exact format depends on the server implementation) + if len(resp.Data) == 0 { + t.Error("Expected non-empty response for grouped count") + } +} + +// TestCountWithTitleContains tests Count with title pattern matching +func TestCountWithTitleContains(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create issues with specific titles + _, err := client.Create(&CreateArgs{ + Title: "Authentication Bug Fix", + Description: "Test description", + IssueType: "bug", + Priority: 1, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + _, err = client.Create(&CreateArgs{ + Title: "Add User Login Feature", + Description: "Test description", + IssueType: "feature", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + // Count issues with "Authentication" in title + resp, err := client.Count(&CountArgs{TitleContains: "Authentication"}) + if err != nil { + t.Fatalf("Count failed: %v", err) + } + + var result CountResult + json.Unmarshal(resp.Data, &result) + + if result.Count != 1 { + t.Errorf("Expected 1 issue with 'Authentication' in title, got %d", result.Count) + } +} + +// TestStaleWithLimit tests Stale with limit parameter +func TestStaleWithLimit(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create multiple issues + for i := 0; i < 5; i++ { + _, err := client.Create(&CreateArgs{ + Title: "Issue for Stale Test", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + } + + // Get stale issues with limit (using 0 days so all issues are considered stale) + resp, err := client.Stale(&StaleArgs{Days: 0, Limit: 2}) + if err != nil { + t.Fatalf("Stale failed: %v", err) + } + + if !resp.Success { + t.Fatalf("Expected success, got error: %s", resp.Error) + } + + var staleIssues []types.Issue + json.Unmarshal(resp.Data, &staleIssues) + + if len(staleIssues) > 2 { + t.Errorf("Expected at most 2 stale issues (limit), got %d", len(staleIssues)) + } +} + +// Helper function to create a pointer to an int +func intPtr(i int) *int { + return &i +} + + +// GetMutations and Export tests + +// TestGetMutations tests the GetMutations operation via RPC +func TestGetMutations(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create an issue to generate a mutation + _, err := client.Create(&CreateArgs{ + Title: "Issue to track mutations", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + // Get recent mutations + resp, err := client.GetMutations(&GetMutationsArgs{Since: 0}) + if err != nil { + t.Fatalf("GetMutations failed: %v", err) + } + + if !resp.Success { + t.Fatalf("Expected success, got error: %s", resp.Error) + } + + // Response should be a slice of mutations + if len(resp.Data) == 0 { + t.Error("Expected non-empty response with mutations") + } +} + +// TestExport tests the Export operation via RPC +func TestExport(t *testing.T) { + _, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create some issues first + for i := 0; i < 3; i++ { + _, err := client.Create(&CreateArgs{ + Title: "Issue for Export", + Description: "Test description", + IssueType: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + } + + // Create a temp file for export + tmpFile, err := os.CreateTemp("", "beads-export-*.jsonl") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + // Export to the temp file + resp, err := client.Export(&ExportArgs{ + JSONLPath: tmpFile.Name(), + }) + if err != nil { + t.Fatalf("Export failed: %v", err) + } + + if !resp.Success { + t.Fatalf("Expected success, got error: %s", resp.Error) + } + + // Verify file was written + info, err := os.Stat(tmpFile.Name()) + if err != nil { + t.Fatalf("Failed to stat export file: %v", err) + } + + if info.Size() == 0 { + t.Error("Expected non-empty export file") + } +} + +// TestMutationChan tests access to the mutation channel +func TestMutationChan(t *testing.T) { + server, _, cleanup := setupTestServer(t) + defer cleanup() + + // Get the mutation channel + ch := server.MutationChan() + + // Channel should be non-nil + if ch == nil { + t.Error("Expected non-nil mutation channel") + } +} + +// TestResetDroppedEventsCount tests resetting the dropped events counter +func TestResetDroppedEventsCount(t *testing.T) { + server, _, cleanup := setupTestServer(t) + defer cleanup() + + // Reset dropped events count (should not panic) + server.ResetDroppedEventsCount() + + // No error means success +}