Files
beads/cmd/bd/cli_coverage_show_test.go
Jordan Hubbard f5ef444d15 cmd/bd: add unit coverage for show/update/close
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-26 12:33:59 -04:00

427 lines
13 KiB
Go

//go:build e2e
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
var cliCoverageMutex sync.Mutex
func runBDForCoverage(t *testing.T, dir string, args ...string) (stdout string, stderr string) {
t.Helper()
cliCoverageMutex.Lock()
defer cliCoverageMutex.Unlock()
// Add --no-daemon to all commands except init.
if len(args) > 0 && args[0] != "init" {
args = append([]string{"--no-daemon"}, args...)
}
oldStdout := os.Stdout
oldStderr := os.Stderr
oldDir, _ := os.Getwd()
oldArgs := os.Args
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir %s: %v", dir, err)
}
rOut, wOut, _ := os.Pipe()
rErr, wErr, _ := os.Pipe()
os.Stdout = wOut
os.Stderr = wErr
// Ensure direct mode.
oldNoDaemon, noDaemonWasSet := os.LookupEnv("BEADS_NO_DAEMON")
os.Setenv("BEADS_NO_DAEMON", "1")
defer func() {
if noDaemonWasSet {
_ = os.Setenv("BEADS_NO_DAEMON", oldNoDaemon)
} else {
os.Unsetenv("BEADS_NO_DAEMON")
}
}()
// Mark tests explicitly.
oldTestMode, testModeWasSet := os.LookupEnv("BEADS_TEST_MODE")
os.Setenv("BEADS_TEST_MODE", "1")
defer func() {
if testModeWasSet {
_ = os.Setenv("BEADS_TEST_MODE", oldTestMode)
} else {
os.Unsetenv("BEADS_TEST_MODE")
}
}()
// Ensure all commands (including init) operate on the temp workspace DB.
db := filepath.Join(dir, ".beads", "beads.db")
beadsDir := filepath.Join(dir, ".beads")
oldBeadsDir, beadsDirWasSet := os.LookupEnv("BEADS_DIR")
os.Setenv("BEADS_DIR", beadsDir)
defer func() {
if beadsDirWasSet {
_ = os.Setenv("BEADS_DIR", oldBeadsDir)
} else {
os.Unsetenv("BEADS_DIR")
}
}()
oldDB, dbWasSet := os.LookupEnv("BEADS_DB")
os.Setenv("BEADS_DB", db)
defer func() {
if dbWasSet {
_ = os.Setenv("BEADS_DB", oldDB)
} else {
os.Unsetenv("BEADS_DB")
}
}()
oldBDDB, bdDBWasSet := os.LookupEnv("BD_DB")
os.Setenv("BD_DB", db)
defer func() {
if bdDBWasSet {
_ = os.Setenv("BD_DB", oldBDDB)
} else {
os.Unsetenv("BD_DB")
}
}()
// Ensure actor is set so label operations record audit fields.
oldActor, actorWasSet := os.LookupEnv("BD_ACTOR")
os.Setenv("BD_ACTOR", "test-user")
defer func() {
if actorWasSet {
_ = os.Setenv("BD_ACTOR", oldActor)
} else {
os.Unsetenv("BD_ACTOR")
}
}()
oldBeadsActor, beadsActorWasSet := os.LookupEnv("BEADS_ACTOR")
os.Setenv("BEADS_ACTOR", "test-user")
defer func() {
if beadsActorWasSet {
_ = os.Setenv("BEADS_ACTOR", oldBeadsActor)
} else {
os.Unsetenv("BEADS_ACTOR")
}
}()
rootCmd.SetArgs(args)
os.Args = append([]string{"bd"}, args...)
err := rootCmd.Execute()
// Close and clean up all global state to prevent contamination between tests.
if store != nil {
store.Close()
store = nil
}
if daemonClient != nil {
daemonClient.Close()
daemonClient = nil
}
// Reset all global flags and state (keep aligned with integration cli_fast_test).
dbPath = ""
actor = ""
jsonOutput = false
noDaemon = false
noAutoFlush = false
noAutoImport = false
sandboxMode = false
noDb = false
autoFlushEnabled = true
storeActive = false
flushFailureCount = 0
lastFlushError = nil
if flushManager != nil {
_ = flushManager.Shutdown()
flushManager = nil
}
rootCtx = nil
rootCancel = nil
// Give SQLite time to release file locks.
time.Sleep(10 * time.Millisecond)
_ = wOut.Close()
_ = wErr.Close()
os.Stdout = oldStdout
os.Stderr = oldStderr
_ = os.Chdir(oldDir)
os.Args = oldArgs
rootCmd.SetArgs(nil)
var outBuf, errBuf bytes.Buffer
_, _ = io.Copy(&outBuf, rOut)
_, _ = io.Copy(&errBuf, rErr)
_ = rOut.Close()
_ = rErr.Close()
stdout = outBuf.String()
stderr = errBuf.String()
if err != nil {
t.Fatalf("bd %v failed: %v\nStdout: %s\nStderr: %s", args, err, stdout, stderr)
}
return stdout, stderr
}
func extractJSONPayload(s string) string {
if i := strings.IndexAny(s, "[{"); i >= 0 {
return s[i:]
}
return s
}
func parseCreatedIssueID(t *testing.T, out string) string {
t.Helper()
p := extractJSONPayload(out)
var m map[string]interface{}
if err := json.Unmarshal([]byte(p), &m); err != nil {
t.Fatalf("parse create JSON: %v\n%s", err, out)
}
id, _ := m["id"].(string)
if id == "" {
t.Fatalf("missing id in create output: %s", out)
}
return id
}
func TestCoverage_ShowUpdateClose(t *testing.T) {
if testing.Short() {
t.Skip("skipping CLI coverage test in short mode")
}
dir := t.TempDir()
runBDForCoverage(t, dir, "init", "--prefix", "test", "--quiet")
out, _ := runBDForCoverage(t, dir, "create", "Show coverage issue", "-p", "1", "--json")
id := parseCreatedIssueID(t, out)
// Exercise update label flows (add -> set -> add/remove).
runBDForCoverage(t, dir, "update", id, "--add-label", "old", "--json")
runBDForCoverage(t, dir, "update", id, "--set-labels", "a,b", "--add-label", "c", "--remove-label", "a", "--json")
runBDForCoverage(t, dir, "update", id, "--remove-label", "old", "--json")
// Show JSON output and verify labels were applied.
showOut, _ := runBDForCoverage(t, dir, "show", "--allow-stale", id, "--json")
showPayload := extractJSONPayload(showOut)
var details []map[string]interface{}
if err := json.Unmarshal([]byte(showPayload), &details); err != nil {
// Some commands may emit a single object; fall back to object parse.
var single map[string]interface{}
if err2 := json.Unmarshal([]byte(showPayload), &single); err2 != nil {
t.Fatalf("parse show JSON: %v / %v\n%s", err, err2, showOut)
}
details = []map[string]interface{}{single}
}
if len(details) != 1 {
t.Fatalf("expected 1 issue, got %d", len(details))
}
labelsAny, ok := details[0]["labels"]
if !ok {
t.Fatalf("expected labels in show output: %s", showOut)
}
labelsBytes, _ := json.Marshal(labelsAny)
labelsStr := string(labelsBytes)
if !strings.Contains(labelsStr, "b") || !strings.Contains(labelsStr, "c") {
t.Fatalf("expected labels b and c, got %s", labelsStr)
}
if strings.Contains(labelsStr, "a") || strings.Contains(labelsStr, "old") {
t.Fatalf("expected labels a and old to be absent, got %s", labelsStr)
}
// Show text output.
showText, _ := runBDForCoverage(t, dir, "show", "--allow-stale", id)
if !strings.Contains(showText, "Show coverage issue") {
t.Fatalf("expected show output to contain title, got: %s", showText)
}
// Multi-ID show should print both issues.
out2, _ := runBDForCoverage(t, dir, "create", "Second issue", "-p", "2", "--json")
id2 := parseCreatedIssueID(t, out2)
multi, _ := runBDForCoverage(t, dir, "show", "--allow-stale", id, id2)
if !strings.Contains(multi, "Show coverage issue") || !strings.Contains(multi, "Second issue") {
t.Fatalf("expected multi-show output to include both titles, got: %s", multi)
}
if !strings.Contains(multi, "─") {
t.Fatalf("expected multi-show output to include a separator line, got: %s", multi)
}
// Close and verify JSON output.
closeOut, _ := runBDForCoverage(t, dir, "close", id, "--reason", "Done", "--json")
closePayload := extractJSONPayload(closeOut)
var closed []map[string]interface{}
if err := json.Unmarshal([]byte(closePayload), &closed); err != nil {
t.Fatalf("parse close JSON: %v\n%s", err, closeOut)
}
if len(closed) != 1 {
t.Fatalf("expected 1 closed issue, got %d", len(closed))
}
if status, _ := closed[0]["status"].(string); status != string(types.StatusClosed) {
t.Fatalf("expected status closed, got %q", status)
}
}
func TestCoverage_TemplateAndPinnedProtections(t *testing.T) {
if testing.Short() {
t.Skip("skipping CLI coverage test in short mode")
}
dir := t.TempDir()
runBDForCoverage(t, dir, "init", "--prefix", "test", "--quiet")
// Create a pinned issue and verify close requires --force.
out, _ := runBDForCoverage(t, dir, "create", "Pinned issue", "-p", "1", "--json")
pinnedID := parseCreatedIssueID(t, out)
runBDForCoverage(t, dir, "update", pinnedID, "--status", string(types.StatusPinned), "--json")
_, closeErr := runBDForCoverage(t, dir, "close", pinnedID, "--reason", "Done")
if !strings.Contains(closeErr, "cannot close pinned issue") {
t.Fatalf("expected pinned close to be rejected, stderr: %s", closeErr)
}
forceOut, _ := runBDForCoverage(t, dir, "close", pinnedID, "--force", "--reason", "Done", "--json")
forcePayload := extractJSONPayload(forceOut)
var closed []map[string]interface{}
if err := json.Unmarshal([]byte(forcePayload), &closed); err != nil {
t.Fatalf("parse close JSON: %v\n%s", err, forceOut)
}
if len(closed) != 1 {
t.Fatalf("expected 1 closed issue, got %d", len(closed))
}
// Insert a template issue directly and verify update/close protect it.
dbFile := filepath.Join(dir, ".beads", "beads.db")
s, err := sqlite.New(context.Background(), dbFile)
if err != nil {
t.Fatalf("sqlite.New: %v", err)
}
ctx := context.Background()
template := &types.Issue{
Title: "Template issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
IsTemplate: true,
}
if err := s.CreateIssue(ctx, template, "test-user"); err != nil {
s.Close()
t.Fatalf("CreateIssue: %v", err)
}
created, err := s.GetIssue(ctx, template.ID)
if err != nil {
s.Close()
t.Fatalf("GetIssue(template): %v", err)
}
if created == nil || !created.IsTemplate {
s.Close()
t.Fatalf("expected inserted issue to be IsTemplate=true, got %+v", created)
}
_ = s.Close()
showOut, _ := runBDForCoverage(t, dir, "show", "--allow-stale", template.ID, "--json")
showPayload := extractJSONPayload(showOut)
var showDetails []map[string]interface{}
if err := json.Unmarshal([]byte(showPayload), &showDetails); err != nil {
t.Fatalf("parse show JSON: %v\n%s", err, showOut)
}
if len(showDetails) != 1 {
t.Fatalf("expected 1 issue from show, got %d", len(showDetails))
}
// Re-open the DB after running the CLI to confirm is_template persisted.
s2, err := sqlite.New(context.Background(), dbFile)
if err != nil {
t.Fatalf("sqlite.New (reopen): %v", err)
}
postShow, err := s2.GetIssue(context.Background(), template.ID)
_ = s2.Close()
if err != nil {
t.Fatalf("GetIssue(template, post-show): %v", err)
}
if postShow == nil || !postShow.IsTemplate {
t.Fatalf("expected template to remain IsTemplate=true post-show, got %+v", postShow)
}
if v, ok := showDetails[0]["is_template"]; ok {
if b, ok := v.(bool); !ok || !b {
t.Fatalf("expected show JSON is_template=true, got %v", v)
}
} else {
t.Fatalf("expected show JSON to include is_template=true, got: %s", showOut)
}
_, updErr := runBDForCoverage(t, dir, "update", template.ID, "--title", "New title")
if !strings.Contains(updErr, "cannot update template") {
t.Fatalf("expected template update to be rejected, stderr: %s", updErr)
}
_, closeTemplateErr := runBDForCoverage(t, dir, "close", template.ID, "--reason", "Done")
if !strings.Contains(closeTemplateErr, "cannot close template") {
t.Fatalf("expected template close to be rejected, stderr: %s", closeTemplateErr)
}
}
func TestCoverage_ShowThread(t *testing.T) {
if testing.Short() {
t.Skip("skipping CLI coverage test in short mode")
}
dir := t.TempDir()
runBDForCoverage(t, dir, "init", "--prefix", "test", "--quiet")
dbFile := filepath.Join(dir, ".beads", "beads.db")
s, err := sqlite.New(context.Background(), dbFile)
if err != nil {
t.Fatalf("sqlite.New: %v", err)
}
ctx := context.Background()
root := &types.Issue{Title: "Root message", IssueType: types.TypeMessage, Status: types.StatusOpen, Sender: "alice", Assignee: "bob"}
reply1 := &types.Issue{Title: "Re: Root", IssueType: types.TypeMessage, Status: types.StatusOpen, Sender: "bob", Assignee: "alice"}
reply2 := &types.Issue{Title: "Re: Re: Root", IssueType: types.TypeMessage, Status: types.StatusOpen, Sender: "alice", Assignee: "bob"}
if err := s.CreateIssue(ctx, root, "test-user"); err != nil {
s.Close()
t.Fatalf("CreateIssue root: %v", err)
}
if err := s.CreateIssue(ctx, reply1, "test-user"); err != nil {
s.Close()
t.Fatalf("CreateIssue reply1: %v", err)
}
if err := s.CreateIssue(ctx, reply2, "test-user"); err != nil {
s.Close()
t.Fatalf("CreateIssue reply2: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{IssueID: reply1.ID, DependsOnID: root.ID, Type: types.DepRepliesTo, ThreadID: root.ID}, "test-user"); err != nil {
s.Close()
t.Fatalf("AddDependency reply1->root: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{IssueID: reply2.ID, DependsOnID: reply1.ID, Type: types.DepRepliesTo, ThreadID: root.ID}, "test-user"); err != nil {
s.Close()
t.Fatalf("AddDependency reply2->reply1: %v", err)
}
_ = s.Close()
out, _ := runBDForCoverage(t, dir, "show", "--allow-stale", reply2.ID, "--thread")
if !strings.Contains(out, "Thread") || !strings.Contains(out, "Total: 3 messages") {
t.Fatalf("expected thread output, got: %s", out)
}
if !strings.Contains(out, root.ID) || !strings.Contains(out, reply1.ID) || !strings.Contains(out, reply2.ID) {
t.Fatalf("expected thread output to include message IDs, got: %s", out)
}
}