337 lines
11 KiB
Go
337 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/qianfree/team-api/relay/common"
|
|
)
|
|
|
|
// stubVideosTaskProvider 查询/删除路径的 TaskDataProvider 桩:
|
|
// 嵌入接口零值,未覆盖的方法被调用时 panic(测试应只触达覆盖的方法)。
|
|
type stubVideosTaskProvider struct {
|
|
common.TaskDataProvider
|
|
|
|
task *common.AsyncTask
|
|
fetchErr error
|
|
deleteCalls int
|
|
deleteErr error
|
|
}
|
|
|
|
func (s *stubVideosTaskProvider) GetTaskByPublicIDAndUser(_ context.Context, publicTaskID string, _ int64, _ int64) (*common.AsyncTask, error) {
|
|
if s.fetchErr != nil {
|
|
return nil, s.fetchErr
|
|
}
|
|
if s.task != nil && s.task.PublicTaskID == publicTaskID {
|
|
return s.task, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *stubVideosTaskProvider) SoftDeleteTask(_ context.Context, task *common.AsyncTask) error {
|
|
s.deleteCalls++
|
|
if s.deleteErr != nil {
|
|
return s.deleteErr
|
|
}
|
|
task.Deleted = true
|
|
return nil
|
|
}
|
|
|
|
func newVideosTestTask() *common.AsyncTask {
|
|
finish := time.Unix(1760000432, 0)
|
|
privateData, _ := json.Marshal(map[string]any{
|
|
"upstream_task_id": "up-1",
|
|
"task_type": "kling",
|
|
"request_echo": map[string]any{"prompt": "a cat", "seconds": "8", "size": "1280x720"},
|
|
})
|
|
return &common.AsyncTask{
|
|
ID: 1,
|
|
PublicTaskID: "video_abc",
|
|
Status: "SUCCESS",
|
|
Progress: "100%",
|
|
ModelName: "kling-v2-master",
|
|
ResultURL: "https://cdn.example.com/v.mp4",
|
|
BillingSettled: true,
|
|
FinishTime: &finish,
|
|
CreatedAt: time.Unix(1760000000, 0),
|
|
PrivateData: privateData,
|
|
TenantID: 7,
|
|
UserID: 9,
|
|
}
|
|
}
|
|
|
|
func videosTestRC(w http.ResponseWriter) *TaskRelayContext {
|
|
return &TaskRelayContext{TenantID: 7, UserID: 9, Writer: w}
|
|
}
|
|
|
|
func decodeVideosJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
|
t.Helper()
|
|
var m map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
|
|
t.Fatalf("decode json: %v, body=%s", err, rec.Body.String())
|
|
}
|
|
return m
|
|
}
|
|
|
|
func TestVideosStatusFromTask(t *testing.T) {
|
|
cases := map[string]string{
|
|
"NOT_START": "queued",
|
|
"SUBMITTED": "queued",
|
|
"QUEUED": "queued",
|
|
"IN_PROGRESS": "in_progress",
|
|
"SUCCESS": "completed",
|
|
"FAILURE": "failed",
|
|
"WEIRD": "queued",
|
|
}
|
|
for in, want := range cases {
|
|
if got := videosStatusFromTask(in); got != want {
|
|
t.Errorf("videosStatusFromTask(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVideosProgressFromTask(t *testing.T) {
|
|
makeTask := func(status, progress string) *common.AsyncTask {
|
|
return &common.AsyncTask{Status: status, Progress: progress}
|
|
}
|
|
if got := videosProgressFromTask(makeTask("IN_PROGRESS", "42%")); got != 42 {
|
|
t.Errorf("42%% → %d", got)
|
|
}
|
|
if got := videosProgressFromTask(makeTask("IN_PROGRESS", "150%")); got != 100 {
|
|
t.Errorf("150%% should clamp to 100, got %d", got)
|
|
}
|
|
if got := videosProgressFromTask(makeTask("IN_PROGRESS", "")); got != 50 {
|
|
t.Errorf("fallback in_progress → 50, got %d", got)
|
|
}
|
|
if got := videosProgressFromTask(makeTask("SUCCESS", "")); got != 100 {
|
|
t.Errorf("fallback success → 100, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildVideoObject_SuccessWithEcho(t *testing.T) {
|
|
obj := buildVideoObject(newVideosTestTask())
|
|
if obj.ID != "video_abc" || obj.Object != "video" || obj.Status != "completed" || obj.Progress != 100 {
|
|
t.Fatalf("unexpected object: %+v", obj)
|
|
}
|
|
if obj.Model != "kling-v2-master" || obj.Prompt == nil || *obj.Prompt != "a cat" {
|
|
t.Fatalf("unexpected model/prompt: %+v", obj)
|
|
}
|
|
if obj.Seconds != "8" || obj.Size != "1280x720" {
|
|
t.Fatalf("expected echo seconds/size, got %q/%q", obj.Seconds, obj.Size)
|
|
}
|
|
if obj.CompletedAt == nil || *obj.CompletedAt != 1760000432 {
|
|
t.Fatalf("unexpected completed_at: %v", obj.CompletedAt)
|
|
}
|
|
if obj.Error != nil || obj.RemixedFromVideoID != nil || obj.ExpiresAt != nil {
|
|
t.Fatalf("nullables must be nil: %+v", obj)
|
|
}
|
|
}
|
|
|
|
func TestBuildVideoObject_Failure(t *testing.T) {
|
|
task := newVideosTestTask()
|
|
task.Status = "FAILURE"
|
|
task.Progress = "30%"
|
|
task.FailReason = "upstream exploded"
|
|
obj := buildVideoObject(task)
|
|
if obj.Status != "failed" || obj.Error == nil {
|
|
t.Fatalf("expected failed with error: %+v", obj)
|
|
}
|
|
if obj.Error.Code != "video_generation_failed" || obj.Error.Message != "upstream exploded" {
|
|
t.Fatalf("unexpected error object: %+v", obj.Error)
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosRetrieve_FoundAndPollHeader(t *testing.T) {
|
|
task := newVideosTestTask()
|
|
task.Status = "IN_PROGRESS"
|
|
task.Progress = "42%"
|
|
task.FinishTime = nil
|
|
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{task: task}
|
|
HandleVideosRetrieve(context.Background(), "video_abc", videosTestRC(rec), provider)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get("openai-poll-after-ms"); got != videosPollAfterMs {
|
|
t.Fatalf("expected poll header %q, got %q", videosPollAfterMs, got)
|
|
}
|
|
m := decodeVideosJSON(t, rec)
|
|
if m["status"] != "in_progress" || m["progress"] != float64(42) || m["object"] != "video" {
|
|
t.Fatalf("unexpected body: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosRetrieve_TerminalNoPollHeader(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{task: newVideosTestTask()}
|
|
HandleVideosRetrieve(context.Background(), "video_abc", videosTestRC(rec), provider)
|
|
if got := rec.Header().Get("openai-poll-after-ms"); got != "" {
|
|
t.Fatalf("terminal task must not carry poll header, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosRetrieve_NotFound(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{}
|
|
HandleVideosRetrieve(context.Background(), "video_missing", videosTestRC(rec), provider)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosDelete_HappyPath(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{task: newVideosTestTask()}
|
|
HandleVideosDelete(context.Background(), "video_abc", videosTestRC(rec), provider)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if provider.deleteCalls != 1 {
|
|
t.Fatalf("expected 1 soft delete call, got %d", provider.deleteCalls)
|
|
}
|
|
m := decodeVideosJSON(t, rec)
|
|
if m["object"] != "video.deleted" || m["deleted"] != true || m["id"] != "video_abc" {
|
|
t.Fatalf("unexpected body: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosDelete_NonTerminalRejected(t *testing.T) {
|
|
task := newVideosTestTask()
|
|
task.Status = "IN_PROGRESS"
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{task: task}
|
|
HandleVideosDelete(context.Background(), "video_abc", videosTestRC(rec), provider)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", rec.Code)
|
|
}
|
|
if provider.deleteCalls != 0 {
|
|
t.Fatal("must not delete non-terminal task")
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "video_not_deletable") {
|
|
t.Fatalf("expected video_not_deletable code, body=%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosDelete_UnsettledRejected(t *testing.T) {
|
|
task := newVideosTestTask()
|
|
task.BillingSettled = false
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{task: task}
|
|
HandleVideosDelete(context.Background(), "video_abc", videosTestRC(rec), provider)
|
|
|
|
if rec.Code != http.StatusBadRequest || provider.deleteCalls != 0 {
|
|
t.Fatalf("expected 400 without delete, got %d calls=%d", rec.Code, provider.deleteCalls)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "billing_pending") {
|
|
t.Fatalf("expected billing_pending code, body=%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosDelete_NotFound(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{}
|
|
HandleVideosDelete(context.Background(), "video_gone", videosTestRC(rec), provider)
|
|
if rec.Code != http.StatusNotFound || provider.deleteCalls != 0 {
|
|
t.Fatalf("expected 404 without delete, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosContent_StreamsBytes(t *testing.T) {
|
|
content := []byte("FAKE-MP4-BYTES")
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
w.Write(content)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
task := newVideosTestTask()
|
|
task.ResultURL = upstream.URL
|
|
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{task: task}
|
|
HandleVideosContent(context.Background(), "video_abc", "video", videosTestRC(rec), provider)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "video/mp4" {
|
|
t.Fatalf("expected video/mp4 passthrough, got %q", ct)
|
|
}
|
|
if rec.Body.String() != string(content) {
|
|
t.Fatalf("body mismatch: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosContent_VariantUnsupported(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{task: newVideosTestTask()}
|
|
HandleVideosContent(context.Background(), "video_abc", "thumbnail", videosTestRC(rec), provider)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosContent_NotCompleted(t *testing.T) {
|
|
task := newVideosTestTask()
|
|
task.Status = "IN_PROGRESS"
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{task: task}
|
|
HandleVideosContent(context.Background(), "video_abc", "", videosTestRC(rec), provider)
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "video_not_ready") {
|
|
t.Fatalf("expected 400 video_not_ready, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosContent_NoResultURL(t *testing.T) {
|
|
task := newVideosTestTask()
|
|
task.ResultURL = ""
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{task: task}
|
|
HandleVideosContent(context.Background(), "video_abc", "", videosTestRC(rec), provider)
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "content_not_available") {
|
|
t.Fatalf("expected 400 content_not_available, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleVideosContent_UpstreamFailure(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
task := newVideosTestTask()
|
|
task.ResultURL = upstream.URL
|
|
|
|
rec := httptest.NewRecorder()
|
|
provider := &stubVideosTaskProvider{task: task}
|
|
HandleVideosContent(context.Background(), "video_abc", "", videosTestRC(rec), provider)
|
|
if rec.Code != http.StatusBadGateway {
|
|
t.Fatalf("expected 502, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestWriteVideosSubmitResponse(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
writeVideosSubmitResponse(rec, "video_new", "kling-v2-master", time.Unix(1760000000, 0), videosRequestEcho{Prompt: "p", Seconds: "8", Size: "720x1280"})
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
m := decodeVideosJSON(t, rec)
|
|
if m["id"] != "video_new" || m["object"] != "video" || m["status"] != "queued" || m["progress"] != float64(0) {
|
|
t.Fatalf("unexpected submit response: %s", rec.Body.String())
|
|
}
|
|
if m["seconds"] != "8" || m["size"] != "720x1280" {
|
|
t.Fatalf("expected echo fields: %s", rec.Body.String())
|
|
}
|
|
}
|