diff --git a/internal/compact/compactor_unit_test.go b/internal/compact/compactor_unit_test.go new file mode 100644 index 00000000..f1a85069 --- /dev/null +++ b/internal/compact/compactor_unit_test.go @@ -0,0 +1,732 @@ +package compact + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +// setupTestStore creates a test SQLite store for unit tests +func setupTestStore(t *testing.T) *sqlite.SQLiteStorage { + t.Helper() + + tmpDB := t.TempDir() + "/test.db" + store, err := sqlite.New(context.Background(), tmpDB) + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + + ctx := context.Background() + // Set issue_prefix to prevent "database not initialized" errors + if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil { + t.Fatalf("failed to set issue_prefix: %v", err) + } + // Use 7 days minimum for Tier 1 compaction + if err := store.SetConfig(ctx, "compact_tier1_days", "7"); err != nil { + t.Fatalf("failed to set config: %v", err) + } + if err := store.SetConfig(ctx, "compact_tier1_dep_levels", "2"); err != nil { + t.Fatalf("failed to set config: %v", err) + } + + return store +} + +// createTestIssue creates a closed issue eligible for compaction +func createTestIssue(t *testing.T, store *sqlite.SQLiteStorage, id string) *types.Issue { + t.Helper() + + ctx := context.Background() + prefix, _ := store.GetConfig(ctx, "issue_prefix") + if prefix == "" { + prefix = "bd" + } + + now := time.Now() + // Issue closed 8 days ago (beyond 7-day threshold for Tier 1) + closedAt := now.Add(-8 * 24 * time.Hour) + issue := &types.Issue{ + ID: id, + Title: "Test Issue", + Description: `Implemented a comprehensive authentication system for the application. + +The system includes JWT token generation, refresh token handling, password hashing with bcrypt, +rate limiting on login attempts, and session management.`, + Design: `Authentication Flow: +1. User submits credentials +2. Server validates against database +3. On success, generate JWT with user claims`, + Notes: "Performance considerations and testing strategy notes.", + AcceptanceCriteria: "- Users can register\n- Users can login\n- Protected endpoints work", + Status: types.StatusClosed, + Priority: 2, + IssueType: types.TypeTask, + CreatedAt: now.Add(-48 * time.Hour), + UpdatedAt: now.Add(-24 * time.Hour), + ClosedAt: &closedAt, + } + + if err := store.CreateIssue(ctx, issue, prefix); err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + return issue +} + +func TestNew_WithConfig(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + config := &Config{ + Concurrency: 10, + DryRun: true, + } + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + if c.config.Concurrency != 10 { + t.Errorf("expected concurrency 10, got %d", c.config.Concurrency) + } + if !c.config.DryRun { + t.Error("expected DryRun to be true") + } +} + +func TestNew_DefaultConcurrency(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + c, err := New(store, "", nil) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + if c.config.Concurrency != defaultConcurrency { + t.Errorf("expected default concurrency %d, got %d", defaultConcurrency, c.config.Concurrency) + } +} + +func TestNew_ZeroConcurrency(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + config := &Config{ + Concurrency: 0, + DryRun: true, + } + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + // Zero concurrency should be replaced with default + if c.config.Concurrency != defaultConcurrency { + t.Errorf("expected default concurrency %d, got %d", defaultConcurrency, c.config.Concurrency) + } +} + +func TestNew_NegativeConcurrency(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + config := &Config{ + Concurrency: -5, + DryRun: true, + } + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + // Negative concurrency should be replaced with default + if c.config.Concurrency != defaultConcurrency { + t.Errorf("expected default concurrency %d, got %d", defaultConcurrency, c.config.Concurrency) + } +} + +func TestNew_WithAPIKey(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + // Clear env var to test explicit key + t.Setenv("ANTHROPIC_API_KEY", "") + + config := &Config{ + DryRun: true, // DryRun so we don't actually need a valid key + } + c, err := New(store, "test-api-key", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + if c.config.APIKey != "test-api-key" { + t.Errorf("expected api key 'test-api-key', got '%s'", c.config.APIKey) + } +} + +func TestNew_NoAPIKeyFallsToDryRun(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + // Clear env var + t.Setenv("ANTHROPIC_API_KEY", "") + + config := &Config{ + DryRun: false, // Try to create real client + } + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + // Should fall back to DryRun when no API key + if !c.config.DryRun { + t.Error("expected DryRun to be true when no API key provided") + } +} + +func TestNew_AuditSettings(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + t.Setenv("ANTHROPIC_API_KEY", "test-key") + + config := &Config{ + AuditEnabled: true, + Actor: "test-actor", + } + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + if c.haiku == nil { + t.Fatal("expected haiku client to be created") + } + if !c.haiku.auditEnabled { + t.Error("expected auditEnabled to be true") + } + if c.haiku.auditActor != "test-actor" { + t.Errorf("expected auditActor 'test-actor', got '%s'", c.haiku.auditActor) + } +} + +func TestCompactTier1_DryRun(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + issue := createTestIssue(t, store, "bd-1") + + config := &Config{DryRun: true} + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + + ctx := context.Background() + err = c.CompactTier1(ctx, issue.ID) + if err == nil { + t.Fatal("expected dry-run error, got nil") + } + if !strings.HasPrefix(err.Error(), "dry-run:") { + t.Errorf("expected dry-run error prefix, got: %v", err) + } + + // Verify issue was not modified + afterIssue, err := store.GetIssue(ctx, issue.ID) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + if afterIssue.Description != issue.Description { + t.Error("dry-run should not modify issue") + } +} + +func TestCompactTier1_IneligibleOpenIssue(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + ctx := context.Background() + prefix, _ := store.GetConfig(ctx, "issue_prefix") + if prefix == "" { + prefix = "bd" + } + + now := time.Now() + issue := &types.Issue{ + ID: "bd-open", + Title: "Open Issue", + Description: "Should not be compacted", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.CreateIssue(ctx, issue, prefix); err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + config := &Config{DryRun: true} + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + + err = c.CompactTier1(ctx, issue.ID) + if err == nil { + t.Fatal("expected error for ineligible issue, got nil") + } + if !strings.Contains(err.Error(), "not eligible") { + t.Errorf("expected 'not eligible' error, got: %v", err) + } +} + +func TestCompactTier1_NonexistentIssue(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + config := &Config{DryRun: true} + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + + ctx := context.Background() + err = c.CompactTier1(ctx, "bd-nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent issue") + } +} + +func TestCompactTier1_ContextCanceled(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + issue := createTestIssue(t, store, "bd-cancel") + + config := &Config{DryRun: true} + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + err = c.CompactTier1(ctx, issue.ID) + if err == nil { + t.Fatal("expected error for canceled context") + } + if err != context.Canceled { + t.Errorf("expected context.Canceled, got: %v", err) + } +} + +func TestCompactTier1Batch_EmptyList(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + config := &Config{DryRun: true} + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + + ctx := context.Background() + results, err := c.CompactTier1Batch(ctx, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if results != nil { + t.Errorf("expected nil results for empty list, got: %v", results) + } +} + +func TestCompactTier1Batch_DryRun(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + issue1 := createTestIssue(t, store, "bd-batch-1") + issue2 := createTestIssue(t, store, "bd-batch-2") + + config := &Config{DryRun: true, Concurrency: 2} + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + + ctx := context.Background() + results, err := c.CompactTier1Batch(ctx, []string{issue1.ID, issue2.ID}) + if err != nil { + t.Fatalf("failed to batch compact: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + for _, result := range results { + if result.Err != nil { + t.Errorf("unexpected error for %s: %v", result.IssueID, result.Err) + } + if result.OriginalSize == 0 { + t.Errorf("expected non-zero original size for %s", result.IssueID) + } + } +} + +func TestCompactTier1Batch_MixedEligibility(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + closedIssue := createTestIssue(t, store, "bd-closed") + + ctx := context.Background() + prefix, _ := store.GetConfig(ctx, "issue_prefix") + if prefix == "" { + prefix = "bd" + } + + now := time.Now() + openIssue := &types.Issue{ + ID: "bd-open", + Title: "Open Issue", + Description: "Should not be compacted", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.CreateIssue(ctx, openIssue, prefix); err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + config := &Config{DryRun: true, Concurrency: 2} + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + + results, err := c.CompactTier1Batch(ctx, []string{closedIssue.ID, openIssue.ID}) + if err != nil { + t.Fatalf("failed to batch compact: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + var foundClosed, foundOpen bool + for _, result := range results { + switch result.IssueID { + case openIssue.ID: + foundOpen = true + if result.Err == nil { + t.Error("expected error for ineligible issue") + } + case closedIssue.ID: + foundClosed = true + if result.Err != nil { + t.Errorf("unexpected error for eligible issue: %v", result.Err) + } + } + } + if !foundClosed || !foundOpen { + t.Error("missing expected results") + } +} + +func TestCompactTier1Batch_NonexistentIssue(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + closedIssue := createTestIssue(t, store, "bd-closed") + + config := &Config{DryRun: true, Concurrency: 2} + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + + ctx := context.Background() + results, err := c.CompactTier1Batch(ctx, []string{closedIssue.ID, "bd-nonexistent"}) + if err != nil { + t.Fatalf("batch operation failed: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + var successCount, errorCount int + for _, r := range results { + if r.Err == nil { + successCount++ + } else { + errorCount++ + } + } + + if successCount != 1 { + t.Errorf("expected 1 success, got %d", successCount) + } + if errorCount != 1 { + t.Errorf("expected 1 error, got %d", errorCount) + } +} + +func TestCompactTier1_WithMockAPI(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + issue := createTestIssue(t, store, "bd-mock-api") + + // Create mock server that returns a short summary + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "msg_test123", + "type": "message", + "role": "assistant", + "model": "claude-3-5-haiku-20241022", + "content": []map[string]interface{}{ + { + "type": "text", + "text": "**Summary:** Short summary.\n\n**Key Decisions:** None.\n\n**Resolution:** Done.", + }, + }, + }) + })) + defer server.Close() + + t.Setenv("ANTHROPIC_API_KEY", "test-key") + + // Create compactor with mock API + config := &Config{Concurrency: 1} + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + + // Replace the haiku client with one pointing to mock server + c.haiku, err = NewHaikuClient("test-key", option.WithBaseURL(server.URL), option.WithMaxRetries(0)) + if err != nil { + t.Fatalf("failed to create mock haiku client: %v", err) + } + + ctx := context.Background() + err = c.CompactTier1(ctx, issue.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify issue was updated + afterIssue, err := store.GetIssue(ctx, issue.ID) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + + if afterIssue.Description == issue.Description { + t.Error("description should have been updated") + } + if afterIssue.Design != "" { + t.Error("design should be cleared") + } + if afterIssue.Notes != "" { + t.Error("notes should be cleared") + } + if afterIssue.AcceptanceCriteria != "" { + t.Error("acceptance criteria should be cleared") + } +} + +func TestCompactTier1_SummaryNotShorter(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + // Create issue with very short content + ctx := context.Background() + prefix, _ := store.GetConfig(ctx, "issue_prefix") + if prefix == "" { + prefix = "bd" + } + + now := time.Now() + closedAt := now.Add(-8 * 24 * time.Hour) + issue := &types.Issue{ + ID: "bd-short", + Title: "Short", + Description: "X", // Very short description + Status: types.StatusClosed, + Priority: 2, + IssueType: types.TypeTask, + CreatedAt: now.Add(-48 * time.Hour), + UpdatedAt: now.Add(-24 * time.Hour), + ClosedAt: &closedAt, + } + if err := store.CreateIssue(ctx, issue, prefix); err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + // Create mock server that returns a longer summary + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "msg_test123", + "type": "message", + "role": "assistant", + "model": "claude-3-5-haiku-20241022", + "content": []map[string]interface{}{ + { + "type": "text", + "text": "**Summary:** This is a much longer summary that exceeds the original content length.\n\n**Key Decisions:** Multiple decisions.\n\n**Resolution:** Complete.", + }, + }, + }) + })) + defer server.Close() + + t.Setenv("ANTHROPIC_API_KEY", "test-key") + + config := &Config{Concurrency: 1} + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + + c.haiku, err = NewHaikuClient("test-key", option.WithBaseURL(server.URL), option.WithMaxRetries(0)) + if err != nil { + t.Fatalf("failed to create mock haiku client: %v", err) + } + + err = c.CompactTier1(ctx, issue.ID) + if err == nil { + t.Fatal("expected error when summary is longer") + } + if !strings.Contains(err.Error(), "would increase size") { + t.Errorf("expected 'would increase size' error, got: %v", err) + } + + // Verify issue was NOT modified (kept original) + afterIssue, err := store.GetIssue(ctx, issue.ID) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + if afterIssue.Description != issue.Description { + t.Error("description should not have been modified when summary is longer") + } +} + +func TestCompactTier1Batch_WithMockAPI(t *testing.T) { + store := setupTestStore(t) + defer store.Close() + + issue1 := createTestIssue(t, store, "bd-batch-mock-1") + issue2 := createTestIssue(t, store, "bd-batch-mock-2") + + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "msg_test123", + "type": "message", + "role": "assistant", + "model": "claude-3-5-haiku-20241022", + "content": []map[string]interface{}{ + { + "type": "text", + "text": "**Summary:** Compacted.\n\n**Key Decisions:** None.\n\n**Resolution:** Done.", + }, + }, + }) + })) + defer server.Close() + + t.Setenv("ANTHROPIC_API_KEY", "test-key") + + config := &Config{Concurrency: 2} + c, err := New(store, "", config) + if err != nil { + t.Fatalf("failed to create compactor: %v", err) + } + + c.haiku, err = NewHaikuClient("test-key", option.WithBaseURL(server.URL), option.WithMaxRetries(0)) + if err != nil { + t.Fatalf("failed to create mock haiku client: %v", err) + } + + ctx := context.Background() + results, err := c.CompactTier1Batch(ctx, []string{issue1.ID, issue2.ID}) + if err != nil { + t.Fatalf("failed to batch compact: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + for _, result := range results { + if result.Err != nil { + t.Errorf("unexpected error for %s: %v", result.IssueID, result.Err) + } + if result.CompactedSize == 0 { + t.Errorf("expected non-zero compacted size for %s", result.IssueID) + } + if result.CompactedSize >= result.OriginalSize { + t.Errorf("expected size reduction for %s: %d → %d", result.IssueID, result.OriginalSize, result.CompactedSize) + } + } +} + +func TestResult_Fields(t *testing.T) { + r := &Result{ + IssueID: "bd-1", + OriginalSize: 100, + CompactedSize: 50, + Err: nil, + } + + if r.IssueID != "bd-1" { + t.Errorf("expected IssueID 'bd-1', got '%s'", r.IssueID) + } + if r.OriginalSize != 100 { + t.Errorf("expected OriginalSize 100, got %d", r.OriginalSize) + } + if r.CompactedSize != 50 { + t.Errorf("expected CompactedSize 50, got %d", r.CompactedSize) + } + if r.Err != nil { + t.Errorf("expected nil Err, got %v", r.Err) + } +} + +func TestConfig_Fields(t *testing.T) { + c := &Config{ + APIKey: "test-key", + Concurrency: 10, + DryRun: true, + AuditEnabled: true, + Actor: "test-actor", + } + + if c.APIKey != "test-key" { + t.Errorf("expected APIKey 'test-key', got '%s'", c.APIKey) + } + if c.Concurrency != 10 { + t.Errorf("expected Concurrency 10, got %d", c.Concurrency) + } + if !c.DryRun { + t.Error("expected DryRun true") + } + if !c.AuditEnabled { + t.Error("expected AuditEnabled true") + } + if c.Actor != "test-actor" { + t.Errorf("expected Actor 'test-actor', got '%s'", c.Actor) + } +} diff --git a/internal/compact/git_test.go b/internal/compact/git_test.go new file mode 100644 index 00000000..6077ac56 --- /dev/null +++ b/internal/compact/git_test.go @@ -0,0 +1,171 @@ +package compact + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "testing" +) + +func TestGetCurrentCommitHash_InGitRepo(t *testing.T) { + // This test runs in the actual beads repo, so it should return a valid hash + hash := GetCurrentCommitHash() + + // Should be a 40-character hex string + if len(hash) != 40 { + t.Errorf("expected 40-char hash, got %d chars: %s", len(hash), hash) + } + + // Should be valid hex + matched, err := regexp.MatchString("^[0-9a-f]{40}$", hash) + if err != nil { + t.Fatalf("regex error: %v", err) + } + if !matched { + t.Errorf("expected hex hash, got: %s", hash) + } +} + +func TestGetCurrentCommitHash_NotInGitRepo(t *testing.T) { + // Save current directory + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get cwd: %v", err) + } + + // Create a temporary directory that is NOT a git repo + tmpDir := t.TempDir() + + // Change to the temp directory + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir to temp dir: %v", err) + } + defer func() { + // Restore original directory + if err := os.Chdir(originalDir); err != nil { + t.Fatalf("failed to restore cwd: %v", err) + } + }() + + // Should return empty string when not in a git repo + hash := GetCurrentCommitHash() + if hash != "" { + t.Errorf("expected empty string outside git repo, got: %s", hash) + } +} + +func TestGetCurrentCommitHash_NewGitRepo(t *testing.T) { + // Save current directory + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get cwd: %v", err) + } + + // Create a temporary directory + tmpDir := t.TempDir() + + // Initialize a new 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 for the commit + cmd = exec.Command("git", "config", "user.email", "test@test.com") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to set git email: %v", err) + } + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to set git name: %v", err) + } + + // Create a file and commit it + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + cmd = exec.Command("git", "add", ".") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "test commit") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git commit: %v", err) + } + + // Change to the new git repo + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir to git repo: %v", err) + } + defer func() { + // Restore original directory + if err := os.Chdir(originalDir); err != nil { + t.Fatalf("failed to restore cwd: %v", err) + } + }() + + // Should return a valid hash + hash := GetCurrentCommitHash() + if len(hash) != 40 { + t.Errorf("expected 40-char hash, got %d chars: %s", len(hash), hash) + } + + // Verify it matches git rev-parse output + cmd = exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = tmpDir + out, err := cmd.Output() + if err != nil { + t.Fatalf("failed to run git rev-parse: %v", err) + } + + expected := string(out) + expected = expected[:len(expected)-1] // trim newline + if hash != expected { + t.Errorf("hash mismatch: got %s, expected %s", hash, expected) + } +} + +func TestGetCurrentCommitHash_EmptyGitRepo(t *testing.T) { + // Save current directory + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get cwd: %v", err) + } + + // Create a temporary directory + tmpDir := t.TempDir() + + // Initialize a new git repo but don't commit anything + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + // Change to the empty git repo + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir to git repo: %v", err) + } + defer func() { + // Restore original directory + if err := os.Chdir(originalDir); err != nil { + t.Fatalf("failed to restore cwd: %v", err) + } + }() + + // Should return empty string for repo with no commits + hash := GetCurrentCommitHash() + if hash != "" { + t.Errorf("expected empty string for empty git repo, got: %s", hash) + } +} diff --git a/internal/compact/haiku.go b/internal/compact/haiku.go index 58eec341..4d2dd9f0 100644 --- a/internal/compact/haiku.go +++ b/internal/compact/haiku.go @@ -38,7 +38,7 @@ type HaikuClient struct { } // NewHaikuClient creates a new Haiku API client. Env var ANTHROPIC_API_KEY takes precedence over explicit apiKey. -func NewHaikuClient(apiKey string) (*HaikuClient, error) { +func NewHaikuClient(apiKey string, opts ...option.RequestOption) (*HaikuClient, error) { envKey := os.Getenv("ANTHROPIC_API_KEY") if envKey != "" { apiKey = envKey @@ -47,7 +47,10 @@ func NewHaikuClient(apiKey string) (*HaikuClient, error) { return nil, fmt.Errorf("%w: set ANTHROPIC_API_KEY environment variable or provide via config", ErrAPIKeyRequired) } - client := anthropic.NewClient(option.WithAPIKey(apiKey)) + // Build options: API key first, then any additional options (for testing) + allOpts := []option.RequestOption{option.WithAPIKey(apiKey)} + allOpts = append(allOpts, opts...) + client := anthropic.NewClient(allOpts...) tier1Tmpl, err := template.New("tier1").Parse(tier1PromptTemplate) if err != nil { diff --git a/internal/compact/haiku_test.go b/internal/compact/haiku_test.go index 11de2827..035638dd 100644 --- a/internal/compact/haiku_test.go +++ b/internal/compact/haiku_test.go @@ -2,11 +2,18 @@ package compact import ( "context" + "encoding/json" "errors" + "net" + "net/http" + "net/http/httptest" "strings" + "sync/atomic" "testing" "time" + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" "github.com/steveyegge/beads/internal/types" ) @@ -189,3 +196,399 @@ func TestIsRetryable(t *testing.T) { }) } } + +// mockTimeoutError implements net.Error for timeout testing +type mockTimeoutError struct { + timeout bool +} + +func (e *mockTimeoutError) Error() string { return "mock timeout error" } +func (e *mockTimeoutError) Timeout() bool { return e.timeout } +func (e *mockTimeoutError) Temporary() bool { return false } + +func TestIsRetryable_NetworkTimeout(t *testing.T) { + // Network timeout should be retryable + timeoutErr := &mockTimeoutError{timeout: true} + if !isRetryable(timeoutErr) { + t.Error("network timeout error should be retryable") + } + + // Non-timeout network error should not be retryable + nonTimeoutErr := &mockTimeoutError{timeout: false} + if isRetryable(nonTimeoutErr) { + t.Error("non-timeout network error should not be retryable") + } +} + +func TestIsRetryable_APIErrors(t *testing.T) { + tests := []struct { + name string + statusCode int + expected bool + }{ + {"rate limit 429", 429, true}, + {"server error 500", 500, true}, + {"server error 502", 502, true}, + {"server error 503", 503, true}, + {"bad request 400", 400, false}, + {"unauthorized 401", 401, false}, + {"forbidden 403", 403, false}, + {"not found 404", 404, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiErr := &anthropic.Error{StatusCode: tt.statusCode} + got := isRetryable(apiErr) + if got != tt.expected { + t.Errorf("isRetryable(API error %d) = %v, want %v", tt.statusCode, got, tt.expected) + } + }) + } +} + +// createMockAnthropicServer creates a mock server that returns Anthropic API responses +func createMockAnthropicServer(handler http.HandlerFunc) *httptest.Server { + return httptest.NewServer(handler) +} + +// mockAnthropicResponse creates a valid Anthropic Messages API response +func mockAnthropicResponse(text string) map[string]interface{} { + return map[string]interface{}{ + "id": "msg_test123", + "type": "message", + "role": "assistant", + "model": "claude-3-5-haiku-20241022", + "stop_reason": "end_turn", + "stop_sequence": nil, + "usage": map[string]int{ + "input_tokens": 100, + "output_tokens": 50, + }, + "content": []map[string]interface{}{ + { + "type": "text", + "text": text, + }, + }, + } +} + +func TestSummarizeTier1_MockAPI(t *testing.T) { + // Create mock server that returns a valid summary + server := createMockAnthropicServer(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and path + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/messages") { + t.Errorf("expected /messages path, got %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + resp := mockAnthropicResponse("**Summary:** Fixed auth bug.\n\n**Key Decisions:** Used OAuth.\n\n**Resolution:** Complete.") + json.NewEncoder(w).Encode(resp) + }) + defer server.Close() + + client, err := NewHaikuClient("test-key", option.WithBaseURL(server.URL)) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + issue := &types.Issue{ + ID: "bd-1", + Title: "Fix authentication bug", + Description: "OAuth login was broken", + Status: types.StatusClosed, + } + + ctx := context.Background() + result, err := client.SummarizeTier1(ctx, issue) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(result, "**Summary:**") { + t.Error("result should contain Summary section") + } + if !strings.Contains(result, "Fixed auth bug") { + t.Error("result should contain summary text") + } +} + +func TestSummarizeTier1_APIError(t *testing.T) { + // Create mock server that returns an error + server := createMockAnthropicServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "error", + "error": map[string]interface{}{ + "type": "invalid_request_error", + "message": "Invalid API key", + }, + }) + }) + defer server.Close() + + client, err := NewHaikuClient("test-key", option.WithBaseURL(server.URL)) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + issue := &types.Issue{ + ID: "bd-1", + Title: "Test", + Description: "Test", + Status: types.StatusClosed, + } + + ctx := context.Background() + _, err = client.SummarizeTier1(ctx, issue) + if err == nil { + t.Fatal("expected error from API") + } + if !strings.Contains(err.Error(), "non-retryable") { + t.Errorf("expected non-retryable error, got: %v", err) + } +} + +func TestCallWithRetry_RetriesOn429(t *testing.T) { + var attempts int32 + + server := createMockAnthropicServer(func(w http.ResponseWriter, r *http.Request) { + attempt := atomic.AddInt32(&attempts, 1) + if attempt <= 2 { + // First two attempts return 429 + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "error", + "error": map[string]interface{}{ + "type": "rate_limit_error", + "message": "Rate limited", + }, + }) + return + } + // Third attempt succeeds + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockAnthropicResponse("Success after retries")) + }) + defer server.Close() + + // Disable SDK's internal retries to test our retry logic only + client, err := NewHaikuClient("test-key", option.WithBaseURL(server.URL), option.WithMaxRetries(0)) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + // Use short backoff for testing + client.initialBackoff = 10 * time.Millisecond + + ctx := context.Background() + result, err := client.callWithRetry(ctx, "test prompt") + if err != nil { + t.Fatalf("expected success after retries, got: %v", err) + } + if result != "Success after retries" { + t.Errorf("expected 'Success after retries', got: %s", result) + } + if attempts != 3 { + t.Errorf("expected 3 attempts, got: %d", attempts) + } +} + +func TestCallWithRetry_RetriesOn500(t *testing.T) { + var attempts int32 + + server := createMockAnthropicServer(func(w http.ResponseWriter, r *http.Request) { + attempt := atomic.AddInt32(&attempts, 1) + if attempt == 1 { + // First attempt returns 500 + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "error", + "error": map[string]interface{}{ + "type": "api_error", + "message": "Internal server error", + }, + }) + return + } + // Second attempt succeeds + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockAnthropicResponse("Recovered from 500")) + }) + defer server.Close() + + // Disable SDK's internal retries to test our retry logic only + client, err := NewHaikuClient("test-key", option.WithBaseURL(server.URL), option.WithMaxRetries(0)) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + client.initialBackoff = 10 * time.Millisecond + + ctx := context.Background() + result, err := client.callWithRetry(ctx, "test prompt") + if err != nil { + t.Fatalf("expected success after retry, got: %v", err) + } + if result != "Recovered from 500" { + t.Errorf("expected 'Recovered from 500', got: %s", result) + } +} + +func TestCallWithRetry_ExhaustsRetries(t *testing.T) { + var attempts int32 + + server := createMockAnthropicServer(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&attempts, 1) + // Always return 429 + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "error", + "error": map[string]interface{}{ + "type": "rate_limit_error", + "message": "Rate limited", + }, + }) + }) + defer server.Close() + + // Disable SDK's internal retries to test our retry logic only + client, err := NewHaikuClient("test-key", option.WithBaseURL(server.URL), option.WithMaxRetries(0)) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + client.initialBackoff = 1 * time.Millisecond + client.maxRetries = 2 + + ctx := context.Background() + _, err = client.callWithRetry(ctx, "test prompt") + if err == nil { + t.Fatal("expected error after exhausting retries") + } + if !strings.Contains(err.Error(), "failed after") { + t.Errorf("expected 'failed after' error, got: %v", err) + } + // Initial attempt + 2 retries = 3 total + if attempts != 3 { + t.Errorf("expected 3 attempts, got: %d", attempts) + } +} + +func TestCallWithRetry_NoRetryOn400(t *testing.T) { + var attempts int32 + + server := createMockAnthropicServer(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&attempts, 1) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "error", + "error": map[string]interface{}{ + "type": "invalid_request_error", + "message": "Bad request", + }, + }) + }) + defer server.Close() + + client, err := NewHaikuClient("test-key", option.WithBaseURL(server.URL)) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + client.initialBackoff = 10 * time.Millisecond + + ctx := context.Background() + _, err = client.callWithRetry(ctx, "test prompt") + if err == nil { + t.Fatal("expected error for bad request") + } + if !strings.Contains(err.Error(), "non-retryable") { + t.Errorf("expected non-retryable error, got: %v", err) + } + if attempts != 1 { + t.Errorf("expected only 1 attempt for non-retryable error, got: %d", attempts) + } +} + +func TestCallWithRetry_ContextTimeout(t *testing.T) { + server := createMockAnthropicServer(func(w http.ResponseWriter, r *http.Request) { + // Delay longer than context timeout + time.Sleep(200 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockAnthropicResponse("too late")) + }) + defer server.Close() + + client, err := NewHaikuClient("test-key", option.WithBaseURL(server.URL)) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err = client.callWithRetry(ctx, "test prompt") + if err == nil { + t.Fatal("expected timeout error") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("expected context.DeadlineExceeded, got: %v", err) + } +} + +func TestCallWithRetry_EmptyContent(t *testing.T) { + server := createMockAnthropicServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Return response with empty content array + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "msg_test123", + "type": "message", + "role": "assistant", + "model": "claude-3-5-haiku-20241022", + "content": []map[string]interface{}{}, + }) + }) + defer server.Close() + + client, err := NewHaikuClient("test-key", option.WithBaseURL(server.URL)) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + ctx := context.Background() + _, err = client.callWithRetry(ctx, "test prompt") + if err == nil { + t.Fatal("expected error for empty content") + } + if !strings.Contains(err.Error(), "no content blocks") { + t.Errorf("expected 'no content blocks' error, got: %v", err) + } +} + +func TestBytesWriter(t *testing.T) { + w := &bytesWriter{} + + n, err := w.Write([]byte("hello")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 5 { + t.Errorf("expected n=5, got %d", n) + } + + n, err = w.Write([]byte(" world")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 6 { + t.Errorf("expected n=6, got %d", n) + } + + if string(w.buf) != "hello world" { + t.Errorf("expected 'hello world', got '%s'", string(w.buf)) + } +} + +// Verify net.Error interface is properly satisfied for test mocks +var _ net.Error = (*mockTimeoutError)(nil)