2026-10-18 16:34:43 +02:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// recordedRequest records a request seen by the fake server.
|
2026-10-18 16:34:43 +02:00
|
|
|
type recordedRequest struct {
|
|
|
|
|
method string
|
|
|
|
|
path string
|
|
|
|
|
header http.Header
|
|
|
|
|
body []byte
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// fakeTTSServer is the httptest fake of a cloud provider: responds with
|
|
|
|
|
// status + body and records the (single) request it saw.
|
2026-10-18 16:34:43 +02:00
|
|
|
func fakeTTSServer(t *testing.T, status int, body []byte) (*httptest.Server, *recordedRequest) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
rec := &recordedRequest{}
|
|
|
|
|
var mu sync.Mutex
|
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
body2, err := io.ReadAll(r.Body)
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("fake server: reading body: %v", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
mu.Lock()
|
|
|
|
|
rec.method, rec.path, rec.header, rec.body = r.Method, r.URL.Path, r.Header.Clone(), body2
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
w.WriteHeader(status)
|
|
|
|
|
w.Write(body)
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(ts.Close)
|
|
|
|
|
return ts, rec
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func envMap(kv map[string]string) func(string) string {
|
|
|
|
|
return func(k string) string { return kv[k] }
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// fakeMP3 is an arbitrary byte sequence; the providers return response
|
|
|
|
|
// bytes unchanged, the audio sink decodes (its own tests).
|
|
|
|
|
var fakeMP3 = []byte("\xff\xfb\x90\x00" + "audio-bytes-from-eleven")
|
2026-10-18 16:34:43 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestElevenLabsSynthesize200: 200 with bytes → bytes come back unchanged;
|
|
|
|
|
// method, path (incl. voice ID), header (xi-api-key) and the JSON body
|
|
|
|
|
// (model_id, voice_settings) match the API contract.
|
|
|
|
|
func TestElevenLabsSynthesize200(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
ts, rec := fakeTTSServer(t, 200, fakeMP3)
|
|
|
|
|
p := NewElevenLabs(ChainConfig{
|
|
|
|
|
Getenv: envMap(map[string]string{
|
|
|
|
|
EnvElevenLabsKey: "testkey",
|
|
|
|
|
EnvElevenLabsEndpoint: ts.URL,
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
data, err := p.Synthesize(context.Background(), "Hello world", "de")
|
2026-10-18 16:34:43 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Synthesize: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if string(data) != string(fakeMP3) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Synthesize = %q, want fake bytes %q", data, fakeMP3)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if rec.method != http.MethodPost {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("method = %s, want POST", rec.method)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("path = %q, want %q (neutral repo default \"Rachel\")", rec.path, want)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if got := rec.header.Get("xi-api-key"); got != "testkey" {
|
|
|
|
|
t.Errorf("xi-api-key = %q, want %q", got, "testkey")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body struct {
|
|
|
|
|
Text string `json:"text"`
|
|
|
|
|
ModelID string `json:"model_id"`
|
|
|
|
|
VoiceSettings struct {
|
|
|
|
|
Stability float64 `json:"stability"`
|
|
|
|
|
SimilarityBoost float64 `json:"similarity_boost"`
|
|
|
|
|
} `json:"voice_settings"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(rec.body, &body); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("request body is not JSON: %v (%s)", err, rec.body)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if body.Text != "Hello world" {
|
2026-10-18 16:34:43 +02:00
|
|
|
t.Errorf("text = %q", body.Text)
|
|
|
|
|
}
|
|
|
|
|
if body.ModelID != "eleven_multilingual_v2" {
|
|
|
|
|
t.Errorf("model_id = %q, want eleven_multilingual_v2", body.ModelID)
|
|
|
|
|
}
|
|
|
|
|
if body.VoiceSettings.Stability != 0.5 || body.VoiceSettings.SimilarityBoost != 0.75 {
|
|
|
|
|
t.Errorf("voice_settings = %+v, want stability 0.5 / similarity_boost 0.75", body.VoiceSettings)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestElevenLabsHTTPError: 500 and 401 are clear errors naming the status
|
|
|
|
|
// and a body snippet (the chain falls back on them).
|
|
|
|
|
func TestElevenLabsHTTPError(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
cases := []struct {
|
|
|
|
|
status int
|
|
|
|
|
body string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{500, "internal boom", "500"},
|
|
|
|
|
{401, `{"detail":{"status":"invalid_api_key"}}`, "401"},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(http.StatusText(tc.status), func(t *testing.T) {
|
|
|
|
|
ts, _ := fakeTTSServer(t, tc.status, []byte(tc.body))
|
|
|
|
|
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{
|
|
|
|
|
EnvElevenLabsKey: "testkey",
|
|
|
|
|
EnvElevenLabsEndpoint: ts.URL,
|
|
|
|
|
})})
|
2026-10-24 11:15:35 +02:00
|
|
|
_, err := p.Synthesize(context.Background(), "Hello", "de")
|
2026-10-18 16:34:43 +02:00
|
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("Synthesize error = %v, want HTTP %s", err, tc.want)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "elevenlabs") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("error should name the provider: %v", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestElevenLabsAvailable: not available without a key, available with
|
|
|
|
|
// one; without any network call.
|
2026-10-18 16:34:43 +02:00
|
|
|
func TestElevenLabsAvailable(t *testing.T) {
|
|
|
|
|
if NewElevenLabs(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Available without key = true, want false")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{EnvElevenLabsKey: "k"})})
|
|
|
|
|
if !p.Available(context.Background()) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Available with key = false, want true")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestElevenLabsVoicePrecedence: the --voice pin beats the config mapping,
|
|
|
|
|
// the mapping beats the repo default (observable in the request path).
|
|
|
|
|
func TestElevenLabsVoicePrecedence(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
mk := func(t *testing.T, voices map[string]string, pin string) (*ElevenLabs, *recordedRequest) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
ts, rec := fakeTTSServer(t, 200, fakeMP3)
|
|
|
|
|
p := NewElevenLabs(ChainConfig{
|
|
|
|
|
Getenv: envMap(map[string]string{
|
|
|
|
|
EnvElevenLabsKey: "k",
|
|
|
|
|
EnvElevenLabsEndpoint: ts.URL,
|
|
|
|
|
}),
|
|
|
|
|
Voices: map[string]map[string]string{"elevenlabs": voices},
|
|
|
|
|
})
|
|
|
|
|
if pin != "" {
|
|
|
|
|
p.PinVoice(pin)
|
|
|
|
|
}
|
|
|
|
|
return p, rec
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("config mapping beats default", func(t *testing.T) {
|
|
|
|
|
p, rec := mk(t, map[string]string{"de": "FromConfig"}, "")
|
|
|
|
|
if _, err := p.Synthesize(context.Background(), "Hello", "de"); err != nil {
|
2026-10-18 16:34:43 +02:00
|
|
|
t.Fatalf("Synthesize: %v", err)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if want := "/v1/text-to-speech/FromConfig"; rec.path != want {
|
|
|
|
|
t.Errorf("path = %q, want %q", rec.path, want)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
})
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("pin (--voice) beats config mapping", func(t *testing.T) {
|
|
|
|
|
p, rec := mk(t, map[string]string{"de": "FromConfig"}, "Pinned")
|
|
|
|
|
if _, err := p.Synthesize(context.Background(), "Hello", "de"); err != nil {
|
2026-10-18 16:34:43 +02:00
|
|
|
t.Fatalf("Synthesize: %v", err)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if want := "/v1/text-to-speech/Pinned"; rec.path != want {
|
|
|
|
|
t.Errorf("path = %q, want %q", rec.path, want)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
})
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("unknown language falls back to en default", func(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
p, rec := mk(t, nil, "")
|
2026-10-24 11:15:35 +02:00
|
|
|
if _, err := p.Synthesize(context.Background(), "Hello", "fr"); err != nil {
|
2026-10-18 16:34:43 +02:00
|
|
|
t.Fatalf("Synthesize: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("path = %q, want en default %q", rec.path, want)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestElevenLabsEmptyText is a clear error without a request.
|
|
|
|
|
func TestElevenLabsEmptyText(t *testing.T) {
|
2026-10-18 16:34:43 +02:00
|
|
|
ts, rec := fakeTTSServer(t, 200, fakeMP3)
|
|
|
|
|
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{
|
|
|
|
|
EnvElevenLabsKey: "k",
|
|
|
|
|
EnvElevenLabsEndpoint: ts.URL,
|
|
|
|
|
})})
|
|
|
|
|
if _, err := p.Synthesize(context.Background(), "", "de"); err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Synthesize(\"\") should fail")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if rec.method != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("no request must be sent for empty text")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
}
|