Merge remote changes
This commit is contained in:
File diff suppressed because one or more lines are too long
375
internal/storage/sqlite/collision_test.go
Normal file
375
internal/storage/sqlite/collision_test.go
Normal 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 ©
|
||||
},
|
||||
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")
|
||||
}
|
||||
}
|
||||
368
internal/storage/sqlite/comments_test.go
Normal file
368
internal/storage/sqlite/comments_test.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// TestAddIssueComment tests basic comment addition
|
||||
func TestAddIssueComment(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Add a comment
|
||||
comment, err := store.AddIssueComment(ctx, issue.ID, "alice", "This is a test comment")
|
||||
if err != nil {
|
||||
t.Fatalf("AddIssueComment failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify comment fields
|
||||
if comment.IssueID != issue.ID {
|
||||
t.Errorf("Expected IssueID %s, got %s", issue.ID, comment.IssueID)
|
||||
}
|
||||
if comment.Author != "alice" {
|
||||
t.Errorf("Expected Author 'alice', got '%s'", comment.Author)
|
||||
}
|
||||
if comment.Text != "This is a test comment" {
|
||||
t.Errorf("Expected Text 'This is a test comment', got '%s'", comment.Text)
|
||||
}
|
||||
if comment.ID == 0 {
|
||||
t.Error("Expected non-zero comment ID")
|
||||
}
|
||||
if comment.CreatedAt.IsZero() {
|
||||
t.Error("Expected non-zero CreatedAt timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddIssueCommentNonexistentIssue tests adding comment to non-existent issue
|
||||
func TestAddIssueCommentNonexistentIssue(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Try to add comment to non-existent issue
|
||||
_, err := store.AddIssueComment(ctx, "nonexistent-id", "alice", "comment")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when adding comment to non-existent issue, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetIssueComments tests retrieving comments
|
||||
func TestGetIssueComments(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Add multiple comments
|
||||
testComments := []struct {
|
||||
author string
|
||||
text string
|
||||
}{
|
||||
{"alice", "First comment"},
|
||||
{"bob", "Second comment"},
|
||||
{"charlie", "Third comment"},
|
||||
}
|
||||
|
||||
for _, tc := range testComments {
|
||||
_, err := store.AddIssueComment(ctx, issue.ID, tc.author, tc.text)
|
||||
if err != nil {
|
||||
t.Fatalf("AddIssueComment failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve comments
|
||||
comments, err := store.GetIssueComments(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssueComments failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify number of comments
|
||||
if len(comments) != len(testComments) {
|
||||
t.Fatalf("Expected %d comments, got %d", len(testComments), len(comments))
|
||||
}
|
||||
|
||||
// Verify comment content and ordering (should be chronological)
|
||||
for i, comment := range comments {
|
||||
if comment.Author != testComments[i].author {
|
||||
t.Errorf("Comment %d: expected author %s, got %s", i, testComments[i].author, comment.Author)
|
||||
}
|
||||
if comment.Text != testComments[i].text {
|
||||
t.Errorf("Comment %d: expected text %s, got %s", i, testComments[i].text, comment.Text)
|
||||
}
|
||||
if comment.IssueID != issue.ID {
|
||||
t.Errorf("Comment %d: expected IssueID %s, got %s", i, issue.ID, comment.IssueID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetIssueCommentsOrdering tests that comments are returned in chronological order
|
||||
func TestGetIssueCommentsOrdering(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Add comments with identifiable ordering
|
||||
for i := 1; i <= 5; i++ {
|
||||
text := "Comment " + string(rune('0'+i))
|
||||
_, err := store.AddIssueComment(ctx, issue.ID, "alice", text)
|
||||
if err != nil {
|
||||
t.Fatalf("AddIssueComment failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve comments
|
||||
comments, err := store.GetIssueComments(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssueComments failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify chronological ordering
|
||||
if len(comments) != 5 {
|
||||
t.Fatalf("Expected 5 comments, got %d", len(comments))
|
||||
}
|
||||
|
||||
for i := 0; i < len(comments); i++ {
|
||||
expectedText := "Comment " + string(rune('0'+i+1))
|
||||
if comments[i].Text != expectedText {
|
||||
t.Errorf("Comment %d: expected text %s, got %s", i, expectedText, comments[i].Text)
|
||||
}
|
||||
|
||||
// Verify timestamps are in ascending order
|
||||
if i > 0 && comments[i].CreatedAt.Before(comments[i-1].CreatedAt) {
|
||||
t.Errorf("Comments not in chronological order: comment %d (%v) is before comment %d (%v)",
|
||||
i, comments[i].CreatedAt, i-1, comments[i-1].CreatedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetIssueCommentsEmpty tests retrieving comments for issue with no comments
|
||||
func TestGetIssueCommentsEmpty(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve comments (should be empty)
|
||||
comments, err := store.GetIssueComments(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssueComments failed: %v", err)
|
||||
}
|
||||
|
||||
if len(comments) != 0 {
|
||||
t.Errorf("Expected 0 comments, got %d", len(comments))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetIssueCommentsNonexistentIssue tests retrieving comments for non-existent issue
|
||||
func TestGetIssueCommentsNonexistentIssue(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Retrieve comments for non-existent issue
|
||||
comments, err := store.GetIssueComments(ctx, "nonexistent-id")
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssueComments failed: %v", err)
|
||||
}
|
||||
|
||||
// Should return empty slice, not error
|
||||
if len(comments) != 0 {
|
||||
t.Errorf("Expected 0 comments for non-existent issue, got %d", len(comments))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddIssueCommentEmptyText tests adding comment with empty text
|
||||
func TestAddIssueCommentEmptyText(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Add comment with empty text (should succeed - validation is caller's responsibility)
|
||||
comment, err := store.AddIssueComment(ctx, issue.ID, "alice", "")
|
||||
if err != nil {
|
||||
t.Fatalf("AddIssueComment with empty text failed: %v", err)
|
||||
}
|
||||
|
||||
if comment.Text != "" {
|
||||
t.Errorf("Expected empty text, got '%s'", comment.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddIssueCommentMarksDirty tests that adding a comment marks the issue dirty
|
||||
func TestAddIssueCommentMarksDirty(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Clear dirty flag (simulating after export)
|
||||
if err := store.ClearDirtyIssuesByID(ctx, []string{issue.ID}); err != nil {
|
||||
t.Fatalf("ClearDirtyIssuesByID failed: %v", err)
|
||||
}
|
||||
|
||||
// Add a comment
|
||||
_, err := store.AddIssueComment(ctx, issue.ID, "alice", "test comment")
|
||||
if err != nil {
|
||||
t.Fatalf("AddIssueComment failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify issue is marked dirty
|
||||
var exists bool
|
||||
err = store.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM dirty_issues WHERE issue_id = ?)`, issue.ID).Scan(&exists)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check dirty flag: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Error("Expected issue to be marked dirty after adding comment")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetIssueCommentsMultipleIssues tests that comments are properly isolated per issue
|
||||
func TestGetIssueCommentsMultipleIssues(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create two issues
|
||||
issue1 := &types.Issue{
|
||||
Title: "Issue 1",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
issue2 := &types.Issue{
|
||||
Title: "Issue 2",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Add comments to each issue
|
||||
_, err := store.AddIssueComment(ctx, issue1.ID, "alice", "Comment for issue 1")
|
||||
if err != nil {
|
||||
t.Fatalf("AddIssueComment failed: %v", err)
|
||||
}
|
||||
_, err = store.AddIssueComment(ctx, issue1.ID, "bob", "Another comment for issue 1")
|
||||
if err != nil {
|
||||
t.Fatalf("AddIssueComment failed: %v", err)
|
||||
}
|
||||
_, err = store.AddIssueComment(ctx, issue2.ID, "charlie", "Comment for issue 2")
|
||||
if err != nil {
|
||||
t.Fatalf("AddIssueComment failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve comments for issue 1
|
||||
comments1, err := store.GetIssueComments(ctx, issue1.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssueComments failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve comments for issue 2
|
||||
comments2, err := store.GetIssueComments(ctx, issue2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssueComments failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify each issue has the correct number of comments
|
||||
if len(comments1) != 2 {
|
||||
t.Errorf("Expected 2 comments for issue 1, got %d", len(comments1))
|
||||
}
|
||||
if len(comments2) != 1 {
|
||||
t.Errorf("Expected 1 comment for issue 2, got %d", len(comments2))
|
||||
}
|
||||
|
||||
// Verify comments belong to correct issues
|
||||
for _, c := range comments1 {
|
||||
if c.IssueID != issue1.ID {
|
||||
t.Errorf("Comment has wrong IssueID: expected %s, got %s", issue1.ID, c.IssueID)
|
||||
}
|
||||
}
|
||||
for _, c := range comments2 {
|
||||
if c.IssueID != issue2.ID {
|
||||
t.Errorf("Comment has wrong IssueID: expected %s, got %s", issue2.ID, c.IssueID)
|
||||
}
|
||||
}
|
||||
}
|
||||
506
internal/storage/sqlite/cycle_detection_test.go
Normal file
506
internal/storage/sqlite/cycle_detection_test.go
Normal file
@@ -0,0 +1,506 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// TestDetectCyclesSimple tests simple 2-node cycles
|
||||
func TestDetectCyclesSimple(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create two issues
|
||||
issue1 := &types.Issue{Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issue2 := &types.Issue{Title: "Issue 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Manually create a cycle by inserting directly into dependencies table
|
||||
// (bypassing AddDependency's cycle prevention)
|
||||
_, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issue1.ID, issue2.ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issue2.ID, issue1.ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Detect cycles
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) == 0 {
|
||||
t.Fatal("Expected to detect a cycle, but found none")
|
||||
}
|
||||
|
||||
// Verify the cycle contains both issues
|
||||
cycle := cycles[0]
|
||||
if len(cycle) != 2 {
|
||||
t.Logf("Cycle issues: %v", cycle)
|
||||
for i, iss := range cycle {
|
||||
t.Logf(" [%d] ID=%s Title=%s", i, iss.ID, iss.Title)
|
||||
}
|
||||
t.Errorf("Expected cycle of length 2, got %d", len(cycle))
|
||||
}
|
||||
|
||||
// Verify both issues are in the cycle
|
||||
foundIDs := make(map[string]bool)
|
||||
for _, issue := range cycle {
|
||||
foundIDs[issue.ID] = true
|
||||
}
|
||||
|
||||
if !foundIDs[issue1.ID] || !foundIDs[issue2.ID] {
|
||||
t.Errorf("Cycle missing expected issues. Got: %v", foundIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCyclesComplex tests a more complex multi-node cycle
|
||||
func TestDetectCyclesComplex(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a 4-node cycle: A → B → C → D → A
|
||||
issues := make([]*types.Issue, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
issues[i] = &types.Issue{
|
||||
Title: "Issue " + string(rune('A'+i)),
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create cycle: 0→1→2→3→0
|
||||
for i := 0; i < 4; i++ {
|
||||
nextIdx := (i + 1) % 4
|
||||
_, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issues[i].ID, issues[nextIdx].ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect cycles
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) == 0 {
|
||||
t.Fatal("Expected to detect a cycle, but found none")
|
||||
}
|
||||
|
||||
// Verify the cycle contains all 4 issues
|
||||
cycle := cycles[0]
|
||||
if len(cycle) != 4 {
|
||||
t.Errorf("Expected cycle of length 4, got %d", len(cycle))
|
||||
}
|
||||
|
||||
// Verify all issues are in the cycle
|
||||
foundIDs := make(map[string]bool)
|
||||
for _, issue := range cycle {
|
||||
foundIDs[issue.ID] = true
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
if !foundIDs[issue.ID] {
|
||||
t.Errorf("Cycle missing issue %s", issue.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCyclesSelfLoop tests detection of self-loops (A → A)
|
||||
func TestDetectCyclesSelfLoop(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
issue := &types.Issue{Title: "Self Loop", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Create self-loop
|
||||
_, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issue.ID, issue.ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Detect cycles
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) == 0 {
|
||||
t.Fatal("Expected to detect a self-loop cycle, but found none")
|
||||
}
|
||||
|
||||
// Verify the cycle contains the issue
|
||||
cycle := cycles[0]
|
||||
if len(cycle) != 1 {
|
||||
t.Errorf("Expected self-loop cycle of length 1, got %d", len(cycle))
|
||||
}
|
||||
|
||||
if cycle[0].ID != issue.ID {
|
||||
t.Errorf("Expected cycle to contain issue %s, got %s", issue.ID, cycle[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCyclesMultipleIndependent tests detection of multiple independent cycles
|
||||
func TestDetectCyclesMultipleIndependent(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create two independent cycles:
|
||||
// Cycle 1: A → B → A
|
||||
// Cycle 2: C → D → C
|
||||
|
||||
cycle1 := make([]*types.Issue, 2)
|
||||
cycle2 := make([]*types.Issue, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
cycle1[i] = &types.Issue{
|
||||
Title: "Cycle1-" + string(rune('A'+i)),
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
cycle2[i] = &types.Issue{
|
||||
Title: "Cycle2-" + string(rune('A'+i)),
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, cycle1[i], "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
if err := store.CreateIssue(ctx, cycle2[i], "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create first cycle: 0→1→0
|
||||
_, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, cycle1[0].ID, cycle1[1].ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
_, err = store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, cycle1[1].ID, cycle1[0].ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Create second cycle: 0→1→0
|
||||
_, err = store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, cycle2[0].ID, cycle2[1].ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
_, err = store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, cycle2[1].ID, cycle2[0].ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Detect cycles
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
// The SQL may detect the same cycle from different entry points,
|
||||
// so we might get more than 2 cycles reported. Verify we have at least 2.
|
||||
if len(cycles) < 2 {
|
||||
t.Errorf("Expected to detect at least 2 independent cycles, got %d", len(cycles))
|
||||
}
|
||||
|
||||
// Verify we found cycles involving all 4 issues
|
||||
foundIssues := make(map[string]bool)
|
||||
for _, cycle := range cycles {
|
||||
for _, issue := range cycle {
|
||||
foundIssues[issue.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
allCycleIssues := append(cycle1, cycle2...)
|
||||
for _, issue := range allCycleIssues {
|
||||
if !foundIssues[issue.ID] {
|
||||
t.Errorf("Cycle detection missing issue %s", issue.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCyclesAcyclicGraph tests that no cycles are detected in an acyclic graph
|
||||
func TestDetectCyclesAcyclicGraph(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a DAG: A → B → C → D (no cycles)
|
||||
issues := make([]*types.Issue, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
issues[i] = &types.Issue{
|
||||
Title: "Issue " + string(rune('A'+i)),
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create chain: 0→1→2→3 (no cycle)
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issues[i].ID, issues[i+1].ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect cycles
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) != 0 {
|
||||
t.Errorf("Expected no cycles in acyclic graph, but found %d", len(cycles))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCyclesEmptyGraph tests cycle detection on empty graph
|
||||
func TestDetectCyclesEmptyGraph(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Detect cycles on empty database
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) != 0 {
|
||||
t.Errorf("Expected no cycles in empty graph, but found %d", len(cycles))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCyclesSingleNode tests cycle detection with a single node (no dependencies)
|
||||
func TestDetectCyclesSingleNode(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a single issue with no dependencies
|
||||
issue := &types.Issue{Title: "Lonely Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Detect cycles
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) != 0 {
|
||||
t.Errorf("Expected no cycles for single node with no dependencies, but found %d", len(cycles))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCyclesDiamond tests cycle detection in a diamond pattern (no cycle)
|
||||
func TestDetectCyclesDiamond(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a diamond pattern: A → B → D, A → C → D (no cycle)
|
||||
issues := make([]*types.Issue, 4)
|
||||
names := []string{"A", "B", "C", "D"}
|
||||
for i := 0; i < 4; i++ {
|
||||
issues[i] = &types.Issue{
|
||||
Title: "Issue " + names[i],
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create dependencies: A→B, A→C, B→D, C→D
|
||||
deps := [][2]int{{0, 1}, {0, 2}, {1, 3}, {2, 3}}
|
||||
for _, dep := range deps {
|
||||
_, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issues[dep[0]].ID, issues[dep[1]].ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect cycles
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) != 0 {
|
||||
t.Errorf("Expected no cycles in diamond pattern, but found %d", len(cycles))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCyclesLongCycle tests detection of a long cycle (10 nodes)
|
||||
func TestDetectCyclesLongCycle(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a 10-node cycle
|
||||
const cycleLength = 10
|
||||
issues := make([]*types.Issue, cycleLength)
|
||||
for i := 0; i < cycleLength; i++ {
|
||||
issues[i] = &types.Issue{
|
||||
Title: "Issue " + string(rune('0'+i)),
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create cycle: 0→1→2→...→9→0
|
||||
for i := 0; i < cycleLength; i++ {
|
||||
nextIdx := (i + 1) % cycleLength
|
||||
_, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issues[i].ID, issues[nextIdx].ID, types.DepBlocks)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect cycles
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) == 0 {
|
||||
t.Fatal("Expected to detect a cycle, but found none")
|
||||
}
|
||||
|
||||
// Verify the cycle contains all 10 issues
|
||||
cycle := cycles[0]
|
||||
if len(cycle) != cycleLength {
|
||||
t.Errorf("Expected cycle of length %d, got %d", cycleLength, len(cycle))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectCyclesMixedTypes tests cycle detection with different dependency types
|
||||
func TestDetectCyclesMixedTypes(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a cycle using different dependency types
|
||||
issues := make([]*types.Issue, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
issues[i] = &types.Issue{
|
||||
Title: "Issue " + string(rune('A'+i)),
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create cycle with mixed types: A -blocks-> B -related-> C -parent-child-> A
|
||||
depTypes := []types.DependencyType{types.DepBlocks, types.DepRelated, types.DepParentChild}
|
||||
for i := 0; i < 3; i++ {
|
||||
nextIdx := (i + 1) % 3
|
||||
_, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
||||
`, issues[i].ID, issues[nextIdx].ID, depTypes[i])
|
||||
if err != nil {
|
||||
t.Fatalf("Insert dependency failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect cycles
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectCycles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cycles) == 0 {
|
||||
t.Fatal("Expected to detect a cycle with mixed types, but found none")
|
||||
}
|
||||
|
||||
// Verify the cycle contains all 3 issues
|
||||
cycle := cycles[0]
|
||||
if len(cycle) != 3 {
|
||||
t.Errorf("Expected cycle of length 3, got %d", len(cycle))
|
||||
}
|
||||
}
|
||||
276
internal/storage/sqlite/delete_test.go
Normal file
276
internal/storage/sqlite/delete_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -560,17 +560,17 @@ func (s *SQLiteStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, err
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
d.issue_id,
|
||||
d.depends_on_id,
|
||||
p.start_id,
|
||||
p.path || '→' || d.depends_on_id,
|
||||
p.depth + 1
|
||||
d.issue_id,
|
||||
d.depends_on_id,
|
||||
p.start_id,
|
||||
p.path || '→' || d.depends_on_id,
|
||||
p.depth + 1
|
||||
FROM dependencies d
|
||||
JOIN paths p ON d.issue_id = p.depends_on_id
|
||||
WHERE p.depth < ?
|
||||
AND p.path NOT LIKE '%' || d.depends_on_id || '→%'
|
||||
AND (d.depends_on_id = p.start_id OR p.path NOT LIKE '%' || d.depends_on_id || '→%')
|
||||
)
|
||||
SELECT DISTINCT path || '→' || start_id as cycle_path
|
||||
SELECT DISTINCT path as cycle_path
|
||||
FROM paths
|
||||
WHERE depends_on_id = start_id
|
||||
ORDER BY cycle_path
|
||||
|
||||
Reference in New Issue
Block a user