refactor: add testEnv helpers, reduce SQLite test file sizes (bd-4opy)

- Add testEnv struct with common test helper methods to test_helpers.go
- Methods include CreateIssue, CreateEpic, AddDep, AddParentChild, Close,
  GetReadyWork, AssertReady, AssertBlocked
- Migrate 12+ tests in ready_test.go to use new helpers (-252 lines)
- Migrate 3 tests in dependencies_test.go to use new helpers (-34 lines)
- Total reduction: ~286 lines from test files

The testEnv pattern makes tests more readable and maintainable by:
- Eliminating boilerplate setup (store, cleanup, ctx)
- Providing semantic helper methods (AddDep vs manual AddDependency)
- Using t.Cleanup for automatic resource management

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 00:24:07 -08:00
parent 59b57f8d06
commit cfe823da61
3 changed files with 270 additions and 412 deletions

View File

@@ -114,37 +114,23 @@ func TestParentChildValidation(t *testing.T) {
} }
func TestRemoveDependency(t *testing.T) { func TestRemoveDependency(t *testing.T) {
store, cleanup := setupTestDB(t) env := newTestEnv(t)
defer cleanup()
ctx := context.Background() issue1 := env.CreateIssue("First")
issue2 := env.CreateIssue("Second")
// Create and link issues env.AddDep(issue2, issue1)
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 // Remove the dependency
err := store.RemoveDependency(ctx, issue2.ID, issue1.ID, "test-user") err := env.Store.RemoveDependency(env.Ctx, issue2.ID, issue1.ID, "test-user")
if err != nil { if err != nil {
t.Fatalf("RemoveDependency failed: %v", err) t.Fatalf("RemoveDependency failed: %v", err)
} }
// Verify dependency was removed // Verify dependency was removed
deps, err := store.GetDependencies(ctx, issue2.ID) deps, err := env.Store.GetDependencies(env.Ctx, issue2.ID)
if err != nil { if err != nil {
t.Fatalf("GetDependencies failed: %v", err) t.Fatalf("GetDependencies failed: %v", err)
} }
if len(deps) != 0 { if len(deps) != 0 {
t.Errorf("Expected 0 dependencies after removal, got %d", len(deps)) t.Errorf("Expected 0 dependencies after removal, got %d", len(deps))
} }
@@ -192,29 +178,21 @@ func TestAddDependencyPreservesProvidedMetadata(t *testing.T) {
} }
func TestGetDependents(t *testing.T) { func TestGetDependents(t *testing.T) {
store, cleanup := setupTestDB(t) env := newTestEnv(t)
defer cleanup()
ctx := context.Background() // Create issues: issue2 and issue3 both depend on issue1
issue1 := env.CreateIssue("Foundation")
issue2 := env.CreateIssue("Feature A")
issue3 := env.CreateIssue("Feature B")
// Create issues: bd-2 and bd-3 both depend on bd-1 env.AddDep(issue2, issue1)
issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} env.AddDep(issue3, issue1)
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 // Get dependents of issue1
dependents, err := store.GetDependents(ctx, issue1.ID) dependents, err := env.Store.GetDependents(env.Ctx, issue1.ID)
if err != nil { if err != nil {
t.Fatalf("GetDependents failed: %v", err) t.Fatalf("GetDependents failed: %v", err)
} }
if len(dependents) != 2 { if len(dependents) != 2 {
t.Fatalf("Expected 2 dependents, got %d", len(dependents)) t.Fatalf("Expected 2 dependents, got %d", len(dependents))
} }
@@ -224,36 +202,27 @@ func TestGetDependents(t *testing.T) {
for _, dep := range dependents { for _, dep := range dependents {
foundIDs[dep.ID] = true foundIDs[dep.ID] = true
} }
if !foundIDs[issue2.ID] || !foundIDs[issue3.ID] { if !foundIDs[issue2.ID] || !foundIDs[issue3.ID] {
t.Errorf("Expected dependents %s and %s", issue2.ID, issue3.ID) t.Errorf("Expected dependents %s and %s", issue2.ID, issue3.ID)
} }
} }
func TestGetDependencyTree(t *testing.T) { func TestGetDependencyTree(t *testing.T) {
store, cleanup := setupTestDB(t) env := newTestEnv(t)
defer cleanup()
ctx := context.Background() // Create a chain: issue3 → issue2 → issue1
issue1 := env.CreateIssue("Level 0")
issue2 := env.CreateIssue("Level 1")
issue3 := env.CreateIssue("Level 2")
// Create a chain: bd-3 → bd-2 → bd-1 env.AddDep(issue2, issue1)
issue1 := &types.Issue{Title: "Level 0", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} env.AddDep(issue3, issue2)
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 // Get tree starting from issue3
tree, err := store.GetDependencyTree(ctx, issue3.ID, 10, false, false) tree, err := env.Store.GetDependencyTree(env.Ctx, issue3.ID, 10, false, false)
if err != nil { if err != nil {
t.Fatalf("GetDependencyTree failed: %v", err) t.Fatalf("GetDependencyTree failed: %v", err)
} }
if len(tree) != 3 { if len(tree) != 3 {
t.Fatalf("Expected 3 nodes in tree, got %d", len(tree)) t.Fatalf("Expected 3 nodes in tree, got %d", len(tree))
} }
@@ -263,15 +232,12 @@ func TestGetDependencyTree(t *testing.T) {
for _, node := range tree { for _, node := range tree {
depthMap[node.ID] = node.Depth depthMap[node.ID] = node.Depth
} }
if depthMap[issue3.ID] != 0 { if depthMap[issue3.ID] != 0 {
t.Errorf("Expected depth 0 for %s, got %d", issue3.ID, depthMap[issue3.ID]) t.Errorf("Expected depth 0 for %s, got %d", issue3.ID, depthMap[issue3.ID])
} }
if depthMap[issue2.ID] != 1 { if depthMap[issue2.ID] != 1 {
t.Errorf("Expected depth 1 for %s, got %d", issue2.ID, depthMap[issue2.ID]) t.Errorf("Expected depth 1 for %s, got %d", issue2.ID, depthMap[issue2.ID])
} }
if depthMap[issue1.ID] != 2 { if depthMap[issue1.ID] != 2 {
t.Errorf("Expected depth 2 for %s, got %d", issue1.ID, depthMap[issue1.ID]) t.Errorf("Expected depth 2 for %s, got %d", issue1.ID, depthMap[issue1.ID])
} }

View File

@@ -13,87 +13,45 @@ import (
) )
func TestGetReadyWork(t *testing.T) { func TestGetReadyWork(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues: // Create issues:
// bd-1: open, no dependencies → READY // issue1: open, no dependencies → READY
// bd-2: open, depends on bd-1 (open) → BLOCKED // issue2: open, depends on issue1 (open) → BLOCKED
// bd-3: open, no dependencies → READY // issue3: open, no dependencies → READY
// bd-4: closed, no dependencies → NOT READY (closed) // issue4: closed, no dependencies → NOT READY (closed)
// bd-5: open, depends on bd-4 (closed) → READY (blocker is closed) // issue5: open, depends on issue4 (closed) → READY (blocker is closed)
env := newTestEnv(t)
issue1 := &types.Issue{Title: "Ready 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue1 := env.CreateIssueWith("Ready 1", types.StatusOpen, 1, types.TypeTask)
issue2 := &types.Issue{Title: "Blocked", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := env.CreateIssueWith("Blocked", types.StatusOpen, 1, types.TypeTask)
issue3 := &types.Issue{Title: "Ready 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} issue3 := env.CreateIssueWith("Ready 2", types.StatusOpen, 2, types.TypeTask)
issue4 := &types.Issue{Title: "Closed", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeTask} issue4 := env.CreateIssueWith("Closed", types.StatusOpen, 1, types.TypeTask) // create as open first
issue5 := &types.Issue{Title: "Ready 3", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask} env.Close(issue4, "Done")
issue5 := env.CreateIssueWith("Ready 3", types.StatusOpen, 0, types.TypeTask)
store.CreateIssue(ctx, issue1, "test-user") env.AddDep(issue2, issue1) // issue2 depends on issue1
store.CreateIssue(ctx, issue2, "test-user") env.AddDep(issue5, issue4) // issue5 depends on issue4 (which is closed)
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 // Verify ready issues: issue1, issue3, issue5
store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen})
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 { if len(ready) != 3 {
t.Fatalf("Expected 3 ready issues, got %d", len(ready)) t.Fatalf("Expected 3 ready issues, got %d", len(ready))
} }
// Verify ready issues env.AssertReady(issue1)
readyIDs := make(map[string]bool) env.AssertReady(issue3)
for _, issue := range ready { env.AssertReady(issue5) // blocker (issue4) is closed
readyIDs[issue.ID] = true env.AssertBlocked(issue2)
}
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) { func TestGetReadyWorkPriorityOrder(t *testing.T) {
store, cleanup := setupTestDB(t) env := newTestEnv(t)
defer cleanup()
ctx := context.Background() // Create issues with different priorities (out of order)
env.CreateIssueWith("Medium", types.StatusOpen, 2, types.TypeTask)
// Create issues with different priorities env.CreateIssueWith("Highest", types.StatusOpen, 0, types.TypeTask)
issueP0 := &types.Issue{Title: "Highest", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask} env.CreateIssueWith("High", types.StatusOpen, 1, 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)
}
ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen})
if len(ready) != 3 { if len(ready) != 3 {
t.Fatalf("Expected 3 ready issues, got %d", len(ready)) t.Fatalf("Expected 3 ready issues, got %d", len(ready))
} }
@@ -111,146 +69,90 @@ func TestGetReadyWorkPriorityOrder(t *testing.T) {
} }
func TestGetReadyWorkWithPriorityFilter(t *testing.T) { func TestGetReadyWorkWithPriorityFilter(t *testing.T) {
store, cleanup := setupTestDB(t) env := newTestEnv(t)
defer cleanup()
ctx := context.Background()
// Create issues with different priorities // Create issues with different priorities
issueP0 := &types.Issue{Title: "P0", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask} env.CreateIssueWith("P0", types.StatusOpen, 0, types.TypeTask)
issueP1 := &types.Issue{Title: "P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} env.CreateIssueWith("P1", types.StatusOpen, 1, types.TypeTask)
issueP2 := &types.Issue{Title: "P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} env.CreateIssueWith("P2", types.StatusOpen, 2, types.TypeTask)
store.CreateIssue(ctx, issueP0, "test-user")
store.CreateIssue(ctx, issueP1, "test-user")
store.CreateIssue(ctx, issueP2, "test-user")
// Filter for P0 only // Filter for P0 only
priority0 := 0 priority0 := 0
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Priority: &priority0}) ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Priority: &priority0})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 1 { if len(ready) != 1 {
t.Fatalf("Expected 1 P0 issue, got %d", len(ready)) t.Fatalf("Expected 1 P0 issue, got %d", len(ready))
} }
if ready[0].Priority != 0 { if ready[0].Priority != 0 {
t.Errorf("Expected P0 issue, got P%d", ready[0].Priority) t.Errorf("Expected P0 issue, got P%d", ready[0].Priority)
} }
} }
func TestGetReadyWorkWithAssigneeFilter(t *testing.T) { func TestGetReadyWorkWithAssigneeFilter(t *testing.T) {
store, cleanup := setupTestDB(t) env := newTestEnv(t)
defer cleanup()
ctx := context.Background()
// Create issues with different assignees // Create issues with different assignees
issueAlice := &types.Issue{Title: "Alice's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "alice"} env.CreateIssueWithAssignee("Alice's task", "alice")
issueBob := &types.Issue{Title: "Bob's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "bob"} env.CreateIssueWithAssignee("Bob's task", "bob")
issueUnassigned := &types.Issue{Title: "Unassigned", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} env.CreateIssue("Unassigned")
store.CreateIssue(ctx, issueAlice, "test-user")
store.CreateIssue(ctx, issueBob, "test-user")
store.CreateIssue(ctx, issueUnassigned, "test-user")
// Filter for alice // Filter for alice
assignee := "alice" assignee := "alice"
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Assignee: &assignee}) ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Assignee: &assignee})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 1 { if len(ready) != 1 {
t.Fatalf("Expected 1 issue for alice, got %d", len(ready)) t.Fatalf("Expected 1 issue for alice, got %d", len(ready))
} }
if ready[0].Assignee != "alice" { if ready[0].Assignee != "alice" {
t.Errorf("Expected alice's issue, got %s", ready[0].Assignee) t.Errorf("Expected alice's issue, got %s", ready[0].Assignee)
} }
} }
func TestGetReadyWorkWithUnassignedFilter(t *testing.T) { func TestGetReadyWorkWithUnassignedFilter(t *testing.T) {
store, cleanup := setupTestDB(t) env := newTestEnv(t)
defer cleanup()
ctx := context.Background()
// Create issues with different assignees // Create issues with different assignees
issueAlice := &types.Issue{Title: "Alice's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "alice"} env.CreateIssueWithAssignee("Alice's task", "alice")
issueBob := &types.Issue{Title: "Bob's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "bob"} env.CreateIssueWithAssignee("Bob's task", "bob")
issueUnassigned := &types.Issue{Title: "Unassigned", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} unassigned := env.CreateIssue("Unassigned")
store.CreateIssue(ctx, issueAlice, "test-user")
store.CreateIssue(ctx, issueBob, "test-user")
store.CreateIssue(ctx, issueUnassigned, "test-user")
// Filter for unassigned issues // Filter for unassigned issues
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Unassigned: true}) ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Unassigned: true})
if err != nil {
t.Fatalf("GetReadyWork with unassigned filter failed: %v", err)
}
if len(ready) != 1 { if len(ready) != 1 {
t.Fatalf("Expected 1 unassigned issue, got %d", len(ready)) t.Fatalf("Expected 1 unassigned issue, got %d", len(ready))
} }
if ready[0].Assignee != "" { if ready[0].Assignee != "" {
t.Errorf("Expected unassigned issue, got assignee %q", ready[0].Assignee) t.Errorf("Expected unassigned issue, got assignee %q", ready[0].Assignee)
} }
if ready[0].ID != unassigned.ID {
if ready[0].ID != issueUnassigned.ID { t.Errorf("Expected issue %s, got %s", unassigned.ID, ready[0].ID)
t.Errorf("Expected issue %s, got %s", issueUnassigned.ID, ready[0].ID)
} }
} }
func TestGetReadyWorkWithLimit(t *testing.T) { func TestGetReadyWorkWithLimit(t *testing.T) {
store, cleanup := setupTestDB(t) env := newTestEnv(t)
defer cleanup()
ctx := context.Background()
// Create 5 ready issues // Create 5 ready issues
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
issue := &types.Issue{Title: "Task", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} env.CreateIssue("Task")
store.CreateIssue(ctx, issue, "test-user")
} }
// Limit to 3 // Limit to 3
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Limit: 3}) ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Limit: 3})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 3 { if len(ready) != 3 {
t.Errorf("Expected 3 issues (limit), got %d", len(ready)) t.Errorf("Expected 3 issues (limit), got %d", len(ready))
} }
} }
func TestGetReadyWorkIgnoresRelatedDeps(t *testing.T) { func TestGetReadyWorkIgnoresRelatedDeps(t *testing.T) {
store, cleanup := setupTestDB(t) env := newTestEnv(t)
defer cleanup()
ctx := context.Background()
// Create two issues with "related" dependency (should not block) // Create two issues with "related" dependency (should not block)
issue1 := &types.Issue{Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue1 := env.CreateIssue("First")
issue2 := &types.Issue{Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := env.CreateIssue("Second")
store.CreateIssue(ctx, issue1, "test-user") env.AddDepType(issue2, issue1, types.DepRelated)
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) // Both should be ready (related deps don't block)
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 2 { if len(ready) != 2 {
t.Fatalf("Expected 2 ready issues (related deps don't block), got %d", len(ready)) t.Fatalf("Expected 2 ready issues (related deps don't block), got %d", len(ready))
} }
@@ -314,61 +216,28 @@ func TestGetBlockedIssues(t *testing.T) {
// TestParentBlockerBlocksChildren tests that children inherit blockage from parents // TestParentBlockerBlocksChildren tests that children inherit blockage from parents
func TestParentBlockerBlocksChildren(t *testing.T) { func TestParentBlockerBlocksChildren(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create: // Create:
// blocker: open // blocker: open
// epic1: open, blocked by 'blocker' // epic1: open, blocked by 'blocker'
// task1: open, child of epic1 (via parent-child) // task1: open, child of epic1 (via parent-child)
// //
// Expected: task1 should NOT be ready (parent is blocked) // Expected: task1 should NOT be ready (parent is blocked)
env := newTestEnv(t)
blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} blocker := env.CreateIssue("Blocker")
epic1 := &types.Issue{Title: "Epic 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} epic1 := env.CreateEpic("Epic 1")
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} task1 := env.CreateIssue("Task 1")
store.CreateIssue(ctx, blocker, "test-user") env.AddDep(epic1, blocker) // epic1 blocked by blocker
store.CreateIssue(ctx, epic1, "test-user") env.AddParentChild(task1, epic1) // task1 is child of epic1
store.CreateIssue(ctx, task1, "test-user")
// epic1 blocked by blocker env.AssertBlocked(epic1)
store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user") env.AssertBlocked(task1)
// task1 is child of epic1 env.AssertReady(blocker)
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 // TestGrandparentBlockerBlocksGrandchildren tests multi-level propagation
func TestGrandparentBlockerBlocksGrandchildren(t *testing.T) { func TestGrandparentBlockerBlocksGrandchildren(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create: // Create:
// blocker: open // blocker: open
// epic1: open, blocked by 'blocker' // epic1: open, blocked by 'blocker'
@@ -376,57 +245,25 @@ func TestGrandparentBlockerBlocksGrandchildren(t *testing.T) {
// task1: open, child of epic2 // task1: open, child of epic2
// //
// Expected: task1 should NOT be ready (grandparent is blocked) // Expected: task1 should NOT be ready (grandparent is blocked)
env := newTestEnv(t)
blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} blocker := env.CreateIssue("Blocker")
epic1 := &types.Issue{Title: "Epic 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} epic1 := env.CreateEpic("Epic 1")
epic2 := &types.Issue{Title: "Epic 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} epic2 := env.CreateEpic("Epic 2")
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} task1 := env.CreateIssue("Task 1")
store.CreateIssue(ctx, blocker, "test-user") env.AddDep(epic1, blocker) // epic1 blocked by blocker
store.CreateIssue(ctx, epic1, "test-user") env.AddParentChild(epic2, epic1) // epic2 is child of epic1
store.CreateIssue(ctx, epic2, "test-user") env.AddParentChild(task1, epic2) // task1 is child of epic2
store.CreateIssue(ctx, task1, "test-user")
// epic1 blocked by blocker env.AssertBlocked(epic1)
store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user") env.AssertBlocked(epic2)
// epic2 is child of epic1 env.AssertBlocked(task1)
store.AddDependency(ctx, &types.Dependency{IssueID: epic2.ID, DependsOnID: epic1.ID, Type: types.DepParentChild}, "test-user") env.AssertReady(blocker)
// 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 // TestMultipleParentsOneBlocked tests that a child is blocked if ANY parent is blocked
func TestMultipleParentsOneBlocked(t *testing.T) { func TestMultipleParentsOneBlocked(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create: // Create:
// blocker: open // blocker: open
// epic1: open, blocked by 'blocker' // epic1: open, blocked by 'blocker'
@@ -434,161 +271,72 @@ func TestMultipleParentsOneBlocked(t *testing.T) {
// task1: open, child of BOTH epic1 and epic2 // task1: open, child of BOTH epic1 and epic2
// //
// Expected: task1 should NOT be ready (one parent is blocked) // Expected: task1 should NOT be ready (one parent is blocked)
env := newTestEnv(t)
blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} blocker := env.CreateIssue("Blocker")
epic1 := &types.Issue{Title: "Epic 1 (blocked)", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} epic1 := env.CreateEpic("Epic 1 (blocked)")
epic2 := &types.Issue{Title: "Epic 2 (ready)", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} epic2 := env.CreateEpic("Epic 2 (ready)")
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} task1 := env.CreateIssue("Task 1")
store.CreateIssue(ctx, blocker, "test-user") env.AddDep(epic1, blocker) // epic1 blocked by blocker
store.CreateIssue(ctx, epic1, "test-user") env.AddParentChild(task1, epic1) // task1 is child of both epic1 and epic2
store.CreateIssue(ctx, epic2, "test-user") env.AddParentChild(task1, epic2)
store.CreateIssue(ctx, task1, "test-user")
// epic1 blocked by blocker env.AssertBlocked(epic1)
store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user") env.AssertBlocked(task1) // blocked because one parent (epic1) is blocked
// task1 is child of both epic1 and epic2 env.AssertReady(blocker)
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic1.ID, Type: types.DepParentChild}, "test-user") env.AssertReady(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 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 // TestBlockerClosedUnblocksChildren tests that closing a blocker unblocks descendants
func TestBlockerClosedUnblocksChildren(t *testing.T) { func TestBlockerClosedUnblocksChildren(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create: // Create:
// blocker: initially open, then closed // blocker: initially open, then closed
// epic1: open, blocked by 'blocker' // epic1: open, blocked by 'blocker'
// task1: open, child of epic1 // task1: open, child of epic1
// //
// After closing blocker: both epic1 and task1 should be ready // After closing blocker: both epic1 and task1 should be ready
env := newTestEnv(t)
blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} blocker := env.CreateIssue("Blocker")
epic1 := &types.Issue{Title: "Epic 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} epic1 := env.CreateEpic("Epic 1")
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} task1 := env.CreateIssue("Task 1")
store.CreateIssue(ctx, blocker, "test-user") env.AddDep(epic1, blocker) // epic1 blocked by blocker
store.CreateIssue(ctx, epic1, "test-user") env.AddParentChild(task1, epic1) // task1 is child of epic1
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 // Initially, epic1 and task1 should be blocked
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) env.AssertBlocked(epic1)
if err != nil { env.AssertBlocked(task1)
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 // Close the blocker
store.CloseIssue(ctx, blocker.ID, "Done", "test-user") env.Close(blocker, "Done")
// Now epic1 and task1 should be ready // Now epic1 and task1 should be ready
ready, err = store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) env.AssertReady(epic1)
if err != nil { env.AssertReady(task1)
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 // TestRelatedDoesNotPropagate tests that 'related' deps don't cause blocking propagation
func TestRelatedDoesNotPropagate(t *testing.T) { func TestRelatedDoesNotPropagate(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create: // Create:
// blocker: open // blocker: open
// epic1: open, blocked by 'blocker' // epic1: open, blocked by 'blocker'
// task1: open, related to epic1 (NOT parent-child) // task1: open, related to epic1 (NOT parent-child)
// //
// Expected: task1 SHOULD be ready (related doesn't propagate blocking) // Expected: task1 SHOULD be ready (related doesn't propagate blocking)
env := newTestEnv(t)
blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} blocker := env.CreateIssue("Blocker")
epic1 := &types.Issue{Title: "Epic 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} epic1 := env.CreateEpic("Epic 1")
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} task1 := env.CreateIssue("Task 1")
store.CreateIssue(ctx, blocker, "test-user") env.AddDep(epic1, blocker) // epic1 blocked by blocker
store.CreateIssue(ctx, epic1, "test-user") env.AddDepType(task1, epic1, types.DepRelated) // task1 is related to epic1 (NOT parent-child)
store.CreateIssue(ctx, task1, "test-user")
// epic1 blocked by blocker env.AssertBlocked(epic1)
store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user") env.AssertReady(task1) // related deps don't propagate blocking
// task1 is related to epic1 (NOT parent-child) env.AssertReady(blocker)
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 // TestCompositeIndexExists verifies the composite index is created

View File

@@ -3,8 +3,152 @@ package sqlite
import ( import (
"context" "context"
"testing" "testing"
"github.com/steveyegge/beads/internal/types"
) )
// testEnv provides a test environment with common setup and helpers.
// Use newTestEnv(t) to create a test environment with automatic cleanup.
type testEnv struct {
t *testing.T
Store *SQLiteStorage
Ctx context.Context
}
// newTestEnv creates a new test environment with a configured store.
// The store is automatically cleaned up when the test completes.
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
store := newTestStore(t, "")
return &testEnv{
t: t,
Store: store,
Ctx: context.Background(),
}
}
// CreateIssue creates a test issue with the given title and defaults.
// Returns the created issue with ID populated.
func (e *testEnv) CreateIssue(title string) *types.Issue {
e.t.Helper()
return e.CreateIssueWith(title, types.StatusOpen, 2, types.TypeTask)
}
// CreateIssueWith creates a test issue with specified attributes.
func (e *testEnv) CreateIssueWith(title string, status types.Status, priority int, issueType types.IssueType) *types.Issue {
e.t.Helper()
issue := &types.Issue{
Title: title,
Status: status,
Priority: priority,
IssueType: issueType,
}
if err := e.Store.CreateIssue(e.Ctx, issue, "test-user"); err != nil {
e.t.Fatalf("CreateIssue(%q) failed: %v", title, err)
}
return issue
}
// CreateIssueWithAssignee creates a test issue with an assignee.
func (e *testEnv) CreateIssueWithAssignee(title, assignee string) *types.Issue {
e.t.Helper()
issue := &types.Issue{
Title: title,
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
Assignee: assignee,
}
if err := e.Store.CreateIssue(e.Ctx, issue, "test-user"); err != nil {
e.t.Fatalf("CreateIssue(%q) failed: %v", title, err)
}
return issue
}
// CreateEpic creates an epic issue.
func (e *testEnv) CreateEpic(title string) *types.Issue {
e.t.Helper()
return e.CreateIssueWith(title, types.StatusOpen, 1, types.TypeEpic)
}
// CreateBug creates a bug issue.
func (e *testEnv) CreateBug(title string, priority int) *types.Issue {
e.t.Helper()
return e.CreateIssueWith(title, types.StatusOpen, priority, types.TypeBug)
}
// AddDep adds a blocking dependency (issue depends on dependsOn).
func (e *testEnv) AddDep(issue, dependsOn *types.Issue) {
e.t.Helper()
e.AddDepType(issue, dependsOn, types.DepBlocks)
}
// AddDepType adds a dependency with the specified type.
func (e *testEnv) AddDepType(issue, dependsOn *types.Issue, depType types.DependencyType) {
e.t.Helper()
dep := &types.Dependency{
IssueID: issue.ID,
DependsOnID: dependsOn.ID,
Type: depType,
}
if err := e.Store.AddDependency(e.Ctx, dep, "test-user"); err != nil {
e.t.Fatalf("AddDependency(%s -> %s) failed: %v", issue.ID, dependsOn.ID, err)
}
}
// AddParentChild adds a parent-child dependency (child belongs to parent).
func (e *testEnv) AddParentChild(child, parent *types.Issue) {
e.t.Helper()
e.AddDepType(child, parent, types.DepParentChild)
}
// Close closes the issue with the given reason.
func (e *testEnv) Close(issue *types.Issue, reason string) {
e.t.Helper()
if err := e.Store.CloseIssue(e.Ctx, issue.ID, reason, "test-user"); err != nil {
e.t.Fatalf("CloseIssue(%s) failed: %v", issue.ID, err)
}
}
// GetReadyWork gets ready work with the given filter.
func (e *testEnv) GetReadyWork(filter types.WorkFilter) []*types.Issue {
e.t.Helper()
ready, err := e.Store.GetReadyWork(e.Ctx, filter)
if err != nil {
e.t.Fatalf("GetReadyWork failed: %v", err)
}
return ready
}
// GetReadyIDs returns a map of issue IDs that are ready (open status).
func (e *testEnv) GetReadyIDs() map[string]bool {
e.t.Helper()
ready := e.GetReadyWork(types.WorkFilter{Status: types.StatusOpen})
ids := make(map[string]bool)
for _, issue := range ready {
ids[issue.ID] = true
}
return ids
}
// AssertReady asserts that the issue is in the ready work list.
func (e *testEnv) AssertReady(issue *types.Issue) {
e.t.Helper()
ids := e.GetReadyIDs()
if !ids[issue.ID] {
e.t.Errorf("expected %s (%s) to be ready, but it was blocked", issue.ID, issue.Title)
}
}
// AssertBlocked asserts that the issue is NOT in the ready work list.
func (e *testEnv) AssertBlocked(issue *types.Issue) {
e.t.Helper()
ids := e.GetReadyIDs()
if ids[issue.ID] {
e.t.Errorf("expected %s (%s) to be blocked, but it was ready", issue.ID, issue.Title)
}
}
// newTestStore creates a SQLiteStorage with issue_prefix configured (bd-166) // newTestStore creates a SQLiteStorage with issue_prefix configured (bd-166)
// This prevents "database not initialized" errors in tests // This prevents "database not initialized" errors in tests
// //