package provider import ( "bytes" "context" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" ) // fakePlayer zeichnet Play-Aufrufe auf (Name steuert die null-Entscheidung // der Kette). 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 antwortet mit status + body und zählt die Requests // (atomic, der Server kann mehrere Stufen-Requests sehen). 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 baut die Getenv-Seam für Tests mit gefakten Cloud-Endpunkten. func chainEnv(kv map[string]string) func(string) string { return func(k string) string { return kv[k] } } // fakeProcBin schreibt ein ausführbares Shell-Skript (gleicher Mechanismus // wie in osbuiltin_test.go — echter Prozess, 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 liefert die Env-Map für eine Kette, deren elevenlabs-Stufe auf // elevenURL und deren openai-Stufe auf openaiURL zeigt (edge tot, damit // kein Test den echten Microsoft-Endpunkt anfasst). 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 } // TestChainFallbackErster500ZweiterTreffer (Abnahmekriterium aus dem // Task-Brief): Stufe 1 (elevenlabs) antwortet 500 → die Kette fällt auf // Stufe 2 (openai) durch, deren Bytes kommen zurück; der Fallback ist im // Log sichtbar, nie still. func TestChainFallbackErster500ZweiterTreffer(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(), "Hallo Welt", "de") if err != nil { t.Fatalf("Synthesize: %v", err) } if string(data) != string(fakeMP3) { t.Errorf("Synthesize = %q, want Bytes der openai-Stufe", data) } if elHits.Load() != 1 || oaHits.Load() != 1 { t.Errorf("Hits: elevenlabs = %d, openai = %d — want je 1 (500 → Fallback)", elHits.Load(), oaHits.Load()) } for _, want := range []string{"elevenlabs", "500", "Fallback", "Ansage über openai"} { if !strings.Contains(log.String(), want) { t.Errorf("Log = %q, will %q", log.String(), want) } } } // TestChainAllesFehlt: beide Cloud-Stufen 500, edge tot, osbuiltin nicht // verfügbar → Fehler nennt die fehlgeschlagenen Stufen; Log zeigt jeden. func TestChainAllesFehlt(t *testing.T) { eleven, _ := countingTTSServer(t, 500, []byte("el-boom")) openai, _ := countingTTSServer(t, 500, []byte("oa-boom")) // PATH leer: osbuiltin nicht verfügbar. 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(), "Hallo", "de") if err == nil { t.Fatal("Synthesize mit allen Stufen kaputt: kein Fehler") } for _, want := range []string{"elevenlabs", "openai", "osbuiltin", "500"} { if !strings.Contains(err.Error(), want) && !strings.Contains(log.String(), want) { t.Errorf("Weder Fehler (%v) noch Log nennen %q", err, want) } } } // TestChainOhneKeysEdgeOsbuiltin (Abnahmekriterium): ohne jeden Key und // mit totem edge wählt die Kette osbuiltin — mit gefaktem espeak kommen // dessen Bytes zurück. func TestChainOhneKeysEdgeOsbuiltin(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(), "Hallo", "de") if err != nil { t.Fatalf("Synthesize: %v", err) } if !bytes.HasPrefix(data, []byte("RIFF")) { t.Errorf("Synthesize = %q, want WAV-Bytes des fake espeak", data) } if !strings.Contains(log.String(), "übersprungen (nicht verfügbar)") { t.Errorf("Log = %q, will Skip-Hinweise für die Key-Stufen", log.String()) } } // TestChainPin: --provider/config pinnen die Kette auf genau eine Stufe — // andere Stufen werden nicht einmal angefragt. 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(), "Hallo", "de"); err == nil { t.Fatal("Gepinnte kaputte Stufe soll fehlschlagen (kein Fallback bei Pin)") } if elHits.Load() != 0 || oaHits.Load() != 1 { t.Errorf("Hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load()) } } // TestChainUnbekannterPin ist ein klarer Fehler mit Namensliste. func TestChainUnbekannterPin(t *testing.T) { if _, err := NewChain(ChainConfig{Pin: "gibtsnicht"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") { t.Errorf("NewChain-Fehler = %v, will verfügbare Namen nennen", err) } } // TestChainSpeakBytesUeberPlayer: erfolgreiche Synthese wird über den // Player abgespielt. func TestChainSpeakBytesUeberPlayer(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(), "Hallo", "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 die Synthese-Bytes", player.played) } } // TestChainDirektMitNullPlayer: fällt die Kette auf die Direkt-Stufe // (osbuiltin ohne espeak), verwirft ein null-Player die Ansage tonlos und // SUCCESS — spd-say wird nicht gerufen. func TestChainDirektMitNullPlayer(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) // kein espeak → keine Bytes, aber spd-say da 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(), "Hallo Welt", "de"); err != nil { t.Fatalf("Speak (null-Player): %v", err) } if _, err := os.Stat(seen); err == nil { t.Error("spd-say wurde gerufen — null-Player hätte verwerfen müssen") } if !strings.Contains(log.String(), "verworfen") { t.Errorf("Log = %q, will Verwurf-Zeile", log.String()) } } // TestChainDirektMitTonPlayer: mit echtem Player (Name ≠ null) läuft die // Direktansage über SpeakDirect — blockierend (-w) mit Sprache und Text. func TestChainDirektMitTonPlayer(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(), "Hallo Welt", "de"); err != nil { t.Fatalf("Speak (Direkt): %v", err) } args, err := os.ReadFile(seen) if err != nil { t.Fatalf("fake spd-say nicht gerufen: %v", err) } if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" { t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt") } if len(player.played) != 0 { t.Errorf("Player.Play wurde gerufen (%x) — Direktansage geht am Player vorbei", player.played) } } // TestChainSynthesizeErrNoByteOutput: Die Kette als Provider meldet // ErrNoByteOutput, wenn nur eine Direkt-Stufe übrig bleibt — der // DirectSpeaker-Vertrag bleibt am Aufrufer erhalten. 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(), "Hallo", "de") if !errors.Is(err, ErrNoByteOutput) { t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err) } // Chain selbst ist DirectSpeaker. if _, ok := any(ch).(DirectSpeaker); !ok { t.Error("Chain implementiert DirectSpeaker nicht") } } // TestChainAvailableUndNamen: Available true sobald eine Stufe verfügbar; // Default-Reihenfolge ist die bindende aus der Spec. func TestChainAvailableUndNamen(t *testing.T) { ch, err := NewChain(ChainConfig{Getenv: chainEnv(map[string]string{ EnvEdgeEndpoint: "wss://edge.example.com", // edge: immer verfügbar mit Endpunkt })}) if err != nil { t.Fatalf("NewChain: %v", err) } if !ch.Available(context.Background()) { t.Error("Available = false trotz verfügbarer edge-Stufe") } 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) } } } // TestChainVoicePinDurchgereicht: ChainConfig.Voice wird via VoicePinner an // die Stufen gepinnt — beobachtbar am Request-Pfad des ElevenLabs-Fakes. func TestChainVoicePinDurchgereicht(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(), "Hallo", "de"); err != nil { t.Fatalf("Synthesize: %v", err) } if want := "/v1/text-to-speech/ChainVoice"; rec.path != want { t.Errorf("Pfad = %q, want %q", rec.path, want) } } // TestChainDirektEndgameEinDurchlauf (Review-Fix): Im DirectSpeaker- // Endgame läuft die Kette genau EINMAL — die fehlgeschlagene Cloud-Stufe // wird nur einmal angefragt (keine doppelten POSTs auf bezahlten APIs, // keine verdoppelte Latenz), jede Stufe protokolliert ihren Fehlschlag // genau einmal, und die Direktansage kommt zustande. func TestChainDirektEndgameEinDurchlauf(t *testing.T) { eleven, elHits := countingTTSServer(t, 500, []byte("boom")) // elevenlabs: Key + Fake (500). openai: Endpunkt, aber KEIN Key → // übersprungen. edge: toter Loopback-Port. osbuiltin: PATH nur mit // fake spd-say (kein espeak) → keine Bytes, Direktansage. 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, // ohne Key ohnehin übersprungen 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(), "Hallo Welt", "de"); err != nil { t.Fatalf("Speak (Direkt-Endgame): %v", err) } if got := elHits.Load(); got != 1 { t.Errorf("elevenlabs wurde %d× angefragt, want genau 1 (kein zweiter Durchlauf)", got) } for _, tier := range []string{"elevenlabs", "edge"} { if got := strings.Count(log.String(), "Stufe "+tier+" fehlgeschlagen"); got != 1 { t.Errorf("„Stufe %s fehlgeschlagen“ steht %d× im Log, want genau 1:\n%s", tier, got, log.String()) } } if got := strings.Count(log.String(), "Stufe openai übersprungen"); got != 1 { t.Errorf("openai-Skip steht %d× im Log, want genau 1", got) } args, err := os.ReadFile(seen) if err != nil { t.Fatalf("fake spd-say nicht gerufen: %v", err) } if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" { t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt") } }