Files
beads/internal/storage/storage_test.go
Aarya Reddy bd5fc214c3 feat(ready,blocked): Add --parent flag for scoping by epic/bead desce… (#743)
* feat(ready,blocked): Add --parent flag for scoping by epic/bead descendants

Add --parent flag to `bd ready` and `bd blocked` CLI commands and MCP tools
to filter results to all descendants of a specific epic or bead.

## Backward Compatibility

- CLI: New optional --parent flag; existing usage unchanged
- RPC: New `blocked` operation added (was missing); existing operations unchanged
- MCP: New optional `parent` parameter; existing calls work as before
- Storage interface: GetBlockedIssues signature changed to accept WorkFilter
  - All callers updated to pass empty filter for existing behavior
  - Empty WorkFilter{} returns identical results to previous implementation

## Implementation Details

SQLite uses recursive CTE to traverse parent-child hierarchy:

    WITH RECURSIVE descendants AS (
        SELECT issue_id FROM dependencies
        WHERE type = 'parent-child' AND depends_on_id = ?
        UNION ALL
        SELECT d.issue_id FROM dependencies d
        JOIN descendants dt ON d.depends_on_id = dt.issue_id
        WHERE d.type = 'parent-child'
    )
    SELECT issue_id FROM descendants

MemoryStorage implements equivalent recursive traversal with visited-set
cycle protection via collectDescendants helper.

Parent filter composes with existing filters (priority, labels, assignee, etc.)
as an additional WHERE clause - all filters are AND'd together.

## RPC Blocked Support

MCP beads_blocked() existed but daemon client raised NotImplementedError.
Added OpBlocked and handleBlocked to enable daemon RPC path, which was
previously broken. Now both CLI and daemon clients work for blocked queries.

## Changes

- internal/types/types.go: Add ParentID *string to WorkFilter
- internal/storage/sqlite/ready.go: Add recursive CTE for parent filtering
- internal/storage/memory/memory.go: Add getAllDescendants/collectDescendants
- internal/storage/storage.go: Update GetBlockedIssues interface signature
- cmd/bd/ready.go: Add --parent flag to ready and blocked commands
- internal/rpc/protocol.go: Add OpBlocked constant and BlockedArgs type
- internal/rpc/server_issues_epics.go: Add handleBlocked RPC handler
- internal/rpc/client.go: Add Blocked client method
- integrations/beads-mcp/: Add BlockedParams model and parent parameter

## Usage

  bd ready --parent bd-abc              # All ready descendants
  bd ready --parent bd-abc --priority 1 # Combined with other filters
  bd blocked --parent bd-abc            # All blocked descendants

## Testing

Added 4 test cases for parent filtering:
- TestParentIDFilterDescendants: Verifies recursive traversal (grandchildren)
- TestParentIDWithOtherFilters: Verifies composition with priority filter
- TestParentIDWithBlockedDescendants: Verifies blocked issues excluded from ready
- TestParentIDEmptyParent: Verifies empty result for childless parent

* fix: Correct blockedCmd indentation and suppress gosec false positive

- Fix syntax error from incorrect indentation in blockedCmd Run function
- Add nolint:gosec comment for GetBlockedIssues SQL formatting (G201)
  The filterSQL variable contains only parameterized WHERE clauses with
  ? placeholders, not user input
2025-12-25 21:11:58 -08:00

404 lines
12 KiB
Go

// Package storage tests for interface compliance and contract verification.
package storage
import (
"context"
"database/sql"
"testing"
"github.com/steveyegge/beads/internal/types"
)
// Compile-time interface conformance checks.
// These verify that mock implementations can satisfy the interfaces.
// Real conformance tests for sqlite and memory are in their respective packages.
var (
_ Storage = (*mockStorage)(nil)
_ Transaction = (*mockTransaction)(nil)
)
// mockStorage is a minimal mock for interface testing.
type mockStorage struct{}
func (m *mockStorage) CreateIssue(ctx context.Context, issue *types.Issue, actor string) error {
return nil
}
func (m *mockStorage) CreateIssues(ctx context.Context, issues []*types.Issue, actor string) error {
return nil
}
func (m *mockStorage) GetIssue(ctx context.Context, id string) (*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) GetIssueByExternalRef(ctx context.Context, externalRef string) (*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error {
return nil
}
func (m *mockStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error {
return nil
}
func (m *mockStorage) DeleteIssue(ctx context.Context, id string) error {
return nil
}
func (m *mockStorage) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) AddDependency(ctx context.Context, dep *types.Dependency, actor string) error {
return nil
}
func (m *mockStorage) RemoveDependency(ctx context.Context, issueID, dependsOnID string, actor string) error {
return nil
}
func (m *mockStorage) GetDependencies(ctx context.Context, issueID string) ([]*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) GetDependents(ctx context.Context, issueID string) ([]*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) GetDependencyRecords(ctx context.Context, issueID string) ([]*types.Dependency, error) {
return nil, nil
}
func (m *mockStorage) GetAllDependencyRecords(ctx context.Context) (map[string][]*types.Dependency, error) {
return nil, nil
}
func (m *mockStorage) GetDependencyCounts(ctx context.Context, issueIDs []string) (map[string]*types.DependencyCounts, error) {
return nil, nil
}
func (m *mockStorage) GetDependencyTree(ctx context.Context, issueID string, maxDepth int, showAllPaths bool, reverse bool) ([]*types.TreeNode, error) {
return nil, nil
}
func (m *mockStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) AddLabel(ctx context.Context, issueID, label, actor string) error {
return nil
}
func (m *mockStorage) RemoveLabel(ctx context.Context, issueID, label, actor string) error {
return nil
}
func (m *mockStorage) GetLabels(ctx context.Context, issueID string) ([]string, error) {
return nil, nil
}
func (m *mockStorage) GetLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error) {
return nil, nil
}
func (m *mockStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) GetBlockedIssues(ctx context.Context, filter types.WorkFilter) ([]*types.BlockedIssue, error) {
return nil, nil
}
func (m *mockStorage) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error) {
return nil, nil
}
func (m *mockStorage) GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) GetNewlyUnblockedByClose(ctx context.Context, closedIssueID string) ([]*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) AddComment(ctx context.Context, issueID, actor, comment string) error {
return nil
}
func (m *mockStorage) GetEvents(ctx context.Context, issueID string, limit int) ([]*types.Event, error) {
return nil, nil
}
func (m *mockStorage) AddIssueComment(ctx context.Context, issueID, author, text string) (*types.Comment, error) {
return nil, nil
}
func (m *mockStorage) GetIssueComments(ctx context.Context, issueID string) ([]*types.Comment, error) {
return nil, nil
}
func (m *mockStorage) GetCommentsForIssues(ctx context.Context, issueIDs []string) (map[string][]*types.Comment, error) {
return nil, nil
}
func (m *mockStorage) GetStatistics(ctx context.Context) (*types.Statistics, error) {
return nil, nil
}
func (m *mockStorage) GetDirtyIssues(ctx context.Context) ([]string, error) {
return nil, nil
}
func (m *mockStorage) GetDirtyIssueHash(ctx context.Context, issueID string) (string, error) {
return "", nil
}
func (m *mockStorage) ClearDirtyIssuesByID(ctx context.Context, issueIDs []string) error {
return nil
}
func (m *mockStorage) GetExportHash(ctx context.Context, issueID string) (string, error) {
return "", nil
}
func (m *mockStorage) SetExportHash(ctx context.Context, issueID, contentHash string) error {
return nil
}
func (m *mockStorage) ClearAllExportHashes(ctx context.Context) error {
return nil
}
func (m *mockStorage) GetJSONLFileHash(ctx context.Context) (string, error) {
return "", nil
}
func (m *mockStorage) SetJSONLFileHash(ctx context.Context, fileHash string) error {
return nil
}
func (m *mockStorage) GetNextChildID(ctx context.Context, parentID string) (string, error) {
return "", nil
}
func (m *mockStorage) SetConfig(ctx context.Context, key, value string) error {
return nil
}
func (m *mockStorage) GetConfig(ctx context.Context, key string) (string, error) {
return "", nil
}
func (m *mockStorage) GetAllConfig(ctx context.Context) (map[string]string, error) {
return nil, nil
}
func (m *mockStorage) DeleteConfig(ctx context.Context, key string) error {
return nil
}
func (m *mockStorage) GetCustomStatuses(ctx context.Context) ([]string, error) {
return nil, nil
}
func (m *mockStorage) SetMetadata(ctx context.Context, key, value string) error {
return nil
}
func (m *mockStorage) GetMetadata(ctx context.Context, key string) (string, error) {
return "", nil
}
func (m *mockStorage) UpdateIssueID(ctx context.Context, oldID, newID string, issue *types.Issue, actor string) error {
return nil
}
func (m *mockStorage) RenameDependencyPrefix(ctx context.Context, oldPrefix, newPrefix string) error {
return nil
}
func (m *mockStorage) RenameCounterPrefix(ctx context.Context, oldPrefix, newPrefix string) error {
return nil
}
func (m *mockStorage) RunInTransaction(ctx context.Context, fn func(tx Transaction) error) error {
return nil
}
func (m *mockStorage) Close() error {
return nil
}
func (m *mockStorage) Path() string {
return ""
}
func (m *mockStorage) UnderlyingDB() *sql.DB {
return nil
}
func (m *mockStorage) UnderlyingConn(ctx context.Context) (*sql.Conn, error) {
return nil, nil
}
// mockTransaction is a minimal mock for Transaction interface testing.
type mockTransaction struct{}
func (m *mockTransaction) CreateIssue(ctx context.Context, issue *types.Issue, actor string) error {
return nil
}
func (m *mockTransaction) CreateIssues(ctx context.Context, issues []*types.Issue, actor string) error {
return nil
}
func (m *mockTransaction) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error {
return nil
}
func (m *mockTransaction) CloseIssue(ctx context.Context, id string, reason string, actor string) error {
return nil
}
func (m *mockTransaction) DeleteIssue(ctx context.Context, id string) error {
return nil
}
func (m *mockTransaction) GetIssue(ctx context.Context, id string) (*types.Issue, error) {
return nil, nil
}
func (m *mockTransaction) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
return nil, nil
}
func (m *mockTransaction) AddDependency(ctx context.Context, dep *types.Dependency, actor string) error {
return nil
}
func (m *mockTransaction) RemoveDependency(ctx context.Context, issueID, dependsOnID string, actor string) error {
return nil
}
func (m *mockTransaction) AddLabel(ctx context.Context, issueID, label, actor string) error {
return nil
}
func (m *mockTransaction) RemoveLabel(ctx context.Context, issueID, label, actor string) error {
return nil
}
func (m *mockTransaction) SetConfig(ctx context.Context, key, value string) error {
return nil
}
func (m *mockTransaction) GetConfig(ctx context.Context, key string) (string, error) {
return "", nil
}
func (m *mockTransaction) SetMetadata(ctx context.Context, key, value string) error {
return nil
}
func (m *mockTransaction) GetMetadata(ctx context.Context, key string) (string, error) {
return "", nil
}
func (m *mockTransaction) AddComment(ctx context.Context, issueID, actor, comment string) error {
return nil
}
// TestConfig verifies the Config struct has expected fields.
func TestConfig(t *testing.T) {
t.Run("sqlite config", func(t *testing.T) {
cfg := Config{
Backend: "sqlite",
Path: "/tmp/test.db",
}
if cfg.Backend != "sqlite" {
t.Errorf("expected backend 'sqlite', got %q", cfg.Backend)
}
if cfg.Path != "/tmp/test.db" {
t.Errorf("expected path '/tmp/test.db', got %q", cfg.Path)
}
})
t.Run("postgres config", func(t *testing.T) {
cfg := Config{
Backend: "postgres",
Host: "localhost",
Port: 5432,
Database: "beads",
User: "test",
Password: "secret",
SSLMode: "disable",
}
if cfg.Backend != "postgres" {
t.Errorf("expected backend 'postgres', got %q", cfg.Backend)
}
if cfg.Port != 5432 {
t.Errorf("expected port 5432, got %d", cfg.Port)
}
})
}
// TestInterfaceDocumentation verifies interface methods exist with expected signatures.
// This serves as documentation and catches accidental signature changes.
func TestInterfaceDocumentation(t *testing.T) {
t.Run("Storage interface has expected method groups", func(t *testing.T) {
var s Storage = &mockStorage{}
// Verify issue operations
_ = s.CreateIssue
_ = s.CreateIssues
_ = s.GetIssue
_ = s.GetIssueByExternalRef
_ = s.UpdateIssue
_ = s.CloseIssue
_ = s.DeleteIssue
_ = s.SearchIssues
// Verify dependency operations
_ = s.AddDependency
_ = s.RemoveDependency
_ = s.GetDependencies
_ = s.GetDependents
_ = s.GetDependencyRecords
_ = s.GetAllDependencyRecords
_ = s.GetDependencyCounts
_ = s.GetDependencyTree
_ = s.DetectCycles
// Verify label operations
_ = s.AddLabel
_ = s.RemoveLabel
_ = s.GetLabels
_ = s.GetLabelsForIssues
_ = s.GetIssuesByLabel
// Verify ready work operations
_ = s.GetReadyWork
_ = s.GetBlockedIssues
_ = s.GetEpicsEligibleForClosure
_ = s.GetStaleIssues
// Verify event/comment operations
_ = s.AddComment
_ = s.GetEvents
_ = s.AddIssueComment
_ = s.GetIssueComments
_ = s.GetCommentsForIssues
// Verify statistics
_ = s.GetStatistics
// Verify dirty tracking
_ = s.GetDirtyIssues
_ = s.GetDirtyIssueHash
_ = s.ClearDirtyIssuesByID
// Verify export hash tracking
_ = s.GetExportHash
_ = s.SetExportHash
_ = s.ClearAllExportHashes
_ = s.GetJSONLFileHash
_ = s.SetJSONLFileHash
// Verify ID generation
_ = s.GetNextChildID
// Verify config operations
_ = s.SetConfig
_ = s.GetConfig
_ = s.GetAllConfig
_ = s.DeleteConfig
_ = s.GetCustomStatuses
// Verify metadata operations
_ = s.SetMetadata
_ = s.GetMetadata
// Verify prefix rename operations
_ = s.UpdateIssueID
_ = s.RenameDependencyPrefix
_ = s.RenameCounterPrefix
// Verify transaction support
_ = s.RunInTransaction
// Verify lifecycle
_ = s.Close
_ = s.Path
_ = s.UnderlyingDB
_ = s.UnderlyingConn
})
t.Run("Transaction interface has expected methods", func(t *testing.T) {
var tx Transaction = &mockTransaction{}
// Issue operations
_ = tx.CreateIssue
_ = tx.CreateIssues
_ = tx.UpdateIssue
_ = tx.CloseIssue
_ = tx.DeleteIssue
_ = tx.GetIssue
_ = tx.SearchIssues
// Dependency operations
_ = tx.AddDependency
_ = tx.RemoveDependency
// Label operations
_ = tx.AddLabel
_ = tx.RemoveLabel
// Config operations
_ = tx.SetConfig
_ = tx.GetConfig
// Metadata operations
_ = tx.SetMetadata
_ = tx.GetMetadata
// Comment operations
_ = tx.AddComment
})
}