When an issue is deleted, issues that depend on it were not being marked dirty. This caused stale dependency references to persist in JSONL after the target issue was deleted, because the dependent issues were never re-exported. This manifests as FK validation failures during multi-repo hydration: "foreign key violation: issue X depends on non-existent issue Y" The fix queries for dependent issues before deleting and marks them dirty so they get re-exported without the stale dependency reference. Adds test: TestDeleteIssueMarksDependentsDirty Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
419 lines
14 KiB
Go
419 lines
14 KiB
Go
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 converted to tombstones (bd-3b4)
|
|
if issue, _ := store.GetIssue(ctx, "bd-1"); issue == nil || issue.Status != types.StatusTombstone {
|
|
t.Error("bd-1 should be tombstone")
|
|
}
|
|
if issue, _ := store.GetIssue(ctx, "bd-2"); issue == nil || issue.Status != types.StatusTombstone {
|
|
t.Error("bd-2 should be tombstone")
|
|
}
|
|
if issue, _ := store.GetIssue(ctx, "bd-3"); issue == nil || issue.Status != types.StatusTombstone {
|
|
t.Error("bd-3 should be tombstone")
|
|
}
|
|
})
|
|
|
|
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 is tombstone, bd-2 and bd-3 still active (bd-3b4)
|
|
if issue, _ := store.GetIssue(ctx, "bd-1"); issue == nil || issue.Status != types.StatusTombstone {
|
|
t.Error("bd-1 should be tombstone")
|
|
}
|
|
if issue, _ := store.GetIssue(ctx, "bd-2"); issue == nil || issue.Status == types.StatusTombstone {
|
|
t.Error("bd-2 should still be active")
|
|
}
|
|
if issue, _ := store.GetIssue(ctx, "bd-3"); issue == nil || issue.Status == types.StatusTombstone {
|
|
t.Error("bd-3 should still be active")
|
|
}
|
|
})
|
|
|
|
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 converted to tombstones (bd-3b4)
|
|
if issue, _ := store.GetIssue(ctx, "bd-10"); issue == nil || issue.Status != types.StatusTombstone {
|
|
t.Error("bd-10 should be tombstone")
|
|
}
|
|
if issue, _ := store.GetIssue(ctx, "bd-11"); issue == nil || issue.Status != types.StatusTombstone {
|
|
t.Error("bd-11 should be tombstone")
|
|
}
|
|
})
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// TestDeleteIssueWithComments verifies that DeleteIssue also removes comments (bd-687g)
|
|
func TestDeleteIssueWithComments(t *testing.T) {
|
|
store := newTestStore(t, "file::memory:?mode=memory&cache=private")
|
|
ctx := context.Background()
|
|
|
|
issue := &types.Issue{
|
|
ID: "bd-1",
|
|
Title: "Issue with Comments",
|
|
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)
|
|
}
|
|
|
|
// Add a comment to the comments table (not events)
|
|
if _, err := store.AddIssueComment(ctx, "bd-1", "test-author", "This is a test comment"); err != nil {
|
|
t.Fatalf("Failed to add comment: %v", err)
|
|
}
|
|
|
|
// Verify comment exists
|
|
commentsMap, err := store.GetCommentsForIssues(ctx, []string{"bd-1"})
|
|
if err != nil {
|
|
t.Fatalf("Failed to get comments: %v", err)
|
|
}
|
|
if len(commentsMap["bd-1"]) != 1 {
|
|
t.Fatalf("Expected 1 comment, got %d", len(commentsMap["bd-1"]))
|
|
}
|
|
|
|
// Delete the issue
|
|
if err := store.DeleteIssue(ctx, "bd-1"); err != nil {
|
|
t.Fatalf("DeleteIssue failed: %v", err)
|
|
}
|
|
|
|
// Verify issue deleted
|
|
if issue, _ := store.GetIssue(ctx, "bd-1"); issue != nil {
|
|
t.Error("Issue should be deleted")
|
|
}
|
|
|
|
// Verify comments also deleted (should not leak)
|
|
commentsMap, err = store.GetCommentsForIssues(ctx, []string{"bd-1"})
|
|
if err != nil {
|
|
t.Fatalf("Failed to get comments after delete: %v", err)
|
|
}
|
|
if len(commentsMap["bd-1"]) != 0 {
|
|
t.Errorf("Comments should be deleted, but found %d", len(commentsMap["bd-1"]))
|
|
}
|
|
}
|
|
|
|
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])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDeleteIssueMarksDependentsDirty verifies that when an issue is deleted,
|
|
// all issues that depend on it are marked dirty so their stale dependencies
|
|
// are removed on next JSONL export. This prevents orphan dependencies in JSONL.
|
|
func TestDeleteIssueMarksDependentsDirty(t *testing.T) {
|
|
store := newTestStore(t, "file::memory:?mode=memory&cache=private")
|
|
ctx := context.Background()
|
|
|
|
// Create a wisp (will be deleted)
|
|
wisp := &types.Issue{
|
|
ID: "bd-wisp-1",
|
|
Title: "Ephemeral Wisp",
|
|
Status: types.StatusClosed,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
Ephemeral: true,
|
|
}
|
|
if err := store.CreateIssue(ctx, wisp, "test"); err != nil {
|
|
t.Fatalf("Failed to create wisp: %v", err)
|
|
}
|
|
|
|
// Create a digest that depends on the wisp
|
|
digest := &types.Issue{
|
|
ID: "bd-digest-1",
|
|
Title: "Digest: Test",
|
|
Status: types.StatusClosed,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
Ephemeral: false, // Digest is persistent
|
|
}
|
|
if err := store.CreateIssue(ctx, digest, "test"); err != nil {
|
|
t.Fatalf("Failed to create digest: %v", err)
|
|
}
|
|
|
|
// Create dependency: digest depends on wisp (parent-child)
|
|
dep := &types.Dependency{
|
|
IssueID: "bd-digest-1",
|
|
DependsOnID: "bd-wisp-1",
|
|
Type: types.DepParentChild,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, "test"); err != nil {
|
|
t.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
|
|
// Clear dirty state (simulate post-flush state)
|
|
if err := store.ClearDirtyIssuesByID(ctx, []string{"bd-wisp-1", "bd-digest-1"}); err != nil {
|
|
t.Fatalf("Failed to clear dirty state: %v", err)
|
|
}
|
|
|
|
// Verify digest is NOT dirty initially
|
|
dirtyBefore, err := store.GetDirtyIssues(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get dirty issues: %v", err)
|
|
}
|
|
for _, id := range dirtyBefore {
|
|
if id == "bd-digest-1" {
|
|
t.Fatal("Digest should not be dirty before wisp deletion")
|
|
}
|
|
}
|
|
|
|
// Delete the wisp
|
|
if err := store.DeleteIssue(ctx, "bd-wisp-1"); err != nil {
|
|
t.Fatalf("Failed to delete wisp: %v", err)
|
|
}
|
|
|
|
// Verify digest IS now dirty (so it gets re-exported without stale dep)
|
|
dirtyAfter, err := store.GetDirtyIssues(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get dirty issues after delete: %v", err)
|
|
}
|
|
|
|
found := false
|
|
for _, id := range dirtyAfter {
|
|
if id == "bd-digest-1" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("Digest should be marked dirty after wisp deletion to remove orphan dependency")
|
|
}
|
|
|
|
// Verify the dependency is gone from the digest
|
|
digestIssue, err := store.GetIssue(ctx, "bd-digest-1")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get digest: %v", err)
|
|
}
|
|
if len(digestIssue.Dependencies) != 0 {
|
|
t.Errorf("Digest should have no dependencies after wisp deleted, got %d", len(digestIssue.Dependencies))
|
|
}
|
|
}
|