src/server/internal/api/characters_test.go
10,501 bytes · 369 lines · capsule://quake0day/[email protected]
raw on github
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/cyberverse/server/internal/character"
"github.com/cyberverse/server/internal/inference"
pb "github.com/cyberverse/server/internal/pb"
)
func TestCharacterResponsesOmitAvatarModel(t *testing.T) {
r := newTestRouter()
createBody := `{
"name":"角色A",
"description":"test",
"voice_provider":"doubao",
"voice_type":"温柔文雅",
"avatar_model":"flash_head"
}`
req := httptest.NewRequest("POST", "/api/v1/characters", strings.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.Handler().ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", w.Code)
}
var created map[string]any
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatal(err)
}
if _, ok := created["avatar_model"]; ok {
t.Fatalf("expected create response to omit avatar_model, got %v", created["avatar_model"])
}
id, ok := created["id"].(string)
if !ok || id == "" {
t.Fatalf("expected response id, got %v", created["id"])
}
req = httptest.NewRequest("GET", "/api/v1/characters/"+id, nil)
w = httptest.NewRecorder()
r.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var fetched map[string]any
if err := json.NewDecoder(w.Body).Decode(&fetched); err != nil {
t.Fatal(err)
}
if _, ok := fetched["avatar_model"]; ok {
t.Fatalf("expected get response to omit avatar_model, got %v", fetched["avatar_model"])
}
updateBody := `{
"name":"角色A",
"description":"updated",
"voice_provider":"doubao",
"voice_type":"温柔文雅",
"avatar_model":"live_act"
}`
req = httptest.NewRequest("PUT", "/api/v1/characters/"+id, strings.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var updated map[string]any
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
t.Fatal(err)
}
if _, ok := updated["avatar_model"]; ok {
t.Fatalf("expected update response to omit avatar_model, got %v", updated["avatar_model"])
}
}
func TestCharacterVoiceTypeAllowsCustomSpeakerID(t *testing.T) {
r := newTestRouter()
createBody := `{
"name":"角色A",
"description":"test",
"voice_provider":"doubao",
"voice_type":"S_123456"
}`
req := httptest.NewRequest("POST", "/api/v1/characters", strings.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.Handler().ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", w.Code)
}
var created map[string]any
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatal(err)
}
if got := created["voice_type"]; got != "S_123456" {
t.Fatalf("expected custom voice_type to round-trip on create, got %v", got)
}
id, ok := created["id"].(string)
if !ok || id == "" {
t.Fatalf("expected response id, got %v", created["id"])
}
req = httptest.NewRequest("GET", "/api/v1/characters/"+id, nil)
w = httptest.NewRecorder()
r.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var fetched map[string]any
if err := json.NewDecoder(w.Body).Decode(&fetched); err != nil {
t.Fatal(err)
}
if got := fetched["voice_type"]; got != "S_123456" {
t.Fatalf("expected custom voice_type to round-trip on get, got %v", got)
}
updateBody := `{
"name":"角色A",
"description":"updated",
"voice_provider":"doubao",
"voice_type":"S_987654"
}`
req = httptest.NewRequest("PUT", "/api/v1/characters/"+id, strings.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var updated map[string]any
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
t.Fatal(err)
}
if got := updated["voice_type"]; got != "S_987654" {
t.Fatalf("expected custom voice_type to round-trip on update, got %v", got)
}
}
func TestTestCharacterVoiceSuccess(t *testing.T) {
inf := &fakeInferenceService{
avatarInfo: &pb.AvatarInfo{ModelName: "avatar.flash_head", OutputFps: 25, OutputWidth: 512, OutputHeight: 512},
checkVoiceConfigs: make(chan inference.VoiceLLMSessionConfig, 1),
}
r := newTestRouterWithInference(inf)
req := httptest.NewRequest(
"POST",
"/api/v1/characters/test-voice",
strings.NewReader(`{"voice_provider":"doubao","voice_type":"温柔文雅"}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %q", resp["status"])
}
select {
case config := <-inf.checkVoiceConfigs:
if config.Provider != "doubao" || config.Voice != "温柔文雅" {
t.Fatalf("unexpected check voice config: %+v", config)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for check voice config")
}
}
func TestTestCharacterVoiceSupportsQwenOmniProvider(t *testing.T) {
inf := &fakeInferenceService{
avatarInfo: &pb.AvatarInfo{ModelName: "avatar.flash_head", OutputFps: 25, OutputWidth: 512, OutputHeight: 512},
checkVoiceConfigs: make(chan inference.VoiceLLMSessionConfig, 1),
}
r := newTestRouterWithInference(inf)
req := httptest.NewRequest(
"POST",
"/api/v1/characters/test-voice",
strings.NewReader(`{"voice_provider":"qwen_omni","voice_type":"Tina"}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
select {
case config := <-inf.checkVoiceConfigs:
if config.Provider != "qwen_omni" || config.Voice != "Tina" {
t.Fatalf("unexpected check voice config: %+v", config)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for check voice config")
}
}
func TestTestCharacterVoiceRejectsUnsupportedProvider(t *testing.T) {
r := newTestRouter()
req := httptest.NewRequest(
"POST",
"/api/v1/characters/test-voice",
strings.NewReader(`{"voice_provider":"other","voice_type":"温柔文雅"}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.Handler().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestTestCharacterVoiceReturnsProviderRawError(t *testing.T) {
r := newTestRouterWithInference(&fakeInferenceService{
checkVoiceProviderError: `{"error":"resource ID is mismatched with speaker related resource"}`,
})
req := httptest.NewRequest(
"POST",
"/api/v1/characters/test-voice",
strings.NewReader(`{"voice_provider":"doubao","voice_type":"S_123456"}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.Handler().ServeHTTP(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("expected 502, got %d", w.Code)
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
want := `{"error":"resource ID is mismatched with speaker related resource"}`
if resp["error"] != want {
t.Fatalf("expected raw provider error %q, got %q", want, resp["error"])
}
}
func TestTestCharacterVoiceReturnsServiceError(t *testing.T) {
r := newTestRouterWithInference(&fakeInferenceService{
checkVoiceErr: errors.New("voice check timed out"),
})
req := httptest.NewRequest(
"POST",
"/api/v1/characters/test-voice",
strings.NewReader(`{"voice_provider":"doubao","voice_type":"温柔文雅"}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.Handler().ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp["error"] != "voice check timed out" {
t.Fatalf("expected service error, got %q", resp["error"])
}
}
func TestIdleVideoURLsOnlyReturnCurrentResolutionVariant(t *testing.T) {
r := newTestRouterWithInference(&fakeInferenceService{
avatarInfo: &pb.AvatarInfo{
ModelName: "avatar.live_act",
OutputFps: 24,
OutputWidth: 320,
OutputHeight: 480,
},
})
char, err := r.charStore.Create(&character.Character{
Name: "角色A",
VoiceType: "温柔文雅",
})
if err != nil {
t.Fatal(err)
}
image := character.ImageInfo{
Filename: "img_001.png",
OrigName: "avatar.png",
AddedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := r.charStore.AddImage(char.ID, image); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(r.charStore.IdleVideosForImageDir(char.ID, image.Filename), 0755); err != nil {
t.Fatal(err)
}
wrongDir := r.charStore.IdleVideosForSizeDir(char.ID, image.Filename, 512, 512)
if err := os.MkdirAll(wrongDir, 0755); err != nil {
t.Fatal(err)
}
wrongPath := filepath.Join(wrongDir, "wrong.mp4")
if err := os.WriteFile(wrongPath, []byte("old"), 0644); err != nil {
t.Fatal(err)
}
rightDir := r.charStore.IdleVideosForSizeDir(char.ID, image.Filename, 320, 480)
if err := os.MkdirAll(rightDir, 0755); err != nil {
t.Fatal(err)
}
firstPath := filepath.Join(rightDir, "custom_a.mp4")
if err := os.WriteFile(firstPath, []byte("a"), 0644); err != nil {
t.Fatal(err)
}
secondPath := filepath.Join(rightDir, "custom_b.mp4")
if err := os.WriteFile(secondPath, []byte("b"), 0644); err != nil {
t.Fatal(err)
}
target := r.currentIdleVideoTarget(context.Background())
urls := r.idleVideoURLs(char.ID, image.Filename, target)
if len(urls) != 2 {
t.Fatalf("expected 2 idle video URLs for current resolution, got %d (%v)", len(urls), urls)
}
wantFirst := "/api/v1/characters/" + char.ID + "/idle-videos/img_001/320x480/custom_a.mp4"
wantSecond := "/api/v1/characters/" + char.ID + "/idle-videos/img_001/320x480/custom_b.mp4"
if urls[0] != wantFirst || urls[1] != wantSecond {
t.Fatalf("expected idle video URLs [%q %q], got %v", wantFirst, wantSecond, urls)
}
}