Files
beads/internal/compact/haiku_test.go
Steve Yegge 1c2e7068ad 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>
2025-12-23 13:42:24 -08:00

595 lines
16 KiB
Go

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"
)
func TestNewHaikuClient_RequiresAPIKey(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
_, err := NewHaikuClient("")
if err == nil {
t.Fatal("expected error when API key is missing")
}
if !errors.Is(err, ErrAPIKeyRequired) {
t.Fatalf("expected ErrAPIKeyRequired, got %v", err)
}
if !strings.Contains(err.Error(), "API key required") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestNewHaikuClient_EnvVarUsedWhenNoExplicitKey(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "test-key-from-env")
client, err := NewHaikuClient("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client == nil {
t.Fatal("expected non-nil client")
}
}
func TestNewHaikuClient_EnvVarOverridesExplicitKey(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "test-key-from-env")
client, err := NewHaikuClient("test-key-explicit")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client == nil {
t.Fatal("expected non-nil client")
}
}
func TestRenderTier1Prompt(t *testing.T) {
client, err := NewHaikuClient("test-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
issue := &types.Issue{
ID: "bd-1",
Title: "Fix authentication bug",
Description: "Users can't log in with OAuth",
Design: "Add error handling to OAuth flow",
AcceptanceCriteria: "Users can log in successfully",
Notes: "Related to issue bd-2",
Status: types.StatusClosed,
}
prompt, err := client.renderTier1Prompt(issue)
if err != nil {
t.Fatalf("failed to render prompt: %v", err)
}
if !strings.Contains(prompt, "Fix authentication bug") {
t.Error("prompt should contain title")
}
if !strings.Contains(prompt, "Users can't log in with OAuth") {
t.Error("prompt should contain description")
}
if !strings.Contains(prompt, "Add error handling to OAuth flow") {
t.Error("prompt should contain design")
}
if !strings.Contains(prompt, "Users can log in successfully") {
t.Error("prompt should contain acceptance criteria")
}
if !strings.Contains(prompt, "Related to issue bd-2") {
t.Error("prompt should contain notes")
}
if !strings.Contains(prompt, "**Summary:**") {
t.Error("prompt should contain format instructions")
}
}
func TestRenderTier1Prompt_HandlesEmptyFields(t *testing.T) {
client, err := NewHaikuClient("test-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
issue := &types.Issue{
ID: "bd-1",
Title: "Simple task",
Description: "Just a simple task",
Status: types.StatusClosed,
}
prompt, err := client.renderTier1Prompt(issue)
if err != nil {
t.Fatalf("failed to render prompt: %v", err)
}
if !strings.Contains(prompt, "Simple task") {
t.Error("prompt should contain title")
}
if !strings.Contains(prompt, "Just a simple task") {
t.Error("prompt should contain description")
}
}
func TestRenderTier1Prompt_UTF8(t *testing.T) {
client, err := NewHaikuClient("test-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
issue := &types.Issue{
ID: "bd-1",
Title: "Fix bug with émojis 🎉",
Description: "Handle UTF-8: café, 日本語, emoji 🚀",
Status: types.StatusClosed,
}
prompt, err := client.renderTier1Prompt(issue)
if err != nil {
t.Fatalf("failed to render prompt: %v", err)
}
if !strings.Contains(prompt, "🎉") {
t.Error("prompt should preserve emoji in title")
}
if !strings.Contains(prompt, "café") {
t.Error("prompt should preserve accented characters")
}
if !strings.Contains(prompt, "日本語") {
t.Error("prompt should preserve unicode characters")
}
if !strings.Contains(prompt, "🚀") {
t.Error("prompt should preserve emoji in description")
}
}
func TestCallWithRetry_ContextCancellation(t *testing.T) {
client, err := NewHaikuClient("test-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
client.initialBackoff = 100 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = client.callWithRetry(ctx, "test prompt")
if err == nil {
t.Fatal("expected error when context is canceled")
}
if err != context.Canceled {
t.Errorf("expected context.Canceled error, got: %v", err)
}
}
func TestIsRetryable(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"nil error", nil, false},
{"context canceled", context.Canceled, false},
{"context deadline exceeded", context.DeadlineExceeded, false},
{"generic error", errors.New("some error"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isRetryable(tt.err)
if got != tt.expected {
t.Errorf("isRetryable(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}
// 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)