198 lines
6.9 KiB
Go
198 lines
6.9 KiB
Go
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
|
||
|
|
}
|
||
|
|
}
|