package main import ( "context" "encoding/json" "fmt" "os" "path/filepath" "testing" "time" "github.com/steveyegge/beads/internal/types" ) func TestCompactSuite(t *testing.T) { tmpDir := t.TempDir() testDB := filepath.Join(tmpDir, ".beads", "beads.db") s := newTestStore(t, testDB) ctx := context.Background() t.Run("DryRun", func(t *testing.T) { // Create a closed issue issue := &types.Issue{ ID: "test-dryrun-1", Title: "Test Issue", Description: "This is a long description that should be compacted. " + string(make([]byte, 500)), Status: types.StatusClosed, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now().Add(-60 * 24 * time.Hour), ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)), } if err := s.CreateIssue(ctx, issue, "test"); err != nil { t.Fatal(err) } // Test dry run - should check eligibility without error even without API key eligible, reason, err := s.CheckEligibility(ctx, "test-dryrun-1", 1) if err != nil { t.Fatalf("CheckEligibility failed: %v", err) } if !eligible { t.Fatalf("Issue should be eligible for compaction: %s", reason) } }) t.Run("Stats", func(t *testing.T) { // Create mix of issues - some eligible, some not issues := []*types.Issue{ { ID: "test-stats-1", Title: "Old closed", Status: types.StatusClosed, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now().Add(-60 * 24 * time.Hour), ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)), }, { ID: "test-stats-2", Title: "Recent closed", Status: types.StatusClosed, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now().Add(-10 * 24 * time.Hour), ClosedAt: ptrTime(time.Now().Add(-5 * 24 * time.Hour)), }, { ID: "test-stats-3", Title: "Still open", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now().Add(-40 * 24 * time.Hour), }, } for _, issue := range issues { if err := s.CreateIssue(ctx, issue, "test"); err != nil { t.Fatal(err) } } // Verify issues were created allIssues, err := s.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { t.Fatalf("SearchIssues failed: %v", err) } // Count issues with stats prefix statCount := 0 for _, issue := range allIssues { if len(issue.ID) >= 11 && issue.ID[:11] == "test-stats-" { statCount++ } } if statCount != 3 { t.Errorf("Expected 3 stats issues, got %d", statCount) } // Test eligibility check for old closed issue eligible, _, err := s.CheckEligibility(ctx, "test-stats-1", 1) if err != nil { t.Fatalf("CheckEligibility failed: %v", err) } if !eligible { t.Error("Old closed issue should be eligible for Tier 1") } }) t.Run("RunCompactStats", func(t *testing.T) { // Create some closed issues for i := 1; i <= 3; i++ { id := fmt.Sprintf("test-runstats-%d", i) issue := &types.Issue{ ID: id, Title: "Test Issue", Description: string(make([]byte, 500)), Status: types.StatusClosed, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now().Add(-60 * 24 * time.Hour), ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)), } if err := s.CreateIssue(ctx, issue, "test"); err != nil { t.Fatal(err) } } // Test stats - should work without API key savedJSONOutput := jsonOutput jsonOutput = false defer func() { jsonOutput = savedJSONOutput }() // Actually call runCompactStats to increase coverage runCompactStats(ctx, s) // Also test with JSON output jsonOutput = true runCompactStats(ctx, s) }) t.Run("CompactStatsJSON", func(t *testing.T) { // Create a closed issue eligible for Tier 1 issue := &types.Issue{ ID: "test-json-1", Title: "Test Issue", Description: string(make([]byte, 500)), Status: types.StatusClosed, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now().Add(-60 * 24 * time.Hour), ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)), } if err := s.CreateIssue(ctx, issue, "test"); err != nil { t.Fatal(err) } // Test with JSON output savedJSONOutput := jsonOutput jsonOutput = true defer func() { jsonOutput = savedJSONOutput }() // Should not panic and should execute JSON path runCompactStats(ctx, s) }) t.Run("RunCompactSingleDryRun", func(t *testing.T) { // Create a closed issue eligible for compaction issue := &types.Issue{ ID: "test-single-1", Title: "Test Compact Issue", Description: string(make([]byte, 500)), Status: types.StatusClosed, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now().Add(-60 * 24 * time.Hour), ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)), } if err := s.CreateIssue(ctx, issue, "test"); err != nil { t.Fatal(err) } // Test eligibility in dry run mode eligible, _, err := s.CheckEligibility(ctx, "test-single-1", 1) if err != nil { t.Fatalf("CheckEligibility failed: %v", err) } if !eligible { t.Error("Issue should be eligible for Tier 1 compaction") } }) t.Run("RunCompactAllDryRun", func(t *testing.T) { // Create multiple closed issues for i := 1; i <= 3; i++ { issue := &types.Issue{ ID: fmt.Sprintf("test-all-%d", i), Title: "Test Issue", Description: string(make([]byte, 500)), Status: types.StatusClosed, Priority: 2, IssueType: types.TypeTask, CreatedAt: time.Now().Add(-60 * 24 * time.Hour), ClosedAt: ptrTime(time.Now().Add(-35 * 24 * time.Hour)), } if err := s.CreateIssue(ctx, issue, "test"); err != nil { t.Fatal(err) } } // Verify issues eligible for compaction closedStatus := types.StatusClosed issues, err := s.SearchIssues(ctx, "", types.IssueFilter{Status: &closedStatus}) if err != nil { t.Fatalf("SearchIssues failed: %v", err) } eligibleCount := 0 for _, issue := range issues { // Only count our test-all issues if len(issue.ID) < 9 || issue.ID[:9] != "test-all-" { continue } eligible, _, err := s.CheckEligibility(ctx, issue.ID, 1) if err != nil { t.Fatalf("CheckEligibility failed for %s: %v", issue.ID, err) } if eligible { eligibleCount++ } } if eligibleCount != 3 { t.Errorf("Expected 3 eligible issues, got %d", eligibleCount) } }) } func TestCompactValidation(t *testing.T) { tests := []struct { name string compactID string compactAll bool dryRun bool force bool wantError bool }{ { name: "both id and all", compactID: "test-1", compactAll: true, wantError: true, }, { name: "force without id", force: true, wantError: true, }, { name: "no flags", wantError: true, }, { name: "dry run only", dryRun: true, wantError: false, }, { name: "id only", compactID: "test-1", wantError: false, }, { name: "all only", compactAll: true, wantError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.compactID != "" && tt.compactAll { // Should fail if !tt.wantError { t.Error("Expected error for both --id and --all") } } if tt.force && tt.compactID == "" { // Should fail if !tt.wantError { t.Error("Expected error for --force without --id") } } if tt.compactID == "" && !tt.compactAll && !tt.dryRun { // Should fail if !tt.wantError { t.Error("Expected error when no action specified") } } }) } } func TestCompactProgressBar(t *testing.T) { // Test progress bar formatting pb := progressBar(50, 100) if len(pb) == 0 { t.Error("Progress bar should not be empty") } pb = progressBar(100, 100) if len(pb) == 0 { t.Error("Full progress bar should not be empty") } pb = progressBar(0, 100) if len(pb) == 0 { t.Error("Zero progress bar should not be empty") } } func TestFormatUptime(t *testing.T) { tests := []struct { name string seconds float64 want string }{ { name: "seconds", seconds: 45.0, want: "45.0 seconds", }, { name: "minutes", seconds: 300.0, want: "5m 0s", }, { name: "hours", seconds: 7200.0, want: "2h 0m", }, { name: "days", seconds: 90000.0, want: "1d 1h", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := formatUptime(tt.seconds) if got != tt.want { t.Errorf("formatUptime(%v) = %q, want %q", tt.seconds, got, tt.want) } }) } } func ptrTime(t time.Time) *time.Time { return &t } func TestCompactInitCommand(t *testing.T) { if compactCmd == nil { t.Fatal("compactCmd should be initialized") } if compactCmd.Use != "compact" { t.Errorf("Expected Use='compact', got %q", compactCmd.Use) } if len(compactCmd.Long) == 0 { t.Error("compactCmd should have Long description") } // Verify --json flag exists jsonFlag := compactCmd.Flags().Lookup("json") if jsonFlag == nil { t.Error("compact command should have --json flag") } } func TestPruneExpiredTombstones(t *testing.T) { // Setup: create a temp .beads directory with issues.jsonl tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("Failed to create .beads dir: %v", err) } // Create issues.jsonl with mix of live issues, fresh tombstones, and expired tombstones issuesPath := filepath.Join(beadsDir, "issues.jsonl") now := time.Now() freshTombstoneTime := now.Add(-10 * 24 * time.Hour) // 10 days ago - NOT expired expiredTombstoneTime := now.Add(-60 * 24 * time.Hour) // 60 days ago - expired (> 30 day TTL) issues := []*types.Issue{ { ID: "test-live", Title: "Live issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: now.Add(-5 * 24 * time.Hour), UpdatedAt: now, }, { ID: "test-fresh-tombstone", Title: "(deleted)", Status: types.StatusTombstone, Priority: 0, IssueType: types.TypeTask, CreatedAt: now.Add(-20 * 24 * time.Hour), UpdatedAt: freshTombstoneTime, DeletedAt: &freshTombstoneTime, DeletedBy: "alice", DeleteReason: "duplicate", }, { ID: "test-expired-tombstone", Title: "(deleted)", Status: types.StatusTombstone, Priority: 0, IssueType: types.TypeTask, CreatedAt: now.Add(-90 * 24 * time.Hour), UpdatedAt: expiredTombstoneTime, DeletedAt: &expiredTombstoneTime, DeletedBy: "bob", DeleteReason: "obsolete", }, } // Write issues to JSONL file, err := os.Create(issuesPath) if err != nil { t.Fatalf("Failed to create issues.jsonl: %v", err) } encoder := json.NewEncoder(file) for _, issue := range issues { if err := encoder.Encode(issue); err != nil { file.Close() t.Fatalf("Failed to write issue: %v", err) } } file.Close() // Save original dbPath and restore after test originalDBPath := dbPath defer func() { dbPath = originalDBPath }() dbPath = filepath.Join(beadsDir, "beads.db") // Run pruning (0 = use default TTL) result, err := pruneExpiredTombstones(0) if err != nil { t.Fatalf("pruneExpiredTombstones failed: %v", err) } // Verify results if result.PrunedCount != 1 { t.Errorf("Expected 1 pruned tombstone, got %d", result.PrunedCount) } if len(result.PrunedIDs) != 1 || result.PrunedIDs[0] != "test-expired-tombstone" { t.Errorf("Expected PrunedIDs [test-expired-tombstone], got %v", result.PrunedIDs) } if result.TTLDays != 30 { t.Errorf("Expected TTLDays 30, got %d", result.TTLDays) } // Verify the file was updated correctly file, err = os.Open(issuesPath) if err != nil { t.Fatalf("Failed to reopen issues.jsonl: %v", err) } defer file.Close() var remaining []*types.Issue decoder := json.NewDecoder(file) for { var issue types.Issue if err := decoder.Decode(&issue); err != nil { if err.Error() == "EOF" { break } t.Fatalf("Failed to decode issue: %v", err) } remaining = append(remaining, &issue) } if len(remaining) != 2 { t.Fatalf("Expected 2 remaining issues, got %d", len(remaining)) } // Verify live issue and fresh tombstone remain ids := make(map[string]bool) for _, issue := range remaining { ids[issue.ID] = true } if !ids["test-live"] { t.Error("Live issue should remain") } if !ids["test-fresh-tombstone"] { t.Error("Fresh tombstone should remain") } if ids["test-expired-tombstone"] { t.Error("Expired tombstone should have been pruned") } } func TestPruneExpiredTombstones_CustomTTL(t *testing.T) { // Setup: create a temp .beads directory with issues.jsonl tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("Failed to create .beads dir: %v", err) } issuesPath := filepath.Join(beadsDir, "issues.jsonl") now := time.Now() // Both tombstones are older than 5 days, so both should be pruned with 5-day TTL tombstoneTime := now.Add(-10 * 24 * time.Hour) // 10 days ago issues := []*types.Issue{ { ID: "test-live", Title: "Live issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: now.Add(-5 * 24 * time.Hour), UpdatedAt: now, }, { ID: "test-tombstone-1", Title: "(deleted)", Status: types.StatusTombstone, Priority: 0, IssueType: types.TypeTask, CreatedAt: now.Add(-20 * 24 * time.Hour), UpdatedAt: tombstoneTime, DeletedAt: &tombstoneTime, DeletedBy: "alice", DeleteReason: "duplicate", }, } // Write issues to JSONL file, err := os.Create(issuesPath) if err != nil { t.Fatalf("Failed to create issues.jsonl: %v", err) } encoder := json.NewEncoder(file) for _, issue := range issues { if err := encoder.Encode(issue); err != nil { file.Close() t.Fatalf("Failed to write issue: %v", err) } } file.Close() // Save original dbPath and restore after test originalDBPath := dbPath defer func() { dbPath = originalDBPath }() dbPath = filepath.Join(beadsDir, "beads.db") // Run pruning with 5-day TTL - tombstone is 10 days old, should be pruned customTTL := 5 * 24 * time.Hour result, err := pruneExpiredTombstones(customTTL) if err != nil { t.Fatalf("pruneExpiredTombstones failed: %v", err) } // Verify results - 5-day TTL means tombstones older than 5 days are pruned if result.PrunedCount != 1 { t.Errorf("Expected 1 pruned tombstone with 5-day TTL, got %d", result.PrunedCount) } if result.TTLDays != 5 { t.Errorf("Expected TTLDays 5, got %d", result.TTLDays) } } func TestPreviewPruneTombstones(t *testing.T) { // Setup: create a temp .beads directory with issues.jsonl tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("Failed to create .beads dir: %v", err) } issuesPath := filepath.Join(beadsDir, "issues.jsonl") now := time.Now() expiredTombstoneTime := now.Add(-60 * 24 * time.Hour) // 60 days ago issues := []*types.Issue{ { ID: "test-live", Title: "Live issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: now, UpdatedAt: now, }, { ID: "test-expired-tombstone", Title: "(deleted)", Status: types.StatusTombstone, Priority: 0, IssueType: types.TypeTask, CreatedAt: now.Add(-90 * 24 * time.Hour), UpdatedAt: expiredTombstoneTime, DeletedAt: &expiredTombstoneTime, DeletedBy: "bob", DeleteReason: "obsolete", }, } // Write issues to JSONL file, err := os.Create(issuesPath) if err != nil { t.Fatalf("Failed to create issues.jsonl: %v", err) } encoder := json.NewEncoder(file) for _, issue := range issues { if err := encoder.Encode(issue); err != nil { file.Close() t.Fatalf("Failed to write issue: %v", err) } } file.Close() // Save original dbPath and restore after test originalDBPath := dbPath defer func() { dbPath = originalDBPath }() dbPath = filepath.Join(beadsDir, "beads.db") // Preview pruning - should not modify file result, err := previewPruneTombstones(0) if err != nil { t.Fatalf("previewPruneTombstones failed: %v", err) } // Verify preview results if result.PrunedCount != 1 { t.Errorf("Expected 1 tombstone to prune, got %d", result.PrunedCount) } if result.PrunedIDs[0] != "test-expired-tombstone" { t.Errorf("Expected PrunedIDs [test-expired-tombstone], got %v", result.PrunedIDs) } // Verify file was NOT modified (preview mode) file, err = os.Open(issuesPath) if err != nil { t.Fatalf("Failed to reopen issues.jsonl: %v", err) } defer file.Close() var remaining []*types.Issue decoder := json.NewDecoder(file) for { var issue types.Issue if err := decoder.Decode(&issue); err != nil { if err.Error() == "EOF" { break } t.Fatalf("Failed to decode issue: %v", err) } remaining = append(remaining, &issue) } // Both issues should still be in file (preview doesn't modify) if len(remaining) != 2 { t.Errorf("Expected 2 issues (preview mode), got %d", len(remaining)) } } func TestCompactPruneFlagExists(t *testing.T) { // Verify --prune flag exists pruneFlag := compactCmd.Flags().Lookup("prune") if pruneFlag == nil { t.Error("compact command should have --prune flag") } // Verify --older-than flag exists olderThanFlag := compactCmd.Flags().Lookup("older-than") if olderThanFlag == nil { t.Error("compact command should have --older-than flag") } } func TestPruneExpiredTombstones_NoTombstones(t *testing.T) { // Setup: create a temp .beads directory with only live issues tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("Failed to create .beads dir: %v", err) } issuesPath := filepath.Join(beadsDir, "issues.jsonl") now := time.Now() issue := &types.Issue{ ID: "test-live", Title: "Live issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, CreatedAt: now, UpdatedAt: now, } file, err := os.Create(issuesPath) if err != nil { t.Fatalf("Failed to create issues.jsonl: %v", err) } encoder := json.NewEncoder(file) if err := encoder.Encode(issue); err != nil { file.Close() t.Fatalf("Failed to write issue: %v", err) } file.Close() // Save original dbPath and restore after test originalDBPath := dbPath defer func() { dbPath = originalDBPath }() dbPath = filepath.Join(beadsDir, "beads.db") // Run pruning - should return zero pruned (0 = use default TTL) result, err := pruneExpiredTombstones(0) if err != nil { t.Fatalf("pruneExpiredTombstones failed: %v", err) } if result.PrunedCount != 0 { t.Errorf("Expected 0 pruned tombstones, got %d", result.PrunedCount) } }