package claudebox import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" ) func TestNewClient_DefaultTimeout(t *testing.T) { client := NewClient(ClientConfig{ BaseURL: "http://localhost:8080", }) if client.httpClient.Timeout != 50*time.Minute { t.Errorf("expected default timeout 50m, got %v", client.httpClient.Timeout) } } func TestNewClient_CustomTimeout(t *testing.T) { client := NewClient(ClientConfig{ BaseURL: "http://localhost:8080", Timeout: 5 * time.Minute, }) if client.httpClient.Timeout != 5*time.Minute { t.Errorf("expected timeout 5m, got %v", client.httpClient.Timeout) } } func TestNewClient_TrimsTrailingSlash(t *testing.T) { client := NewClient(ClientConfig{ BaseURL: "http://localhost:8080/", }) if client.baseURL != "http://localhost:8080" { t.Errorf("expected trailing slash trimmed, got %s", client.baseURL) } } func TestHealth_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } if r.URL.Path != "/health" { t.Errorf("expected /health, got %s", r.URL.Path) } resp := HealthResponse{ Status: "healthy", Timestamp: "2024-01-15T10:30:00Z", WorkDir: "/workspace", } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) health, err := client.Health(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if health.Status != "healthy" { t.Errorf("expected status 'healthy', got %s", health.Status) } if health.WorkDir != "/workspace" { t.Errorf("expected work_dir '/workspace', got %s", health.WorkDir) } } func TestHealth_Unhealthy(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) _, err := client.Health(context.Background()) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "health check returned status 503") { t.Errorf("expected 503 error, got %v", err) } } func TestHealth_NetworkError(t *testing.T) { client := NewClient(ClientConfig{ BaseURL: "http://localhost:1", Timeout: 100 * time.Millisecond, }) _, err := client.Health(context.Background()) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "health check:") { t.Errorf("expected error wrapped with 'health check:', got %v", err) } } func TestExecute_Success(t *testing.T) { var receivedReq ExecuteRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/execute" { t.Errorf("expected /execute, got %s", r.URL.Path) } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) } body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &receivedReq) resp := ExecuteResponse{ Success: true, Output: "Task completed", ExitCode: 0, DurationMs: 5000, SessionID: "session-123", } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) req := &ExecuteRequest{ Prompt: "Build the project", AllowedTools: []string{"Bash", "Read", "Write"}, WorkingDir: "/workspace/project", Timeout: 300, Metadata: map[string]string{"task_id": "task-1"}, } result, err := client.Execute(context.Background(), req) if err != nil { t.Fatalf("unexpected error: %v", err) } if !result.Success { t.Error("expected success=true") } if result.Output != "Task completed" { t.Errorf("expected output 'Task completed', got %s", result.Output) } if result.ExitCode != 0 { t.Errorf("expected exit code 0, got %d", result.ExitCode) } // Verify request was serialized correctly if receivedReq.Prompt != "Build the project" { t.Errorf("expected prompt 'Build the project', got %s", receivedReq.Prompt) } if len(receivedReq.AllowedTools) != 3 { t.Errorf("expected 3 allowed tools, got %d", len(receivedReq.AllowedTools)) } } func TestExecute_ErrorStatus(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":"invalid prompt"}`)) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) _, err := client.Execute(context.Background(), &ExecuteRequest{Prompt: ""}) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "execute returned status 400") { t.Errorf("expected 400 error, got %v", err) } if !strings.Contains(err.Error(), "invalid prompt") { t.Errorf("expected error body in message, got %v", err) } } func TestExecute_MalformedResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{invalid json`)) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) _, err := client.Execute(context.Background(), &ExecuteRequest{Prompt: "test"}) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "decode response") { t.Errorf("expected decode error, got %v", err) } } func TestExecuteStream_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/execute/stream" { t.Errorf("expected /execute/stream, got %s", r.URL.Path) } if r.Header.Get("Accept") != "text/event-stream" { t.Errorf("expected Accept text/event-stream, got %s", r.Header.Get("Accept")) } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") flusher, ok := w.(http.Flusher) if !ok { t.Fatal("expected http.Flusher") } events := []StreamEvent{ {Type: "start", Timestamp: "2024-01-15T10:30:00Z"}, {Type: "output", Content: "Building...", Timestamp: "2024-01-15T10:30:01Z"}, {Type: "tool_call", ToolName: "Bash", Timestamp: "2024-01-15T10:30:02Z"}, {Type: "complete", Content: "Done", Timestamp: "2024-01-15T10:30:05Z"}, } for _, event := range events { data, _ := json.Marshal(event) fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() } })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) var receivedEvents []StreamEvent var mu sync.Mutex err := client.ExecuteStream(context.Background(), &ExecuteRequest{Prompt: "build"}, func(event StreamEvent) { mu.Lock() receivedEvents = append(receivedEvents, event) mu.Unlock() }) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(receivedEvents) != 4 { t.Fatalf("expected 4 events, got %d", len(receivedEvents)) } if receivedEvents[0].Type != "start" { t.Errorf("expected first event type 'start', got %s", receivedEvents[0].Type) } if receivedEvents[1].Content != "Building..." { t.Errorf("expected second event content 'Building...', got %s", receivedEvents[1].Content) } if receivedEvents[2].ToolName != "Bash" { t.Errorf("expected third event tool name 'Bash', got %s", receivedEvents[2].ToolName) } } func TestExecuteStream_SkipsMalformedEvents(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") flusher, _ := w.(http.Flusher) // Valid event event1, _ := json.Marshal(StreamEvent{Type: "start"}) fmt.Fprintf(w, "data: %s\n\n", event1) flusher.Flush() // Malformed JSON - should be skipped fmt.Fprintf(w, "data: {invalid json}\n\n") flusher.Flush() // Empty data - should be skipped fmt.Fprintf(w, "data: \n\n") flusher.Flush() // Non-data line - should be skipped fmt.Fprintf(w, "event: ping\n\n") flusher.Flush() // Valid event event2, _ := json.Marshal(StreamEvent{Type: "complete"}) fmt.Fprintf(w, "data: %s\n\n", event2) flusher.Flush() })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) var receivedEvents []StreamEvent var mu sync.Mutex err := client.ExecuteStream(context.Background(), &ExecuteRequest{Prompt: "test"}, func(event StreamEvent) { mu.Lock() receivedEvents = append(receivedEvents, event) mu.Unlock() }) if err != nil { t.Fatalf("unexpected error: %v", err) } // Should only receive the 2 valid events if len(receivedEvents) != 2 { t.Fatalf("expected 2 events (malformed skipped), got %d", len(receivedEvents)) } if receivedEvents[0].Type != "start" { t.Errorf("expected first event 'start', got %s", receivedEvents[0].Type) } if receivedEvents[1].Type != "complete" { t.Errorf("expected second event 'complete', got %s", receivedEvents[1].Type) } } func TestExecuteStream_ErrorStatus(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"agent unavailable"}`)) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) err := client.ExecuteStream(context.Background(), &ExecuteRequest{Prompt: "test"}, func(event StreamEvent) { t.Error("handler should not be called on error") }) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "execute stream returned status 500") { t.Errorf("expected 500 error, got %v", err) } } func TestExecuteStream_ContextCanceledBeforeRequest(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("handler should not be called when context is already canceled") })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) // Cancel context before making request ctx, cancel := context.WithCancel(context.Background()) cancel() err := client.ExecuteStream(ctx, &ExecuteRequest{Prompt: "test"}, func(event StreamEvent) {}) if err == nil { t.Fatal("expected error, got nil") } // Should get a context canceled error } func TestGitClone_Success(t *testing.T) { var receivedReq GitCloneRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/git/clone" { t.Errorf("expected /git/clone, got %s", r.URL.Path) } body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &receivedReq) resp := GitCloneResponse{ Success: true, Cloned: true, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) result, err := client.GitClone(context.Background(), "https://github.com/example/repo.git", "/workspace") if err != nil { t.Fatalf("unexpected error: %v", err) } if !result.Success { t.Error("expected success=true") } if !result.Cloned { t.Error("expected cloned=true") } if receivedReq.CloneURL != "https://github.com/example/repo.git" { t.Errorf("expected clone URL, got %s", receivedReq.CloneURL) } if receivedReq.WorkDir != "/workspace" { t.Errorf("expected work dir '/workspace', got %s", receivedReq.WorkDir) } } func TestGitClone_AlreadyExists(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := GitCloneResponse{ Success: true, Cloned: false, // Already existed, just updated } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) result, err := client.GitClone(context.Background(), "https://github.com/example/repo.git", "/workspace") if err != nil { t.Fatalf("unexpected error: %v", err) } if !result.Success { t.Error("expected success=true") } if result.Cloned { t.Error("expected cloned=false for existing repo") } } func TestGitClone_ErrorStatus(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":"invalid clone URL"}`)) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) _, err := client.GitClone(context.Background(), "invalid", "/workspace") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "git clone returned status 400") { t.Errorf("expected 400 error, got %v", err) } } func TestGitCommitAndPush_Success(t *testing.T) { var receivedReq GitCommitAndPushRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/git/commit-and-push" { t.Errorf("expected /git/commit-and-push, got %s", r.URL.Path) } body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &receivedReq) resp := GitCommitAndPushResponse{ Success: true, HasChanges: true, CommitSHA: "abc123def456", FilesChanged: []string{"main.go", "go.mod"}, Pushed: true, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) result, err := client.GitCommitAndPush(context.Background(), "feat: add feature", true, "/workspace") if err != nil { t.Fatalf("unexpected error: %v", err) } if !result.Success { t.Error("expected success=true") } if !result.HasChanges { t.Error("expected has_changes=true") } if result.CommitSHA != "abc123def456" { t.Errorf("expected commit SHA abc123def456, got %s", result.CommitSHA) } if !result.Pushed { t.Error("expected pushed=true") } if len(result.FilesChanged) != 2 { t.Errorf("expected 2 files changed, got %d", len(result.FilesChanged)) } // Verify request if receivedReq.Message != "feat: add feature" { t.Errorf("expected message 'feat: add feature', got %s", receivedReq.Message) } if !receivedReq.Push { t.Error("expected push=true in request") } } func TestGitCommitAndPush_NoChanges(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := GitCommitAndPushResponse{ Success: true, HasChanges: false, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) result, err := client.GitCommitAndPush(context.Background(), "test", false, "/workspace") if err != nil { t.Fatalf("unexpected error: %v", err) } if !result.Success { t.Error("expected success=true") } if result.HasChanges { t.Error("expected has_changes=false") } if result.CommitSHA != "" { t.Errorf("expected empty commit SHA, got %s", result.CommitSHA) } } func TestGitCommitAndPush_ErrorStatus(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"git push failed"}`)) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) _, err := client.GitCommitAndPush(context.Background(), "test", true, "/workspace") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "git commit returned status 500") { t.Errorf("expected 500 error, got %v", err) } } func TestGitStatus_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } if r.URL.Path != "/git/status" { t.Errorf("expected /git/status, got %s", r.URL.Path) } if r.URL.Query().Get("work_dir") != "/workspace/project" { t.Errorf("expected work_dir query param, got %s", r.URL.Query().Get("work_dir")) } resp := GitStatusResponse{ IsRepo: true, HasChanges: true, ChangedFiles: []string{"main.go", "README.md"}, Branch: "feature/test", } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) result, err := client.GitStatus(context.Background(), "/workspace/project") if err != nil { t.Fatalf("unexpected error: %v", err) } if !result.IsRepo { t.Error("expected is_repo=true") } if !result.HasChanges { t.Error("expected has_changes=true") } if result.Branch != "feature/test" { t.Errorf("expected branch 'feature/test', got %s", result.Branch) } if len(result.ChangedFiles) != 2 { t.Errorf("expected 2 changed files, got %d", len(result.ChangedFiles)) } } func TestGitStatus_EmptyWorkDir(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // work_dir should not be in query when empty if r.URL.Query().Get("work_dir") != "" { t.Errorf("expected empty work_dir, got %s", r.URL.Query().Get("work_dir")) } resp := GitStatusResponse{IsRepo: true} w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) _, err := client.GitStatus(context.Background(), "") if err != nil { t.Fatalf("unexpected error: %v", err) } } func TestGitStatus_ErrorStatus(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"not a git repository"}`)) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) _, err := client.GitStatus(context.Background(), "/workspace") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "git status returned status 404") { t.Errorf("expected 404 error, got %v", err) } } func TestRunSDLC_Success(t *testing.T) { var receivedReq SDLCRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/sdlc" { t.Errorf("expected /sdlc, got %s", r.URL.Path) } body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &receivedReq) resp := SDLCResponse{ Success: true, Output: "Feature started successfully", Data: json.RawMessage(`{"feature_id":"feat-123"}`), } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) result, err := client.RunSDLC(context.Background(), "start", []string{"--name", "test-feature"}, "/workspace") if err != nil { t.Fatalf("unexpected error: %v", err) } if !result.Success { t.Error("expected success=true") } if result.Output != "Feature started successfully" { t.Errorf("expected output message, got %s", result.Output) } if result.Data == nil { t.Error("expected data to be set") } // Verify request if receivedReq.Command != "start" { t.Errorf("expected command 'start', got %s", receivedReq.Command) } if len(receivedReq.Args) != 2 { t.Errorf("expected 2 args, got %d", len(receivedReq.Args)) } if receivedReq.WorkDir != "/workspace" { t.Errorf("expected work dir '/workspace', got %s", receivedReq.WorkDir) } } func TestRunSDLC_CommandFailed(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := SDLCResponse{ Success: false, Output: "Command output before failure", Error: "validation failed: missing required field", } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) result, err := client.RunSDLC(context.Background(), "validate", nil, "/workspace") if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Success { t.Error("expected success=false") } if result.Error != "validation failed: missing required field" { t.Errorf("expected error message, got %s", result.Error) } } func TestRunSDLC_ErrorStatus(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"sdlc binary not found"}`)) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) _, err := client.RunSDLC(context.Background(), "status", nil, "/workspace") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "sdlc returned status 500") { t.Errorf("expected 500 error, got %v", err) } } func TestRunSDLC_MalformedResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{invalid`)) })) defer server.Close() client := NewClient(ClientConfig{BaseURL: server.URL}) _, err := client.RunSDLC(context.Background(), "status", nil, "/workspace") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "decode response") { t.Errorf("expected decode error, got %v", err) } }