From cb280b0fad305f073790323bf90493bf2d794356 Mon Sep 17 00:00:00 2001 From: Jordan Hubbard Date: Sun, 28 Dec 2025 23:45:17 -0400 Subject: [PATCH] test: improve nodb and orphans coverage --- cmd/bd/nodb_test.go | 66 ++++++++++++++++++++- cmd/bd/orphans.go | 22 ++++--- cmd/bd/orphans_test.go | 129 ++++++++++++++++++++++------------------- 3 files changed, 146 insertions(+), 71 deletions(-) diff --git a/cmd/bd/nodb_test.go b/cmd/bd/nodb_test.go index 0063c58e..f8849542 100644 --- a/cmd/bd/nodb_test.go +++ b/cmd/bd/nodb_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/storage/memory" "github.com/steveyegge/beads/internal/types" ) @@ -156,6 +157,61 @@ func TestDetectPrefix(t *testing.T) { t.Errorf("Expected prefix 'myproject', got '%s'", prefix) } }) + + t.Run("config override", func(t *testing.T) { + memStore := memory.New(filepath.Join(beadsDir, "issues.jsonl")) + prev := config.GetString("issue-prefix") + config.Set("issue-prefix", "custom-prefix") + t.Cleanup(func() { config.Set("issue-prefix", prev) }) + + prefix, err := detectPrefix(beadsDir, memStore) + if err != nil { + t.Fatalf("detectPrefix failed: %v", err) + } + if prefix != "custom-prefix" { + t.Errorf("Expected config override prefix, got %q", prefix) + } + }) + + t.Run("sanitizes directory names", func(t *testing.T) { + memStore := memory.New(filepath.Join(beadsDir, "issues.jsonl")) + weirdDir := filepath.Join(tempDir, "My Project!!!") + if err := os.MkdirAll(weirdDir, 0o755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + t.Chdir(weirdDir) + prev := config.GetString("issue-prefix") + config.Set("issue-prefix", "") + t.Cleanup(func() { config.Set("issue-prefix", prev) }) + + prefix, err := detectPrefix(beadsDir, memStore) + if err != nil { + t.Fatalf("detectPrefix failed: %v", err) + } + if prefix != "myproject" { + t.Errorf("Expected sanitized prefix 'myproject', got %q", prefix) + } + }) + + t.Run("invalid directory falls back to bd", func(t *testing.T) { + memStore := memory.New(filepath.Join(beadsDir, "issues.jsonl")) + emptyDir := filepath.Join(tempDir, "!!!") + if err := os.MkdirAll(emptyDir, 0o755); err != nil { + t.Fatalf("Failed to create dir: %v", err) + } + t.Chdir(emptyDir) + prev := config.GetString("issue-prefix") + config.Set("issue-prefix", "") + t.Cleanup(func() { config.Set("issue-prefix", prev) }) + + prefix, err := detectPrefix(beadsDir, memStore) + if err != nil { + t.Fatalf("detectPrefix failed: %v", err) + } + if prefix != "bd" { + t.Errorf("Expected fallback prefix 'bd', got %q", prefix) + } + }) } func TestInitializeNoDbMode_SetsStoreActive(t *testing.T) { @@ -253,7 +309,8 @@ func TestWriteIssuesToJSONL(t *testing.T) { issues := []*types.Issue{ {ID: "bd-1", Title: "Test Issue 1", Description: "Desc 1"}, - {ID: "bd-2", Title: "Test Issue 2", Description: "Desc 2"}, + {ID: "bd-2", Title: "Test Issue 2", Description: "Desc 2", Ephemeral: true}, + {ID: "bd-3", Title: "Regular", Description: "Persistent"}, } if err := memStore.LoadFromIssues(issues); err != nil { t.Fatalf("Failed to load issues: %v", err) @@ -271,6 +328,11 @@ func TestWriteIssuesToJSONL(t *testing.T) { } if len(loadedIssues) != 2 { - t.Errorf("Expected 2 issues in JSONL, got %d", len(loadedIssues)) + t.Fatalf("Expected 2 non-ephemeral issues in JSONL, got %d", len(loadedIssues)) + } + for _, issue := range loadedIssues { + if issue.Ephemeral { + t.Fatalf("Ephemeral issue %s should not be persisted", issue.ID) + } } } diff --git a/cmd/bd/orphans.go b/cmd/bd/orphans.go index dd8a681f..f6d97843 100644 --- a/cmd/bd/orphans.go +++ b/cmd/bd/orphans.go @@ -12,6 +12,13 @@ import ( "github.com/steveyegge/beads/internal/ui" ) +var doctorFindOrphanedIssues = doctor.FindOrphanedIssues + +var closeIssueRunner = func(issueID string) error { + cmd := exec.Command("bd", "close", issueID, "--reason", "Implemented") + return cmd.Run() +} + var orphansCmd = &cobra.Command{ Use: "orphans", Short: "Identify orphaned issues (referenced in commits but still open)", @@ -89,16 +96,16 @@ Examples: // orphanIssueOutput is the JSON output format for orphaned issues type orphanIssueOutput struct { - IssueID string `json:"issue_id"` - Title string `json:"title"` - Status string `json:"status"` - LatestCommit string `json:"latest_commit,omitempty"` - LatestCommitMessage string `json:"latest_commit_message,omitempty"` + IssueID string `json:"issue_id"` + Title string `json:"title"` + Status string `json:"status"` + LatestCommit string `json:"latest_commit,omitempty"` + LatestCommitMessage string `json:"latest_commit_message,omitempty"` } // findOrphanedIssues wraps the shared doctor package function and converts to output format func findOrphanedIssues(path string) ([]orphanIssueOutput, error) { - orphans, err := doctor.FindOrphanedIssues(path) + orphans, err := doctorFindOrphanedIssues(path) if err != nil { return nil, fmt.Errorf("unable to find orphaned issues: %w", err) } @@ -118,8 +125,7 @@ func findOrphanedIssues(path string) ([]orphanIssueOutput, error) { // closeIssue closes an issue using bd close func closeIssue(issueID string) error { - cmd := exec.Command("bd", "close", issueID, "--reason", "Implemented") - return cmd.Run() + return closeIssueRunner(issueID) } func init() { diff --git a/cmd/bd/orphans_test.go b/cmd/bd/orphans_test.go index c4bc1c5e..711ecd2e 100644 --- a/cmd/bd/orphans_test.go +++ b/cmd/bd/orphans_test.go @@ -1,82 +1,89 @@ package main import ( - "context" - "os" - "os/exec" - "path/filepath" + "errors" + "strings" "testing" + + "github.com/steveyegge/beads/cmd/bd/doctor" ) -// TestOrphansBasic tests basic orphan detection -func TestOrphansBasic(t *testing.T) { - // Create a temporary directory with a git repo and beads database - tmpDir := t.TempDir() - - // Initialize git repo - cmd := exec.Command("git", "init") - cmd.Dir = tmpDir - if err := cmd.Run(); err != nil { - t.Fatalf("Failed to init git repo: %v", err) - } - - // Configure git user (needed for commits) - ctx := context.Background() - for _, cmd := range []*exec.Cmd{ - exec.CommandContext(ctx, "git", "-C", tmpDir, "config", "user.email", "test@example.com"), - exec.CommandContext(ctx, "git", "-C", tmpDir, "config", "user.name", "Test User"), - } { - if err := cmd.Run(); err != nil { - t.Fatalf("Failed to configure git: %v", err) +func TestFindOrphanedIssues_ConvertsDoctorOutput(t *testing.T) { + orig := doctorFindOrphanedIssues + doctorFindOrphanedIssues = func(path string) ([]doctor.OrphanIssue, error) { + if path != "/tmp/repo" { + t.Fatalf("unexpected path %q", path) } + return []doctor.OrphanIssue{{ + IssueID: "bd-123", + Title: "Fix login", + Status: "open", + LatestCommit: "abc123", + LatestCommitMessage: "(bd-123) implement fix", + }}, nil } + t.Cleanup(func() { doctorFindOrphanedIssues = orig }) - // Create .beads directory - beadsDir := filepath.Join(tmpDir, ".beads") - if err := os.MkdirAll(beadsDir, 0755); err != nil { - t.Fatalf("Failed to create .beads dir: %v", err) - } - - // Create a minimal database with beads.db - // For this test, we'll skip creating an actual database - // since the test is primarily about integration - - // Test: findOrphanedIssues should handle missing database gracefully - orphans, err := findOrphanedIssues(tmpDir) + result, err := findOrphanedIssues("/tmp/repo") if err != nil { - t.Fatalf("findOrphanedIssues failed: %v", err) + t.Fatalf("findOrphanedIssues returned error: %v", err) } - - // Should be empty list since no database - if len(orphans) != 0 { - t.Errorf("Expected empty orphans list, got %d", len(orphans)) + if len(result) != 1 { + t.Fatalf("expected 1 orphan, got %d", len(result)) + } + orphan := result[0] + if orphan.IssueID != "bd-123" || orphan.Title != "Fix login" || orphan.Status != "open" { + t.Fatalf("unexpected orphan output: %#v", orphan) + } + if orphan.LatestCommit != "abc123" || !strings.Contains(orphan.LatestCommitMessage, "implement") { + t.Fatalf("commit metadata not preserved: %#v", orphan) } } -// TestOrphansNotGitRepo tests behavior in non-git directories -func TestOrphansNotGitRepo(t *testing.T) { - tmpDir := t.TempDir() - - // Should not error, just return empty list - orphans, err := findOrphanedIssues(tmpDir) - if err != nil { - t.Fatalf("findOrphanedIssues failed: %v", err) +func TestFindOrphanedIssues_ErrorWrapped(t *testing.T) { + orig := doctorFindOrphanedIssues + doctorFindOrphanedIssues = func(string) ([]doctor.OrphanIssue, error) { + return nil, errors.New("boom") } + t.Cleanup(func() { doctorFindOrphanedIssues = orig }) - if len(orphans) != 0 { - t.Errorf("Expected empty orphans list for non-git repo, got %d", len(orphans)) + _, err := findOrphanedIssues("/tmp/repo") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "unable to find orphaned issues") { + t.Fatalf("expected wrapped error message, got %v", err) } } -// TestCloseIssueCommand tests that close issue command is properly formed -func TestCloseIssueCommand(t *testing.T) { - // This is a basic test to ensure the closeIssue function - // attempts to run the correct command. - // In a real environment, this would fail since bd close requires - // a valid beads database. +func TestCloseIssue_UsesRunner(t *testing.T) { + orig := closeIssueRunner + defer func() { closeIssueRunner = orig }() - // Just test that the function doesn't panic - // (actual close will fail, which is expected) - _ = closeIssue("bd-test-invalid") - // Error is expected since the issue doesn't exist + called := false + closeIssueRunner = func(issueID string) error { + called = true + if issueID != "bd-999" { + t.Fatalf("unexpected issue id %q", issueID) + } + return nil + } + + if err := closeIssue("bd-999"); err != nil { + t.Fatalf("closeIssue returned error: %v", err) + } + if !called { + t.Fatal("closeIssueRunner was not invoked") + } +} + +func TestCloseIssue_PropagatesError(t *testing.T) { + orig := closeIssueRunner + closeIssueRunner = func(string) error { return errors.New("nope") } + t.Cleanup(func() { closeIssueRunner = orig }) + + err := closeIssue("bd-1") + if err == nil || !strings.Contains(err.Error(), "nope") { + t.Fatalf("expected delegated error, got %v", err) + } }