package main import ( "context" "path/filepath" "strings" "testing" "time" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/util" ) // listTestHelper provides test setup and assertion methods type listTestHelper struct { t *testing.T ctx context.Context store *sqlite.SQLiteStorage issues []*types.Issue } func newListTestHelper(t *testing.T, store *sqlite.SQLiteStorage) *listTestHelper { return &listTestHelper{t: t, ctx: context.Background(), store: store} } func (h *listTestHelper) createTestIssues() { now := time.Now() h.issues = []*types.Issue{ { Title: "Bug Issue", Description: "Test bug", Priority: 0, IssueType: types.TypeBug, Status: types.StatusOpen, }, { Title: "Feature Issue", Description: "Test feature", Priority: 1, IssueType: types.TypeFeature, Status: types.StatusInProgress, Assignee: testUserAlice, }, { Title: "Task Issue", Description: "Test task", Priority: 2, IssueType: types.TypeTask, Status: types.StatusClosed, ClosedAt: &now, }, } for _, issue := range h.issues { if err := h.store.CreateIssue(h.ctx, issue, "test-user"); err != nil { h.t.Fatalf("Failed to create issue: %v", err) } } } func (h *listTestHelper) addLabel(id, label string) { if err := h.store.AddLabel(h.ctx, id, label, "test-user"); err != nil { h.t.Fatalf("Failed to add label: %v", err) } } func (h *listTestHelper) search(filter types.IssueFilter) []*types.Issue { results, err := h.store.SearchIssues(h.ctx, "", filter) if err != nil { h.t.Fatalf("Failed to search issues: %v", err) } return results } func (h *listTestHelper) assertCount(count, expected int, desc string) { if count != expected { h.t.Errorf("Expected %d %s, got %d", expected, desc, count) } } func (h *listTestHelper) assertEqual(expected, actual interface{}, field string) { if expected != actual { h.t.Errorf("Expected %s %v, got %v", field, expected, actual) } } func (h *listTestHelper) assertAtMost(count, maxCount int, desc string) { if count > maxCount { h.t.Errorf("Expected at most %d %s, got %d", maxCount, desc, count) } } func TestListCommandSuite(t *testing.T) { tmpDir := t.TempDir() testDB := filepath.Join(tmpDir, ".beads", "beads.db") s := newTestStore(t, testDB) t.Run("ListCommand", func(t *testing.T) { h := newListTestHelper(t, s) h.createTestIssues() h.addLabel(h.issues[0].ID, "critical") t.Run("list all issues", func(t *testing.T) { results := h.search(types.IssueFilter{}) h.assertCount(len(results), 3, "issues") }) t.Run("filter by status", func(t *testing.T) { status := types.StatusOpen results := h.search(types.IssueFilter{Status: &status}) h.assertCount(len(results), 1, "open issues") h.assertEqual(types.StatusOpen, results[0].Status, "status") }) t.Run("filter by priority", func(t *testing.T) { priority := 0 results := h.search(types.IssueFilter{Priority: &priority}) h.assertCount(len(results), 1, "P0 issues") h.assertEqual(0, results[0].Priority, "priority") }) t.Run("filter by assignee", func(t *testing.T) { assignee := testUserAlice results := h.search(types.IssueFilter{Assignee: &assignee}) h.assertCount(len(results), 1, "issues for alice") h.assertEqual(testUserAlice, results[0].Assignee, "assignee") }) t.Run("filter by issue type", func(t *testing.T) { issueType := types.TypeBug results := h.search(types.IssueFilter{IssueType: &issueType}) h.assertCount(len(results), 1, "bug issues") h.assertEqual(types.TypeBug, results[0].IssueType, "type") }) t.Run("filter by label", func(t *testing.T) { results := h.search(types.IssueFilter{Labels: []string{"critical"}}) h.assertCount(len(results), 1, "issues with critical label") }) t.Run("filter by title search", func(t *testing.T) { results := h.search(types.IssueFilter{TitleSearch: "Bug"}) h.assertCount(len(results), 1, "issues matching 'Bug'") }) t.Run("limit results", func(t *testing.T) { results := h.search(types.IssueFilter{Limit: 2}) h.assertAtMost(len(results), 2, "issues") }) t.Run("normalize labels", func(t *testing.T) { labels := []string{" bug ", "critical", "", "bug", " feature "} normalized := util.NormalizeLabels(labels) expected := []string{"bug", "critical", "feature"} h.assertCount(len(normalized), len(expected), "normalized labels") // Check deduplication and trimming seen := make(map[string]bool) for _, label := range normalized { if label == "" { t.Error("Found empty label after normalization") } if label != strings.TrimSpace(label) { t.Errorf("Label not trimmed: '%s'", label) } if seen[label] { t.Errorf("Duplicate label found: %s", label) } seen[label] = true } }) t.Run("output dot format", func(t *testing.T) { // Add a dependency to make the graph more interesting dep := &types.Dependency{ IssueID: h.issues[0].ID, DependsOnID: h.issues[1].ID, Type: types.DepBlocks, } if err := h.store.AddDependency(h.ctx, dep, "test-user"); err != nil { t.Fatalf("Failed to add dependency: %v", err) } err := outputDotFormat(h.ctx, h.store, h.issues) if err != nil { t.Errorf("outputDotFormat failed: %v", err) } }) t.Run("output formatted list dot", func(t *testing.T) { err := outputFormattedList(h.ctx, h.store, h.issues, "dot") if err != nil { t.Errorf("outputFormattedList with dot format failed: %v", err) } }) t.Run("output formatted list digraph preset", func(t *testing.T) { // Dependency already added in previous test, just use it err := outputFormattedList(h.ctx, h.store, h.issues, "digraph") if err != nil { t.Errorf("outputFormattedList with digraph format failed: %v", err) } }) t.Run("output formatted list custom template", func(t *testing.T) { err := outputFormattedList(h.ctx, h.store, h.issues, "{{.ID}} {{.Title}}") if err != nil { t.Errorf("outputFormattedList with custom template failed: %v", err) } }) t.Run("output formatted list invalid template", func(t *testing.T) { err := outputFormattedList(h.ctx, h.store, h.issues, "{{.ID") if err == nil { t.Error("Expected error for invalid template") } }) }) } func TestListQueryCapabilitiesSuite(t *testing.T) { tmpDir := t.TempDir() testDB := filepath.Join(tmpDir, ".beads", "beads.db") s := newTestStore(t, testDB) ctx := context.Background() now := time.Now() yesterday := now.Add(-24 * time.Hour) twoDaysAgo := now.Add(-48 * time.Hour) // Create test issues with varied attributes issue1 := &types.Issue{ Title: "Authentication Bug", Description: "Login fails with special characters", Notes: "Needs urgent fix", Priority: 0, IssueType: types.TypeBug, Status: types.StatusOpen, Assignee: "alice", } issue2 := &types.Issue{ Title: "Add OAuth Support", Description: "", // Empty description Priority: 2, IssueType: types.TypeFeature, Status: types.StatusInProgress, // No assignee } issue3 := &types.Issue{ Title: "Update Documentation", Description: "Update README with new features", Notes: "Include OAuth setup", Priority: 3, IssueType: types.TypeTask, Status: types.StatusOpen, Assignee: "bob", } for _, issue := range []*types.Issue{issue1, issue2, issue3} { if err := s.CreateIssue(ctx, issue, "test-user"); err != nil { t.Fatalf("Failed to create issue: %v", err) } } // Close issue3 to set closed_at timestamp if err := s.CloseIssue(ctx, issue3.ID, "test-user", "Testing", ""); err != nil { t.Fatalf("Failed to close issue3: %v", err) } // Add labels s.AddLabel(ctx, issue1.ID, "critical", "test-user") s.AddLabel(ctx, issue1.ID, "security", "test-user") s.AddLabel(ctx, issue3.ID, "docs", "test-user") t.Run("pattern matching - title contains", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ TitleContains: "Auth", }) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 2 { t.Errorf("Expected 2 results with 'Auth' in title, got %d", len(results)) } }) t.Run("pattern matching - description contains", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ DescriptionContains: "special characters", }) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 1 { t.Errorf("Expected 1 result, got %d", len(results)) } if len(results) > 0 && results[0].ID != issue1.ID { t.Errorf("Expected issue1, got %s", results[0].ID) } }) t.Run("pattern matching - notes contains", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ NotesContains: "OAuth", }) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 1 { t.Errorf("Expected 1 result, got %d", len(results)) } if len(results) > 0 && results[0].ID != issue3.ID { t.Errorf("Expected issue3, got %s", results[0].ID) } }) t.Run("empty description check", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ EmptyDescription: true, }) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 1 { t.Errorf("Expected 1 issue with empty description, got %d", len(results)) } if len(results) > 0 && results[0].ID != issue2.ID { t.Errorf("Expected issue2, got %s", results[0].ID) } }) t.Run("no assignee check", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ NoAssignee: true, }) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 1 { t.Errorf("Expected 1 issue with no assignee, got %d", len(results)) } if len(results) > 0 && results[0].ID != issue2.ID { t.Errorf("Expected issue2, got %s", results[0].ID) } }) t.Run("no labels check", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ NoLabels: true, }) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 1 { t.Errorf("Expected 1 issue with no labels, got %d", len(results)) } if len(results) > 0 && results[0].ID != issue2.ID { t.Errorf("Expected issue2, got %s", results[0].ID) } }) t.Run("priority range - min", func(t *testing.T) { minPrio := 2 results, err := s.SearchIssues(ctx, "", types.IssueFilter{ PriorityMin: &minPrio, }) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 2 { t.Errorf("Expected 2 issues with priority >= 2, got %d", len(results)) } }) t.Run("priority range - max", func(t *testing.T) { maxPrio := 1 results, err := s.SearchIssues(ctx, "", types.IssueFilter{ PriorityMax: &maxPrio, }) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 1 { t.Errorf("Expected 1 issue with priority <= 1, got %d", len(results)) } }) t.Run("priority range - min and max", func(t *testing.T) { minPrio := 1 maxPrio := 2 results, err := s.SearchIssues(ctx, "", types.IssueFilter{ PriorityMin: &minPrio, PriorityMax: &maxPrio, }) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 1 { t.Errorf("Expected 1 issue with priority between 1-2, got %d", len(results)) } }) t.Run("date range - created after", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ CreatedAfter: &twoDaysAgo, }) if err != nil { t.Fatalf("Search failed: %v", err) } // All issues created recently if len(results) != 3 { t.Errorf("Expected 3 issues created after two days ago, got %d", len(results)) } }) t.Run("date range - updated before", func(t *testing.T) { futureTime := now.Add(24 * time.Hour) results, err := s.SearchIssues(ctx, "", types.IssueFilter{ UpdatedBefore: &futureTime, }) if err != nil { t.Fatalf("Search failed: %v", err) } // All issues updated before tomorrow if len(results) != 3 { t.Errorf("Expected 3 issues, got %d", len(results)) } }) t.Run("date range - closed after", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ ClosedAfter: &yesterday, }) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 1 { t.Errorf("Expected 1 closed issue, got %d", len(results)) } }) t.Run("combined filters", func(t *testing.T) { minPrio := 0 maxPrio := 2 results, err := s.SearchIssues(ctx, "", types.IssueFilter{ TitleContains: "Auth", PriorityMin: &minPrio, PriorityMax: &maxPrio, }) if err != nil { t.Fatalf("Search failed: %v", err) } if len(results) != 2 { t.Errorf("Expected 2 results matching combined filters, got %d", len(results)) } }) } func TestFormatIssueLong(t *testing.T) { tests := []struct { name string issue *types.Issue labels []string want string // substring to check for }{ { name: "open issue", issue: &types.Issue{ ID: "test-123", Title: "Test Issue", Priority: 1, IssueType: types.TypeBug, Status: types.StatusOpen, }, labels: nil, want: "test-123", }, { name: "closed issue", issue: &types.Issue{ ID: "test-456", Title: "Closed Issue", Priority: 0, IssueType: types.TypeTask, Status: types.StatusClosed, }, labels: nil, want: "test-456", }, { name: "issue with assignee", issue: &types.Issue{ ID: "test-789", Title: "Assigned Issue", Priority: 2, IssueType: types.TypeFeature, Status: types.StatusInProgress, Assignee: "alice", }, labels: nil, want: "Assignee: alice", }, { name: "issue with labels", issue: &types.Issue{ ID: "test-abc", Title: "Labeled Issue", Priority: 1, IssueType: types.TypeBug, Status: types.StatusOpen, }, labels: []string{"critical", "security"}, want: "Labels:", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var buf strings.Builder formatIssueLong(&buf, tt.issue, tt.labels) result := buf.String() if !strings.Contains(result, tt.want) { t.Errorf("formatIssueLong() = %q, want to contain %q", result, tt.want) } }) } } func TestFormatIssueCompact(t *testing.T) { tests := []struct { name string issue *types.Issue labels []string want string }{ { name: "basic issue", issue: &types.Issue{ ID: "test-123", Title: "Test Issue", Priority: 1, IssueType: types.TypeBug, Status: types.StatusOpen, }, labels: nil, want: "Test Issue", }, { name: "issue with assignee", issue: &types.Issue{ ID: "test-456", Title: "Assigned Issue", Priority: 2, IssueType: types.TypeTask, Status: types.StatusInProgress, Assignee: "bob", }, labels: nil, want: "@bob", }, { name: "issue with labels", issue: &types.Issue{ ID: "test-789", Title: "Labeled Issue", Priority: 0, IssueType: types.TypeFeature, Status: types.StatusOpen, }, labels: []string{"urgent"}, want: "[urgent]", }, { name: "closed issue", issue: &types.Issue{ ID: "test-def", Title: "Closed Issue", Priority: 3, IssueType: types.TypeTask, Status: types.StatusClosed, }, labels: nil, want: "Closed Issue", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var buf strings.Builder formatIssueCompact(&buf, tt.issue, tt.labels) result := buf.String() if !strings.Contains(result, tt.want) { t.Errorf("formatIssueCompact() = %q, want to contain %q", result, tt.want) } }) } } func TestParseTimeFlag(t *testing.T) { tests := []struct { name string input string wantErr bool }{ // Absolute formats {"RFC3339", "2023-01-15T10:30:00Z", false}, {"Date only", "2023-01-15", false}, // Compact duration formats (GH#820) {"Compact hours", "+6h", false}, {"Compact days", "+1d", false}, {"Compact weeks", "+2w", false}, {"Compact negative", "-3d", false}, // Natural language (GH#820) {"Natural tomorrow", "tomorrow", false}, {"Natural next monday", "next monday", false}, // Invalid formats {"Invalid format", "not-a-date", true}, {"Empty string", "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := parseTimeFlag(tt.input) if (err != nil) != tt.wantErr { t.Errorf("parseTimeFlag(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) } }) } } // TestListTimeBasedFilters tests the time-based scheduling filters (GH#820) func TestListTimeBasedFilters(t *testing.T) { tmpDir := t.TempDir() testDB := filepath.Join(tmpDir, ".beads", "beads.db") s := newTestStore(t, testDB) ctx := context.Background() now := time.Now() yesterday := now.Add(-24 * time.Hour) tomorrow := now.Add(24 * time.Hour) nextWeek := now.Add(7 * 24 * time.Hour) // Create test issues with varied due_at and defer_until values issueNoSchedule := &types.Issue{ Title: "Issue without scheduling", Priority: 2, IssueType: types.TypeTask, Status: types.StatusOpen, } issueDeferredFuture := &types.Issue{ Title: "Deferred until tomorrow", Priority: 2, IssueType: types.TypeTask, Status: types.StatusOpen, DeferUntil: &tomorrow, } issueDeferredPast := &types.Issue{ Title: "Was deferred until yesterday", Priority: 2, IssueType: types.TypeTask, Status: types.StatusOpen, DeferUntil: &yesterday, } issueDueNextWeek := &types.Issue{ Title: "Due next week", Priority: 1, IssueType: types.TypeTask, Status: types.StatusOpen, DueAt: &nextWeek, } issueOverdue := &types.Issue{ Title: "Overdue issue", Priority: 0, IssueType: types.TypeBug, Status: types.StatusOpen, DueAt: &yesterday, } issueOverdueClosed := &types.Issue{ Title: "Overdue but closed", Priority: 0, IssueType: types.TypeBug, Status: types.StatusClosed, DueAt: &yesterday, ClosedAt: &now, } for _, issue := range []*types.Issue{ issueNoSchedule, issueDeferredFuture, issueDeferredPast, issueDueNextWeek, issueOverdue, issueOverdueClosed, } { if err := s.CreateIssue(ctx, issue, "test-user"); err != nil { t.Fatalf("Failed to create issue: %v", err) } } t.Run("filter by deferred flag", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ Deferred: true, }) if err != nil { t.Fatalf("Search failed: %v", err) } // Should find issues with defer_until set (future and past) if len(results) != 2 { t.Errorf("Expected 2 deferred issues, got %d", len(results)) } }) t.Run("filter by defer-after", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ DeferAfter: &now, }) if err != nil { t.Fatalf("Search failed: %v", err) } // Should find issues deferred after now (tomorrow) if len(results) != 1 { t.Errorf("Expected 1 issue deferred after now, got %d", len(results)) } }) t.Run("filter by defer-before", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ DeferBefore: &now, }) if err != nil { t.Fatalf("Search failed: %v", err) } // Should find issues deferred before now (yesterday) if len(results) != 1 { t.Errorf("Expected 1 issue deferred before now, got %d", len(results)) } }) t.Run("filter by due-after", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ DueAfter: &now, }) if err != nil { t.Fatalf("Search failed: %v", err) } // Should find issues due after now (next week) if len(results) != 1 { t.Errorf("Expected 1 issue due after now, got %d", len(results)) } }) t.Run("filter by due-before", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ DueBefore: &now, }) if err != nil { t.Fatalf("Search failed: %v", err) } // Should find issues due before now (overdue open + closed = 2) if len(results) != 2 { t.Errorf("Expected 2 issues due before now, got %d", len(results)) } }) t.Run("filter by overdue", func(t *testing.T) { results, err := s.SearchIssues(ctx, "", types.IssueFilter{ Overdue: true, }) if err != nil { t.Fatalf("Search failed: %v", err) } // Should find only the open overdue issue (not the closed one) if len(results) != 1 { t.Errorf("Expected 1 overdue issue, got %d", len(results)) } if len(results) > 0 && results[0].ID != issueOverdue.ID { t.Errorf("Expected issue %s, got %s", issueOverdue.ID, results[0].ID) } }) t.Run("combined filters defer and due", func(t *testing.T) { // Issue with both defer_until and due_at bothSet := &types.Issue{ Title: "Both deferred and due", Priority: 1, IssueType: types.TypeTask, Status: types.StatusOpen, DeferUntil: &tomorrow, DueAt: &nextWeek, } if err := s.CreateIssue(ctx, bothSet, "test-user"); err != nil { t.Fatalf("Failed to create issue: %v", err) } results, err := s.SearchIssues(ctx, "", types.IssueFilter{ Deferred: true, DueAfter: &now, }) if err != nil { t.Fatalf("Search failed: %v", err) } // Should find the issue with both defer_until set and due_at > now if len(results) != 1 { t.Errorf("Expected 1 issue with both filters, got %d", len(results)) } }) }