package kokoro_test import ( "context" "net/http" "net/http/httptest" "strings" "testing" "github.com/libnovel/backend/internal/kokoro" ) // ── VoiceSampleKey ──────────────────────────────────────────────────────────── func TestVoiceSampleKey(t *testing.T) { tests := []struct { voice string want string }{ {"af_bella", "_voice-samples/af_bella.mp3"}, {"am_echo", "_voice-samples/am_echo.mp3"}, {"voice with spaces", "_voice-samples/voice_with_spaces.mp3"}, {"special!@#chars", "_voice-samples/special___chars.mp3"}, {"", "_voice-samples/.mp3"}, } for _, tt := range tests { t.Run(tt.voice, func(t *testing.T) { got := kokoro.VoiceSampleKey(tt.voice) if got != tt.want { t.Errorf("VoiceSampleKey(%q) = %q, want %q", tt.voice, got, tt.want) } }) } } // ── GenerateAudio ───────────────────────────────────────────────────────────── func TestGenerateAudio_EmptyText(t *testing.T) { srv := httptest.NewServer(http.NotFoundHandler()) defer srv.Close() c := kokoro.New(srv.URL) _, err := c.GenerateAudio(context.Background(), "", "af_bella") if err == nil { t.Fatal("expected error for empty text, got nil") } if !strings.Contains(err.Error(), "empty text") { t.Errorf("expected 'empty text' in error, got: %v", err) } } func TestGenerateAudio_DefaultVoice(t *testing.T) { // Tracks that the voice defaults to af_bella when empty. var capturedBody string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/audio/speech" { buf := make([]byte, 512) n, _ := r.Body.Read(buf) capturedBody = string(buf[:n]) w.Header().Set("X-Download-Path", "/download/test_file.mp3") w.WriteHeader(http.StatusOK) return } if strings.HasPrefix(r.URL.Path, "/v1/download/") { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("fake-mp3-data")) return } http.NotFound(w, r) })) defer srv.Close() c := kokoro.New(srv.URL) data, err := c.GenerateAudio(context.Background(), "hello world", "") if err != nil { t.Fatalf("unexpected error: %v", err) } if string(data) != "fake-mp3-data" { t.Errorf("unexpected data: %q", string(data)) } if !strings.Contains(capturedBody, `"af_bella"`) { t.Errorf("expected default voice af_bella in request body, got: %s", capturedBody) } } func TestGenerateAudio_SpeechNon200(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/audio/speech" { w.WriteHeader(http.StatusInternalServerError) return } http.NotFound(w, r) })) defer srv.Close() c := kokoro.New(srv.URL) _, err := c.GenerateAudio(context.Background(), "text", "af_bella") if err == nil { t.Fatal("expected error for non-200 speech response") } if !strings.Contains(err.Error(), "500") { t.Errorf("expected 500 in error, got: %v", err) } } func TestGenerateAudio_NoDownloadPathHeader(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/audio/speech" { // No X-Download-Path header w.WriteHeader(http.StatusOK) return } http.NotFound(w, r) })) defer srv.Close() c := kokoro.New(srv.URL) _, err := c.GenerateAudio(context.Background(), "text", "af_bella") if err == nil { t.Fatal("expected error for missing X-Download-Path") } if !strings.Contains(err.Error(), "X-Download-Path") { t.Errorf("expected X-Download-Path in error, got: %v", err) } } func TestGenerateAudio_DownloadFails(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/audio/speech" { w.Header().Set("X-Download-Path", "/v1/download/speech.mp3") w.WriteHeader(http.StatusOK) return } if strings.HasPrefix(r.URL.Path, "/v1/download/") { w.WriteHeader(http.StatusNotFound) return } http.NotFound(w, r) })) defer srv.Close() c := kokoro.New(srv.URL) _, err := c.GenerateAudio(context.Background(), "text", "af_bella") if err == nil { t.Fatal("expected error for failed download") } if !strings.Contains(err.Error(), "404") { t.Errorf("expected 404 in error, got: %v", err) } } func TestGenerateAudio_FullPath(t *testing.T) { // X-Download-Path with a full path: extract just filename. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/audio/speech" { w.Header().Set("X-Download-Path", "/some/nested/path/audio_abc123.mp3") w.WriteHeader(http.StatusOK) return } if r.URL.Path == "/v1/download/audio_abc123.mp3" { _, _ = w.Write([]byte("audio-bytes")) return } http.NotFound(w, r) })) defer srv.Close() c := kokoro.New(srv.URL) data, err := c.GenerateAudio(context.Background(), "text", "af_bella") if err != nil { t.Fatalf("unexpected error: %v", err) } if string(data) != "audio-bytes" { t.Errorf("unexpected data: %q", string(data)) } } func TestGenerateAudio_ContextCancelled(t *testing.T) { // Server that hangs — context should cancel before we get a response. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Never respond. select {} })) defer srv.Close() ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel immediately c := kokoro.New(srv.URL) _, err := c.GenerateAudio(ctx, "text", "af_bella") if err == nil { t.Fatal("expected error for cancelled context") } } // ── ListVoices ──────────────────────────────────────────────────────────────── func TestListVoices_Success(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/audio/voices" { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"voices":["af_bella","am_adam","bf_emma"]}`)) return } http.NotFound(w, r) })) defer srv.Close() c := kokoro.New(srv.URL) voices, err := c.ListVoices(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(voices) != 3 { t.Errorf("expected 3 voices, got %d: %v", len(voices), voices) } if voices[0] != "af_bella" { t.Errorf("expected first voice to be af_bella, got %q", voices[0]) } } func TestListVoices_Non200(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })) defer srv.Close() c := kokoro.New(srv.URL) _, err := c.ListVoices(context.Background()) if err == nil { t.Fatal("expected error for non-200 response") } if !strings.Contains(err.Error(), "503") { t.Errorf("expected 503 in error, got: %v", err) } } func TestListVoices_MalformedJSON(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`not-json`)) })) defer srv.Close() c := kokoro.New(srv.URL) _, err := c.ListVoices(context.Background()) if err == nil { t.Fatal("expected error for malformed JSON") } } func TestListVoices_EmptyVoices(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"voices":[]}`)) })) defer srv.Close() c := kokoro.New(srv.URL) voices, err := c.ListVoices(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(voices) != 0 { t.Errorf("expected 0 voices, got %d", len(voices)) } } // ── New ─────────────────────────────────────────────────────────────────────── func TestNew_TrailingSlashStripped(t *testing.T) { // Verify that a trailing slash on baseURL doesn't produce double-slash paths. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/audio/voices" { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"voices":["af_bella"]}`)) return } http.NotFound(w, r) })) defer srv.Close() c := kokoro.New(srv.URL + "/") // trailing slash voices, err := c.ListVoices(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(voices) == 0 { t.Error("expected at least one voice") } }