Analysis found these commands are dead code: - gt never calls `bd pin` - uses `bd update --status=pinned` instead - Beads.Pin() wrapper exists but is never called - bd hook functionality duplicated by gt mol status - Code comment says "pinned field is cosmetic for bd hook visibility" Removed: - cmd/bd/pin.go - cmd/bd/unpin.go - cmd/bd/hook.go 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1056 lines
29 KiB
Go
1056 lines
29 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/memory"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestHandleCreate_SetsCreatedBy verifies that CreatedBy is passed through RPC and stored (GH#748)
|
|
func TestHandleCreate_SetsCreatedBy(t *testing.T) {
|
|
store := memory.New("/tmp/test.jsonl")
|
|
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
|
|
|
|
createArgs := CreateArgs{
|
|
Title: "Test CreatedBy Field",
|
|
IssueType: "task",
|
|
Priority: 2,
|
|
CreatedBy: "test-actor",
|
|
}
|
|
createJSON, _ := json.Marshal(createArgs)
|
|
createReq := &Request{
|
|
Operation: OpCreate,
|
|
Args: createJSON,
|
|
Actor: "test-actor",
|
|
}
|
|
|
|
resp := server.handleCreate(createReq)
|
|
if !resp.Success {
|
|
t.Fatalf("create failed: %s", resp.Error)
|
|
}
|
|
|
|
var createdIssue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &createdIssue); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
|
|
// Verify CreatedBy was set in the response
|
|
if createdIssue.CreatedBy != "test-actor" {
|
|
t.Errorf("expected CreatedBy 'test-actor' in response, got %q", createdIssue.CreatedBy)
|
|
}
|
|
|
|
// Verify CreatedBy was persisted to storage
|
|
storedIssue, err := store.GetIssue(context.Background(), createdIssue.ID)
|
|
if err != nil {
|
|
t.Fatalf("failed to get issue from storage: %v", err)
|
|
}
|
|
if storedIssue.CreatedBy != "test-actor" {
|
|
t.Errorf("expected CreatedBy 'test-actor' in storage, got %q", storedIssue.CreatedBy)
|
|
}
|
|
}
|
|
|
|
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_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_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"])
|
|
}
|
|
}
|