test: improve internal/compact coverage from 17% to 82% (bd-thgk)
Add comprehensive unit tests for the compact package: - haiku.go: Mock API tests for SummarizeTier1, retry logic tests for callWithRetry (429/500 handling, exhaust retries, context timeout), expanded isRetryable tests for network timeouts and API error codes - git.go: Tests for GetCurrentCommitHash in various git states (in repo, outside repo, new repo, empty repo) - compactor.go: Unit tests for New(), CompactTier1(), CompactTier1Batch() with mock API server, config validation, error paths Small production change: NewHaikuClient now accepts variadic options for testing (option.WithBaseURL, option.WithMaxRetries). Coverage: 17.3% → 81.8% 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
732
internal/compact/compactor_unit_test.go
Normal file
732
internal/compact/compactor_unit_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
171
internal/compact/git_test.go
Normal file
171
internal/compact/git_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user