Files
beads/internal/rpc/server_mutations_test.go
Steve Yegge 1a818cbced test(rpc): add comprehensive daemon delete RPC handler tests (bd-dxtc)
Add tests for the daemon Delete RPC handler to verify:

- Dry-run mode returns preview without actual deletion
- Error handling for empty issue IDs
- Error handling for non-existent issues
- Partial success when some issues exist and others do not
- Templates cannot be deleted (read-only protection)
- Invalid JSON args are properly rejected
- Reason field is passed through correctly
- Cascade and Force flags are accepted (documents current behavior)

All test scenarios from bd-dxtc issue are covered.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 00:06:51 -08:00

1189 lines
33 KiB
Go

package rpc
import (
"encoding/json"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage/memory"
"github.com/steveyegge/beads/internal/types"
)
func TestEmitMutation(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Emit a mutation
server.emitMutation(MutationCreate, "bd-123", "Test Issue", "")
// Check that mutation was stored in buffer
mutations := server.GetRecentMutations(0)
if len(mutations) != 1 {
t.Fatalf("expected 1 mutation, got %d", len(mutations))
}
if mutations[0].Type != MutationCreate {
t.Errorf("expected type %s, got %s", MutationCreate, mutations[0].Type)
}
if mutations[0].IssueID != "bd-123" {
t.Errorf("expected issue ID bd-123, got %s", mutations[0].IssueID)
}
}
func TestGetRecentMutations_EmptyBuffer(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
mutations := server.GetRecentMutations(0)
if len(mutations) != 0 {
t.Errorf("expected empty mutations, got %d", len(mutations))
}
}
func TestGetRecentMutations_TimestampFiltering(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Emit mutations with delays
server.emitMutation(MutationCreate, "bd-1", "Issue 1", "")
time.Sleep(10 * time.Millisecond)
checkpoint := time.Now().UnixMilli()
time.Sleep(10 * time.Millisecond)
server.emitMutation(MutationUpdate, "bd-2", "Issue 2", "")
server.emitMutation(MutationUpdate, "bd-3", "Issue 3", "")
// Get mutations after checkpoint
mutations := server.GetRecentMutations(checkpoint)
if len(mutations) != 2 {
t.Fatalf("expected 2 mutations after checkpoint, got %d", len(mutations))
}
// Verify the mutations are bd-2 and bd-3
ids := make(map[string]bool)
for _, m := range mutations {
ids[m.IssueID] = true
}
if !ids["bd-2"] || !ids["bd-3"] {
t.Errorf("expected bd-2 and bd-3, got %v", ids)
}
if ids["bd-1"] {
t.Errorf("bd-1 should be filtered out by timestamp")
}
}
func TestGetRecentMutations_CircularBuffer(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Emit more than maxMutationBuffer (100) mutations
for i := 0; i < 150; i++ {
server.emitMutation(MutationCreate, "bd-"+string(rune(i)), "", "")
time.Sleep(time.Millisecond) // Ensure different timestamps
}
// Buffer should only keep last 100
mutations := server.GetRecentMutations(0)
if len(mutations) != 100 {
t.Errorf("expected 100 mutations (circular buffer limit), got %d", len(mutations))
}
// First mutation should be from iteration 50 (150-100)
firstID := mutations[0].IssueID
expectedFirstID := "bd-" + string(rune(50))
if firstID != expectedFirstID {
t.Errorf("expected first mutation to be %s (after circular buffer wraparound), got %s", expectedFirstID, firstID)
}
}
func TestGetRecentMutations_ConcurrentAccess(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Simulate concurrent writes and reads
done := make(chan bool)
// Writer goroutine
go func() {
for i := 0; i < 50; i++ {
server.emitMutation(MutationUpdate, "bd-write", "", "")
time.Sleep(time.Millisecond)
}
done <- true
}()
// Reader goroutine
go func() {
for i := 0; i < 50; i++ {
_ = server.GetRecentMutations(0)
time.Sleep(time.Millisecond)
}
done <- true
}()
// Wait for both to complete
<-done
<-done
// Verify no race conditions (test will fail with -race flag if there are)
mutations := server.GetRecentMutations(0)
if len(mutations) == 0 {
t.Error("expected some mutations after concurrent access")
}
}
func TestHandleGetMutations(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Emit some mutations
server.emitMutation(MutationCreate, "bd-1", "Issue 1", "")
time.Sleep(10 * time.Millisecond)
checkpoint := time.Now().UnixMilli()
time.Sleep(10 * time.Millisecond)
server.emitMutation(MutationUpdate, "bd-2", "Issue 2", "")
// Create RPC request
args := GetMutationsArgs{Since: checkpoint}
argsJSON, _ := json.Marshal(args)
req := &Request{
Operation: OpGetMutations,
Args: argsJSON,
}
// Handle request
resp := server.handleGetMutations(req)
if !resp.Success {
t.Fatalf("expected successful response, got error: %s", resp.Error)
}
// Parse response
var mutations []MutationEvent
if err := json.Unmarshal(resp.Data, &mutations); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(mutations) != 1 {
t.Errorf("expected 1 mutation, got %d", len(mutations))
}
if len(mutations) > 0 && mutations[0].IssueID != "bd-2" {
t.Errorf("expected bd-2, got %s", mutations[0].IssueID)
}
}
func TestHandleGetMutations_InvalidArgs(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Create RPC request with invalid JSON
req := &Request{
Operation: OpGetMutations,
Args: []byte("invalid json"),
}
// Handle request
resp := server.handleGetMutations(req)
if resp.Success {
t.Error("expected error response for invalid args")
}
if resp.Error == "" {
t.Error("expected error message")
}
}
func TestMutationEventTypes(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Test all mutation types
types := []string{
MutationCreate,
MutationUpdate,
MutationDelete,
MutationComment,
}
for _, mutationType := range types {
server.emitMutation(mutationType, "bd-test", "", "")
}
mutations := server.GetRecentMutations(0)
if len(mutations) != len(types) {
t.Fatalf("expected %d mutations, got %d", len(types), len(mutations))
}
// Verify each type was stored correctly
foundTypes := make(map[string]bool)
for _, m := range mutations {
foundTypes[m.Type] = true
}
for _, expectedType := range types {
if !foundTypes[expectedType] {
t.Errorf("expected mutation type %s not found", expectedType)
}
}
}
// TestEmitRichMutation verifies that rich mutation events include metadata fields
func TestEmitRichMutation(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Emit a rich status change event
server.emitRichMutation(MutationEvent{
Type: MutationStatus,
IssueID: "bd-456",
OldStatus: "open",
NewStatus: "in_progress",
})
mutations := server.GetRecentMutations(0)
if len(mutations) != 1 {
t.Fatalf("expected 1 mutation, got %d", len(mutations))
}
m := mutations[0]
if m.Type != MutationStatus {
t.Errorf("expected type %s, got %s", MutationStatus, m.Type)
}
if m.IssueID != "bd-456" {
t.Errorf("expected issue ID bd-456, got %s", m.IssueID)
}
if m.OldStatus != "open" {
t.Errorf("expected OldStatus 'open', got %s", m.OldStatus)
}
if m.NewStatus != "in_progress" {
t.Errorf("expected NewStatus 'in_progress', got %s", m.NewStatus)
}
if m.Timestamp.IsZero() {
t.Error("expected Timestamp to be set automatically")
}
}
// TestEmitRichMutation_Bonded verifies bonded events include step count
func TestEmitRichMutation_Bonded(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Emit a bonded event with metadata
server.emitRichMutation(MutationEvent{
Type: MutationBonded,
IssueID: "bd-789",
ParentID: "bd-parent",
StepCount: 5,
})
mutations := server.GetRecentMutations(0)
if len(mutations) != 1 {
t.Fatalf("expected 1 mutation, got %d", len(mutations))
}
m := mutations[0]
if m.Type != MutationBonded {
t.Errorf("expected type %s, got %s", MutationBonded, m.Type)
}
if m.ParentID != "bd-parent" {
t.Errorf("expected ParentID 'bd-parent', got %s", m.ParentID)
}
if m.StepCount != 5 {
t.Errorf("expected StepCount 5, got %d", m.StepCount)
}
}
func TestMutationTimestamps(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
before := time.Now()
server.emitMutation(MutationCreate, "bd-123", "Test Issue", "")
after := time.Now()
mutations := server.GetRecentMutations(0)
if len(mutations) != 1 {
t.Fatalf("expected 1 mutation, got %d", len(mutations))
}
timestamp := mutations[0].Timestamp
if timestamp.Before(before) || timestamp.After(after) {
t.Errorf("mutation timestamp %v is outside expected range [%v, %v]", timestamp, before, after)
}
}
func TestEmitMutation_NonBlocking(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Don't consume from mutationChan to test non-blocking behavior
// Fill the buffer (default size is 512 from BEADS_MUTATION_BUFFER or default)
for i := 0; i < 600; i++ {
// This should not block even when channel is full
server.emitMutation(MutationCreate, "bd-test", "", "")
}
// Verify mutations were still stored in recent buffer
mutations := server.GetRecentMutations(0)
if len(mutations) == 0 {
t.Error("expected mutations in recent buffer even when channel is full")
}
// Verify buffer is capped at 100 (maxMutationBuffer)
if len(mutations) > 100 {
t.Errorf("expected at most 100 mutations in buffer, got %d", len(mutations))
}
}
// TestHandleClose_EmitsStatusMutation verifies that close operations emit MutationStatus events
// with old/new status metadata (bd-313v fix)
func TestHandleClose_EmitsStatusMutation(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Create an issue first
createArgs := CreateArgs{
Title: "Test Issue for Close",
IssueType: "bug",
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)
// Clear mutation buffer
time.Sleep(10 * time.Millisecond)
checkpoint := time.Now().UnixMilli()
time.Sleep(10 * time.Millisecond)
// Close the issue
closeArgs := CloseArgs{
ID: issueID,
Reason: "test complete",
}
closeJSON, _ := json.Marshal(closeArgs)
closeReq := &Request{
Operation: OpClose,
Args: closeJSON,
Actor: "test-user",
}
closeResp := server.handleClose(closeReq)
if !closeResp.Success {
t.Fatalf("close operation failed: %s", closeResp.Error)
}
// Verify MutationStatus event was emitted with correct metadata
mutations := server.GetRecentMutations(checkpoint)
var statusMutation *MutationEvent
for _, m := range mutations {
if m.Type == MutationStatus && m.IssueID == issueID {
statusMutation = &m
break
}
}
if statusMutation == nil {
t.Fatalf("expected MutationStatus event for issue %s, but none found in mutations: %+v", issueID, mutations)
}
if statusMutation.OldStatus != "open" {
t.Errorf("expected OldStatus 'open', got %s", statusMutation.OldStatus)
}
if statusMutation.NewStatus != "closed" {
t.Errorf("expected NewStatus 'closed', got %s", statusMutation.NewStatus)
}
}
// TestHandleUpdate_EmitsStatusMutationOnStatusChange verifies that status updates emit MutationStatus
func TestHandleUpdate_EmitsStatusMutationOnStatusChange(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Create an issue first
createArgs := CreateArgs{
Title: "Test Issue for Status Update",
IssueType: "task",
Priority: 2,
}
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)
// Clear mutation buffer
time.Sleep(10 * time.Millisecond)
checkpoint := time.Now().UnixMilli()
time.Sleep(10 * time.Millisecond)
// Update status to in_progress
status := "in_progress"
updateArgs := UpdateArgs{
ID: issueID,
Status: &status,
}
updateJSON, _ := json.Marshal(updateArgs)
updateReq := &Request{
Operation: OpUpdate,
Args: updateJSON,
Actor: "test-user",
}
updateResp := server.handleUpdate(updateReq)
if !updateResp.Success {
t.Fatalf("update operation failed: %s", updateResp.Error)
}
// Verify MutationStatus event was emitted
mutations := server.GetRecentMutations(checkpoint)
var statusMutation *MutationEvent
for _, m := range mutations {
if m.Type == MutationStatus && m.IssueID == issueID {
statusMutation = &m
break
}
}
if statusMutation == nil {
t.Fatalf("expected MutationStatus event, but none found in mutations: %+v", mutations)
}
if statusMutation.OldStatus != "open" {
t.Errorf("expected OldStatus 'open', got %s", statusMutation.OldStatus)
}
if statusMutation.NewStatus != "in_progress" {
t.Errorf("expected NewStatus 'in_progress', got %s", statusMutation.NewStatus)
}
}
// TestHandleUpdate_EmitsUpdateMutationForNonStatusChanges verifies non-status updates emit MutationUpdate
func TestHandleUpdate_EmitsUpdateMutationForNonStatusChanges(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Create an issue first
createArgs := CreateArgs{
Title: "Test Issue for Non-Status Update",
IssueType: "task",
Priority: 2,
}
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)
// Clear mutation buffer
time.Sleep(10 * time.Millisecond)
checkpoint := time.Now().UnixMilli()
time.Sleep(10 * time.Millisecond)
// Update title (not status)
newTitle := "Updated Title"
updateArgs := UpdateArgs{
ID: issueID,
Title: &newTitle,
}
updateJSON, _ := json.Marshal(updateArgs)
updateReq := &Request{
Operation: OpUpdate,
Args: updateJSON,
Actor: "test-user",
}
updateResp := server.handleUpdate(updateReq)
if !updateResp.Success {
t.Fatalf("update operation failed: %s", updateResp.Error)
}
// Verify MutationUpdate event was emitted (not MutationStatus)
mutations := server.GetRecentMutations(checkpoint)
var updateMutation *MutationEvent
for _, m := range mutations {
if m.IssueID == issueID {
updateMutation = &m
break
}
}
if updateMutation == nil {
t.Fatal("expected mutation event, but none found")
}
if updateMutation.Type != MutationUpdate {
t.Errorf("expected MutationUpdate type, got %s", updateMutation.Type)
}
}
// TestHandleDelete_EmitsMutation verifies that delete operations emit mutation events
// This is a regression test for the issue where delete operations bypass the daemon
// and don't trigger auto-sync. The delete RPC handler should emit MutationDelete events.
func TestHandleDelete_EmitsMutation(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Create an issue first
createArgs := CreateArgs{
Title: "Test Issue for Deletion",
IssueType: "bug",
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)
}
// Parse the created issue to get its ID
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)
// Clear mutation buffer to isolate delete event
_ = server.GetRecentMutations(time.Now().UnixMilli())
// Now delete the issue via RPC
deleteArgs := DeleteArgs{
IDs: []string{issueID},
Force: true,
Reason: "test deletion",
}
deleteJSON, _ := json.Marshal(deleteArgs)
deleteReq := &Request{
Operation: OpDelete,
Args: deleteJSON,
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
if !deleteResp.Success {
t.Fatalf("delete operation failed: %s", deleteResp.Error)
}
// Verify mutation event was emitted
mutations := server.GetRecentMutations(0)
if len(mutations) == 0 {
t.Fatal("expected delete mutation event, but no mutations were emitted")
}
// Find the delete mutation
var deleteMutation *MutationEvent
for _, m := range mutations {
if m.Type == MutationDelete && m.IssueID == issueID {
deleteMutation = &m
break
}
}
if deleteMutation == nil {
t.Errorf("expected MutationDelete event for issue %s, but none found in mutations: %+v", issueID, mutations)
}
}
// TestHandleDelete_BatchEmitsMutations verifies batch delete emits mutation for each issue
func TestHandleDelete_BatchEmitsMutations(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Create multiple issues
issueIDs := make([]string, 3)
for i := 0; i < 3; i++ {
createArgs := CreateArgs{
Title: "Test Issue " + string(rune('A'+i)),
IssueType: "bug",
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)
}
issueIDs[i] = createdIssue["id"].(string)
}
// Clear mutation buffer
_ = server.GetRecentMutations(time.Now().UnixMilli())
// Batch delete all issues
deleteArgs := DeleteArgs{
IDs: issueIDs,
Force: true,
Reason: "batch test deletion",
}
deleteJSON, _ := json.Marshal(deleteArgs)
deleteReq := &Request{
Operation: OpDelete,
Args: deleteJSON,
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
if !deleteResp.Success {
t.Fatalf("batch delete operation failed: %s", deleteResp.Error)
}
// Verify mutation events were emitted for each deleted issue
mutations := server.GetRecentMutations(0)
deleteMutations := 0
deletedIDs := make(map[string]bool)
for _, m := range mutations {
if m.Type == MutationDelete {
deleteMutations++
deletedIDs[m.IssueID] = true
}
}
if deleteMutations != len(issueIDs) {
t.Errorf("expected %d delete mutations, got %d", len(issueIDs), deleteMutations)
}
// Verify all issue IDs have corresponding mutations
for _, id := range issueIDs {
if !deletedIDs[id] {
t.Errorf("no delete mutation found for issue %s", id)
}
}
}
// TestHandleDelete_DryRun verifies that dry-run mode returns preview without actual deletion
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 := make([]string, 2)
for i := 0; i < 2; i++ {
createArgs := CreateArgs{
Title: "Issue for Dry Run " + string(rune('A'+i)),
IssueType: "task",
Priority: 2,
}
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)
}
issueIDs[i] = createdIssue["id"].(string)
}
// Clear mutation buffer
_ = server.GetRecentMutations(time.Now().UnixMilli())
// Dry-run delete
deleteArgs := DeleteArgs{
IDs: issueIDs,
DryRun: true,
}
deleteJSON, _ := json.Marshal(deleteArgs)
deleteReq := &Request{
Operation: OpDelete,
Args: deleteJSON,
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
if !deleteResp.Success {
t.Fatalf("dry-run delete operation failed: %s", deleteResp.Error)
}
// Parse response
var result map[string]interface{}
if err := json.Unmarshal(deleteResp.Data, &result); err != nil {
t.Fatalf("failed to parse delete response: %v", err)
}
// Verify dry-run response structure
if dryRun, ok := result["dry_run"].(bool); !ok || !dryRun {
t.Errorf("expected dry_run=true in response, got %v", result["dry_run"])
}
if issueCount, ok := result["issue_count"].(float64); !ok || int(issueCount) != 2 {
t.Errorf("expected issue_count=2 in response, got %v", result["issue_count"])
}
// Verify no mutation events were emitted (dry-run doesn't delete)
mutations := server.GetRecentMutations(0)
for _, m := range mutations {
if m.Type == MutationDelete {
t.Errorf("unexpected delete mutation in dry-run mode: %s", m.IssueID)
}
}
// Verify issues still exist (not actually deleted)
for _, id := range issueIDs {
showArgs := ShowArgs{ID: id}
showJSON, _ := json.Marshal(showArgs)
showReq := &Request{
Operation: OpShow,
Args: showJSON,
}
showResp := server.handleShow(showReq)
if !showResp.Success {
t.Errorf("issue %s should still exist after dry-run, but got error: %s", id, showResp.Error)
}
}
}
// TestHandleDelete_ErrorEmptyIDs verifies error when no issue IDs provided
func TestHandleDelete_ErrorEmptyIDs(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
deleteArgs := DeleteArgs{
IDs: []string{},
Force: true,
}
deleteJSON, _ := json.Marshal(deleteArgs)
deleteReq := &Request{
Operation: OpDelete,
Args: deleteJSON,
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
if deleteResp.Success {
t.Error("expected error for empty IDs, but got success")
}
if deleteResp.Error == "" {
t.Error("expected error message for empty IDs")
}
// Verify error message mentions missing IDs
if deleteResp.Error != "no issue IDs provided for deletion" {
t.Errorf("unexpected error message: %s", deleteResp.Error)
}
}
// TestHandleDelete_ErrorIssueNotFound verifies error when issue doesn't exist
func TestHandleDelete_ErrorIssueNotFound(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-12345"},
Force: true,
}
deleteJSON, _ := json.Marshal(deleteArgs)
deleteReq := &Request{
Operation: OpDelete,
Args: deleteJSON,
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
// Parse response to check for errors
var result map[string]interface{}
if deleteResp.Success {
if err := json.Unmarshal(deleteResp.Data, &result); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// Check for partial success with errors
if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 {
// This is expected - the response includes errors for not found issues
found := false
for _, e := range errors {
if errStr, ok := e.(string); ok {
if errStr == "bd-nonexistent-12345: not found" {
found = true
break
}
}
}
if !found {
t.Errorf("expected 'not found' error, got: %v", errors)
}
}
} else {
// Complete failure is also acceptable
if deleteResp.Error == "" {
t.Error("expected error message")
}
}
}
// TestHandleDelete_PartialSuccess verifies partial success when some issues exist and some don'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
createArgs := CreateArgs{
Title: "Valid Issue for Partial Delete",
IssueType: "bug",
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)
}
validID := createdIssue["id"].(string)
// Try to delete both valid and invalid issues
deleteArgs := DeleteArgs{
IDs: []string{validID, "bd-nonexistent-xyz"},
Force: true,
}
deleteJSON, _ := json.Marshal(deleteArgs)
deleteReq := &Request{
Operation: OpDelete,
Args: deleteJSON,
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
if !deleteResp.Success {
t.Fatalf("partial delete should succeed with partial_success flag: %s", deleteResp.Error)
}
// Parse response
var result map[string]interface{}
if err := json.Unmarshal(deleteResp.Data, &result); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// Verify partial success
if deletedCount, ok := result["deleted_count"].(float64); !ok || int(deletedCount) != 1 {
t.Errorf("expected deleted_count=1, got %v", result["deleted_count"])
}
if totalCount, ok := result["total_count"].(float64); !ok || int(totalCount) != 2 {
t.Errorf("expected total_count=2, got %v", result["total_count"])
}
if partialSuccess, ok := result["partial_success"].(bool); !ok || !partialSuccess {
t.Errorf("expected partial_success=true, got %v", result["partial_success"])
}
// Verify errors array contains the not found error
if errors, ok := result["errors"].([]interface{}); ok {
if len(errors) != 1 {
t.Errorf("expected 1 error, got %d", len(errors))
}
} else {
t.Error("expected errors array in response")
}
// Verify the valid issue was actually deleted
showArgs := ShowArgs{ID: validID}
showJSON, _ := json.Marshal(showArgs)
showReq := &Request{
Operation: OpShow,
Args: showJSON,
}
showResp := server.handleShow(showReq)
if showResp.Success {
t.Error("deleted issue should not be found")
}
}
// TestHandleDelete_ErrorCannotDeleteTemplate verifies that templates cannot be deleted
func TestHandleDelete_ErrorCannotDeleteTemplate(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Create a template issue directly in memory store
ctx := server.reqCtx(&Request{})
template := &types.Issue{
ID: "bd-template-test",
Title: "Template Issue",
Description: "This is a template",
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: 2,
IsTemplate: true,
}
if err := store.CreateIssue(ctx, template, "test"); err != nil {
t.Fatalf("failed to create template: %v", err)
}
// Try to delete the template
deleteArgs := DeleteArgs{
IDs: []string{"bd-template-test"},
Force: true,
}
deleteJSON, _ := json.Marshal(deleteArgs)
deleteReq := &Request{
Operation: OpDelete,
Args: deleteJSON,
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
// Parse response
var result map[string]interface{}
if deleteResp.Success {
if err := json.Unmarshal(deleteResp.Data, &result); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// Check for errors
if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 {
found := false
for _, e := range errors {
if errStr, ok := e.(string); ok {
if errStr == "bd-template-test: cannot delete template (templates are read-only)" {
found = true
break
}
}
}
if !found {
t.Errorf("expected template deletion error, got: %v", errors)
}
} else {
t.Error("expected errors in response for template deletion")
}
} else {
// Complete failure with appropriate error is also acceptable
if deleteResp.Error == "" {
t.Error("expected error message")
}
}
// Verify template still exists
showArgs := ShowArgs{ID: "bd-template-test"}
showJSON, _ := json.Marshal(showArgs)
showReq := &Request{
Operation: OpShow,
Args: showJSON,
}
showResp := server.handleShow(showReq)
if !showResp.Success {
t.Errorf("template should still exist after failed delete: %s", showResp.Error)
}
}
// TestHandleDelete_InvalidArgs verifies error for malformed request
func TestHandleDelete_InvalidArgs(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Send invalid JSON
deleteReq := &Request{
Operation: OpDelete,
Args: []byte("invalid json"),
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
if deleteResp.Success {
t.Error("expected error for invalid args")
}
if deleteResp.Error == "" {
t.Error("expected error message for invalid args")
}
}
// TestHandleDelete_ReasonField verifies that the reason field is passed through
func TestHandleDelete_ReasonField(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Create test issue
createArgs := CreateArgs{
Title: "Issue with Reason",
IssueType: "task",
Priority: 2,
}
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 with reason
deleteArgs := DeleteArgs{
IDs: []string{issueID},
Force: true,
Reason: "no longer needed",
}
deleteJSON, _ := json.Marshal(deleteArgs)
deleteReq := &Request{
Operation: OpDelete,
Args: deleteJSON,
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
if !deleteResp.Success {
t.Fatalf("delete with reason failed: %s", deleteResp.Error)
}
// Parse response
var result map[string]interface{}
if err := json.Unmarshal(deleteResp.Data, &result); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if deletedCount, ok := result["deleted_count"].(float64); !ok || int(deletedCount) != 1 {
t.Errorf("expected deleted_count=1, got %v", result["deleted_count"])
}
}
// TestHandleDelete_CascadeAndForceFlags documents current behavior of cascade/force flags
// Note: At daemon level, these flags are accepted but cascade is not fully implemented
// The CLI handles cascade logic before calling the daemon
func TestHandleDelete_CascadeAndForceFlags(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Create test issue
createArgs := CreateArgs{
Title: "Issue with Flags",
IssueType: "task",
Priority: 2,
}
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 with cascade and force flags
deleteArgs := DeleteArgs{
IDs: []string{issueID},
Force: true,
Cascade: true,
}
deleteJSON, _ := json.Marshal(deleteArgs)
deleteReq := &Request{
Operation: OpDelete,
Args: deleteJSON,
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
if !deleteResp.Success {
t.Fatalf("delete with flags failed: %s", deleteResp.Error)
}
// Verify successful deletion
var result map[string]interface{}
if err := json.Unmarshal(deleteResp.Data, &result); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if deletedCount, ok := result["deleted_count"].(float64); !ok || int(deletedCount) != 1 {
t.Errorf("expected deleted_count=1, got %v", result["deleted_count"])
}
}