Files

198 lines
6.9 KiB
Go
Raw Permalink Normal View History

2026-08-26 06:41:07 +08:00
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
}
}