feature/go-handler-refactor #1
267
e2e_test.go
Normal file
267
e2e_test.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// E2E tests: voice registry + XTTS synthesis + audio streaming pipeline
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSynthesisE2E_StreamChunks(t *testing.T) {
|
||||||
|
// Mock XTTS returning 64 KB of audio
|
||||||
|
audioSize := 65536
|
||||||
|
xttsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var payload map[string]any
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["text"] == nil || payload["text"] == "" {
|
||||||
|
w.WriteHeader(400)
|
||||||
|
w.Write([]byte("empty text"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(make([]byte, audioSize))
|
||||||
|
}))
|
||||||
|
defer xttsSrv.Close()
|
||||||
|
|
||||||
|
// Test synthesize + chunking logic
|
||||||
|
client := &http.Client{}
|
||||||
|
body := `{"text":"hello world","speaker":"default","language":"en"}`
|
||||||
|
resp, err := client.Post(xttsSrv.URL+"/v1/audio/speech", "application/json",
|
||||||
|
bytes.NewReader([]byte(body)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
audioBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
if len(audioBytes) != audioSize {
|
||||||
|
t.Fatalf("audio size = %d, want %d", len(audioBytes), audioSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate streaming: chunk into 32 KB pieces
|
||||||
|
chunkSize := 32768
|
||||||
|
totalChunks := (len(audioBytes) + chunkSize - 1) / chunkSize
|
||||||
|
if totalChunks != 2 {
|
||||||
|
t.Errorf("totalChunks = %d, want 2", totalChunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(audioBytes); i += chunkSize {
|
||||||
|
end := i + chunkSize
|
||||||
|
if end > len(audioBytes) {
|
||||||
|
end = len(audioBytes)
|
||||||
|
}
|
||||||
|
chunk := audioBytes[i:end]
|
||||||
|
chunkIdx := i / chunkSize
|
||||||
|
isLast := end >= len(audioBytes)
|
||||||
|
|
||||||
|
// Verify chunk message shape
|
||||||
|
msg := map[string]any{
|
||||||
|
"session_id": "test-session",
|
||||||
|
"chunk_index": chunkIdx,
|
||||||
|
"total_chunks": totalChunks,
|
||||||
|
"audio_b64": base64.StdEncoding.EncodeToString(chunk),
|
||||||
|
"is_last": isLast,
|
||||||
|
"sample_rate": 24000,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip through JSON
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
var decoded map[string]any
|
||||||
|
json.Unmarshal(data, &decoded)
|
||||||
|
|
||||||
|
if decoded["session_id"] != "test-session" {
|
||||||
|
t.Errorf("chunk %d: session = %v", chunkIdx, decoded["session_id"])
|
||||||
|
}
|
||||||
|
if decoded["is_last"] != isLast {
|
||||||
|
t.Errorf("chunk %d: is_last = %v, want %v", chunkIdx, decoded["is_last"], isLast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSynthesisE2E_CustomVoice(t *testing.T) {
|
||||||
|
// Set up voice registry with temp dir
|
||||||
|
dir := t.TempDir()
|
||||||
|
voiceDir := filepath.Join(dir, "custom-en")
|
||||||
|
os.MkdirAll(voiceDir, 0o755)
|
||||||
|
info := map[string]string{
|
||||||
|
"name": "custom-en", "language": "en",
|
||||||
|
"type": "coqui-tts", "created_at": "2024-06-01",
|
||||||
|
}
|
||||||
|
infoData, _ := json.Marshal(info)
|
||||||
|
os.WriteFile(filepath.Join(voiceDir, "model_info.json"), infoData, 0o644)
|
||||||
|
os.WriteFile(filepath.Join(voiceDir, "model.pth"), []byte("fake-model"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(voiceDir, "config.json"), []byte("{}"), 0o644)
|
||||||
|
|
||||||
|
registry := newVoiceRegistry(dir)
|
||||||
|
count := registry.refresh()
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("refresh() = %d, want 1", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// XTTS mock that validates custom voice fields
|
||||||
|
xttsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var payload map[string]any
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
|
||||||
|
// When custom voice is used, model_path should be set
|
||||||
|
if payload["model_path"] == nil {
|
||||||
|
t.Error("expected model_path in custom voice request")
|
||||||
|
}
|
||||||
|
if payload["config_path"] == nil {
|
||||||
|
t.Error("expected config_path for voice with config")
|
||||||
|
}
|
||||||
|
w.Write(make([]byte, 4000))
|
||||||
|
}))
|
||||||
|
defer xttsSrv.Close()
|
||||||
|
|
||||||
|
voice := registry.get("custom-en")
|
||||||
|
if voice == nil {
|
||||||
|
t.Fatal("voice 'custom-en' not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request payload like main.go does
|
||||||
|
payload := map[string]any{
|
||||||
|
"text": "hello custom voice",
|
||||||
|
"speaker": "custom-en",
|
||||||
|
"language": "en",
|
||||||
|
}
|
||||||
|
if voice != nil {
|
||||||
|
payload["model_path"] = voice.ModelPath
|
||||||
|
if voice.ConfigPath != "" {
|
||||||
|
payload["config_path"] = voice.ConfigPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
|
resp, err := http.Post(xttsSrv.URL+"/v1/audio/speech", "application/json",
|
||||||
|
bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSynthesisE2E_XTTSError(t *testing.T) {
|
||||||
|
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(503)
|
||||||
|
w.Write([]byte("model not loaded"))
|
||||||
|
}))
|
||||||
|
defer failSrv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(failSrv.URL+"/v1/audio/speech", "application/json",
|
||||||
|
bytes.NewReader([]byte(`{"text":"test"}`)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 503 {
|
||||||
|
t.Errorf("status = %d, want 503", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVoiceRegistryMultiple(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create 3 voices
|
||||||
|
for _, name := range []string{"alice", "bob", "charlie"} {
|
||||||
|
vDir := filepath.Join(dir, name)
|
||||||
|
os.MkdirAll(vDir, 0o755)
|
||||||
|
info := map[string]string{"name": name, "language": "en"}
|
||||||
|
data, _ := json.Marshal(info)
|
||||||
|
os.WriteFile(filepath.Join(vDir, "model_info.json"), data, 0o644)
|
||||||
|
os.WriteFile(filepath.Join(vDir, "model.pth"), []byte("fake"), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := newVoiceRegistry(dir)
|
||||||
|
count := registry.refresh()
|
||||||
|
if count != 3 {
|
||||||
|
t.Errorf("refresh() = %d, want 3", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
voices := registry.listVoices()
|
||||||
|
if len(voices) != 3 {
|
||||||
|
t.Errorf("listVoices() = %d, want 3", len(voices))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"alice", "bob", "charlie"} {
|
||||||
|
if v := registry.get(name); v == nil {
|
||||||
|
t.Errorf("voice %q not found", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := registry.get("nonexistent"); v != nil {
|
||||||
|
t.Error("expected nil for nonexistent voice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Benchmarks
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func BenchmarkSynthesisRoundtrip(b *testing.B) {
|
||||||
|
xttsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write(make([]byte, 16000))
|
||||||
|
}))
|
||||||
|
defer xttsSrv.Close()
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
body := []byte(`{"text":"benchmark text","speaker":"default","language":"en"}`)
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for b.Loop() {
|
||||||
|
resp, _ := client.Post(xttsSrv.URL+"/v1/audio/speech", "application/json",
|
||||||
|
bytes.NewReader(body))
|
||||||
|
io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkVoiceRegistryRefresh(b *testing.B) {
|
||||||
|
dir := b.TempDir()
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
name := "voice-" + strconv.Itoa(i)
|
||||||
|
vDir := filepath.Join(dir, name)
|
||||||
|
os.MkdirAll(vDir, 0o755)
|
||||||
|
info := map[string]string{"name": name}
|
||||||
|
data, _ := json.Marshal(info)
|
||||||
|
os.WriteFile(filepath.Join(vDir, "model_info.json"), data, 0o644)
|
||||||
|
os.WriteFile(filepath.Join(vDir, "model.pth"), []byte("fake"), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := newVoiceRegistry(dir)
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for b.Loop() {
|
||||||
|
registry.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkAudioChunking(b *testing.B) {
|
||||||
|
audioBytes := make([]byte, 256*1024) // 256 KB audio
|
||||||
|
chunkSize := 32768
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for b.Loop() {
|
||||||
|
totalChunks := (len(audioBytes) + chunkSize - 1) / chunkSize
|
||||||
|
for i := 0; i < len(audioBytes); i += chunkSize {
|
||||||
|
end := i + chunkSize
|
||||||
|
if end > len(audioBytes) {
|
||||||
|
end = len(audioBytes)
|
||||||
|
}
|
||||||
|
chunk := audioBytes[i:end]
|
||||||
|
_ = base64.StdEncoding.EncodeToString(chunk)
|
||||||
|
_ = totalChunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
go.mod
1
go.mod
@@ -11,6 +11,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -4,6 +4,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
|||||||
Reference in New Issue
Block a user