package provider import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" ) // fakeTTServation zeichnet einen Request am Fake-Server auf. type recordedRequest struct { method string path string header http.Header body []byte } // fakeTTSServer ist der httptest-Fake eines Cloud-Providers: antwortet mit // status + body und zeichnet den (einzigen) gesehenen Request auf. 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 { t.Errorf("Fake-Server: Body lesen: %v", err) } 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] } } // fakeMP3 ist eine beliebige Byte-Folge — die Provider geben Antwort-Bytes // unverändert zurück, der audio-Sink dekodiert (eigene Tests). var fakeMP3 = []byte("\xff\xfb\x90\x00" + "audio-bytes-von-elf") // TestElevenLabsSynthese200: 200 mit Bytes → Bytes kommen unverändert // zurück; Methode, Pfad (inkl. Voice-ID), Header (xi-api-key) und der // JSON-Body (model_id, voice_settings) entsprechen dem Task-Brief. func TestElevenLabsSynthese200(t *testing.T) { ts, rec := fakeTTSServer(t, 200, fakeMP3) p := NewElevenLabs(ChainConfig{ Getenv: envMap(map[string]string{ EnvElevenLabsKey: "testkey", EnvElevenLabsEndpoint: ts.URL, }), }) data, err := p.Synthesize(context.Background(), "Hallo Welt", "de") if err != nil { t.Fatalf("Synthesize: %v", err) } if string(data) != string(fakeMP3) { t.Errorf("Synthesize = %q, want Fake-Bytes %q", data, fakeMP3) } if rec.method != http.MethodPost { t.Errorf("Methode = %s, want POST", rec.method) } if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want { t.Errorf("Pfad = %q, want %q (neutrales Repo-Default „Rachel“)", rec.path, want) } 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 { t.Fatalf("Antwort-Body ist kein JSON: %v (%s)", err, rec.body) } if body.Text != "Hallo Welt" { 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) } } // TestElevenLabsHTTPFehler: 500 und 401 sind klare Fehler, die Status und // einen Body-Ausschnitt nennen (die Kette fällt daran weiter). func TestElevenLabsHTTPFehler(t *testing.T) { 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, })}) _, err := p.Synthesize(context.Background(), "Hallo", "de") if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("Synthesize-Fehler = %v, will HTTP %s", err, tc.want) } if !strings.Contains(err.Error(), "elevenlabs") { t.Errorf("Fehler soll den Provider nennen: %v", err) } }) } } // TestElevenLabsAvailable: ohne Key nicht verfügbar, mit Key verfügbar — // ohne jeden Netz-Call. func TestElevenLabsAvailable(t *testing.T) { if NewElevenLabs(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) { t.Error("Available ohne Key = true, want false") } p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{EnvElevenLabsKey: "k"})}) if !p.Available(context.Background()) { t.Error("Available mit Key = false, want true") } } // TestElevenLabsVoiceRangfolge: --voice-Pin schlägt config-Mapping, Mapping // schlägt Repo-Default (beobachtbar am Request-Pfad). func TestElevenLabsVoiceRangfolge(t *testing.T) { 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 } t.Run("config-Mapping schlägt Default", func(t *testing.T) { p, rec := mk(t, map[string]string{"de": "AusConfig"}, "") if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil { t.Fatalf("Synthesize: %v", err) } if want := "/v1/text-to-speech/AusConfig"; rec.path != want { t.Errorf("Pfad = %q, want %q", rec.path, want) } }) t.Run("Pin (--voice) schlägt config-Mapping", func(t *testing.T) { p, rec := mk(t, map[string]string{"de": "AusConfig"}, "Gepinnt") if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil { t.Fatalf("Synthesize: %v", err) } if want := "/v1/text-to-speech/Gepinnt"; rec.path != want { t.Errorf("Pfad = %q, want %q", rec.path, want) } }) t.Run("unbekannte Sprache fällt auf en-Default", func(t *testing.T) { p, rec := mk(t, nil, "") if _, err := p.Synthesize(context.Background(), "Hallo", "fr"); err != nil { t.Fatalf("Synthesize: %v", err) } if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want { t.Errorf("Pfad = %q, want en-Default %q", rec.path, want) } }) } // TestElevenLabsLeererText ist ein klarer Fehler ohne Request. func TestElevenLabsLeererText(t *testing.T) { 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 { t.Error("Synthesize(\"\") soll fehlschlagen") } if rec.method != "" { t.Error("Bei leerem Text darf kein Request gehen") } }