package voiceprovider import ( "context" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" ) type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } type failingReadCloser struct { err error } func (f failingReadCloser) Read([]byte) (int, error) { return 0, f.err } func (f failingReadCloser) Close() error { return nil } func TestNewHTTPClientValidatesConfiguration(t *testing.T) { tests := []struct { name string baseURL string apiKey string timeout time.Duration }{ {name: "missing base URL", apiKey: "key", timeout: time.Second}, {name: "missing API key", baseURL: "http://provider", timeout: time.Second}, {name: "zero timeout", baseURL: "http://provider", apiKey: "key"}, {name: "negative timeout", baseURL: "http://provider", apiKey: "key", timeout: -time.Second}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if _, err := NewHTTPClient(test.baseURL, test.apiKey, test.timeout); err == nil { t.Fatal("expected invalid provider configuration to be rejected") } }) } client, err := NewHTTPClient("http://provider///", "key", time.Second) if err != nil { t.Fatal(err) } if client.baseURL != "http://provider" { t.Fatalf("base URL was not normalized: %q", client.baseURL) } } func TestHTTPClientStartsAndStopsProviderTask(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { if request.Method != http.MethodPost || request.Header.Get("Content-Type") != "application/json" || request.Header.Get("X-Mock-Provider-Key") != "provider-secret" { t.Errorf("unexpected provider request: method=%s content_type=%q key=%q", request.Method, request.Header.Get("Content-Type"), request.Header.Get("X-Mock-Provider-Key")) w.WriteHeader(http.StatusBadRequest) return } switch request.URL.Path { case "/mock/v1/voice-chats:start": _, _ = io.WriteString(w, `{"task_ref":"task-1","status":"ACTIVE"}`) case "/mock/v1/voice-chats:stop": _, _ = io.WriteString(w, `{"task_ref":"task-1","status":"STOPPED"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer server.Close() client, err := NewHTTPClient(server.URL, "provider-secret", time.Second) if err != nil { t.Fatal(err) } started, err := client.Start(context.Background(), StartRequest{TaskRef: "task-1", RoomID: "room-1", DeviceUserID: "device-user", AgentUserID: "agent-user", ConversationID: "conversation-1"}) if err != nil || started.Status != "ACTIVE" { t.Fatalf("start result=%+v error=%v", started, err) } stopped, err := client.Stop(context.Background(), StopRequest{TaskRef: "task-1", ConversationID: "conversation-1", Reason: "user_request"}) if err != nil || stopped.Status != "STOPPED" { t.Fatalf("stop result=%+v error=%v", stopped, err) } } func TestHTTPClientRejectsInvalidProviderResponses(t *testing.T) { tests := []struct { name string body string }{ {name: "malformed JSON", body: "{"}, {name: "mismatched task", body: `{"task_ref":"other-task","status":"ACTIVE"}`}, {name: "missing status", body: `{"task_ref":"task-1"}`}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, test.body) })) defer server.Close() client, err := NewHTTPClient(server.URL, "provider-secret", time.Second) if err != nil { t.Fatal(err) } if _, err := client.Start(context.Background(), StartRequest{TaskRef: "task-1"}); err == nil || err.Error() != "provider start returned an invalid response" { t.Fatalf("unexpected start error: %v", err) } if _, err := client.Stop(context.Background(), StopRequest{TaskRef: "task-1"}); err == nil || err.Error() != "provider stop returned an invalid response" { t.Fatalf("unexpected stop error: %v", err) } if _, err := client.Query(context.Background(), QueryRequest{TaskRef: "task-1"}); err == nil || err.Error() != "provider query returned an invalid response" { t.Fatalf("unexpected query error: %v", err) } }) } } func TestHTTPClientClassifiesStopHTTPResponses(t *testing.T) { for _, test := range []struct { status int temporary bool }{ {status: http.StatusTooManyRequests, temporary: true}, {status: http.StatusBadGateway, temporary: true}, {status: http.StatusConflict, temporary: false}, } { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(test.status) })) client, err := NewHTTPClient(server.URL, "provider-secret", time.Second) if err != nil { server.Close() t.Fatal(err) } _, err = client.Stop(context.Background(), StopRequest{TaskRef: "task-1"}) server.Close() if err == nil || IsTemporary(err) != test.temporary { t.Fatalf("status=%d error=%v temporary=%v", test.status, err, IsTemporary(err)) } var providerError *Error if !errors.As(err, &providerError) || providerError.StatusCode != test.status || !strings.Contains(err.Error(), "HTTP") { t.Fatalf("status=%d unexpected provider error: %v", test.status, err) } } } func TestHTTPClientWrapsTransportAndBodyReadFailures(t *testing.T) { transportFailure := errors.New("provider connection reset") readFailure := errors.New("provider response read failed") for _, operation := range []string{"start", "stop", "query"} { t.Run(operation+" transport failure", func(t *testing.T) { client, err := NewHTTPClient("http://provider", "provider-secret", time.Second) if err != nil { t.Fatal(err) } client.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, transportFailure }) err = invokeProviderOperation(client, operation) if !errors.Is(err, transportFailure) || !IsTemporary(err) || !strings.Contains(err.Error(), "provider "+operation+" failed") { t.Fatalf("unexpected transport error: %v", err) } }) t.Run(operation+" body read failure", func(t *testing.T) { client, err := NewHTTPClient("http://provider", "provider-secret", time.Second) if err != nil { t.Fatal(err) } client.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusOK, Body: failingReadCloser{err: readFailure}, Header: make(http.Header)}, nil }) err = invokeProviderOperation(client, operation) if !errors.Is(err, readFailure) || !IsTemporary(err) { t.Fatalf("unexpected body read error: %v", err) } }) } } func invokeProviderOperation(client *HTTPClient, operation string) error { switch operation { case "start": _, err := client.Start(context.Background(), StartRequest{TaskRef: "task-1"}) return err case "stop": _, err := client.Stop(context.Background(), StopRequest{TaskRef: "task-1"}) return err default: _, err := client.Query(context.Background(), QueryRequest{TaskRef: "task-1"}) return err } }