Files
kandev/apps/backend/pkg/claudecode/client_test.go
T
2026-02-14 22:04:22 +00:00

366 lines
9.4 KiB
Go

package claudecode
import (
"bytes"
"context"
"encoding/json"
"io"
"strings"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
)
// syncBuffer is a thread-safe bytes.Buffer for tests where a goroutine writes
// and the test goroutine reads.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (sb *syncBuffer) Write(p []byte) (int, error) {
sb.mu.Lock()
defer sb.mu.Unlock()
return sb.buf.Write(p)
}
func (sb *syncBuffer) Len() int {
sb.mu.Lock()
defer sb.mu.Unlock()
return sb.buf.Len()
}
func (sb *syncBuffer) Bytes() []byte {
sb.mu.Lock()
defer sb.mu.Unlock()
return append([]byte(nil), sb.buf.Bytes()...)
}
func newTestLogger() *logger.Logger {
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"})
return log
}
func TestClient_SendUserMessage(t *testing.T) {
var buf bytes.Buffer
client := NewClient(&buf, strings.NewReader(""), newTestLogger())
err := client.SendUserMessage("Hello, Claude!")
if err != nil {
t.Fatalf("SendUserMessage() error = %v", err)
}
// Parse what was written
var msg UserMessage
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &msg); err != nil {
t.Fatalf("failed to parse sent message: %v", err)
}
if msg.Type != MessageTypeUser {
t.Errorf("Type = %q, want %q", msg.Type, MessageTypeUser)
}
if msg.Message.Role != "user" {
t.Errorf("Message.Role = %q, want %q", msg.Message.Role, "user")
}
if msg.Message.Content != "Hello, Claude!" {
t.Errorf("Message.Content = %q, want %q", msg.Message.Content, "Hello, Claude!")
}
}
func TestClient_SendControlResponse(t *testing.T) {
var buf bytes.Buffer
client := NewClient(&buf, strings.NewReader(""), newTestLogger())
resp := &ControlResponseMessage{
Type: MessageTypeControlResponse,
RequestID: "req123",
Response: &ControlResponse{
Subtype: "success",
Result: &PermissionResult{
Behavior: BehaviorAllow,
},
},
}
err := client.SendControlResponse(resp)
if err != nil {
t.Fatalf("SendControlResponse() error = %v", err)
}
// Parse what was written
var parsed ControlResponseMessage
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
t.Fatalf("failed to parse sent message: %v", err)
}
if parsed.RequestID != "req123" {
t.Errorf("RequestID = %q, want %q", parsed.RequestID, "req123")
}
}
func TestClient_SendControlRequest(t *testing.T) {
var buf bytes.Buffer
client := NewClient(&buf, strings.NewReader(""), newTestLogger())
req := &SDKControlRequest{
Type: MessageTypeControlRequest,
RequestID: "init123",
Request: SDKControlRequestBody{
Subtype: SubtypeInitialize,
},
}
err := client.SendControlRequest(req)
if err != nil {
t.Fatalf("SendControlRequest() error = %v", err)
}
// Parse what was written
var parsed SDKControlRequest
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
t.Fatalf("failed to parse sent message: %v", err)
}
if parsed.Request.Subtype != SubtypeInitialize {
t.Errorf("Request.Subtype = %q, want %q", parsed.Request.Subtype, SubtypeInitialize)
}
}
func TestClient_HandleMessages(t *testing.T) {
// Create input with multiple messages
messages := []string{
`{"type":"system","session_id":"sess123"}`,
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`,
}
input := strings.Join(messages, "\n") + "\n"
var buf bytes.Buffer
client := NewClient(&buf, strings.NewReader(input), newTestLogger())
var received []CLIMessage
var mu sync.Mutex
client.SetMessageHandler(func(msg *CLIMessage) {
mu.Lock()
received = append(received, *msg)
mu.Unlock()
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
client.Start(ctx)
time.Sleep(50 * time.Millisecond) // Give time for processing
mu.Lock()
count := len(received)
mu.Unlock()
if count != 2 {
t.Errorf("received %d messages, want 2", count)
}
}
func TestClient_HandleControlRequest(t *testing.T) {
// Create a control request message
input := `{"type":"control_request","request_id":"req123","request":{"subtype":"can_use_tool","tool_name":"Bash"}}` + "\n"
var buf bytes.Buffer
client := NewClient(&buf, strings.NewReader(input), newTestLogger())
var receivedReq *ControlRequest
var receivedID string
var mu sync.Mutex
client.SetRequestHandler(func(requestID string, req *ControlRequest) {
mu.Lock()
receivedID = requestID
receivedReq = req
mu.Unlock()
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
client.Start(ctx)
time.Sleep(50 * time.Millisecond) // Give time for processing
mu.Lock()
defer mu.Unlock()
if receivedID != "req123" {
t.Errorf("requestID = %q, want %q", receivedID, "req123")
}
if receivedReq == nil {
t.Fatal("receivedReq is nil")
}
if receivedReq.Subtype != SubtypeCanUseTool {
t.Errorf("Subtype = %q, want %q", receivedReq.Subtype, SubtypeCanUseTool)
}
}
func TestClient_Stop(t *testing.T) {
// Use a pipe for continuous input
pr, _ := io.Pipe()
var buf bytes.Buffer
client := NewClient(&buf, pr, newTestLogger())
ctx := context.Background()
client.Start(ctx)
// Stop should not panic even if called multiple times
client.Stop()
client.Stop()
}
func TestClient_NoHandlerAutoReject(t *testing.T) {
// Create a control request message
input := `{"type":"control_request","request_id":"req123","request":{"subtype":"can_use_tool","tool_name":"Bash"}}` + "\n"
buf := &syncBuffer{}
client := NewClient(buf, strings.NewReader(input), newTestLogger())
// No request handler set - should auto-reject
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
client.Start(ctx)
time.Sleep(50 * time.Millisecond) // Give time for processing
// Should have sent an error response
if buf.Len() == 0 {
t.Error("expected error response to be sent")
}
var resp ControlResponseMessage
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Response == nil || resp.Response.Subtype != "error" {
t.Error("expected error response")
}
}
func TestClient_EmptyLines(t *testing.T) {
// Test that empty lines are skipped
input := "\n\n{\"type\":\"system\",\"session_id\":\"abc\"}\n\n"
var buf bytes.Buffer
client := NewClient(&buf, strings.NewReader(input), newTestLogger())
var count int
var mu sync.Mutex
client.SetMessageHandler(func(msg *CLIMessage) {
mu.Lock()
count++
mu.Unlock()
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
client.Start(ctx)
time.Sleep(50 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
if count != 1 {
t.Errorf("count = %d, want 1", count)
}
}
func TestClient_InvalidJSON(t *testing.T) {
// Test that invalid JSON is handled gracefully
input := "{invalid json}\n{\"type\":\"system\"}\n"
var buf bytes.Buffer
client := NewClient(&buf, strings.NewReader(input), newTestLogger())
var count int
var mu sync.Mutex
client.SetMessageHandler(func(msg *CLIMessage) {
mu.Lock()
count++
mu.Unlock()
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
client.Start(ctx)
time.Sleep(50 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
// Should still process the valid message
if count != 1 {
t.Errorf("count = %d, want 1", count)
}
}
func TestClient_HandleControlResponse(t *testing.T) {
// Simulate a control response coming back from Claude Code CLI
responseJSON := `{"type":"control_response","response":{"subtype":"success","request_id":"test-req-123","response":{"commands":[{"name":"cost","description":"Show cost"}],"agents":["Bash"]}}}` + "\n"
var buf bytes.Buffer
client := NewClient(&buf, strings.NewReader(responseJSON), newTestLogger())
// Register a pending request
pending := &pendingRequest{
ch: make(chan *IncomingControlResponse, 1),
}
client.pendingRequestsMu.Lock()
client.pendingRequests["test-req-123"] = pending
client.pendingRequestsMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
client.Start(ctx)
// Wait for the response
select {
case resp := <-pending.ch:
if resp.Subtype != "success" {
t.Errorf("Subtype = %q, want %q", resp.Subtype, "success")
}
if resp.RequestID != "test-req-123" {
t.Errorf("RequestID = %q, want %q", resp.RequestID, "test-req-123")
}
if resp.Response == nil {
t.Fatal("Response is nil")
}
if len(resp.Response.Commands) != 1 {
t.Errorf("Commands count = %d, want %d", len(resp.Response.Commands), 1)
}
if resp.Response.Commands[0].Name != "cost" {
t.Errorf("Commands[0].Name = %q, want %q", resp.Response.Commands[0].Name, "cost")
}
case <-time.After(50 * time.Millisecond):
t.Error("timeout waiting for control response")
}
}
func TestClient_HandleControlResponse_UnknownRequest(t *testing.T) {
// Simulate a control response for an unknown request ID (should be ignored)
responseJSON := `{"type":"control_response","response":{"subtype":"success","request_id":"unknown-req","response":{"commands":[]}}}` + "\n"
var buf bytes.Buffer
client := NewClient(&buf, strings.NewReader(responseJSON), newTestLogger())
// Don't register any pending request - response should be ignored
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
client.Start(ctx)
time.Sleep(50 * time.Millisecond)
// Test passes if no panic occurs - the unknown response is just logged and ignored
}