Files
beads/internal/storage/sqlite/ready_test.go
Steve Yegge f6a75415a5 fix: Propagate blocking through parent-child hierarchy [fixes #19]
When an epic is blocked, all its children should also be considered
blocked in the ready work calculation. Previously, only direct blocking
dependencies were checked, allowing children of blocked epics to appear
as ready work.

Implementation:
- Use recursive CTE to propagate blocking from parents to descendants
- Only 'parent-child' dependencies propagate blocking (not 'related')
- Changed NOT IN to NOT EXISTS for better NULL safety and performance
- Added depth limit of 50 to prevent pathological cases

Test coverage:
- TestParentBlockerBlocksChildren: Basic parent→child propagation
- TestGrandparentBlockerBlocksGrandchildren: Multi-level depth
- TestMultipleParentsOneBlocked: Child blocked if ANY parent blocked
- TestBlockerClosedUnblocksChildren: Dynamic unblocking works
- TestRelatedDoesNotPropagate: Only parent-child propagates

Closes: https://github.com/steveyegge/beads/issues/19
Resolves: bd-58

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 13:02:12 -07:00

577 lines
20 KiB
Go

package sqlite
import (
"context"
"testing"
"github.com/steveyegge/beads/internal/types"
)
func TestGetReadyWork(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues:
// bd-1: open, no dependencies → READY
// bd-2: open, depends on bd-1 (open) → BLOCKED
// bd-3: open, no dependencies → READY
// bd-4: closed, no dependencies → NOT READY (closed)
// bd-5: open, depends on bd-4 (closed) → READY (blocker is closed)
issue1 := &types.Issue{Title: "Ready 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issue2 := &types.Issue{Title: "Blocked", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issue3 := &types.Issue{Title: "Ready 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issue4 := &types.Issue{Title: "Closed", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeTask}
issue5 := &types.Issue{Title: "Ready 3", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
store.CreateIssue(ctx, issue1, "test-user")
store.CreateIssue(ctx, issue2, "test-user")
store.CreateIssue(ctx, issue3, "test-user")
store.CreateIssue(ctx, issue4, "test-user")
store.CloseIssue(ctx, issue4.ID, "Done", "test-user")
store.CreateIssue(ctx, issue5, "test-user")
// Add dependencies
store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user")
store.AddDependency(ctx, &types.Dependency{IssueID: issue5.ID, DependsOnID: issue4.ID, Type: types.DepBlocks}, "test-user")
// Get ready work
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
// Should have 3 ready issues: bd-1, bd-3, bd-5
if len(ready) != 3 {
t.Fatalf("Expected 3 ready issues, got %d", len(ready))
}
// Verify ready issues
readyIDs := make(map[string]bool)
for _, issue := range ready {
readyIDs[issue.ID] = true
}
if !readyIDs[issue1.ID] {
t.Errorf("Expected %s to be ready", issue1.ID)
}
if !readyIDs[issue3.ID] {
t.Errorf("Expected %s to be ready", issue3.ID)
}
if !readyIDs[issue5.ID] {
t.Errorf("Expected %s to be ready", issue5.ID)
}
if readyIDs[issue2.ID] {
t.Errorf("Expected %s to be blocked, but it was ready", issue2.ID)
}
}
func TestGetReadyWorkPriorityOrder(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues with different priorities
issueP0 := &types.Issue{Title: "Highest", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
issueP2 := &types.Issue{Title: "Medium", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issueP1 := &types.Issue{Title: "High", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, issueP2, "test-user")
store.CreateIssue(ctx, issueP0, "test-user")
store.CreateIssue(ctx, issueP1, "test-user")
// Get ready work
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 3 {
t.Fatalf("Expected 3 ready issues, got %d", len(ready))
}
// Verify priority ordering (P0 first, then P1, then P2)
if ready[0].Priority != 0 {
t.Errorf("Expected first issue to be P0, got P%d", ready[0].Priority)
}
if ready[1].Priority != 1 {
t.Errorf("Expected second issue to be P1, got P%d", ready[1].Priority)
}
if ready[2].Priority != 2 {
t.Errorf("Expected third issue to be P2, got P%d", ready[2].Priority)
}
}
func TestGetReadyWorkWithPriorityFilter(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues with different priorities
issueP0 := &types.Issue{Title: "P0", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
issueP1 := &types.Issue{Title: "P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issueP2 := &types.Issue{Title: "P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
store.CreateIssue(ctx, issueP0, "test-user")
store.CreateIssue(ctx, issueP1, "test-user")
store.CreateIssue(ctx, issueP2, "test-user")
// Filter for P0 only
priority0 := 0
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Priority: &priority0})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 1 {
t.Fatalf("Expected 1 P0 issue, got %d", len(ready))
}
if ready[0].Priority != 0 {
t.Errorf("Expected P0 issue, got P%d", ready[0].Priority)
}
}
func TestGetReadyWorkWithAssigneeFilter(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues with different assignees
issueAlice := &types.Issue{Title: "Alice's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "alice"}
issueBob := &types.Issue{Title: "Bob's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "bob"}
issueUnassigned := &types.Issue{Title: "Unassigned", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, issueAlice, "test-user")
store.CreateIssue(ctx, issueBob, "test-user")
store.CreateIssue(ctx, issueUnassigned, "test-user")
// Filter for alice
assignee := "alice"
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Assignee: &assignee})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 1 {
t.Fatalf("Expected 1 issue for alice, got %d", len(ready))
}
if ready[0].Assignee != "alice" {
t.Errorf("Expected alice's issue, got %s", ready[0].Assignee)
}
}
func TestGetReadyWorkWithLimit(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create 5 ready issues
for i := 0; i < 5; i++ {
issue := &types.Issue{Title: "Task", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
store.CreateIssue(ctx, issue, "test-user")
}
// Limit to 3
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Limit: 3})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 3 {
t.Errorf("Expected 3 issues (limit), got %d", len(ready))
}
}
func TestGetReadyWorkIgnoresRelatedDeps(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create two issues with "related" dependency (should not block)
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")
// Add "related" dependency (not blocking)
store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepRelated}, "test-user")
// Both should be ready (related deps don't block)
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 2 {
t.Fatalf("Expected 2 ready issues (related deps don't block), got %d", len(ready))
}
}
func TestGetBlockedIssues(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues:
// bd-1: open, no dependencies → not blocked
// bd-2: open, depends on bd-1 (open) → blocked by bd-1
// bd-3: open, depends on bd-1 and bd-2 (both open) → blocked by 2 issues
issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issue2 := &types.Issue{Title: "Blocked by 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issue3 := &types.Issue{Title: "Blocked by 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: issue1.ID, Type: types.DepBlocks}, "test-user")
store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user")
// Get blocked issues
blocked, err := store.GetBlockedIssues(ctx)
if err != nil {
t.Fatalf("GetBlockedIssues failed: %v", err)
}
if len(blocked) != 2 {
t.Fatalf("Expected 2 blocked issues, got %d", len(blocked))
}
// Find issue3 in blocked list
var issue3Blocked *types.BlockedIssue
for i := range blocked {
if blocked[i].ID == issue3.ID {
issue3Blocked = blocked[i]
break
}
}
if issue3Blocked == nil {
t.Fatal("Expected issue3 to be in blocked list")
}
if issue3Blocked.BlockedByCount != 2 {
t.Errorf("Expected issue3 to be blocked by 2 issues, got %d", issue3Blocked.BlockedByCount)
}
// Verify the blockers are correct
if len(issue3Blocked.BlockedBy) != 2 {
t.Errorf("Expected 2 blocker IDs, got %d", len(issue3Blocked.BlockedBy))
}
}
// TestParentBlockerBlocksChildren tests that children inherit blockage from parents
func TestParentBlockerBlocksChildren(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create:
// blocker: open
// epic1: open, blocked by 'blocker'
// task1: open, child of epic1 (via parent-child)
//
// Expected: task1 should NOT be ready (parent is blocked)
blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
epic1 := &types.Issue{Title: "Epic 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic}
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, blocker, "test-user")
store.CreateIssue(ctx, epic1, "test-user")
store.CreateIssue(ctx, task1, "test-user")
// epic1 blocked by blocker
store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user")
// task1 is child of epic1
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic1.ID, Type: types.DepParentChild}, "test-user")
// Get ready work
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
// Should have only blocker ready
readyIDs := make(map[string]bool)
for _, issue := range ready {
readyIDs[issue.ID] = true
}
if readyIDs[epic1.ID] {
t.Errorf("Expected epic1 to be blocked, but it was ready")
}
if readyIDs[task1.ID] {
t.Errorf("Expected task1 to be blocked (parent is blocked), but it was ready")
}
if !readyIDs[blocker.ID] {
t.Errorf("Expected blocker to be ready")
}
}
// TestGrandparentBlockerBlocksGrandchildren tests multi-level propagation
func TestGrandparentBlockerBlocksGrandchildren(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create:
// blocker: open
// epic1: open, blocked by 'blocker'
// epic2: open, child of epic1
// task1: open, child of epic2
//
// Expected: task1 should NOT be ready (grandparent is blocked)
blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
epic1 := &types.Issue{Title: "Epic 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic}
epic2 := &types.Issue{Title: "Epic 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic}
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, blocker, "test-user")
store.CreateIssue(ctx, epic1, "test-user")
store.CreateIssue(ctx, epic2, "test-user")
store.CreateIssue(ctx, task1, "test-user")
// epic1 blocked by blocker
store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user")
// epic2 is child of epic1
store.AddDependency(ctx, &types.Dependency{IssueID: epic2.ID, DependsOnID: epic1.ID, Type: types.DepParentChild}, "test-user")
// task1 is child of epic2
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic2.ID, Type: types.DepParentChild}, "test-user")
// Get ready work
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
// Should have only blocker ready
readyIDs := make(map[string]bool)
for _, issue := range ready {
readyIDs[issue.ID] = true
}
if readyIDs[epic1.ID] {
t.Errorf("Expected epic1 to be blocked, but it was ready")
}
if readyIDs[epic2.ID] {
t.Errorf("Expected epic2 to be blocked (parent is blocked), but it was ready")
}
if readyIDs[task1.ID] {
t.Errorf("Expected task1 to be blocked (grandparent is blocked), but it was ready")
}
if !readyIDs[blocker.ID] {
t.Errorf("Expected blocker to be ready")
}
}
// TestMultipleParentsOneBlocked tests that a child is blocked if ANY parent is blocked
func TestMultipleParentsOneBlocked(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create:
// blocker: open
// epic1: open, blocked by 'blocker'
// epic2: open, no blockers
// task1: open, child of BOTH epic1 and epic2
//
// Expected: task1 should NOT be ready (one parent is blocked)
blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
epic1 := &types.Issue{Title: "Epic 1 (blocked)", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic}
epic2 := &types.Issue{Title: "Epic 2 (ready)", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic}
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, blocker, "test-user")
store.CreateIssue(ctx, epic1, "test-user")
store.CreateIssue(ctx, epic2, "test-user")
store.CreateIssue(ctx, task1, "test-user")
// epic1 blocked by blocker
store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user")
// task1 is child of both epic1 and epic2
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic1.ID, Type: types.DepParentChild}, "test-user")
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic2.ID, Type: types.DepParentChild}, "test-user")
// Get ready work
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
// Should have blocker and epic2 ready, but NOT epic1 or task1
readyIDs := make(map[string]bool)
for _, issue := range ready {
readyIDs[issue.ID] = true
}
if readyIDs[epic1.ID] {
t.Errorf("Expected epic1 to be blocked, but it was ready")
}
if readyIDs[task1.ID] {
t.Errorf("Expected task1 to be blocked (one parent is blocked), but it was ready")
}
if !readyIDs[blocker.ID] {
t.Errorf("Expected blocker to be ready")
}
if !readyIDs[epic2.ID] {
t.Errorf("Expected epic2 to be ready")
}
}
// TestBlockerClosedUnblocksChildren tests that closing a blocker unblocks descendants
func TestBlockerClosedUnblocksChildren(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create:
// blocker: initially open, then closed
// epic1: open, blocked by 'blocker'
// task1: open, child of epic1
//
// After closing blocker: both epic1 and task1 should be ready
blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
epic1 := &types.Issue{Title: "Epic 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic}
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, blocker, "test-user")
store.CreateIssue(ctx, epic1, "test-user")
store.CreateIssue(ctx, task1, "test-user")
// epic1 blocked by blocker
store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user")
// task1 is child of epic1
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic1.ID, Type: types.DepParentChild}, "test-user")
// Initially, epic1 and task1 should be blocked
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
readyIDs := make(map[string]bool)
for _, issue := range ready {
readyIDs[issue.ID] = true
}
if readyIDs[epic1.ID] || readyIDs[task1.ID] {
t.Errorf("Expected epic1 and task1 to be blocked initially")
}
// Close the blocker
store.CloseIssue(ctx, blocker.ID, "Done", "test-user")
// Now epic1 and task1 should be ready
ready, err = store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
if err != nil {
t.Fatalf("GetReadyWork failed after closing blocker: %v", err)
}
readyIDs = make(map[string]bool)
for _, issue := range ready {
readyIDs[issue.ID] = true
}
if !readyIDs[epic1.ID] {
t.Errorf("Expected epic1 to be ready after blocker closed")
}
if !readyIDs[task1.ID] {
t.Errorf("Expected task1 to be ready after blocker closed")
}
}
// TestRelatedDoesNotPropagate tests that 'related' deps don't cause blocking propagation
func TestRelatedDoesNotPropagate(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create:
// blocker: open
// epic1: open, blocked by 'blocker'
// task1: open, related to epic1 (NOT parent-child)
//
// Expected: task1 SHOULD be ready (related doesn't propagate blocking)
blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
epic1 := &types.Issue{Title: "Epic 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic}
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, blocker, "test-user")
store.CreateIssue(ctx, epic1, "test-user")
store.CreateIssue(ctx, task1, "test-user")
// epic1 blocked by blocker
store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user")
// task1 is related to epic1 (NOT parent-child)
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic1.ID, Type: types.DepRelated}, "test-user")
// Get ready work
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
// Should have blocker AND task1 ready (related doesn't propagate)
readyIDs := make(map[string]bool)
for _, issue := range ready {
readyIDs[issue.ID] = true
}
if readyIDs[epic1.ID] {
t.Errorf("Expected epic1 to be blocked, but it was ready")
}
if !readyIDs[task1.ID] {
t.Errorf("Expected task1 to be ready (related deps don't propagate blocking), but it was blocked")
}
if !readyIDs[blocker.ID] {
t.Errorf("Expected blocker to be ready")
}
}
// TestCompositeIndexExists verifies the composite index is created
func TestCompositeIndexExists(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Query sqlite_master to check if the index exists
var indexName string
err := store.db.QueryRowContext(ctx, `
SELECT name FROM sqlite_master
WHERE type='index' AND name='idx_dependencies_depends_on_type'
`).Scan(&indexName)
if err != nil {
t.Fatalf("Composite index idx_dependencies_depends_on_type not found: %v", err)
}
if indexName != "idx_dependencies_depends_on_type" {
t.Errorf("Expected index name 'idx_dependencies_depends_on_type', got '%s'", indexName)
}
}