From 0e6ed91f2e97105d0553668fc2df434ac859c169 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 23 Nov 2025 22:05:23 -0800 Subject: [PATCH] Add explicit cache validation tests for blocked_issues_cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements bd-13gm with 8 comprehensive tests that verify cache invalidation behavior: - Cache updates when blocking dependencies added/removed - Cache updates when issue status changes (close/reopen) - Cache consistency across multiple operations - Parent-child transitive blocking propagates to cache - Related dependencies don't affect cache - Deep hierarchies handled correctly - Multiple blockers handled correctly Tests directly query blocked_issues_cache table to verify implementation correctness, complementing existing behavior tests in ready_test.go. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/storage/sqlite/blocked_cache_test.go | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 internal/storage/sqlite/blocked_cache_test.go diff --git a/internal/storage/sqlite/blocked_cache_test.go b/internal/storage/sqlite/blocked_cache_test.go new file mode 100644 index 00000000..6f3e8340 --- /dev/null +++ b/internal/storage/sqlite/blocked_cache_test.go @@ -0,0 +1,375 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +// getCachedBlockedIssues returns the set of issue IDs currently in blocked_issues_cache +func getCachedBlockedIssues(t *testing.T, store *SQLiteStorage) map[string]bool { + t.Helper() + ctx := context.Background() + + rows, err := store.db.QueryContext(ctx, "SELECT issue_id FROM blocked_issues_cache") + if err != nil { + t.Fatalf("Failed to query blocked_issues_cache: %v", err) + } + defer rows.Close() + + cached := make(map[string]bool) + for rows.Next() { + var issueID string + if err := rows.Scan(&issueID); err != nil { + t.Fatalf("Failed to scan issue_id: %v", err) + } + cached[issueID] = true + } + + return cached +} + +// TestCacheInvalidationOnDependencyAdd tests that adding a blocking dependency updates the cache +func TestCacheInvalidationOnDependencyAdd(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create two issues: blocker and blocked + blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + blocked := &types.Issue{Title: "Blocked", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, blocker, "test-user") + store.CreateIssue(ctx, blocked, "test-user") + + // Initially, cache should be empty (no blocked issues) + cached := getCachedBlockedIssues(t, store) + if len(cached) != 0 { + t.Errorf("Expected empty cache initially, got %d issues", len(cached)) + } + + // Add blocking dependency + dep := &types.Dependency{IssueID: blocked.ID, DependsOnID: blocker.ID, Type: types.DepBlocks} + if err := store.AddDependency(ctx, dep, "test-user"); err != nil { + t.Fatalf("AddDependency failed: %v", err) + } + + // Verify blocked issue appears in cache + cached = getCachedBlockedIssues(t, store) + if !cached[blocked.ID] { + t.Errorf("Expected %s to be in cache after adding blocking dependency", blocked.ID) + } + if cached[blocker.ID] { + t.Errorf("Expected %s NOT to be in cache (it's the blocker, not blocked)", blocker.ID) + } +} + +// TestCacheInvalidationOnDependencyRemove tests that removing a blocking dependency updates the cache +func TestCacheInvalidationOnDependencyRemove(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create blocker → blocked relationship + blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + blocked := &types.Issue{Title: "Blocked", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, blocker, "test-user") + store.CreateIssue(ctx, blocked, "test-user") + + dep := &types.Dependency{IssueID: blocked.ID, DependsOnID: blocker.ID, Type: types.DepBlocks} + store.AddDependency(ctx, dep, "test-user") + + // Verify blocked issue is in cache + cached := getCachedBlockedIssues(t, store) + if !cached[blocked.ID] { + t.Fatalf("Setup failed: expected %s in cache before removal", blocked.ID) + } + + // Remove the blocking dependency + if err := store.RemoveDependency(ctx, blocked.ID, blocker.ID, "test-user"); err != nil { + t.Fatalf("RemoveDependency failed: %v", err) + } + + // Verify blocked issue removed from cache + cached = getCachedBlockedIssues(t, store) + if cached[blocked.ID] { + t.Errorf("Expected %s to be removed from cache after removing blocking dependency", blocked.ID) + } +} + +// TestCacheInvalidationOnStatusChange tests cache updates when blocker status changes +func TestCacheInvalidationOnStatusChange(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create blocker → blocked relationship + blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + blocked := &types.Issue{Title: "Blocked", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, blocker, "test-user") + store.CreateIssue(ctx, blocked, "test-user") + + dep := &types.Dependency{IssueID: blocked.ID, DependsOnID: blocker.ID, Type: types.DepBlocks} + store.AddDependency(ctx, dep, "test-user") + + // Initially blocked issue should be in cache + cached := getCachedBlockedIssues(t, store) + if !cached[blocked.ID] { + t.Fatalf("Setup failed: expected %s in cache", blocked.ID) + } + + // Close the blocker + if err := store.CloseIssue(ctx, blocker.ID, "Done", "test-user"); err != nil { + t.Fatalf("CloseIssue failed: %v", err) + } + + // Verify blocked issue removed from cache (blocker is closed) + cached = getCachedBlockedIssues(t, store) + if cached[blocked.ID] { + t.Errorf("Expected %s to be removed from cache after blocker closed", blocked.ID) + } + + // Reopen the blocker + updates := map[string]interface{}{"status": string(types.StatusOpen)} + if err := store.UpdateIssue(ctx, blocker.ID, updates, "test-user"); err != nil { + t.Fatalf("UpdateIssue (reopen) failed: %v", err) + } + + // Verify blocked issue added back to cache + cached = getCachedBlockedIssues(t, store) + if !cached[blocked.ID] { + t.Errorf("Expected %s to be added back to cache after blocker reopened", blocked.ID) + } +} + +// TestCacheConsistencyAcrossOperations tests cache stays consistent through multiple changes +func TestCacheConsistencyAcrossOperations(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create multiple issues: blocker1, blocker2, blocked1, blocked2 + blocker1 := &types.Issue{Title: "Blocker 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + blocker2 := &types.Issue{Title: "Blocker 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + blocked1 := &types.Issue{Title: "Blocked 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + blocked2 := &types.Issue{Title: "Blocked 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, blocker1, "test-user") + store.CreateIssue(ctx, blocker2, "test-user") + store.CreateIssue(ctx, blocked1, "test-user") + store.CreateIssue(ctx, blocked2, "test-user") + + // Operation 1: Add blocker1 → blocked1 + dep1 := &types.Dependency{IssueID: blocked1.ID, DependsOnID: blocker1.ID, Type: types.DepBlocks} + store.AddDependency(ctx, dep1, "test-user") + + cached := getCachedBlockedIssues(t, store) + if !cached[blocked1.ID] || cached[blocked2.ID] { + t.Errorf("After op1: expected only blocked1 in cache, got: %v", cached) + } + + // Operation 2: Add blocker2 → blocked2 + dep2 := &types.Dependency{IssueID: blocked2.ID, DependsOnID: blocker2.ID, Type: types.DepBlocks} + store.AddDependency(ctx, dep2, "test-user") + + cached = getCachedBlockedIssues(t, store) + if !cached[blocked1.ID] || !cached[blocked2.ID] { + t.Errorf("After op2: expected both blocked1 and blocked2 in cache, got: %v", cached) + } + + // Operation 3: Close blocker1 + store.CloseIssue(ctx, blocker1.ID, "Done", "test-user") + + cached = getCachedBlockedIssues(t, store) + if cached[blocked1.ID] || !cached[blocked2.ID] { + t.Errorf("After op3: expected only blocked2 in cache, got: %v", cached) + } + + // Operation 4: Remove blocker2 → blocked2 dependency + store.RemoveDependency(ctx, blocked2.ID, blocker2.ID, "test-user") + + cached = getCachedBlockedIssues(t, store) + if len(cached) != 0 { + t.Errorf("After op4: expected empty cache, got: %v", cached) + } +} + +// TestParentChildTransitiveBlocking tests that children of blocked parents appear in cache +func TestParentChildTransitiveBlocking(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create: blocker → epic → task1, task2 + blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + epic := &types.Issue{Title: "Epic", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} + task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + task2 := &types.Issue{Title: "Task 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, blocker, "test-user") + store.CreateIssue(ctx, epic, "test-user") + store.CreateIssue(ctx, task1, "test-user") + store.CreateIssue(ctx, task2, "test-user") + + // Add blocking dependency: epic blocked by blocker + depBlock := &types.Dependency{IssueID: epic.ID, DependsOnID: blocker.ID, Type: types.DepBlocks} + store.AddDependency(ctx, depBlock, "test-user") + + // Add parent-child relationships: task1 and task2 are children of epic + depChild1 := &types.Dependency{IssueID: task1.ID, DependsOnID: epic.ID, Type: types.DepParentChild} + depChild2 := &types.Dependency{IssueID: task2.ID, DependsOnID: epic.ID, Type: types.DepParentChild} + store.AddDependency(ctx, depChild1, "test-user") + store.AddDependency(ctx, depChild2, "test-user") + + // Verify all children appear in cache (transitive blocking) + cached := getCachedBlockedIssues(t, store) + + if !cached[epic.ID] { + t.Errorf("Expected epic to be in cache (directly blocked)") + } + if !cached[task1.ID] { + t.Errorf("Expected task1 to be in cache (parent is blocked)") + } + if !cached[task2.ID] { + t.Errorf("Expected task2 to be in cache (parent is blocked)") + } + if cached[blocker.ID] { + t.Errorf("Expected blocker NOT to be in cache (it's the blocker)") + } + + // Expected: exactly 3 blocked issues (epic, task1, task2) + if len(cached) != 3 { + t.Errorf("Expected 3 blocked issues in cache, got %d: %v", len(cached), cached) + } +} + +// TestRelatedDepsDoNotAffectCache tests that 'related' dependencies don't cause cache entries +func TestRelatedDepsDoNotAffectCache(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + issue1 := &types.Issue{Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + issue2 := &types.Issue{Title: "Issue 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, issue1, "test-user") + store.CreateIssue(ctx, issue2, "test-user") + + // Add 'related' dependency (should NOT cause blocking) + dep := &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepRelated} + store.AddDependency(ctx, dep, "test-user") + + // Cache should be empty (related deps don't block) + cached := getCachedBlockedIssues(t, store) + if len(cached) != 0 { + t.Errorf("Expected empty cache (related deps don't block), got: %v", cached) + } +} + +// TestDeepHierarchyCacheCorrectness tests cache handles deep parent-child hierarchies +func TestDeepHierarchyCacheCorrectness(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create blocker → level0 → level1 → level2 → level3 + blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + store.CreateIssue(ctx, blocker, "test-user") + + var issues []*types.Issue + for i := 0; i < 4; i++ { + issue := &types.Issue{ + Title: "Level " + string(rune('0'+i)), + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeEpic, + } + store.CreateIssue(ctx, issue, "test-user") + issues = append(issues, issue) + + if i == 0 { + // First level: blocked by blocker + dep := &types.Dependency{IssueID: issue.ID, DependsOnID: blocker.ID, Type: types.DepBlocks} + store.AddDependency(ctx, dep, "test-user") + } else { + // Each subsequent level: child of previous level + dep := &types.Dependency{IssueID: issue.ID, DependsOnID: issues[i-1].ID, Type: types.DepParentChild} + store.AddDependency(ctx, dep, "test-user") + } + } + + // Verify all 4 levels are in cache + cached := getCachedBlockedIssues(t, store) + if len(cached) != 4 { + t.Errorf("Expected 4 blocked issues in cache, got %d", len(cached)) + } + + for i, issue := range issues { + if !cached[issue.ID] { + t.Errorf("Expected level %d (issue %s) to be in cache", i, issue.ID) + } + } + + // Close the blocker and verify all become unblocked + store.CloseIssue(ctx, blocker.ID, "Done", "test-user") + + cached = getCachedBlockedIssues(t, store) + if len(cached) != 0 { + t.Errorf("Expected empty cache after closing blocker, got %d issues: %v", len(cached), cached) + } +} + +// TestMultipleBlockersInCache tests issue blocked by multiple blockers +func TestMultipleBlockersInCache(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create blocker1 → blocked ← blocker2 + blocker1 := &types.Issue{Title: "Blocker 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + blocker2 := &types.Issue{Title: "Blocker 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + blocked := &types.Issue{Title: "Blocked", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + + store.CreateIssue(ctx, blocker1, "test-user") + store.CreateIssue(ctx, blocker2, "test-user") + store.CreateIssue(ctx, blocked, "test-user") + + // Add both blocking dependencies + dep1 := &types.Dependency{IssueID: blocked.ID, DependsOnID: blocker1.ID, Type: types.DepBlocks} + dep2 := &types.Dependency{IssueID: blocked.ID, DependsOnID: blocker2.ID, Type: types.DepBlocks} + store.AddDependency(ctx, dep1, "test-user") + store.AddDependency(ctx, dep2, "test-user") + + // Verify blocked issue appears in cache + cached := getCachedBlockedIssues(t, store) + if !cached[blocked.ID] { + t.Errorf("Expected %s to be in cache (blocked by 2 issues)", blocked.ID) + } + + // Close one blocker - should still be blocked + store.CloseIssue(ctx, blocker1.ID, "Done", "test-user") + + cached = getCachedBlockedIssues(t, store) + if !cached[blocked.ID] { + t.Errorf("Expected %s to still be in cache (still blocked by blocker2)", blocked.ID) + } + + // Close the second blocker - should be unblocked + store.CloseIssue(ctx, blocker2.ID, "Done", "test-user") + + cached = getCachedBlockedIssues(t, store) + if cached[blocked.ID] { + t.Errorf("Expected %s to be removed from cache (both blockers closed)", blocked.ID) + } +}