chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user