From e0d8abe8c3a15e5bf47e2447e6151ac26b3400ac Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 18 Dec 2025 11:25:17 -0800 Subject: [PATCH] Add tests for Decision 004 Phase 4 edge schema consolidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests added: - TestTransactionAddDependency_RelatesTo: bidirectional relates-to in txn - TestTransactionAddDependency_RepliesTo: thread_id preserved in txn - TestRelateCommand: bd relate/unrelate CLI commands - TestRelateCommandInit: command registration Provides regression coverage for transaction.go fixes and relates-to behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/relate_test.go | 234 ++++++++++++++++++++ internal/storage/sqlite/transaction_test.go | 152 +++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 cmd/bd/relate_test.go diff --git a/cmd/bd/relate_test.go b/cmd/bd/relate_test.go new file mode 100644 index 00000000..a9ce5a85 --- /dev/null +++ b/cmd/bd/relate_test.go @@ -0,0 +1,234 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/steveyegge/beads/internal/types" +) + +// TestRelateCommand tests the bd relate command functionality. +// This is a regression test for Decision 004 Phase 4 - relates-to links +// are now stored in the dependencies table. +func TestRelateCommand(t *testing.T) { + tmpDir := t.TempDir() + testDB := filepath.Join(tmpDir, ".beads", "beads.db") + s := newTestStore(t, testDB) + ctx := context.Background() + + t.Run("relate creates bidirectional link", func(t *testing.T) { + // Create two issues + issue1 := &types.Issue{ + ID: "test-relate-1", + Title: "Issue 1", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + } + issue2 := &types.Issue{ + ID: "test-relate-2", + Title: "Issue 2", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + } + + if err := s.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := s.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Simulate what bd relate does: add bidirectional relates-to + dep1 := &types.Dependency{ + IssueID: issue1.ID, + DependsOnID: issue2.ID, + Type: types.DepRelatesTo, + } + if err := s.AddDependency(ctx, dep1, "test"); err != nil { + t.Fatalf("AddDependency (1->2) failed: %v", err) + } + + dep2 := &types.Dependency{ + IssueID: issue2.ID, + DependsOnID: issue1.ID, + Type: types.DepRelatesTo, + } + if err := s.AddDependency(ctx, dep2, "test"); err != nil { + t.Fatalf("AddDependency (2->1) failed: %v", err) + } + + // Verify bidirectional link exists + deps1, err := s.GetDependenciesWithMetadata(ctx, issue1.ID) + if err != nil { + t.Fatalf("GetDependenciesWithMetadata failed: %v", err) + } + found1to2 := false + for _, d := range deps1 { + if d.ID == issue2.ID && d.DependencyType == types.DepRelatesTo { + found1to2 = true + } + } + if !found1to2 { + t.Errorf("issue1 should have relates-to link to issue2") + } + + deps2, err := s.GetDependenciesWithMetadata(ctx, issue2.ID) + if err != nil { + t.Fatalf("GetDependenciesWithMetadata failed: %v", err) + } + found2to1 := false + for _, d := range deps2 { + if d.ID == issue1.ID && d.DependencyType == types.DepRelatesTo { + found2to1 = true + } + } + if !found2to1 { + t.Errorf("issue2 should have relates-to link to issue1") + } + }) + + t.Run("unrelate removes bidirectional link", func(t *testing.T) { + // Create two issues + issue1 := &types.Issue{ + ID: "test-unrelate-1", + Title: "Issue 1", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + } + issue2 := &types.Issue{ + ID: "test-unrelate-2", + Title: "Issue 2", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + } + + if err := s.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := s.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add bidirectional relates-to + dep1 := &types.Dependency{ + IssueID: issue1.ID, + DependsOnID: issue2.ID, + Type: types.DepRelatesTo, + } + if err := s.AddDependency(ctx, dep1, "test"); err != nil { + t.Fatalf("AddDependency (1->2) failed: %v", err) + } + dep2 := &types.Dependency{ + IssueID: issue2.ID, + DependsOnID: issue1.ID, + Type: types.DepRelatesTo, + } + if err := s.AddDependency(ctx, dep2, "test"); err != nil { + t.Fatalf("AddDependency (2->1) failed: %v", err) + } + + // Simulate what bd unrelate does: remove both directions + if err := s.RemoveDependency(ctx, issue1.ID, issue2.ID, "test"); err != nil { + t.Fatalf("RemoveDependency (1->2) failed: %v", err) + } + if err := s.RemoveDependency(ctx, issue2.ID, issue1.ID, "test"); err != nil { + t.Fatalf("RemoveDependency (2->1) failed: %v", err) + } + + // Verify links are gone + deps1, err := s.GetDependenciesWithMetadata(ctx, issue1.ID) + if err != nil { + t.Fatalf("GetDependenciesWithMetadata failed: %v", err) + } + for _, d := range deps1 { + if d.ID == issue2.ID && d.DependencyType == types.DepRelatesTo { + t.Errorf("issue1 should NOT have relates-to link to issue2 after unrelate") + } + } + + deps2, err := s.GetDependenciesWithMetadata(ctx, issue2.ID) + if err != nil { + t.Fatalf("GetDependenciesWithMetadata failed: %v", err) + } + for _, d := range deps2 { + if d.ID == issue1.ID && d.DependencyType == types.DepRelatesTo { + t.Errorf("issue2 should NOT have relates-to link to issue1 after unrelate") + } + } + }) + + t.Run("relates-to does not block", func(t *testing.T) { + // Create two issues + issue1 := &types.Issue{ + ID: "test-noblock-1", + Title: "Issue 1", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + } + issue2 := &types.Issue{ + ID: "test-noblock-2", + Title: "Issue 2", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + CreatedAt: time.Now(), + } + + if err := s.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := s.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add relates-to (not blocks) + dep := &types.Dependency{ + IssueID: issue1.ID, + DependsOnID: issue2.ID, + Type: types.DepRelatesTo, + } + if err := s.AddDependency(ctx, dep, "test"); err != nil { + t.Fatalf("AddDependency failed: %v", err) + } + + // Issue1 should NOT be blocked (relates-to doesn't block) + blocked, err := s.GetBlockedIssues(ctx) + if err != nil { + t.Fatalf("GetBlockedIssues failed: %v", err) + } + for _, b := range blocked { + if b.ID == issue1.ID { + t.Errorf("issue1 should NOT be blocked by relates-to dependency") + } + } + }) +} + +// TestRelateCommandInit tests that the relate and unrelate commands are properly initialized. +func TestRelateCommandInit(t *testing.T) { + if relateCmd == nil { + t.Fatal("relateCmd should be initialized") + } + if relateCmd.Use != "relate " { + t.Errorf("Expected Use='relate ', got %q", relateCmd.Use) + } + + if unrelateCmd == nil { + t.Fatal("unrelateCmd should be initialized") + } + if unrelateCmd.Use != "unrelate " { + t.Errorf("Expected Use='unrelate ', got %q", unrelateCmd.Use) + } +} diff --git a/internal/storage/sqlite/transaction_test.go b/internal/storage/sqlite/transaction_test.go index ba81e5a4..282dbd31 100644 --- a/internal/storage/sqlite/transaction_test.go +++ b/internal/storage/sqlite/transaction_test.go @@ -2,6 +2,7 @@ package sqlite import ( "context" + "fmt" "testing" "time" @@ -432,6 +433,157 @@ func TestTransactionAddDependency(t *testing.T) { } } +// TestTransactionAddDependency_RelatesTo tests that bidirectional relates-to +// dependencies work in transaction context. This is a regression test for +// Decision 004 Phase 4 - the cycle detection must exempt relates-to type +// since bidirectional relationships are semantically valid. +func TestTransactionAddDependency_RelatesTo(t *testing.T) { + ctx := context.Background() + store, cleanup := setupTestDB(t) + defer cleanup() + + // 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-actor"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := store.CreateIssue(ctx, issue2, "test-actor"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add bidirectional relates-to in a single transaction + // This should NOT fail cycle detection since relates-to is exempt + err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { + // First direction: issue1 relates-to issue2 + dep1 := &types.Dependency{ + IssueID: issue1.ID, + DependsOnID: issue2.ID, + Type: types.DepRelatesTo, + } + if err := tx.AddDependency(ctx, dep1, "test-actor"); err != nil { + return fmt.Errorf("first relates-to failed: %w", err) + } + + // Second direction: issue2 relates-to issue1 (would be a cycle for other types) + dep2 := &types.Dependency{ + IssueID: issue2.ID, + DependsOnID: issue1.ID, + Type: types.DepRelatesTo, + } + if err := tx.AddDependency(ctx, dep2, "test-actor"); err != nil { + return fmt.Errorf("second relates-to failed: %w", err) + } + + return nil + }) + + if err != nil { + t.Fatalf("RunInTransaction failed: %v", err) + } + + // Verify both directions exist + deps1, err := store.GetDependenciesWithMetadata(ctx, issue1.ID) + if err != nil { + t.Fatalf("GetDependenciesWithMetadata failed: %v", err) + } + found1 := false + for _, d := range deps1 { + if d.ID == issue2.ID && d.DependencyType == types.DepRelatesTo { + found1 = true + } + } + if !found1 { + t.Errorf("issue1 should have relates-to link to issue2") + } + + deps2, err := store.GetDependenciesWithMetadata(ctx, issue2.ID) + if err != nil { + t.Fatalf("GetDependenciesWithMetadata failed: %v", err) + } + found2 := false + for _, d := range deps2 { + if d.ID == issue1.ID && d.DependencyType == types.DepRelatesTo { + found2 = true + } + } + if !found2 { + t.Errorf("issue2 should have relates-to link to issue1") + } +} + +// TestTransactionAddDependency_RepliesTo tests that replies-to dependencies +// preserve thread_id in transaction context (Decision 004 Phase 4). +func TestTransactionAddDependency_RepliesTo(t *testing.T) { + ctx := context.Background() + store, cleanup := setupTestDB(t) + defer cleanup() + + // Create original message and reply + original := &types.Issue{ + Title: "Original Message", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeMessage, + Sender: "alice", + } + reply := &types.Issue{ + Title: "Re: Original Message", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeMessage, + Sender: "bob", + } + if err := store.CreateIssue(ctx, original, "test-actor"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + if err := store.CreateIssue(ctx, reply, "test-actor"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add replies-to with thread_id in transaction + err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { + dep := &types.Dependency{ + IssueID: reply.ID, + DependsOnID: original.ID, + Type: types.DepRepliesTo, + ThreadID: original.ID, // Thread root + } + return tx.AddDependency(ctx, dep, "test-actor") + }) + + if err != nil { + t.Fatalf("RunInTransaction failed: %v", err) + } + + // Verify the dependency and thread_id were preserved + deps, err := store.GetDependenciesWithMetadata(ctx, reply.ID) + if err != nil { + t.Fatalf("GetDependenciesWithMetadata failed: %v", err) + } + found := false + for _, d := range deps { + if d.ID == original.ID && d.DependencyType == types.DepRepliesTo { + found = true + } + } + if !found { + t.Errorf("reply should have replies-to link to original") + } + + // Verify thread_id by querying dependencies table directly + var threadID string + err = store.UnderlyingDB().QueryRowContext(ctx, + `SELECT thread_id FROM dependencies WHERE issue_id = ? AND depends_on_id = ?`, + reply.ID, original.ID).Scan(&threadID) + if err != nil { + t.Fatalf("Failed to query thread_id: %v", err) + } + if threadID != original.ID { + t.Errorf("thread_id = %q, want %q", threadID, original.ID) + } +} + // TestTransactionRemoveDependency tests removing a dependency within a transaction. func TestTransactionRemoveDependency(t *testing.T) { ctx := context.Background()