All checks were successful
Release / Scraper / Test (push) Successful in 10s
Release / UI / Build (push) Successful in 26s
Release / v2 / Build ui-v2 (push) Successful in 17s
Release / Scraper / Docker (push) Successful in 47s
Release / UI / Docker (push) Successful in 56s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / Scraper / Test (pull_request) Successful in 8s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / v2 / Docker / ui-v2 (push) Successful in 56s
Release / v2 / Test backend (push) Successful in 4m35s
iOS CI / Build (pull_request) Successful in 4m28s
Release / v2 / Docker / backend (push) Successful in 1m29s
Release / v2 / Docker / runner (push) Successful in 1m39s
iOS CI / Test (pull_request) Successful in 9m51s
- backend/: Go API server and runner binaries with PocketBase + MinIO storage - ui-v2/: SvelteKit frontend rewrite - docker-compose-new.yml: compose file for the v2 stack - .gitea/workflows/release-v2.yaml: CI/CD for backend, runner, and ui-v2 Docker Hub images - scripts/pb-init.sh: migrate from wget to curl, add superuser bootstrap for fresh installs - .env.example: document DOCKER_BUILDKIT=1 for Colima users
292 lines
8.6 KiB
Go
292 lines
8.6 KiB
Go
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")
|
|
}
|
|
}
|