test: add sqlite collision and delete test coverage

- Add collision_test.go: comprehensive ID collision handling tests
- Add delete_test.go: test soft delete, cascade, and edge cases
- Improves sqlite package coverage from 57.8% to 68.2%

Amp-Thread-ID: https://ampcode.com/threads/T-f4a96cad-a2a4-4f6f-af47-cd56700429f7
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-11-01 22:47:07 -07:00
parent 39b49ca4b6
commit e9bb1ac121
2 changed files with 651 additions and 0 deletions

View File

@@ -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 &copy
},
wantNoConflicts: true,
},
{
name: "different title",
modify: func(i *types.Issue) *types.Issue {
copy := *i
copy.Title = "Modified"
return &copy
},
wantConflicts: []string{"title"},
},
{
name: "different description",
modify: func(i *types.Issue) *types.Issue {
copy := *i
copy.Description = "Modified"
return &copy
},
wantConflicts: []string{"description"},
},
{
name: "different status",
modify: func(i *types.Issue) *types.Issue {
copy := *i
copy.Status = types.StatusClosed
return &copy
},
wantConflicts: []string{"status"},
},
{
name: "different priority",
modify: func(i *types.Issue) *types.Issue {
copy := *i
copy.Priority = 2
return &copy
},
wantConflicts: []string{"priority"},
},
{
name: "different assignee",
modify: func(i *types.Issue) *types.Issue {
copy := *i
copy.Assignee = "bob"
return &copy
},
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 &copy
},
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")
}
}

View File

@@ -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])
}
}
}