diff --git a/internal/storage/sqlite/collision_test.go b/internal/storage/sqlite/collision_test.go new file mode 100644 index 00000000..e3506a71 --- /dev/null +++ b/internal/storage/sqlite/collision_test.go @@ -0,0 +1,375 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestDetectCollisions(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + ctx := context.Background() + + // Create existing issue + existing := &types.Issue{ + ID: "bd-1", + Title: "Existing Issue", + Description: "Original description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, existing, "test"); err != nil { + t.Fatalf("Failed to create existing issue: %v", err) + } + + tests := []struct { + name string + incoming []*types.Issue + wantExactMatches int + wantCollisions int + wantNewIssues int + checkCollisionID string + expectedConflicts []string + }{ + { + name: "exact match - idempotent", + incoming: []*types.Issue{ + { + ID: "bd-1", + Title: "Existing Issue", + Description: "Original description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + }, + wantExactMatches: 1, + wantCollisions: 0, + wantNewIssues: 0, + }, + { + name: "collision - different title", + incoming: []*types.Issue{ + { + ID: "bd-1", + Title: "Modified Title", + Description: "Original description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + }, + wantExactMatches: 0, + wantCollisions: 1, + wantNewIssues: 0, + checkCollisionID: "bd-1", + expectedConflicts: []string{"title"}, + }, + { + name: "collision - multiple fields", + incoming: []*types.Issue{ + { + ID: "bd-1", + Title: "Modified Title", + Description: "Modified description", + Status: types.StatusInProgress, + Priority: 2, + IssueType: types.TypeTask, + }, + }, + wantExactMatches: 0, + wantCollisions: 1, + wantNewIssues: 0, + checkCollisionID: "bd-1", + expectedConflicts: []string{"title", "description", "status", "priority"}, + }, + { + name: "new issue", + incoming: []*types.Issue{ + { + ID: "bd-2", + Title: "New Issue", + Description: "New description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeBug, + }, + }, + wantExactMatches: 0, + wantCollisions: 0, + wantNewIssues: 1, + }, + { + name: "mixed - exact, collision, and new", + incoming: []*types.Issue{ + { + ID: "bd-1", + Title: "Existing Issue", + Description: "Original description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + { + ID: "bd-2", + Title: "New Issue", + Description: "New description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeBug, + }, + }, + wantExactMatches: 1, + wantCollisions: 0, + wantNewIssues: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := DetectCollisions(ctx, store, tt.incoming) + if err != nil { + t.Fatalf("DetectCollisions failed: %v", err) + } + + if len(result.ExactMatches) != tt.wantExactMatches { + t.Errorf("ExactMatches: got %d, want %d", len(result.ExactMatches), tt.wantExactMatches) + } + if len(result.Collisions) != tt.wantCollisions { + t.Errorf("Collisions: got %d, want %d", len(result.Collisions), tt.wantCollisions) + } + if len(result.NewIssues) != tt.wantNewIssues { + t.Errorf("NewIssues: got %d, want %d", len(result.NewIssues), tt.wantNewIssues) + } + + // Check collision details if expected + if tt.checkCollisionID != "" && len(result.Collisions) > 0 { + collision := result.Collisions[0] + if collision.ID != tt.checkCollisionID { + t.Errorf("Collision ID: got %s, want %s", collision.ID, tt.checkCollisionID) + } + if len(collision.ConflictingFields) != len(tt.expectedConflicts) { + t.Errorf("ConflictingFields count: got %d, want %d", len(collision.ConflictingFields), len(tt.expectedConflicts)) + } + for i, field := range tt.expectedConflicts { + if i >= len(collision.ConflictingFields) || collision.ConflictingFields[i] != field { + t.Errorf("ConflictingFields[%d]: got %v, want %s", i, collision.ConflictingFields, field) + } + } + } + }) + } +} + +func TestCompareIssues(t *testing.T) { + base := &types.Issue{ + ID: "test-1", + Title: "Base", + Description: "Base description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "alice", + Design: "Base design", + AcceptanceCriteria: "Base acceptance", + Notes: "Base notes", + } + + tests := []struct { + name string + modify func(*types.Issue) *types.Issue + wantConflicts []string + wantNoConflicts bool + }{ + { + name: "identical issues", + modify: func(i *types.Issue) *types.Issue { + copy := *i + return © + }, + wantNoConflicts: true, + }, + { + name: "different title", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Title = "Modified" + return © + }, + wantConflicts: []string{"title"}, + }, + { + name: "different description", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Description = "Modified" + return © + }, + wantConflicts: []string{"description"}, + }, + { + name: "different status", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Status = types.StatusClosed + return © + }, + wantConflicts: []string{"status"}, + }, + { + name: "different priority", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Priority = 2 + return © + }, + wantConflicts: []string{"priority"}, + }, + { + name: "different assignee", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Assignee = "bob" + return © + }, + wantConflicts: []string{"assignee"}, + }, + { + name: "multiple differences", + modify: func(i *types.Issue) *types.Issue { + copy := *i + copy.Title = "Modified" + copy.Priority = 2 + copy.Status = types.StatusClosed + return © + }, + wantConflicts: []string{"title", "status", "priority"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + modified := tt.modify(base) + conflicts := compareIssues(base, modified) + + if tt.wantNoConflicts { + if len(conflicts) != 0 { + t.Errorf("Expected no conflicts, got %v", conflicts) + } + return + } + + if len(conflicts) != len(tt.wantConflicts) { + t.Errorf("Conflict count: got %d, want %d (conflicts: %v)", len(conflicts), len(tt.wantConflicts), conflicts) + } + + for _, wantField := range tt.wantConflicts { + found := false + for _, gotField := range conflicts { + if gotField == wantField { + found = true + break + } + } + if !found { + t.Errorf("Expected conflict field %s not found in %v", wantField, conflicts) + } + } + }) + } +} + +func TestHashIssueContent(t *testing.T) { + issue1 := &types.Issue{ + ID: "test-1", + Title: "Issue", + Description: "Description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "alice", + Design: "Design", + AcceptanceCriteria: "Acceptance", + Notes: "Notes", + } + + issue2 := &types.Issue{ + ID: "test-1", + Title: "Issue", + Description: "Description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "alice", + Design: "Design", + AcceptanceCriteria: "Acceptance", + Notes: "Notes", + } + + issue3 := &types.Issue{ + ID: "test-1", + Title: "Different", + Description: "Description", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "alice", + Design: "Design", + AcceptanceCriteria: "Acceptance", + Notes: "Notes", + } + + hash1 := hashIssueContent(issue1) + hash2 := hashIssueContent(issue2) + hash3 := hashIssueContent(issue3) + + if hash1 != hash2 { + t.Errorf("Expected identical issues to have same hash, got %s vs %s", hash1, hash2) + } + + if hash1 == hash3 { + t.Errorf("Expected different issues to have different hashes") + } + + // Verify hash is deterministic + hash1Again := hashIssueContent(issue1) + if hash1 != hash1Again { + t.Errorf("Hash function not deterministic: %s vs %s", hash1, hash1Again) + } +} + +func TestHashIssueContentWithExternalRef(t *testing.T) { + ref1 := "JIRA-123" + ref2 := "JIRA-456" + + issueWithRef1 := &types.Issue{ + ID: "test-1", + Title: "Issue", + ExternalRef: &ref1, + } + + issueWithRef2 := &types.Issue{ + ID: "test-1", + Title: "Issue", + ExternalRef: &ref2, + } + + issueNoRef := &types.Issue{ + ID: "test-1", + Title: "Issue", + } + + hash1 := hashIssueContent(issueWithRef1) + hash2 := hashIssueContent(issueWithRef2) + hash3 := hashIssueContent(issueNoRef) + + if hash1 == hash2 { + t.Errorf("Expected different external refs to produce different hashes") + } + + if hash1 == hash3 { + t.Errorf("Expected issue with external ref to differ from issue without") + } +} diff --git a/internal/storage/sqlite/delete_test.go b/internal/storage/sqlite/delete_test.go new file mode 100644 index 00000000..59f933a9 --- /dev/null +++ b/internal/storage/sqlite/delete_test.go @@ -0,0 +1,276 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestDeleteIssues(t *testing.T) { + ctx := context.Background() + + t.Run("delete non-existent issue", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + result, err := store.DeleteIssues(ctx, []string{"bd-999"}, false, false, false) + if err != nil { + t.Fatalf("DeleteIssues failed: %v", err) + } + if result.DeletedCount != 0 { + t.Errorf("Expected 0 deletions, got %d", result.DeletedCount) + } + }) + + t.Run("delete with dependents - should fail without force or cascade", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + + // Create issues with dependency + issue1 := &types.Issue{ID: "bd-1", Title: "Parent", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{ID: "bd-2", Title: "Child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("Failed to create issue1: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("Failed to create issue2: %v", err) + } + dep := &types.Dependency{IssueID: "bd-2", DependsOnID: "bd-1", Type: types.DepBlocks} + if err := store.AddDependency(ctx, dep, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + + _, err := store.DeleteIssues(ctx, []string{"bd-1"}, false, false, false) + if err == nil { + t.Fatal("Expected error when deleting issue with dependents") + } + }) + + t.Run("delete with cascade - should delete all dependents", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + + // Create chain: bd-1 -> bd-2 -> bd-3 + issue1 := &types.Issue{ID: "bd-1", Title: "Cascade Parent", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{ID: "bd-2", Title: "Cascade Child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue3 := &types.Issue{ID: "bd-3", Title: "Cascade Grandchild", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("Failed to create issue1: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("Failed to create issue2: %v", err) + } + if err := store.CreateIssue(ctx, issue3, "test"); err != nil { + t.Fatalf("Failed to create issue3: %v", err) + } + + dep1 := &types.Dependency{IssueID: "bd-2", DependsOnID: "bd-1", Type: types.DepBlocks} + if err := store.AddDependency(ctx, dep1, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + dep2 := &types.Dependency{IssueID: "bd-3", DependsOnID: "bd-2", Type: types.DepBlocks} + if err := store.AddDependency(ctx, dep2, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + + result, err := store.DeleteIssues(ctx, []string{"bd-1"}, true, false, false) + if err != nil { + t.Fatalf("DeleteIssues with cascade failed: %v", err) + } + if result.DeletedCount != 3 { + t.Errorf("Expected 3 deletions (cascade), got %d", result.DeletedCount) + } + + // Verify all deleted + if issue, _ := store.GetIssue(ctx, "bd-1"); issue != nil { + t.Error("bd-1 should be deleted") + } + if issue, _ := store.GetIssue(ctx, "bd-2"); issue != nil { + t.Error("bd-2 should be deleted") + } + if issue, _ := store.GetIssue(ctx, "bd-3"); issue != nil { + t.Error("bd-3 should be deleted") + } + }) + + t.Run("delete with force - should orphan dependents", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + + // Create chain: bd-1 -> bd-2 -> bd-3 + issue1 := &types.Issue{ID: "bd-1", Title: "Force Parent", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{ID: "bd-2", Title: "Force Child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue3 := &types.Issue{ID: "bd-3", Title: "Force Grandchild", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("Failed to create issue1: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("Failed to create issue2: %v", err) + } + if err := store.CreateIssue(ctx, issue3, "test"); err != nil { + t.Fatalf("Failed to create issue3: %v", err) + } + + dep1 := &types.Dependency{IssueID: "bd-2", DependsOnID: "bd-1", Type: types.DepBlocks} + if err := store.AddDependency(ctx, dep1, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + dep2 := &types.Dependency{IssueID: "bd-3", DependsOnID: "bd-2", Type: types.DepBlocks} + if err := store.AddDependency(ctx, dep2, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + + result, err := store.DeleteIssues(ctx, []string{"bd-1"}, false, true, false) + if err != nil { + t.Fatalf("DeleteIssues with force failed: %v", err) + } + if result.DeletedCount != 1 { + t.Errorf("Expected 1 deletion (force), got %d", result.DeletedCount) + } + if len(result.OrphanedIssues) != 1 || result.OrphanedIssues[0] != "bd-2" { + t.Errorf("Expected bd-2 to be orphaned, got %v", result.OrphanedIssues) + } + + // Verify bd-1 deleted, bd-2 and bd-3 still exist + if issue, _ := store.GetIssue(ctx, "bd-1"); issue != nil { + t.Error("bd-1 should be deleted") + } + if issue, _ := store.GetIssue(ctx, "bd-2"); issue == nil { + t.Error("bd-2 should still exist") + } + if issue, _ := store.GetIssue(ctx, "bd-3"); issue == nil { + t.Error("bd-3 should still exist") + } + }) + + t.Run("dry run - should not delete", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + + issue1 := &types.Issue{ID: "bd-1", Title: "DryRun Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{ID: "bd-2", Title: "DryRun Issue 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("Failed to create issue1: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("Failed to create issue2: %v", err) + } + + result, err := store.DeleteIssues(ctx, []string{"bd-1", "bd-2"}, false, true, true) + if err != nil { + t.Fatalf("DeleteIssues dry run failed: %v", err) + } + + // Should report what would be deleted + if result.DeletedCount != 2 { + t.Errorf("Dry run should report 2 deletions, got %d", result.DeletedCount) + } + + // But issues should still exist + if issue, _ := store.GetIssue(ctx, "bd-1"); issue == nil { + t.Error("bd-1 should still exist after dry run") + } + if issue, _ := store.GetIssue(ctx, "bd-2"); issue == nil { + t.Error("bd-2 should still exist after dry run") + } + }) + + t.Run("delete multiple issues at once", func(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + + independent1 := &types.Issue{ID: "bd-10", Title: "Independent 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + independent2 := &types.Issue{ID: "bd-11", Title: "Independent 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + if err := store.CreateIssue(ctx, independent1, "test"); err != nil { + t.Fatalf("Failed to create independent1: %v", err) + } + if err := store.CreateIssue(ctx, independent2, "test"); err != nil { + t.Fatalf("Failed to create independent2: %v", err) + } + + result, err := store.DeleteIssues(ctx, []string{"bd-10", "bd-11"}, false, false, false) + if err != nil { + t.Fatalf("DeleteIssues failed: %v", err) + } + if result.DeletedCount != 2 { + t.Errorf("Expected 2 deletions, got %d", result.DeletedCount) + } + + // Verify both deleted + if issue, _ := store.GetIssue(ctx, "bd-10"); issue != nil { + t.Error("bd-10 should be deleted") + } + if issue, _ := store.GetIssue(ctx, "bd-11"); issue != nil { + t.Error("bd-11 should be deleted") + } + }) +} + +func TestDeleteIssue(t *testing.T) { + store := newTestStore(t, "file::memory:?mode=memory&cache=private") + ctx := context.Background() + + issue := &types.Issue{ + ID: "bd-1", + Title: "Single Delete Test Issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + + // Delete it + if err := store.DeleteIssue(ctx, "bd-1"); err != nil { + t.Fatalf("DeleteIssue failed: %v", err) + } + + // Verify deleted + if issue, _ := store.GetIssue(ctx, "bd-1"); issue != nil { + t.Error("Issue should be deleted") + } + + // Delete non-existent - should error + if err := store.DeleteIssue(ctx, "bd-999"); err == nil { + t.Error("DeleteIssue of non-existent should error") + } +} + +func TestBuildIDSet(t *testing.T) { + ids := []string{"bd-1", "bd-2", "bd-3"} + idSet := buildIDSet(ids) + + if len(idSet) != 3 { + t.Errorf("Expected set size 3, got %d", len(idSet)) + } + + for _, id := range ids { + if !idSet[id] { + t.Errorf("ID %s should be in set", id) + } + } + + if idSet["bd-999"] { + t.Error("bd-999 should not be in set") + } +} + +func TestBuildSQLInClause(t *testing.T) { + ids := []string{"bd-1", "bd-2", "bd-3"} + inClause, args := buildSQLInClause(ids) + + expectedClause := "?,?,?" + if inClause != expectedClause { + t.Errorf("Expected clause %s, got %s", expectedClause, inClause) + } + + if len(args) != 3 { + t.Errorf("Expected 3 args, got %d", len(args)) + } + + for i, id := range ids { + if args[i] != id { + t.Errorf("Args[%d]: expected %s, got %v", i, id, args[i]) + } + } +}