package sqlite import ( "context" "fmt" "strings" "testing" "time" "github.com/steveyegge/beads/internal/types" ) // Helper function to test adding a dependency with a specific type func testAddDependencyWithType(t *testing.T, depType types.DependencyType, title1, title2 string) { t.Helper() store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create two issues issue1 := &types.Issue{Title: title1, Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: title2, Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") // Add dependency (issue2 depends on issue1) dep := &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, Type: depType, } err := store.AddDependency(ctx, dep, "test-user") if err != nil { t.Fatalf("AddDependency failed: %v", err) } // Verify dependency was added deps, err := store.GetDependencies(ctx, issue2.ID) if err != nil { t.Fatalf("GetDependencies failed: %v", err) } if len(deps) != 1 { t.Fatalf("Expected 1 dependency, got %d", len(deps)) } if deps[0].ID != issue1.ID { t.Errorf("Expected dependency on %s, got %s", issue1.ID, deps[0].ID) } } func TestAddDependency(t *testing.T) { testAddDependencyWithType(t, types.DepBlocks, "First", "Second") } func TestAddDependencyDiscoveredFrom(t *testing.T) { testAddDependencyWithType(t, types.DepDiscoveredFrom, "Parent task", "Bug found during work") } func TestParentChildValidation(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create an epic (parent) and a task (child) epic := &types.Issue{Title: "Epic Feature", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} task := &types.Issue{Title: "Subtask", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, epic, "test-user") store.CreateIssue(ctx, task, "test-user") // Test 1: Valid direction - Task depends on Epic (child belongs to parent) err := store.AddDependency(ctx, &types.Dependency{ IssueID: task.ID, DependsOnID: epic.ID, Type: types.DepParentChild, }, "test-user") if err != nil { t.Fatalf("Valid parent-child dependency failed: %v", err) } // Verify it was added deps, err := store.GetDependencies(ctx, task.ID) if err != nil { t.Fatalf("GetDependencies failed: %v", err) } if len(deps) != 1 { t.Fatalf("Expected 1 dependency, got %d", len(deps)) } // Remove the dependency for next test err = store.RemoveDependency(ctx, task.ID, epic.ID, "test-user") if err != nil { t.Fatalf("RemoveDependency failed: %v", err) } // Test 2: Invalid direction - Epic depends on Task (parent depends on child - backwards!) err = store.AddDependency(ctx, &types.Dependency{ IssueID: epic.ID, DependsOnID: task.ID, Type: types.DepParentChild, }, "test-user") if err == nil { t.Fatal("Expected error when parent depends on child, but got none") } if !strings.Contains(err.Error(), "child") || !strings.Contains(err.Error(), "parent") { t.Errorf("Expected error message to mention child/parent relationship, got: %v", err) } } func TestRemoveDependency(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create and link issues issue1 := &types.Issue{Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") dep := &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks, } store.AddDependency(ctx, dep, "test-user") // Remove the dependency err := store.RemoveDependency(ctx, issue2.ID, issue1.ID, "test-user") if err != nil { t.Fatalf("RemoveDependency failed: %v", err) } // Verify dependency was removed deps, err := store.GetDependencies(ctx, issue2.ID) if err != nil { t.Fatalf("GetDependencies failed: %v", err) } if len(deps) != 0 { t.Errorf("Expected 0 dependencies after removal, got %d", len(deps)) } } func TestAddDependencyPreservesProvidedMetadata(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() parent := &types.Issue{Title: "Parent", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} child := &types.Issue{Title: "Child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, parent, "test-user") store.CreateIssue(ctx, child, "test-user") customTime := time.Date(2024, 10, 24, 12, 0, 0, 0, time.UTC) dep := &types.Dependency{ IssueID: child.ID, DependsOnID: parent.ID, Type: types.DepParentChild, CreatedAt: customTime, CreatedBy: "import", } if err := store.AddDependency(ctx, dep, "test-user"); err != nil { t.Fatalf("AddDependency failed: %v", err) } records, err := store.GetDependencyRecords(ctx, child.ID) if err != nil { t.Fatalf("GetDependencyRecords failed: %v", err) } if len(records) != 1 { t.Fatalf("Expected 1 dependency record, got %d", len(records)) } got := records[0] if !got.CreatedAt.Equal(customTime) { t.Fatalf("Expected CreatedAt %v, got %v", customTime, got.CreatedAt) } if got.CreatedBy != "import" { t.Fatalf("Expected CreatedBy 'import', got %q", got.CreatedBy) } } func TestGetDependents(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues: bd-2 and bd-3 both depend on bd-1 issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Feature A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue3 := &types.Issue{Title: "Feature B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") store.CreateIssue(ctx, issue3, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") // Get dependents of issue1 dependents, err := store.GetDependents(ctx, issue1.ID) if err != nil { t.Fatalf("GetDependents failed: %v", err) } if len(dependents) != 2 { t.Fatalf("Expected 2 dependents, got %d", len(dependents)) } // Verify both dependents are present foundIDs := make(map[string]bool) for _, dep := range dependents { foundIDs[dep.ID] = true } if !foundIDs[issue2.ID] || !foundIDs[issue3.ID] { t.Errorf("Expected dependents %s and %s", issue2.ID, issue3.ID) } } func TestGetDependencyTree(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create a chain: bd-3 → bd-2 → bd-1 issue1 := &types.Issue{Title: "Level 0", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Level 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue3 := &types.Issue{Title: "Level 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") store.CreateIssue(ctx, issue3, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user") // Get tree starting from issue3 tree, err := store.GetDependencyTree(ctx, issue3.ID, 10, false, false) if err != nil { t.Fatalf("GetDependencyTree failed: %v", err) } if len(tree) != 3 { t.Fatalf("Expected 3 nodes in tree, got %d", len(tree)) } // Verify depths depthMap := make(map[string]int) for _, node := range tree { depthMap[node.ID] = node.Depth } if depthMap[issue3.ID] != 0 { t.Errorf("Expected depth 0 for %s, got %d", issue3.ID, depthMap[issue3.ID]) } if depthMap[issue2.ID] != 1 { t.Errorf("Expected depth 1 for %s, got %d", issue2.ID, depthMap[issue2.ID]) } if depthMap[issue1.ID] != 2 { t.Errorf("Expected depth 2 for %s, got %d", issue1.ID, depthMap[issue1.ID]) } } func TestGetDependencyTree_TruncationDepth(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create a long chain: bd-5 → bd-4 → bd-3 → bd-2 → bd-1 issues := make([]*types.Issue, 5) for i := 0; i < 5; i++ { issues[i] = &types.Issue{ Title: fmt.Sprintf("Level %d", i), Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } err := store.CreateIssue(ctx, issues[i], "test-user") if err != nil { t.Fatalf("CreateIssue failed: %v", err) } } // Link them in chain for i := 1; i < 5; i++ { err := store.AddDependency(ctx, &types.Dependency{ IssueID: issues[i].ID, DependsOnID: issues[i-1].ID, Type: types.DepBlocks, }, "test-user") if err != nil { t.Fatalf("AddDependency failed: %v", err) } } // Get tree with maxDepth=2 (should only get 3 nodes: depths 0, 1, 2) tree, err := store.GetDependencyTree(ctx, issues[4].ID, 2, false, false) if err != nil { t.Fatalf("GetDependencyTree failed: %v", err) } if len(tree) != 3 { t.Fatalf("Expected 3 nodes with maxDepth=2, got %d", len(tree)) } // Check that last node is marked as truncated foundTruncated := false for _, node := range tree { if node.Depth == 2 && node.Truncated { foundTruncated = true break } } if !foundTruncated { t.Error("Expected node at depth 2 to be marked as truncated") } } func TestGetDependencyTree_DefaultDepth(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create a simple chain issue1 := &types.Issue{Title: "Level 0", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Level 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") store.AddDependency(ctx, &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks, }, "test-user") // Get tree with default depth (50) tree, err := store.GetDependencyTree(ctx, issue2.ID, 50, false, false) if err != nil { t.Fatalf("GetDependencyTree failed: %v", err) } if len(tree) != 2 { t.Fatalf("Expected 2 nodes, got %d", len(tree)) } // No truncation should occur for _, node := range tree { if node.Truncated { t.Error("Expected no truncation with default depth on short chain") } } } func TestGetDependencyTree_MaxDepthOne(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create a chain: bd-3 → bd-2 → bd-1 issue1 := &types.Issue{Title: "Level 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Level 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue3 := &types.Issue{Title: "Root", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") store.CreateIssue(ctx, issue3, "test-user") store.AddDependency(ctx, &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks, }, "test-user") store.AddDependency(ctx, &types.Dependency{ IssueID: issue3.ID, DependsOnID: issue2.ID, Type: types.DepBlocks, }, "test-user") // Get tree with maxDepth=1 (should get root + one level) tree, err := store.GetDependencyTree(ctx, issue3.ID, 1, false, false) if err != nil { t.Fatalf("GetDependencyTree failed: %v", err) } // Should get root (depth 0) and one child (depth 1) if len(tree) != 2 { t.Fatalf("Expected 2 nodes with maxDepth=1, got %d", len(tree)) } // Check root is at depth 0 and not truncated rootFound := false for _, node := range tree { if node.ID == issue3.ID && node.Depth == 0 && !node.Truncated { rootFound = true } } if !rootFound { t.Error("Expected root at depth 0, not truncated") } // Check child at depth 1 is truncated childTruncated := false for _, node := range tree { if node.Depth == 1 && node.Truncated { childTruncated = true } } if !childTruncated { t.Error("Expected child at depth 1 to be truncated") } } func TestDetectCycles(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Try to create a cycle: bd-1 → bd-2 → bd-3 → bd-1 // This should be prevented by AddDependency issue1 := &types.Issue{Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue3 := &types.Issue{Title: "Third", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") store.CreateIssue(ctx, issue3, "test-user") // Add first two dependencies successfully err := store.AddDependency(ctx, &types.Dependency{IssueID: issue1.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user") if err != nil { t.Fatalf("First dependency failed: %v", err) } err = store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue3.ID, Type: types.DepBlocks}, "test-user") if err != nil { t.Fatalf("Second dependency failed: %v", err) } // The third dependency should fail because it would create a cycle err = store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") if err == nil { t.Fatal("Expected error when creating cycle, but got none") } // Verify no cycles exist cycles, err := store.DetectCycles(ctx) if err != nil { t.Fatalf("DetectCycles failed: %v", err) } if len(cycles) != 0 { t.Errorf("Expected no cycles after prevention, but found %d", len(cycles)) } } func TestNoCyclesDetected(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create a valid chain with no cycles issue1 := &types.Issue{Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") cycles, err := store.DetectCycles(ctx) if err != nil { t.Fatalf("DetectCycles failed: %v", err) } if len(cycles) != 0 { t.Errorf("Expected no cycles, but found %d", len(cycles)) } } func TestCrossTypeCyclePrevention(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues for cross-type cycle test issue1 := &types.Issue{Title: "Task A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Task B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") // Add: issue1 blocks issue2 err := store.AddDependency(ctx, &types.Dependency{ IssueID: issue1.ID, DependsOnID: issue2.ID, Type: types.DepBlocks, }, "test-user") if err != nil { t.Fatalf("First dependency (blocks) failed: %v", err) } // Try to add: issue2 parent-child issue1 (this would create a cross-type cycle) err = store.AddDependency(ctx, &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepParentChild, }, "test-user") if err == nil { t.Fatal("Expected error when creating cross-type cycle, but got none") } // Verify no cycles exist cycles, err := store.DetectCycles(ctx) if err != nil { t.Fatalf("DetectCycles failed: %v", err) } if len(cycles) != 0 { t.Errorf("Expected no cycles after prevention, but found %d", len(cycles)) } } func TestCrossTypeCyclePreventionDiscoveredFrom(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues issue1 := &types.Issue{Title: "Parent Task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Bug Found", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeBug} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") // Add: issue2 discovered-from issue1 err := store.AddDependency(ctx, &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepDiscoveredFrom, }, "test-user") if err != nil { t.Fatalf("First dependency (discovered-from) failed: %v", err) } // Try to add: issue1 blocks issue2 (this would create a cross-type cycle) err = store.AddDependency(ctx, &types.Dependency{ IssueID: issue1.ID, DependsOnID: issue2.ID, Type: types.DepBlocks, }, "test-user") if err == nil { t.Fatal("Expected error when creating cross-type cycle with discovered-from, but got none") } } func TestSelfDependencyPrevention(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() issue := &types.Issue{Title: "Task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue, "test-user") // Try to create self-dependency (issue depends on itself) err := store.AddDependency(ctx, &types.Dependency{ IssueID: issue.ID, DependsOnID: issue.ID, Type: types.DepBlocks, }, "test-user") if err == nil { t.Fatal("Expected error when creating self-dependency, but got none") } if !strings.Contains(err.Error(), "cannot depend on itself") { t.Errorf("Expected self-dependency error message, got: %v", err) } } func TestRelatedTypeCyclePrevention(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() issue1 := &types.Issue{Title: "Task A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Task B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") // Add: issue1 related issue2 err := store.AddDependency(ctx, &types.Dependency{ IssueID: issue1.ID, DependsOnID: issue2.ID, Type: types.DepRelated, }, "test-user") if err != nil { t.Fatalf("First dependency (related) failed: %v", err) } // Try to add: issue2 related issue1 (this creates a 2-node cycle with related type) err = store.AddDependency(ctx, &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepRelated, }, "test-user") if err == nil { t.Fatal("Expected error when creating related-type cycle, but got none") } } func TestMixedTypeRelatedCyclePrevention(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() issue1 := &types.Issue{Title: "Task A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Task B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") // Add: issue1 blocks issue2 err := store.AddDependency(ctx, &types.Dependency{ IssueID: issue1.ID, DependsOnID: issue2.ID, Type: types.DepBlocks, }, "test-user") if err != nil { t.Fatalf("First dependency (blocks) failed: %v", err) } // Try to add: issue2 related issue1 (this creates a cross-type cycle) err = store.AddDependency(ctx, &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepRelated, }, "test-user") if err == nil { t.Fatal("Expected error when creating blocks+related cycle, but got none") } } func TestCrossTypeCyclePreventionThreeIssues(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues for 3-node cross-type cycle test issue1 := &types.Issue{Title: "Task A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Task B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue3 := &types.Issue{Title: "Task C", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") store.CreateIssue(ctx, issue3, "test-user") // Add: issue1 blocks issue2 err := store.AddDependency(ctx, &types.Dependency{ IssueID: issue1.ID, DependsOnID: issue2.ID, Type: types.DepBlocks, }, "test-user") if err != nil { t.Fatalf("First dependency failed: %v", err) } // Add: issue2 parent-child issue3 err = store.AddDependency(ctx, &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue3.ID, Type: types.DepParentChild, }, "test-user") if err != nil { t.Fatalf("Second dependency failed: %v", err) } // Try to add: issue3 discovered-from issue1 (this would create a 3-node cross-type cycle) err = store.AddDependency(ctx, &types.Dependency{ IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepDiscoveredFrom, }, "test-user") if err == nil { t.Fatal("Expected error when creating 3-node cross-type cycle, but got none") } // Verify no cycles exist cycles, err := store.DetectCycles(ctx) if err != nil { t.Fatalf("DetectCycles failed: %v", err) } if len(cycles) != 0 { t.Errorf("Expected no cycles after prevention, but found %d", len(cycles)) } } func TestGetDependencyTree_Reverse(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create a dependency chain: issue1 <- issue2 <- issue3 // (issue3 depends on issue2, issue2 depends on issue1) issue1 := &types.Issue{ Title: "Base issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } issue2 := &types.Issue{ Title: "Depends on issue1", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } issue3 := &types.Issue{ Title: "Depends on issue2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } store.CreateIssue(ctx, issue1, "test") store.CreateIssue(ctx, issue2, "test") store.CreateIssue(ctx, issue3, "test") // Create dependencies: issue3 → issue2 → issue1 dep1 := &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks} dep2 := &types.Dependency{IssueID: issue3.ID, DependsOnID: issue2.ID, Type: types.DepBlocks} store.AddDependency(ctx, dep1, "test") store.AddDependency(ctx, dep2, "test") // Test normal mode: from issue3, should traverse UP to issue1 normalTree, err := store.GetDependencyTree(ctx, issue3.ID, 10, false, false) if err != nil { t.Fatalf("GetDependencyTree normal mode failed: %v", err) } if len(normalTree) != 3 { t.Fatalf("Expected 3 nodes in normal tree, got %d", len(normalTree)) } // Test reverse mode: from issue1, should traverse DOWN to issue3 reverseTree, err := store.GetDependencyTree(ctx, issue1.ID, 10, false, true) if err != nil { t.Fatalf("GetDependencyTree reverse mode failed: %v", err) } if len(reverseTree) != 3 { t.Fatalf("Expected 3 nodes in reverse tree, got %d", len(reverseTree)) } // Verify reverse tree structure: issue1 at depth 0 depthMap := make(map[string]int) for _, node := range reverseTree { depthMap[node.ID] = node.Depth } if depthMap[issue1.ID] != 0 { t.Errorf("Expected depth 0 for %s in reverse tree, got %d", issue1.ID, depthMap[issue1.ID]) } // issue2 should be at depth 1 (depends on issue1) if depthMap[issue2.ID] != 1 { t.Errorf("Expected depth 1 for %s in reverse tree, got %d", issue2.ID, depthMap[issue2.ID]) } // issue3 should be at depth 2 (depends on issue2) if depthMap[issue3.ID] != 2 { t.Errorf("Expected depth 2 for %s in reverse tree, got %d", issue3.ID, depthMap[issue3.ID]) } } func TestGetDependencyTree_SubstringBug(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create 10 issues so we have both bd-1 and bd-10 (substring issue) // The bug: when traversing from bd-10, bd-1 gets incorrectly excluded // because "bd-10" contains "bd-1" as a substring issues := make([]*types.Issue, 10) for i := 0; i < 10; i++ { issues[i] = &types.Issue{ Title: fmt.Sprintf("Issue %d", i+1), Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } err := store.CreateIssue(ctx, issues[i], "test-user") if err != nil { t.Fatalf("CreateIssue failed: %v", err) } } // Create chain: bd-10 → bd-9 → bd-8 → bd-2 → bd-1 // This tests the substring bug where bd-1 should appear but won't due to substring matching err := store.AddDependency(ctx, &types.Dependency{ IssueID: issues[9].ID, // bd-10 DependsOnID: issues[8].ID, // bd-9 Type: types.DepBlocks, }, "test-user") if err != nil { t.Fatalf("AddDependency bd-10→bd-9 failed: %v", err) } err = store.AddDependency(ctx, &types.Dependency{ IssueID: issues[8].ID, // bd-9 DependsOnID: issues[7].ID, // bd-8 Type: types.DepBlocks, }, "test-user") if err != nil { t.Fatalf("AddDependency bd-9→bd-8 failed: %v", err) } err = store.AddDependency(ctx, &types.Dependency{ IssueID: issues[7].ID, // bd-8 DependsOnID: issues[1].ID, // bd-2 Type: types.DepBlocks, }, "test-user") if err != nil { t.Fatalf("AddDependency bd-8→bd-2 failed: %v", err) } err = store.AddDependency(ctx, &types.Dependency{ IssueID: issues[1].ID, // bd-2 DependsOnID: issues[0].ID, // bd-1 Type: types.DepBlocks, }, "test-user") if err != nil { t.Fatalf("AddDependency bd-2→bd-1 failed: %v", err) } // Get tree starting from bd-10 tree, err := store.GetDependencyTree(ctx, issues[9].ID, 10, false, false) if err != nil { t.Fatalf("GetDependencyTree failed: %v", err) } // Create map of issue IDs in tree for easy checking treeIDs := make(map[string]bool) for _, node := range tree { treeIDs[node.ID] = true } // Verify all issues in the chain appear in the tree // This is the KEY test: bd-1 should be in the tree // With the substring bug, bd-1 will be missing because "bd-10" contains "bd-1" expectedIssues := []int{9, 8, 7, 1, 0} // bd-10, bd-9, bd-8, bd-2, bd-1 for _, idx := range expectedIssues { if !treeIDs[issues[idx].ID] { t.Errorf("Expected %s in dependency tree, but it was missing (substring bug)", issues[idx].ID) } } // Verify we have the correct number of nodes if len(tree) != 5 { t.Errorf("Expected 5 nodes in tree, got %d. Missing nodes indicate substring bug.", len(tree)) } // Verify depths are correct depthMap := make(map[string]int) for _, node := range tree { depthMap[node.ID] = node.Depth } // Check depths: bd-10(0) → bd-9(1) → bd-8(2) → bd-2(3) → bd-1(4) if depthMap[issues[9].ID] != 0 { t.Errorf("Expected bd-10 at depth 0, got %d", depthMap[issues[9].ID]) } if depthMap[issues[0].ID] != 4 { t.Errorf("Expected bd-1 at depth 4, got %d", depthMap[issues[0].ID]) } } func TestGetDependencyCounts(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create a network of issues with dependencies // A (depends on B, C) // B (depends on C) // C (no dependencies) // D (depends on A) // E (no dependencies, no dependents) issueA := &types.Issue{Title: "Task A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issueB := &types.Issue{Title: "Task B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issueC := &types.Issue{Title: "Task C", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issueD := &types.Issue{Title: "Task D", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issueE := &types.Issue{Title: "Task E", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issueA, "test-user") store.CreateIssue(ctx, issueB, "test-user") store.CreateIssue(ctx, issueC, "test-user") store.CreateIssue(ctx, issueD, "test-user") store.CreateIssue(ctx, issueE, "test-user") // Add dependencies store.AddDependency(ctx, &types.Dependency{IssueID: issueA.ID, DependsOnID: issueB.ID, Type: types.DepBlocks}, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: issueA.ID, DependsOnID: issueC.ID, Type: types.DepBlocks}, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: issueB.ID, DependsOnID: issueC.ID, Type: types.DepBlocks}, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: issueD.ID, DependsOnID: issueA.ID, Type: types.DepBlocks}, "test-user") // Get counts for all issues issueIDs := []string{issueA.ID, issueB.ID, issueC.ID, issueD.ID, issueE.ID} counts, err := store.GetDependencyCounts(ctx, issueIDs) if err != nil { t.Fatalf("GetDependencyCounts failed: %v", err) } // Verify counts testCases := []struct { issueID string name string expectedDeps int expectedDepents int }{ {issueA.ID, "A", 2, 1}, // depends on B and C, D depends on A {issueB.ID, "B", 1, 1}, // depends on C, A depends on B {issueC.ID, "C", 0, 2}, // no dependencies, A and B depend on C {issueD.ID, "D", 1, 0}, // depends on A, nothing depends on D {issueE.ID, "E", 0, 0}, // isolated issue } for _, tc := range testCases { count := counts[tc.issueID] if count == nil { t.Errorf("Issue %s (%s): no counts returned", tc.name, tc.issueID) continue } if count.DependencyCount != tc.expectedDeps { t.Errorf("Issue %s (%s): expected %d dependencies, got %d", tc.name, tc.issueID, tc.expectedDeps, count.DependencyCount) } if count.DependentCount != tc.expectedDepents { t.Errorf("Issue %s (%s): expected %d dependents, got %d", tc.name, tc.issueID, tc.expectedDepents, count.DependentCount) } } } func TestGetDependencyCountsEmpty(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Test with empty list counts, err := store.GetDependencyCounts(ctx, []string{}) if err != nil { t.Fatalf("GetDependencyCounts failed on empty list: %v", err) } if len(counts) != 0 { t.Errorf("Expected empty map for empty input, got %d entries", len(counts)) } } func TestGetDependencyCountsNonexistent(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Test with non-existent issue IDs counts, err := store.GetDependencyCounts(ctx, []string{"fake-1", "fake-2"}) if err != nil { t.Fatalf("GetDependencyCounts failed on nonexistent IDs: %v", err) } // Should return zero counts for non-existent issues for id, count := range counts { if count.DependencyCount != 0 || count.DependentCount != 0 { t.Errorf("Expected zero counts for nonexistent issue %s, got deps=%d, dependents=%d", id, count.DependencyCount, count.DependentCount) } } } func TestGetDependenciesWithMetadata(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Feature A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue3 := &types.Issue{Title: "Feature B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") store.CreateIssue(ctx, issue3, "test-user") // Add dependencies with different types // issue2 depends on issue1 (blocks) store.AddDependency(ctx, &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks, }, "test-user") // issue3 depends on issue1 (discovered-from) store.AddDependency(ctx, &types.Dependency{ IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepDiscoveredFrom, }, "test-user") // Get dependencies with metadata for issue2 deps, err := store.GetDependenciesWithMetadata(ctx, issue2.ID) if err != nil { t.Fatalf("GetDependenciesWithMetadata failed: %v", err) } if len(deps) != 1 { t.Fatalf("Expected 1 dependency, got %d", len(deps)) } // Verify the dependency includes type metadata dep := deps[0] if dep.ID != issue1.ID { t.Errorf("Expected dependency on %s, got %s", issue1.ID, dep.ID) } if dep.DependencyType != types.DepBlocks { t.Errorf("Expected dependency type 'blocks', got %s", dep.DependencyType) } if dep.Title != "Foundation" { t.Errorf("Expected title 'Foundation', got %s", dep.Title) } // Get dependencies with metadata for issue3 deps3, err := store.GetDependenciesWithMetadata(ctx, issue3.ID) if err != nil { t.Fatalf("GetDependenciesWithMetadata failed: %v", err) } if len(deps3) != 1 { t.Fatalf("Expected 1 dependency, got %d", len(deps3)) } // Verify the dependency type is discovered-from if deps3[0].DependencyType != types.DepDiscoveredFrom { t.Errorf("Expected dependency type 'discovered-from', got %s", deps3[0].DependencyType) } } func TestGetDependentsWithMetadata(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues: issue2 and issue3 both depend on issue1 issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Feature A", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} issue3 := &types.Issue{Title: "Feature B", Status: types.StatusOpen, Priority: 3, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") store.CreateIssue(ctx, issue3, "test-user") // Add dependencies with different types store.AddDependency(ctx, &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks, }, "test-user") store.AddDependency(ctx, &types.Dependency{ IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepRelated, }, "test-user") // Get dependents of issue1 with metadata dependents, err := store.GetDependentsWithMetadata(ctx, issue1.ID) if err != nil { t.Fatalf("GetDependentsWithMetadata failed: %v", err) } if len(dependents) != 2 { t.Fatalf("Expected 2 dependents, got %d", len(dependents)) } // Verify dependents are ordered by priority (issue2=P1 before issue3=P2) if dependents[0].ID != issue2.ID { t.Errorf("Expected first dependent to be %s, got %s", issue2.ID, dependents[0].ID) } if dependents[0].DependencyType != types.DepBlocks { t.Errorf("Expected first dependent type 'blocks', got %s", dependents[0].DependencyType) } if dependents[1].ID != issue3.ID { t.Errorf("Expected second dependent to be %s, got %s", issue3.ID, dependents[1].ID) } if dependents[1].DependencyType != types.DepRelated { t.Errorf("Expected second dependent type 'related', got %s", dependents[1].DependencyType) } } func TestGetDependenciesWithMetadataEmpty(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issue with no dependencies issue := &types.Issue{Title: "Standalone", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue, "test-user") // Get dependencies with metadata deps, err := store.GetDependenciesWithMetadata(ctx, issue.ID) if err != nil { t.Fatalf("GetDependenciesWithMetadata failed: %v", err) } if len(deps) != 0 { t.Errorf("Expected 0 dependencies, got %d", len(deps)) } } func TestGetDependenciesWithMetadataMultipleTypes(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues base := &types.Issue{Title: "Base", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} blocks := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} related := &types.Issue{Title: "Related", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} discovered := &types.Issue{Title: "Discovered", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, base, "test-user") store.CreateIssue(ctx, blocks, "test-user") store.CreateIssue(ctx, related, "test-user") store.CreateIssue(ctx, discovered, "test-user") // Add dependencies of different types store.AddDependency(ctx, &types.Dependency{ IssueID: base.ID, DependsOnID: blocks.ID, Type: types.DepBlocks, }, "test-user") store.AddDependency(ctx, &types.Dependency{ IssueID: base.ID, DependsOnID: related.ID, Type: types.DepRelated, }, "test-user") store.AddDependency(ctx, &types.Dependency{ IssueID: base.ID, DependsOnID: discovered.ID, Type: types.DepDiscoveredFrom, }, "test-user") // Get all dependencies with metadata deps, err := store.GetDependenciesWithMetadata(ctx, base.ID) if err != nil { t.Fatalf("GetDependenciesWithMetadata failed: %v", err) } if len(deps) != 3 { t.Fatalf("Expected 3 dependencies, got %d", len(deps)) } // Create a map of dependency types typeMap := make(map[string]types.DependencyType) for _, dep := range deps { typeMap[dep.ID] = dep.DependencyType } // Verify all types are correctly returned if typeMap[blocks.ID] != types.DepBlocks { t.Errorf("Expected blocks dependency type 'blocks', got %s", typeMap[blocks.ID]) } if typeMap[related.ID] != types.DepRelated { t.Errorf("Expected related dependency type 'related', got %s", typeMap[related.ID]) } if typeMap[discovered.ID] != types.DepDiscoveredFrom { t.Errorf("Expected discovered dependency type 'discovered-from', got %s", typeMap[discovered.ID]) } }