diff --git a/internal/storage/sqlite/dependencies_test.go b/internal/storage/sqlite/dependencies_test.go index 82de5508..fc22f550 100644 --- a/internal/storage/sqlite/dependencies_test.go +++ b/internal/storage/sqlite/dependencies_test.go @@ -114,37 +114,23 @@ func TestParentChildValidation(t *testing.T) { } func TestRemoveDependency(t *testing.T) { - store, cleanup := setupTestDB(t) - defer cleanup() + env := newTestEnv(t) - 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") + issue1 := env.CreateIssue("First") + issue2 := env.CreateIssue("Second") + env.AddDep(issue2, issue1) // 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 { t.Fatalf("RemoveDependency failed: %v", err) } // Verify dependency was removed - deps, err := store.GetDependencies(ctx, issue2.ID) + deps, err := env.Store.GetDependencies(env.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)) } @@ -192,29 +178,21 @@ func TestAddDependencyPreservesProvidedMetadata(t *testing.T) { } func TestGetDependents(t *testing.T) { - store, cleanup := setupTestDB(t) - defer cleanup() + env := newTestEnv(t) - 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 - 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") + env.AddDep(issue2, issue1) + env.AddDep(issue3, issue1) // Get dependents of issue1 - dependents, err := store.GetDependents(ctx, issue1.ID) + dependents, err := env.Store.GetDependents(env.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)) } @@ -224,36 +202,27 @@ func TestGetDependents(t *testing.T) { 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() + env := newTestEnv(t) - 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 - 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") + env.AddDep(issue2, issue1) + env.AddDep(issue3, issue2) // 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 { t.Fatalf("GetDependencyTree failed: %v", err) } - if len(tree) != 3 { t.Fatalf("Expected 3 nodes in tree, got %d", len(tree)) } @@ -263,15 +232,12 @@ func TestGetDependencyTree(t *testing.T) { 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]) } diff --git a/internal/storage/sqlite/ready_test.go b/internal/storage/sqlite/ready_test.go index 131e7b9c..e7d99e67 100644 --- a/internal/storage/sqlite/ready_test.go +++ b/internal/storage/sqlite/ready_test.go @@ -13,87 +13,45 @@ import ( ) 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: open, no dependencies → READY + // issue2: open, depends on issue1 (open) → BLOCKED + // issue3: open, no dependencies → READY + // issue4: closed, no dependencies → NOT READY (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} - 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} + issue1 := env.CreateIssueWith("Ready 1", types.StatusOpen, 1, types.TypeTask) + issue2 := env.CreateIssueWith("Blocked", types.StatusOpen, 1, types.TypeTask) + issue3 := env.CreateIssueWith("Ready 2", types.StatusOpen, 2, types.TypeTask) + issue4 := env.CreateIssueWith("Closed", types.StatusOpen, 1, types.TypeTask) // create as open first + env.Close(issue4, "Done") + issue5 := env.CreateIssueWith("Ready 3", types.StatusOpen, 0, 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") + env.AddDep(issue2, issue1) // issue2 depends on issue1 + env.AddDep(issue5, issue4) // issue5 depends on issue4 (which is closed) - // 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 + // Verify ready issues: issue1, issue3, issue5 + ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen}) 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) - } + env.AssertReady(issue1) + env.AssertReady(issue3) + env.AssertReady(issue5) // blocker (issue4) is closed + env.AssertBlocked(issue2) } func TestGetReadyWorkPriorityOrder(t *testing.T) { - store, cleanup := setupTestDB(t) - defer cleanup() + env := newTestEnv(t) - 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) - } + // Create issues with different priorities (out of order) + env.CreateIssueWith("Medium", types.StatusOpen, 2, types.TypeTask) + env.CreateIssueWith("Highest", types.StatusOpen, 0, types.TypeTask) + env.CreateIssueWith("High", types.StatusOpen, 1, types.TypeTask) + ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen}) if len(ready) != 3 { t.Fatalf("Expected 3 ready issues, got %d", len(ready)) } @@ -111,146 +69,90 @@ func TestGetReadyWorkPriorityOrder(t *testing.T) { } func TestGetReadyWorkWithPriorityFilter(t *testing.T) { - store, cleanup := setupTestDB(t) - defer cleanup() - - ctx := context.Background() + env := newTestEnv(t) // 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") + env.CreateIssueWith("P0", types.StatusOpen, 0, types.TypeTask) + env.CreateIssueWith("P1", types.StatusOpen, 1, types.TypeTask) + env.CreateIssueWith("P2", types.StatusOpen, 2, types.TypeTask) // 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) - } - + ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Priority: &priority0}) 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() + env := newTestEnv(t) // 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") + env.CreateIssueWithAssignee("Alice's task", "alice") + env.CreateIssueWithAssignee("Bob's task", "bob") + env.CreateIssue("Unassigned") // 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) - } - + ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Assignee: &assignee}) 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 TestGetReadyWorkWithUnassignedFilter(t *testing.T) { - store, cleanup := setupTestDB(t) - defer cleanup() - - ctx := context.Background() + env := newTestEnv(t) // 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") + env.CreateIssueWithAssignee("Alice's task", "alice") + env.CreateIssueWithAssignee("Bob's task", "bob") + unassigned := env.CreateIssue("Unassigned") // Filter for unassigned issues - ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Unassigned: true}) - if err != nil { - t.Fatalf("GetReadyWork with unassigned filter failed: %v", err) - } - + ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Unassigned: true}) if len(ready) != 1 { t.Fatalf("Expected 1 unassigned issue, got %d", len(ready)) } - if ready[0].Assignee != "" { t.Errorf("Expected unassigned issue, got assignee %q", ready[0].Assignee) } - - if ready[0].ID != issueUnassigned.ID { - t.Errorf("Expected issue %s, got %s", issueUnassigned.ID, ready[0].ID) + if ready[0].ID != unassigned.ID { + t.Errorf("Expected issue %s, got %s", unassigned.ID, ready[0].ID) } } func TestGetReadyWorkWithLimit(t *testing.T) { - store, cleanup := setupTestDB(t) - defer cleanup() - - ctx := context.Background() + env := newTestEnv(t) // 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") + env.CreateIssue("Task") } // Limit to 3 - ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Limit: 3}) - if err != nil { - t.Fatalf("GetReadyWork failed: %v", err) - } - + ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Limit: 3}) 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() + env := newTestEnv(t) // 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} + issue1 := env.CreateIssue("First") + issue2 := env.CreateIssue("Second") - 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") + env.AddDepType(issue2, issue1, types.DepRelated) // 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) - } - + ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen}) if len(ready) != 2 { 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 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) + env := newTestEnv(t) - 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} + blocker := env.CreateIssue("Blocker") + epic1 := env.CreateEpic("Epic 1") + task1 := env.CreateIssue("Task 1") - store.CreateIssue(ctx, blocker, "test-user") - store.CreateIssue(ctx, epic1, "test-user") - store.CreateIssue(ctx, task1, "test-user") + env.AddDep(epic1, blocker) // epic1 blocked by blocker + env.AddParentChild(task1, epic1) // task1 is child of epic1 - // 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") - } + env.AssertBlocked(epic1) + env.AssertBlocked(task1) + env.AssertReady(blocker) } // 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' @@ -376,57 +245,25 @@ func TestGrandparentBlockerBlocksGrandchildren(t *testing.T) { // task1: open, child of epic2 // // 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} - 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} + blocker := env.CreateIssue("Blocker") + epic1 := env.CreateEpic("Epic 1") + epic2 := env.CreateEpic("Epic 2") + task1 := env.CreateIssue("Task 1") - store.CreateIssue(ctx, blocker, "test-user") - store.CreateIssue(ctx, epic1, "test-user") - store.CreateIssue(ctx, epic2, "test-user") - store.CreateIssue(ctx, task1, "test-user") + env.AddDep(epic1, blocker) // epic1 blocked by blocker + env.AddParentChild(epic2, epic1) // epic2 is child of epic1 + env.AddParentChild(task1, epic2) // task1 is child of epic2 - // 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") - } + env.AssertBlocked(epic1) + env.AssertBlocked(epic2) + env.AssertBlocked(task1) + env.AssertReady(blocker) } // 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' @@ -434,161 +271,72 @@ func TestMultipleParentsOneBlocked(t *testing.T) { // task1: open, child of BOTH epic1 and epic2 // // 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} - 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} + blocker := env.CreateIssue("Blocker") + epic1 := env.CreateEpic("Epic 1 (blocked)") + epic2 := env.CreateEpic("Epic 2 (ready)") + task1 := env.CreateIssue("Task 1") - store.CreateIssue(ctx, blocker, "test-user") - store.CreateIssue(ctx, epic1, "test-user") - store.CreateIssue(ctx, epic2, "test-user") - store.CreateIssue(ctx, task1, "test-user") + env.AddDep(epic1, blocker) // epic1 blocked by blocker + env.AddParentChild(task1, epic1) // task1 is child of both epic1 and epic2 + env.AddParentChild(task1, epic2) - // 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") - } + env.AssertBlocked(epic1) + env.AssertBlocked(task1) // blocked because one parent (epic1) is blocked + env.AssertReady(blocker) + env.AssertReady(epic2) } // 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 + env := newTestEnv(t) - 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} + blocker := env.CreateIssue("Blocker") + epic1 := env.CreateEpic("Epic 1") + task1 := env.CreateIssue("Task 1") - 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") + env.AddDep(epic1, blocker) // epic1 blocked by blocker + env.AddParentChild(task1, epic1) // task1 is child of epic1 // 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") - } + env.AssertBlocked(epic1) + env.AssertBlocked(task1) // Close the blocker - store.CloseIssue(ctx, blocker.ID, "Done", "test-user") + env.Close(blocker, "Done") // 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") - } + env.AssertReady(epic1) + env.AssertReady(task1) } // 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) + env := newTestEnv(t) - 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} + blocker := env.CreateIssue("Blocker") + epic1 := env.CreateEpic("Epic 1") + task1 := env.CreateIssue("Task 1") - store.CreateIssue(ctx, blocker, "test-user") - store.CreateIssue(ctx, epic1, "test-user") - store.CreateIssue(ctx, task1, "test-user") + env.AddDep(epic1, blocker) // epic1 blocked by blocker + env.AddDepType(task1, epic1, types.DepRelated) // task1 is related to epic1 (NOT parent-child) - // 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") - } + env.AssertBlocked(epic1) + env.AssertReady(task1) // related deps don't propagate blocking + env.AssertReady(blocker) } // TestCompositeIndexExists verifies the composite index is created diff --git a/internal/storage/sqlite/test_helpers.go b/internal/storage/sqlite/test_helpers.go index 338e22a3..2371f350 100644 --- a/internal/storage/sqlite/test_helpers.go +++ b/internal/storage/sqlite/test_helpers.go @@ -3,8 +3,152 @@ package sqlite import ( "context" "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) // This prevents "database not initialized" errors in tests //