Files
beads/internal/rpc/server_delete_test.go
beads/crew/emma b161e22144 fix: support delete in --no-db mode (GH#822)
Add CreateTombstone() to MemoryStorage and deleteBatchFallback() to
handle deletion when SQLite is not available. This fixes the error
"tombstone operation not supported by this storage backend" when
using bd delete with --no-db flag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:45:52 -08:00

507 lines
14 KiB
Go

package rpc
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/storage/memory"
"github.com/steveyegge/beads/internal/types"
)
// 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 converted to tombstone (now that MemoryStorage supports CreateTombstone)
ctx := context.Background()
issue, _ := store.GetIssue(ctx, issueIDs[0])
if issue == nil {
t.Error("issue should exist as tombstone")
} else if issue.Status != types.StatusTombstone {
t.Errorf("issue should be tombstone, got status=%s", issue.Status)
} else if issue.DeleteReason != "test deletion with reason" {
t.Errorf("expected DeleteReason='test deletion with reason', got '%s'", issue.DeleteReason)
}
}
// 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
}