Files
talk/internal/provider/elevenlabs_test.go
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

205 lines
6.6 KiB
Go

package provider
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// recordedRequest records a request seen by the fake server.
type recordedRequest struct {
method string
path string
header http.Header
body []byte
}
// fakeTTSServer is the httptest fake of a cloud provider: responds with
// status + body and records the (single) request it saw.
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: reading body: %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 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")
// 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) {
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(), "Hello world", "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("method = %s, want POST", rec.method)
}
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
t.Errorf("path = %q, want %q (neutral 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("request body is not JSON: %v (%s)", err, rec.body)
}
if body.Text != "Hello world" {
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)
}
}
// 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) {
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(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Synthesize error = %v, want HTTP %s", err, tc.want)
}
if !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("error should name the provider: %v", err)
}
})
}
}
// TestElevenLabsAvailable: not available without a key, available with
// one; without any network call.
func TestElevenLabsAvailable(t *testing.T) {
if NewElevenLabs(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available without key = true, want false")
}
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{EnvElevenLabsKey: "k"})})
if !p.Available(context.Background()) {
t.Error("Available with key = false, want true")
}
}
// TestElevenLabsVoicePrecedence: the --voice pin beats the config mapping,
// the mapping beats the repo default (observable in the request path).
func TestElevenLabsVoicePrecedence(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 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 {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/FromConfig"; rec.path != want {
t.Errorf("path = %q, want %q", rec.path, want)
}
})
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 {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/Pinned"; rec.path != want {
t.Errorf("path = %q, want %q", rec.path, want)
}
})
t.Run("unknown language falls back to en default", func(t *testing.T) {
p, rec := mk(t, nil, "")
if _, err := p.Synthesize(context.Background(), "Hello", "fr"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
t.Errorf("path = %q, want en default %q", rec.path, want)
}
})
}
// TestElevenLabsEmptyText is a clear error without a request.
func TestElevenLabsEmptyText(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(\"\") should fail")
}
if rec.method != "" {
t.Error("no request must be sent for empty text")
}
}