From d75eb2c409660d4ad7e821b5dd1b26ea950cfb83 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 23 Dec 2025 20:41:25 -0800 Subject: [PATCH] test(rpc): Add comprehensive tests for daemon RPC delete handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests for the daemon-side RPC delete handler (bd-dxtc): - Dry-run mode returns preview without actual deletion - Invalid issue IDs return appropriate errors - Partial success when some IDs valid, some invalid - No IDs provided error case - Invalid JSON args handling - Response structure validation (deleted_count, total_count) - Storage not available error - Tombstone creation with SQLite storage - All failures error handling - Dry-run preserves data across multiple runs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/rpc/server_delete_test.go | 501 +++++++++++++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 internal/rpc/server_delete_test.go diff --git a/internal/rpc/server_delete_test.go b/internal/rpc/server_delete_test.go new file mode 100644 index 00000000..583547f2 --- /dev/null +++ b/internal/rpc/server_delete_test.go @@ -0,0 +1,501 @@ +package rpc + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/storage/memory" +) + +// TestHandleDelete_DryRun verifies that dry-run mode returns what would be deleted +// without actually deleting the issues +func TestHandleDelete_DryRun(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Create test issues + issueIDs := createTestIssues(t, server, 3) + + // Request dry-run deletion + deleteArgs := DeleteArgs{ + IDs: issueIDs, + DryRun: true, + } + deleteJSON, _ := json.Marshal(deleteArgs) + deleteReq := &Request{ + Operation: OpDelete, + Args: deleteJSON, + Actor: "test-user", + } + + resp := server.handleDelete(deleteReq) + if !resp.Success { + t.Fatalf("dry-run delete failed: %s", resp.Error) + } + + // Parse response + var result map[string]interface{} + if err := json.Unmarshal(resp.Data, &result); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // Verify dry-run flag in response + if dryRun, ok := result["dry_run"].(bool); !ok || !dryRun { + t.Error("expected dry_run: true in response") + } + + // Verify issue count + if count, ok := result["issue_count"].(float64); !ok || int(count) != 3 { + t.Errorf("expected issue_count: 3, got %v", result["issue_count"]) + } + + // Verify issues are still present (not actually deleted) + ctx := context.Background() + for _, id := range issueIDs { + issue, err := store.GetIssue(ctx, id) + if err != nil { + t.Errorf("issue %s should still exist after dry-run, got error: %v", id, err) + } + if issue == nil { + t.Errorf("issue %s should still exist after dry-run, but was deleted", id) + } + } +} + +// TestHandleDelete_InvalidIssueID verifies error handling for non-existent issue IDs +func TestHandleDelete_InvalidIssueID(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Try to delete non-existent issue + deleteArgs := DeleteArgs{ + IDs: []string{"bd-nonexistent"}, + } + deleteJSON, _ := json.Marshal(deleteArgs) + deleteReq := &Request{ + Operation: OpDelete, + Args: deleteJSON, + Actor: "test-user", + } + + resp := server.handleDelete(deleteReq) + + // Should fail since all deletes failed + if resp.Success { + t.Error("expected failure for non-existent issue ID") + } + + if resp.Error == "" { + t.Error("expected error message for failed deletion") + } +} + +// TestHandleDelete_PartialSuccess verifies behavior when some IDs are valid and others aren't +func TestHandleDelete_PartialSuccess(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Create one valid issue + validIDs := createTestIssues(t, server, 1) + validID := validIDs[0] + + // Mix valid and invalid IDs + deleteArgs := DeleteArgs{ + IDs: []string{validID, "bd-fake1", "bd-fake2"}, + } + deleteJSON, _ := json.Marshal(deleteArgs) + deleteReq := &Request{ + Operation: OpDelete, + Args: deleteJSON, + Actor: "test-user", + } + + resp := server.handleDelete(deleteReq) + + // Should succeed (partial success) + if !resp.Success { + t.Errorf("expected partial success, got error: %s", resp.Error) + } + + // Parse response + var result map[string]interface{} + if err := json.Unmarshal(resp.Data, &result); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // Verify counts + if deleted, ok := result["deleted_count"].(float64); !ok || int(deleted) != 1 { + t.Errorf("expected deleted_count: 1, got %v", result["deleted_count"]) + } + + if total, ok := result["total_count"].(float64); !ok || int(total) != 3 { + t.Errorf("expected total_count: 3, got %v", result["total_count"]) + } + + // Verify errors array exists + if errors, ok := result["errors"].([]interface{}); !ok || len(errors) != 2 { + t.Errorf("expected 2 errors in response, got %v", result["errors"]) + } + + // Verify partial_success flag + if partial, ok := result["partial_success"].(bool); !ok || !partial { + t.Error("expected partial_success: true in response") + } +} + +// TestHandleDelete_NoIDs verifies error when no issue IDs are provided +func TestHandleDelete_NoIDs(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Try to delete with empty IDs array + deleteArgs := DeleteArgs{ + IDs: []string{}, + } + deleteJSON, _ := json.Marshal(deleteArgs) + deleteReq := &Request{ + Operation: OpDelete, + Args: deleteJSON, + Actor: "test-user", + } + + resp := server.handleDelete(deleteReq) + + if resp.Success { + t.Error("expected failure when no IDs provided") + } + + if resp.Error != "no issue IDs provided for deletion" { + t.Errorf("unexpected error message: %s", resp.Error) + } +} + +// TestHandleDelete_StorageNotAvailable verifies error when storage is nil +func TestHandleDelete_StorageNotAvailable(t *testing.T) { + // Create server without storage + server := NewServer("/tmp/test.sock", nil, "/tmp", "/tmp/test.db") + + deleteArgs := DeleteArgs{ + IDs: []string{"bd-123"}, + } + deleteJSON, _ := json.Marshal(deleteArgs) + deleteReq := &Request{ + Operation: OpDelete, + Args: deleteJSON, + Actor: "test-user", + } + + resp := server.handleDelete(deleteReq) + + if resp.Success { + t.Error("expected failure when storage not available") + } + + if resp.Error == "" { + t.Error("expected error message about storage not available") + } +} + +// TestHandleDelete_InvalidJSON verifies error handling for malformed JSON args +func TestHandleDelete_InvalidJSON(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + deleteReq := &Request{ + Operation: OpDelete, + Args: []byte("not valid json"), + Actor: "test-user", + } + + resp := server.handleDelete(deleteReq) + + if resp.Success { + t.Error("expected failure for invalid JSON") + } + + if resp.Error == "" { + t.Error("expected error message for invalid JSON") + } +} + +// TestHandleDelete_ResponseStructure verifies the response format for successful deletion +func TestHandleDelete_ResponseStructure(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Create test issues + issueIDs := createTestIssues(t, server, 2) + + // Delete issues + deleteArgs := DeleteArgs{ + IDs: issueIDs, + Reason: "testing response structure", + } + deleteJSON, _ := json.Marshal(deleteArgs) + deleteReq := &Request{ + Operation: OpDelete, + Args: deleteJSON, + Actor: "test-user", + } + + resp := server.handleDelete(deleteReq) + if !resp.Success { + t.Fatalf("delete failed: %s", resp.Error) + } + + // Parse response + var result map[string]interface{} + if err := json.Unmarshal(resp.Data, &result); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // Verify required fields + if _, ok := result["deleted_count"]; !ok { + t.Error("response missing 'deleted_count' field") + } + + if _, ok := result["total_count"]; !ok { + t.Error("response missing 'total_count' field") + } + + // Verify counts match + deleted := result["deleted_count"].(float64) + total := result["total_count"].(float64) + + if int(deleted) != 2 { + t.Errorf("expected deleted_count: 2, got %d", int(deleted)) + } + + if int(total) != 2 { + t.Errorf("expected total_count: 2, got %d", int(total)) + } + + // Should not have errors field when all succeed + if _, ok := result["errors"]; ok { + t.Error("should not have 'errors' field when all deletions succeed") + } + + // Should not have partial_success when all succeed + if _, ok := result["partial_success"]; ok { + t.Error("should not have 'partial_success' field when all deletions succeed") + } +} + +// TestHandleDelete_WithReason verifies deletion with a reason +func TestHandleDelete_WithReason(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Create test issue + issueIDs := createTestIssues(t, server, 1) + + // Delete with reason + deleteArgs := DeleteArgs{ + IDs: issueIDs, + Reason: "test deletion with reason", + } + deleteJSON, _ := json.Marshal(deleteArgs) + deleteReq := &Request{ + Operation: OpDelete, + Args: deleteJSON, + Actor: "test-user", + } + + resp := server.handleDelete(deleteReq) + if !resp.Success { + t.Fatalf("delete with reason failed: %s", resp.Error) + } + + // Verify issue was deleted + ctx := context.Background() + issue, _ := store.GetIssue(ctx, issueIDs[0]) + if issue != nil { + t.Error("issue should have been deleted") + } +} + +// TestHandleDelete_WithTombstone tests delete handler with SQLite storage that supports tombstones +func TestHandleDelete_WithTombstone(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + store := newTestStore(t, dbPath) + defer store.Close() + + server := NewServer("/tmp/test.sock", store, "/tmp", dbPath) + + // Create a test issue using the SQLite store + ctx := context.Background() + createArgs := CreateArgs{ + Title: "Issue for tombstone test", + IssueType: "task", + Priority: 1, + } + createJSON, _ := json.Marshal(createArgs) + createReq := &Request{ + Operation: OpCreate, + Args: createJSON, + Actor: "test-user", + } + + createResp := server.handleCreate(createReq) + if !createResp.Success { + t.Fatalf("failed to create test issue: %s", createResp.Error) + } + + var createdIssue map[string]interface{} + if err := json.Unmarshal(createResp.Data, &createdIssue); err != nil { + t.Fatalf("failed to parse created issue: %v", err) + } + issueID := createdIssue["id"].(string) + + // Delete the issue (should create tombstone) + deleteArgs := DeleteArgs{ + IDs: []string{issueID}, + Reason: "tombstone test", + } + deleteJSON, _ := json.Marshal(deleteArgs) + deleteReq := &Request{ + Operation: OpDelete, + Args: deleteJSON, + Actor: "test-user", + } + + deleteResp := server.handleDelete(deleteReq) + if !deleteResp.Success { + t.Fatalf("delete failed: %s", deleteResp.Error) + } + + // Verify issue was tombstoned (still exists but with tombstone status) + issue, err := store.GetIssue(ctx, issueID) + if err != nil { + t.Fatalf("failed to get tombstoned issue: %v", err) + } + if issue == nil { + t.Fatal("tombstoned issue should still exist in database") + } + if issue.Status != "tombstone" { + t.Errorf("expected status=tombstone, got %s", issue.Status) + } + if issue.DeletedAt == nil { + t.Error("DeletedAt should be set for tombstoned issue") + } + if issue.DeleteReason != "tombstone test" { + t.Errorf("expected DeleteReason='tombstone test', got %q", issue.DeleteReason) + } +} + +// TestHandleDelete_AllFail verifies behavior when all deletions fail +func TestHandleDelete_AllFail(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Try to delete multiple non-existent issues + deleteArgs := DeleteArgs{ + IDs: []string{"bd-fake1", "bd-fake2", "bd-fake3"}, + } + deleteJSON, _ := json.Marshal(deleteArgs) + deleteReq := &Request{ + Operation: OpDelete, + Args: deleteJSON, + Actor: "test-user", + } + + resp := server.handleDelete(deleteReq) + + // Should fail since all deletes failed + if resp.Success { + t.Error("expected failure when all deletions fail") + } + + if resp.Error == "" { + t.Error("expected error message when all deletions fail") + } +} + +// TestHandleDelete_DryRunPreservesData verifies dry-run doesn't modify anything +func TestHandleDelete_DryRunPreservesData(t *testing.T) { + store := memory.New("/tmp/test.jsonl") + server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db") + + // Create test issues + issueIDs := createTestIssues(t, server, 3) + + // Get issues before dry-run + ctx := context.Background() + beforeIssues := make(map[string]string) + for _, id := range issueIDs { + issue, _ := store.GetIssue(ctx, id) + if issue != nil { + beforeIssues[id] = issue.Title + } + } + + // Do dry-run deletion multiple times + for i := 0; i < 3; i++ { + deleteArgs := DeleteArgs{ + IDs: issueIDs, + DryRun: true, + } + deleteJSON, _ := json.Marshal(deleteArgs) + deleteReq := &Request{ + Operation: OpDelete, + Args: deleteJSON, + Actor: "test-user", + } + + resp := server.handleDelete(deleteReq) + if !resp.Success { + t.Fatalf("dry-run %d failed: %s", i, resp.Error) + } + } + + // Verify all issues still exist with same data + for id, title := range beforeIssues { + issue, err := store.GetIssue(ctx, id) + if err != nil { + t.Errorf("issue %s disappeared after dry-runs: %v", id, err) + continue + } + if issue == nil { + t.Errorf("issue %s was deleted by dry-run", id) + continue + } + if issue.Title != title { + t.Errorf("issue %s title changed: expected %q, got %q", id, title, issue.Title) + } + } +} + +// createTestIssues is a helper to create test issues and return their IDs +func createTestIssues(t *testing.T, server *Server, count int) []string { + t.Helper() + ids := make([]string, count) + + for i := 0; i < count; i++ { + createArgs := CreateArgs{ + Title: "Test Issue for Delete", + IssueType: "task", + Priority: 1, + } + createJSON, _ := json.Marshal(createArgs) + createReq := &Request{ + Operation: OpCreate, + Args: createJSON, + Actor: "test-user", + } + + createResp := server.handleCreate(createReq) + if !createResp.Success { + t.Fatalf("failed to create test issue %d: %s", i, createResp.Error) + } + + var createdIssue map[string]interface{} + if err := json.Unmarshal(createResp.Data, &createdIssue); err != nil { + t.Fatalf("failed to parse created issue %d: %v", i, err) + } + ids[i] = createdIssue["id"].(string) + } + + return ids +}