package provider import ( "bytes" "context" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" ) // fakePlayer records Play calls (the name drives the chain's null // decision). type fakePlayer struct { name string played [][]byte } func (f *fakePlayer) Play(_ context.Context, data []byte) error { f.played = append(f.played, data) return nil } func (f *fakePlayer) Name() string { return f.name } // countingTTSServer responds with status + body and counts the requests // (atomic; the server may see requests from several tiers). func countingTTSServer(t *testing.T, status int, body []byte) (url string, hits *atomic.Int32) { t.Helper() var n atomic.Int32 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n.Add(1) w.WriteHeader(status) w.Write(body) })) t.Cleanup(ts.Close) return ts.URL, &n } // chainEnv builds the Getenv seam for tests with faked cloud endpoints. func chainEnv(kv map[string]string) func(string) string { return func(k string) string { return kv[k] } } // fakeProcBin writes an executable shell script (same mechanism as in // osbuiltin_test.go: real process, PATH manipulation). func fakeProcBin(t *testing.T, dir, name, body string) { t.Helper() script := "#!/bin/sh\n" + body + "\n" if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil { t.Fatal(err) } } // cloudEnv returns the env map for a chain whose elevenlabs tier points at // elevenURL and whose openai tier points at openaiURL (edge is dead so that // no test touches the real Microsoft endpoint). func cloudEnv(elevenURL, openaiURL string, extra map[string]string) map[string]string { kv := map[string]string{ EnvElevenLabsKey: "k", EnvElevenLabsEndpoint: elevenURL, EnvOpenAIKey: "k", EnvOpenAIEndpoint: openaiURL, EnvEdgeEndpoint: "ws://127.0.0.1:1/", } for k, v := range extra { kv[k] = v } return kv } // TestChainFallbackFirst500SecondHit (acceptance criterion): tier 1 // (elevenlabs) answers 500 → the chain falls through to tier 2 (openai), // whose bytes come back; the fallback is visible in the log, never silent. func TestChainFallbackFirst500SecondHit(t *testing.T) { eleven, elHits := countingTTSServer(t, 500, []byte("boom")) openai, oaHits := countingTTSServer(t, 200, fakeMP3) var log bytes.Buffer ch, err := NewChain(ChainConfig{ Getenv: chainEnv(cloudEnv(eleven, openai, nil)), Log: &log, }) if err != nil { t.Fatalf("NewChain: %v", err) } data, err := ch.Synthesize(context.Background(), "Hello world", "de") if err != nil { t.Fatalf("Synthesize: %v", err) } if string(data) != string(fakeMP3) { t.Errorf("Synthesize = %q, want bytes of the openai tier", data) } if elHits.Load() != 1 || oaHits.Load() != 1 { t.Errorf("hits: elevenlabs = %d, openai = %d; want 1 each (500 → fallback)", elHits.Load(), oaHits.Load()) } for _, want := range []string{"elevenlabs", "500", "falling back", "spoken via openai"} { if !strings.Contains(log.String(), want) { t.Errorf("log = %q, want %q", log.String(), want) } } } // TestChainEverythingFails: both cloud tiers 500, edge dead, osbuiltin not // available → the error names the failed tiers; the log shows each one. func TestChainEverythingFails(t *testing.T) { eleven, _ := countingTTSServer(t, 500, []byte("el-boom")) openai, _ := countingTTSServer(t, 500, []byte("oa-boom")) // Empty PATH: osbuiltin not available. t.Setenv("PATH", t.TempDir()) var log bytes.Buffer ch, err := NewChain(ChainConfig{ Getenv: chainEnv(cloudEnv(eleven, openai, nil)), Log: &log, }) if err != nil { t.Fatalf("NewChain: %v", err) } _, err = ch.Synthesize(context.Background(), "Hello", "de") if err == nil { t.Fatal("Synthesize with all tiers broken: no error") } for _, want := range []string{"elevenlabs", "openai", "osbuiltin", "500"} { if !strings.Contains(err.Error(), want) && !strings.Contains(log.String(), want) { t.Errorf("neither error (%v) nor log mention %q", err, want) } } } // TestChainWithoutKeysEdgeOsbuiltin (acceptance criterion): without any // key and with a dead edge, the chain picks osbuiltin; with a faked espeak // its bytes come back. func TestChainWithoutKeysEdgeOsbuiltin(t *testing.T) { dir := t.TempDir() fakeProcBin(t, dir, "espeak", `printf 'RIFFxxxxWAVEfmt '`) t.Setenv("PATH", dir) var log bytes.Buffer ch, err := NewChain(ChainConfig{ Getenv: chainEnv(map[string]string{EnvEdgeEndpoint: "ws://127.0.0.1:1/"}), Log: &log, }) if err != nil { t.Fatalf("NewChain: %v", err) } data, err := ch.Synthesize(context.Background(), "Hello", "de") if err != nil { t.Fatalf("Synthesize: %v", err) } if !bytes.HasPrefix(data, []byte("RIFF")) { t.Errorf("Synthesize = %q, want WAV bytes of the fake espeak", data) } if !strings.Contains(log.String(), "skipped (not available)") { t.Errorf("log = %q, want skip notes for the key tiers", log.String()) } } // TestChainPin: --provider/config pin the chain to exactly one tier; other // tiers are not even requested. func TestChainPin(t *testing.T) { eleven, elHits := countingTTSServer(t, 200, fakeMP3) openai, oaHits := countingTTSServer(t, 500, []byte("boom")) ch, err := NewChain(ChainConfig{ Pin: "openai", Getenv: chainEnv(cloudEnv(eleven, openai, nil)), }) if err != nil { t.Fatalf("NewChain: %v", err) } if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err == nil { t.Fatal("pinned broken tier should fail (no fallback when pinned)") } if elHits.Load() != 0 || oaHits.Load() != 1 { t.Errorf("hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load()) } } // TestChainUnknownPin is a clear error listing the names. func TestChainUnknownPin(t *testing.T) { if _, err := NewChain(ChainConfig{Pin: "doesnotexist"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") { t.Errorf("NewChain error = %v, want the available names", err) } } // TestChainSpeakBytesViaPlayer: a successful synthesis is played via the // Player. func TestChainSpeakBytesViaPlayer(t *testing.T) { eleven, _ := countingTTSServer(t, 200, fakeMP3) player := &fakePlayer{name: "oto"} ch, err := NewChain(ChainConfig{ Getenv: chainEnv(cloudEnv(eleven, eleven, nil)), Player: player, }) if err != nil { t.Fatalf("NewChain: %v", err) } if err := ch.Speak(context.Background(), "Hello", "de"); err != nil { t.Fatalf("Speak: %v", err) } if len(player.played) != 1 || string(player.played[0]) != string(fakeMP3) { t.Errorf("Player.Play = %x, want the synthesis bytes", player.played) } } // TestChainDirectWithNullPlayer: when the chain falls back to the direct // tier (osbuiltin without espeak), a null player discards the announcement // silently and SUCCEEDS; spd-say is not called. func TestChainDirectWithNullPlayer(t *testing.T) { dir := t.TempDir() seen := filepath.Join(dir, "seen.txt") fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`) t.Setenv("PATH", dir) // no espeak → no bytes, but spd-say present var log bytes.Buffer ch, err := NewChain(ChainConfig{ Pin: "osbuiltin", Getenv: chainEnv(nil), Player: &fakePlayer{name: NullPlayerName}, Log: &log, }) if err != nil { t.Fatalf("NewChain: %v", err) } if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil { t.Fatalf("Speak (null player): %v", err) } if _, err := os.Stat(seen); err == nil { t.Error("spd-say was called; the null player should have discarded") } if !strings.Contains(log.String(), "discarded") { t.Errorf("log = %q, want discard line", log.String()) } } // TestChainDirectWithSoundPlayer: with a real player (name != null) the // direct announcement runs via SpeakDirect, blocking (-w) with language and // text. func TestChainDirectWithSoundPlayer(t *testing.T) { dir := t.TempDir() seen := filepath.Join(dir, "seen.txt") fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`) t.Setenv("PATH", dir) player := &fakePlayer{name: "oto"} ch, err := NewChain(ChainConfig{ Pin: "osbuiltin", Getenv: chainEnv(nil), Player: player, }) if err != nil { t.Fatalf("NewChain: %v", err) } if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil { t.Fatalf("Speak (direct): %v", err) } args, err := os.ReadFile(seen) if err != nil { t.Fatalf("fake spd-say not called: %v", err) } if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" { t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world") } if len(player.played) != 0 { t.Errorf("Player.Play was called (%x); a direct announcement bypasses the player", player.played) } } // TestChainSynthesizeErrNoByteOutput: the chain as a Provider reports // ErrNoByteOutput when only a direct tier remains; the DirectSpeaker // contract is preserved towards the caller. func TestChainSynthesizeErrNoByteOutput(t *testing.T) { dir := t.TempDir() fakeProcBin(t, dir, "spd-say", "true") t.Setenv("PATH", dir) ch, err := NewChain(ChainConfig{Pin: "osbuiltin", Getenv: chainEnv(nil)}) if err != nil { t.Fatalf("NewChain: %v", err) } _, err = ch.Synthesize(context.Background(), "Hello", "de") if !errors.Is(err, ErrNoByteOutput) { t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err) } // Chain itself is a DirectSpeaker. if _, ok := any(ch).(DirectSpeaker); !ok { t.Error("Chain does not implement DirectSpeaker") } } // TestChainAvailableAndNames: Available is true as soon as one tier is // available; the default order is the binding one from the spec. func TestChainAvailableAndNames(t *testing.T) { ch, err := NewChain(ChainConfig{Getenv: chainEnv(map[string]string{ EnvEdgeEndpoint: "wss://edge.example.com", // edge: always available with an endpoint })}) if err != nil { t.Fatalf("NewChain: %v", err) } if !ch.Available(context.Background()) { t.Error("Available = false despite an available edge tier") } want := []string{"elevenlabs", "openai", "edge", "osbuiltin"} if len(ch.Tiers()) != len(want) { t.Fatalf("Tiers = %d, want %d", len(ch.Tiers()), len(want)) } for i, name := range want { if ch.Tiers()[i].Name() != name { t.Errorf("Tiers[%d] = %s, want %s", i, ch.Tiers()[i].Name(), name) } } } // TestChainVoicePinForwarded: ChainConfig.Voice is pinned on the tiers via // VoicePinner; observable in the request path of the ElevenLabs fake. func TestChainVoicePinForwarded(t *testing.T) { ts, rec := fakeTTSServer(t, 200, fakeMP3) ch, err := NewChain(ChainConfig{ Pin: "elevenlabs", Voice: "ChainVoice", Getenv: chainEnv(map[string]string{ EnvElevenLabsKey: "k", EnvElevenLabsEndpoint: ts.URL, }), }) if err != nil { t.Fatalf("NewChain: %v", err) } if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err != nil { t.Fatalf("Synthesize: %v", err) } if want := "/v1/text-to-speech/ChainVoice"; rec.path != want { t.Errorf("path = %q, want %q", rec.path, want) } } // TestChainDirectEndgameSingleWalk (review fix): in the DirectSpeaker // endgame the chain walks exactly ONCE; the failed cloud tier is requested // only once (no duplicate POSTs to paid APIs, no doubled latency), every // tier logs its failure exactly once, and the direct announcement happens. func TestChainDirectEndgameSingleWalk(t *testing.T) { eleven, elHits := countingTTSServer(t, 500, []byte("boom")) // elevenlabs: key + fake (500). openai: endpoint but NO key → skipped. // edge: dead loopback port. osbuiltin: PATH with only a fake spd-say (no // espeak) → no bytes, direct announcement. dir := t.TempDir() seen := filepath.Join(dir, "seen.txt") fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`) t.Setenv("PATH", dir) var log bytes.Buffer ch, err := NewChain(ChainConfig{ Getenv: chainEnv(map[string]string{ EnvElevenLabsKey: "k", EnvElevenLabsEndpoint: eleven, EnvOpenAIEndpoint: eleven, // skipped anyway without a key EnvEdgeEndpoint: "ws://127.0.0.1:1/", }), Player: &fakePlayer{name: "oto"}, Log: &log, }) if err != nil { t.Fatalf("NewChain: %v", err) } if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil { t.Fatalf("Speak (direct endgame): %v", err) } if got := elHits.Load(); got != 1 { t.Errorf("elevenlabs was requested %d times, want exactly 1 (no second walk)", got) } for _, tier := range []string{"elevenlabs", "edge"} { if got := strings.Count(log.String(), "tier "+tier+" failed"); got != 1 { t.Errorf("\"tier %s failed\" appears %d times in the log, want exactly 1:\n%s", tier, got, log.String()) } } if got := strings.Count(log.String(), "tier openai skipped"); got != 1 { t.Errorf("openai skip appears %d times in the log, want exactly 1", got) } args, err := os.ReadFile(seen) if err != nil { t.Fatalf("fake spd-say not called: %v", err) } if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" { t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world") } }