//go:build unit package service import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) var _ SoraClient = (*stubSoraClientForPoll)(nil) type stubSoraClientForPoll struct { imageStatus *SoraImageTaskStatus videoStatus *SoraVideoTaskStatus imageCalls int videoCalls int enhanced string enhanceErr error storyboard bool videoReq SoraVideoRequest parseErr error postCalls int deleteCalls int } func (s *stubSoraClientForPoll) Enabled() bool { return true } func (s *stubSoraClientForPoll) UploadImage(ctx context.Context, account *Account, data []byte, filename string) (string, error) { return "", nil } func (s *stubSoraClientForPoll) CreateImageTask(ctx context.Context, account *Account, req SoraImageRequest) (string, error) { return "task-image", nil } func (s *stubSoraClientForPoll) CreateVideoTask(ctx context.Context, account *Account, req SoraVideoRequest) (string, error) { s.videoReq = req return "task-video", nil } func (s *stubSoraClientForPoll) CreateStoryboardTask(ctx context.Context, account *Account, req SoraStoryboardRequest) (string, error) { s.storyboard = true return "task-video", nil } func (s *stubSoraClientForPoll) UploadCharacterVideo(ctx context.Context, account *Account, data []byte) (string, error) { return "cameo-1", nil } func (s *stubSoraClientForPoll) GetCameoStatus(ctx context.Context, account *Account, cameoID string) (*SoraCameoStatus, error) { return &SoraCameoStatus{ Status: "finalized", StatusMessage: "Completed", DisplayNameHint: "Character", UsernameHint: "user.character", ProfileAssetURL: "https://example.com/avatar.webp", }, nil } func (s *stubSoraClientForPoll) DownloadCharacterImage(ctx context.Context, account *Account, imageURL string) ([]byte, error) { return []byte("avatar"), nil } func (s *stubSoraClientForPoll) UploadCharacterImage(ctx context.Context, account *Account, data []byte) (string, error) { return "asset-pointer", nil } func (s *stubSoraClientForPoll) FinalizeCharacter(ctx context.Context, account *Account, req SoraCharacterFinalizeRequest) (string, error) { return "character-1", nil } func (s *stubSoraClientForPoll) SetCharacterPublic(ctx context.Context, account *Account, cameoID string) error { return nil } func (s *stubSoraClientForPoll) DeleteCharacter(ctx context.Context, account *Account, characterID string) error { return nil } func (s *stubSoraClientForPoll) PostVideoForWatermarkFree(ctx context.Context, account *Account, generationID string) (string, error) { s.postCalls++ return "s_post", nil } func (s *stubSoraClientForPoll) DeletePost(ctx context.Context, account *Account, postID string) error { s.deleteCalls++ return nil } func (s *stubSoraClientForPoll) GetWatermarkFreeURLCustom(ctx context.Context, account *Account, parseURL, parseToken, postID string) (string, error) { if s.parseErr != nil { return "", s.parseErr } return "https://example.com/no-watermark.mp4", nil } func (s *stubSoraClientForPoll) EnhancePrompt(ctx context.Context, account *Account, prompt, expansionLevel string, durationS int) (string, error) { if s.enhanced != "" { return s.enhanced, s.enhanceErr } return "enhanced prompt", s.enhanceErr } func (s *stubSoraClientForPoll) GetImageTask(ctx context.Context, account *Account, taskID string) (*SoraImageTaskStatus, error) { s.imageCalls++ return s.imageStatus, nil } func (s *stubSoraClientForPoll) GetVideoTask(ctx context.Context, account *Account, taskID string) (*SoraVideoTaskStatus, error) { s.videoCalls++ return s.videoStatus, nil } func TestSoraGatewayService_PollImageTaskCompleted(t *testing.T) { client := &stubSoraClientForPoll{ imageStatus: &SoraImageTaskStatus{ Status: "completed", URLs: []string{"https://example.com/a.png"}, }, } cfg := &config.Config{ Sora: config.SoraConfig{ Client: config.SoraClientConfig{ PollIntervalSeconds: 1, MaxPollAttempts: 1, }, }, } service := NewSoraGatewayService(client, nil, nil, cfg) urls, err := service.pollImageTask(context.Background(), nil, &Account{ID: 1}, "task", false) require.NoError(t, err) require.Equal(t, []string{"https://example.com/a.png"}, urls) require.Equal(t, 1, client.imageCalls) } func TestSoraGatewayService_ForwardPromptEnhance(t *testing.T) { client := &stubSoraClientForPoll{ enhanced: "cinematic prompt", } cfg := &config.Config{ Sora: config.SoraConfig{ Client: config.SoraClientConfig{ PollIntervalSeconds: 1, MaxPollAttempts: 1, }, }, } svc := NewSoraGatewayService(client, nil, nil, cfg) account := &Account{ ID: 1, Platform: PlatformSora, Status: StatusActive, } body := []byte(`{"model":"prompt-enhance-short-10s","messages":[{"role":"user","content":"cat running"}],"stream":false}`) result, err := svc.Forward(context.Background(), nil, account, body, false) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "prompt", result.MediaType) require.Equal(t, "prompt-enhance-short-10s", result.Model) } func TestSoraGatewayService_ForwardStoryboardPrompt(t *testing.T) { client := &stubSoraClientForPoll{ videoStatus: &SoraVideoTaskStatus{ Status: "completed", URLs: []string{"https://example.com/v.mp4"}, }, } cfg := &config.Config{ Sora: config.SoraConfig{ Client: config.SoraClientConfig{ PollIntervalSeconds: 1, MaxPollAttempts: 1, }, }, } svc := NewSoraGatewayService(client, nil, nil, cfg) account := &Account{ID: 1, Platform: PlatformSora, Status: StatusActive} body := []byte(`{"model":"sora2-landscape-10s","messages":[{"role":"user","content":"[5.0s]猫猫跳伞 [5.0s]猫猫落地"}],"stream":false}`) result, err := svc.Forward(context.Background(), nil, account, body, false) require.NoError(t, err) require.NotNil(t, result) require.True(t, client.storyboard) } func TestSoraGatewayService_ForwardCharacterOnly(t *testing.T) { client := &stubSoraClientForPoll{} cfg := &config.Config{ Sora: config.SoraConfig{ Client: config.SoraClientConfig{ PollIntervalSeconds: 1, MaxPollAttempts: 1, }, }, } svc := NewSoraGatewayService(client, nil, nil, cfg) account := &Account{ID: 1, Platform: PlatformSora, Status: StatusActive} body := []byte(`{"model":"sora2-landscape-10s","video":"aGVsbG8=","stream":false}`) result, err := svc.Forward(context.Background(), nil, account, body, false) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "prompt", result.MediaType) require.Equal(t, 0, client.videoCalls) } func TestSoraGatewayService_ForwardWatermarkFallback(t *testing.T) { client := &stubSoraClientForPoll{ videoStatus: &SoraVideoTaskStatus{ Status: "completed", URLs: []string{"https://example.com/original.mp4"}, GenerationID: "gen_1", }, parseErr: errors.New("parse failed"), } cfg := &config.Config{ Sora: config.SoraConfig{ Client: config.SoraClientConfig{ PollIntervalSeconds: 1, MaxPollAttempts: 1, }, }, } svc := NewSoraGatewayService(client, nil, nil, cfg) account := &Account{ID: 1, Platform: PlatformSora, Status: StatusActive} body := []byte(`{"model":"sora2-landscape-10s","messages":[{"role":"user","content":"cat running"}],"stream":false,"watermark_free":true,"watermark_parse_method":"custom","watermark_parse_url":"https://parser.example.com","watermark_parse_token":"token","watermark_fallback_on_failure":true}`) result, err := svc.Forward(context.Background(), nil, account, body, false) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "https://example.com/original.mp4", result.MediaURL) require.Equal(t, 1, client.postCalls) require.Equal(t, 0, client.deleteCalls) } func TestSoraGatewayService_ForwardWatermarkCustomSuccessAndDelete(t *testing.T) { client := &stubSoraClientForPoll{ videoStatus: &SoraVideoTaskStatus{ Status: "completed", URLs: []string{"https://example.com/original.mp4"}, GenerationID: "gen_1", }, } cfg := &config.Config{ Sora: config.SoraConfig{ Client: config.SoraClientConfig{ PollIntervalSeconds: 1, MaxPollAttempts: 1, }, }, } svc := NewSoraGatewayService(client, nil, nil, cfg) account := &Account{ID: 1, Platform: PlatformSora, Status: StatusActive} body := []byte(`{"model":"sora2-landscape-10s","messages":[{"role":"user","content":"cat running"}],"stream":false,"watermark_free":true,"watermark_parse_method":"custom","watermark_parse_url":"https://parser.example.com","watermark_parse_token":"token","watermark_delete_post":true}`) result, err := svc.Forward(context.Background(), nil, account, body, false) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "https://example.com/no-watermark.mp4", result.MediaURL) require.Equal(t, 1, client.postCalls) require.Equal(t, 1, client.deleteCalls) } func TestSoraGatewayService_PollVideoTaskFailed(t *testing.T) { client := &stubSoraClientForPoll{ videoStatus: &SoraVideoTaskStatus{ Status: "failed", ErrorMsg: "reject", }, } cfg := &config.Config{ Sora: config.SoraConfig{ Client: config.SoraClientConfig{ PollIntervalSeconds: 1, MaxPollAttempts: 1, }, }, } service := NewSoraGatewayService(client, nil, nil, cfg) status, err := service.pollVideoTaskDetailed(context.Background(), nil, &Account{ID: 1}, "task", false) require.Error(t, err) require.Nil(t, status) require.Contains(t, err.Error(), "reject") require.Equal(t, 1, client.videoCalls) } func TestSoraGatewayService_BuildSoraMediaURLSigned(t *testing.T) { cfg := &config.Config{ Gateway: config.GatewayConfig{ SoraMediaSigningKey: "test-key", SoraMediaSignedURLTTLSeconds: 600, }, } service := NewSoraGatewayService(nil, nil, nil, cfg) url := service.buildSoraMediaURL("/image/2025/01/01/a.png", "") require.Contains(t, url, "/sora/media-signed") require.Contains(t, url, "expires=") require.Contains(t, url, "sig=") } func TestNormalizeSoraMediaURLs_Empty(t *testing.T) { svc := NewSoraGatewayService(nil, nil, nil, &config.Config{}) result := svc.normalizeSoraMediaURLs(nil) require.Empty(t, result) result = svc.normalizeSoraMediaURLs([]string{}) require.Empty(t, result) } func TestNormalizeSoraMediaURLs_HTTPUrls(t *testing.T) { svc := NewSoraGatewayService(nil, nil, nil, &config.Config{}) urls := []string{"https://example.com/a.png", "http://example.com/b.mp4"} result := svc.normalizeSoraMediaURLs(urls) require.Equal(t, urls, result) } func TestNormalizeSoraMediaURLs_LocalPaths(t *testing.T) { cfg := &config.Config{} svc := NewSoraGatewayService(nil, nil, nil, cfg) urls := []string{"/image/2025/01/a.png", "video/2025/01/b.mp4"} result := svc.normalizeSoraMediaURLs(urls) require.Len(t, result, 2) require.Contains(t, result[0], "/sora/media") require.Contains(t, result[1], "/sora/media") } func TestNormalizeSoraMediaURLs_SkipsBlank(t *testing.T) { svc := NewSoraGatewayService(nil, nil, nil, &config.Config{}) urls := []string{"https://example.com/a.png", "", " ", "https://example.com/b.png"} result := svc.normalizeSoraMediaURLs(urls) require.Len(t, result, 2) } func TestBuildSoraContent_Image(t *testing.T) { content := buildSoraContent("image", []string{"https://a.com/1.png", "https://a.com/2.png"}) require.Contains(t, content, "![image](https://a.com/1.png)") require.Contains(t, content, "![image](https://a.com/2.png)") } func TestBuildSoraContent_Video(t *testing.T) { content := buildSoraContent("video", []string{"https://a.com/v.mp4"}) require.Contains(t, content, "