Expose gateway billing flows from the synced server baseline
The preserved local work adds PopiNewAPI billing checkout, gateway account binding, and matching web console/pricing surfaces on top of the current test-server baseline. During the merge, the start-end video helpers from the deployed baseline were kept alongside the actionGenerate prompt handling from the WIP.
Constraint: Current usable baseline is 7054436, already deployed on the test server.
Rejected: Commit the WIP before syncing the baseline | would have hidden conflicts with the deployed start-end-frame changes.
Confidence: medium
Scope-risk: broad
Directive: Do not deploy this commit to the test server without rechecking gateway credentials and billing checkout behavior in that environment.
Tested: go test ./...
Tested: make build
Tested: cd web && npm run build
This commit is contained in:
@@ -288,6 +288,243 @@ func TestSubmitMiniMaxVideoTaskKeepsS2VImagesArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitJimengVideoTaskUsesVideoGenerationsEndpoint(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody map[string]any
|
||||
|
||||
imageBase64 := base64.StdEncoding.EncodeToString([]byte("fake-jimeng-image-body"))
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
if got := r.Header.Get("Content-Type"); got != "application/json" {
|
||||
t.Fatalf("expected application/json content type, got %q", got)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_action_123","status":"in_queue"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL})
|
||||
taskID, err := client.submitJimengVideoTask(context.Background(), "sk-test", "jimeng_dreamactor_m20_gen_video", map[string]any{
|
||||
"images": []any{"data:image/jpeg;base64," + imageBase64},
|
||||
"videos": []any{"https://example.com/source-action.mp4"},
|
||||
"metadata": map[string]any{
|
||||
"cut_result_first_second_switch": true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("submitJimengVideoTask: %v", err)
|
||||
}
|
||||
if taskID != "task_jimeng_action_123" {
|
||||
t.Fatalf("unexpected task id: %q", taskID)
|
||||
}
|
||||
if gotPath != "/v1/video/generations" {
|
||||
t.Fatalf("expected /v1/video/generations, got %q", gotPath)
|
||||
}
|
||||
if gotBody["model"] != "jimeng_dreamactor_m20_gen_video" {
|
||||
t.Fatalf("unexpected model: %#v", gotBody["model"])
|
||||
}
|
||||
images, ok := gotBody["images"].([]any)
|
||||
if !ok || len(images) != 1 || images[0] != imageBase64 {
|
||||
t.Fatalf("expected pure base64 image payload, got %#v", gotBody["images"])
|
||||
}
|
||||
videos, ok := gotBody["videos"].([]any)
|
||||
if !ok || len(videos) != 1 || videos[0] != "https://example.com/source-action.mp4" {
|
||||
t.Fatalf("unexpected videos payload: %#v", gotBody["videos"])
|
||||
}
|
||||
metadata, ok := gotBody["metadata"].(map[string]any)
|
||||
if !ok || metadata["action"] != "actionGenerate" || metadata["cut_result_first_second_switch"] != true {
|
||||
t.Fatalf("unexpected metadata: %#v", gotBody["metadata"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildJimengVideoGenerationPayloadMapsDurationToFrames(t *testing.T) {
|
||||
payload, err := buildJimengVideoGenerationPayload("jimeng_t2v_v30", map[string]any{
|
||||
"prompt": "cinematic cavalry charge",
|
||||
"duration": 10,
|
||||
"aspect_ratio": "16:9",
|
||||
"seed": -1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildJimengVideoGenerationPayload: %v", err)
|
||||
}
|
||||
if payload["duration"] != 10 {
|
||||
t.Fatalf("expected duration 10, got %#v", payload["duration"])
|
||||
}
|
||||
metadata := payload["metadata"].(map[string]any)
|
||||
if metadata["frames"] != 241 {
|
||||
t.Fatalf("expected frames 241, got %#v", metadata["frames"])
|
||||
}
|
||||
if metadata["aspect_ratio"] != "16:9" || metadata["seed"] != -1 {
|
||||
t.Fatalf("unexpected metadata: %#v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitSeedanceVideoTaskUsesVideoGenerationsEndpoint(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody map[string]any
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"task_seedance_123","status":"submitted"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL})
|
||||
taskID, err := client.submitSeedanceVideoTask(context.Background(), "sk-test", "doubao-seedance-2-0-260128", map[string]any{
|
||||
"prompt": "keep the motion style consistent",
|
||||
"size": "720p",
|
||||
"duration": 5,
|
||||
"images": []any{"https://example.com/a.jpg", "https://example.com/b.jpg"},
|
||||
"videos": []any{"https://example.com/ref.mp4"},
|
||||
"audios": []any{"https://example.com/ref.mp3"},
|
||||
"ratio": "16:9",
|
||||
"return_last_frame": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("submitSeedanceVideoTask: %v", err)
|
||||
}
|
||||
if taskID != "task_seedance_123" {
|
||||
t.Fatalf("unexpected task id: %q", taskID)
|
||||
}
|
||||
if gotPath != "/v1/video/generations" {
|
||||
t.Fatalf("expected /v1/video/generations, got %q", gotPath)
|
||||
}
|
||||
if gotBody["model"] != "doubao-seedance-2-0-260128" {
|
||||
t.Fatalf("unexpected model: %#v", gotBody["model"])
|
||||
}
|
||||
metadata := gotBody["metadata"].(map[string]any)
|
||||
if metadata["ratio"] != "16:9" || metadata["return_last_frame"] != true {
|
||||
t.Fatalf("unexpected metadata: %#v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVideoGenerationsRelaysSeedanceRequest(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/video/generations" {
|
||||
t.Fatalf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer sk-test-upstream" {
|
||||
t.Fatalf("unexpected upstream auth: %q", got)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode upstream body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_seedance_direct_123","status":"PENDING"}}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
server, err := NewWithConfig(Config{
|
||||
NewAPIBaseURL: upstream.URL,
|
||||
SQLitePath: filepath.Join(t.TempDir(), "popiart.db"),
|
||||
SkillhubDir: makeEmptySkillhub(t),
|
||||
SessionSecret: "test-secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithConfig: %v", err)
|
||||
}
|
||||
token, _, ok, err := server.store.createSession("sk-test-upstream")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected session creation to succeed")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/video/generations", strings.NewReader(`{"model":"doubao-seedance-2-0-260128","prompt":"ping","metadata":{"ratio":"16:9"}}`))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if gotBody["model"] != "doubao-seedance-2-0-260128" || gotBody["prompt"] != "ping" {
|
||||
t.Fatalf("unexpected upstream body: %#v", gotBody)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !env.OK || env.Data.TaskID != "task_seedance_direct_123" || env.Data.Status != "PENDING" {
|
||||
t.Fatalf("unexpected response: %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVideoGenerationFetchSurfacesLastFrameURL(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1/video/generations/task_seedance_direct_123" {
|
||||
t.Fatalf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer sk-test-upstream" {
|
||||
t.Fatalf("unexpected upstream auth: %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_seedance_direct_123","status":"SUCCESS","metadata":{"url":"https://cdn.example.com/video.mp4","last_frame_url":"https://cdn.example.com/last-frame.png"}}}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
server, err := NewWithConfig(Config{
|
||||
NewAPIBaseURL: upstream.URL,
|
||||
SQLitePath: filepath.Join(t.TempDir(), "popiart.db"),
|
||||
SkillhubDir: makeEmptySkillhub(t),
|
||||
SessionSecret: "test-secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithConfig: %v", err)
|
||||
}
|
||||
token, _, ok, err := server.store.createSession("sk-test-upstream")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected session creation to succeed")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/video/generations/task_seedance_direct_123", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
ResultURL string `json:"result_url"`
|
||||
LastFrameURL string `json:"last_frame_url"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !env.OK || env.Data.Status != "SUCCESS" || env.Data.ResultURL != "https://cdn.example.com/video.mp4" || env.Data.LastFrameURL != "https://cdn.example.com/last-frame.png" {
|
||||
t.Fatalf("unexpected response: %#v", env)
|
||||
}
|
||||
if env.Data.Metadata["last_frame_url"] != "https://cdn.example.com/last-frame.png" {
|
||||
t.Fatalf("unexpected metadata: %#v", env.Data.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVideoReferencesSupportsImagesArray(t *testing.T) {
|
||||
cfg := Config{
|
||||
SQLitePath: filepath.Join(t.TempDir(), "popiart.db"),
|
||||
@@ -585,6 +822,61 @@ func TestFetchVideoTaskFallsBackToGenericTaskEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchVideoTaskPrefersGenericGenerationStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/videos/task_generic_wins":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"task_generic_wins","status":"in_progress","progress":50}`))
|
||||
case "/v1/video/generations/task_generic_wins":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"id":"task_generic_wins","status":"SUCCESS","result_url":"http://example.com/done.mp4","progress":100}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL})
|
||||
result, err := client.fetchVideoTask(context.Background(), "sk-test", "task_generic_wins")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchVideoTask: %v", err)
|
||||
}
|
||||
if result.Status != "completed" {
|
||||
t.Fatalf("expected generic SUCCESS to win, got %q", result.Status)
|
||||
}
|
||||
if result.URL != "http://example.com/done.mp4" {
|
||||
t.Fatalf("expected result url to round-trip, got %q", result.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchVideoTaskMapsJimengDoneNonSuccessCodeToFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/videos/task_jimeng_failed":
|
||||
http.NotFound(w, r)
|
||||
case "/v1/video/generations/task_jimeng_failed":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_failed","status":"done","code":40001,"fail_reason":"Image Decode Error"}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL})
|
||||
result, err := client.fetchVideoTask(context.Background(), "sk-test", "task_jimeng_failed")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchVideoTask: %v", err)
|
||||
}
|
||||
if result.Status != "failed" {
|
||||
t.Fatalf("expected failed status, got %q", result.Status)
|
||||
}
|
||||
if result.ErrorReason != "Image Decode Error" {
|
||||
t.Fatalf("expected fail reason to round-trip, got %q", result.ErrorReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) {
|
||||
refBytes := tinyPNG(t)
|
||||
videoBytes := []byte("not-a-real-mp4-but-good-enough-for-streaming")
|
||||
@@ -746,6 +1038,107 @@ func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteJimengActionTransferJobUsesImagesAndVideos(t *testing.T) {
|
||||
videoBytes := []byte("jimeng-mp4")
|
||||
var gotBody map[string]any
|
||||
|
||||
var outputURL string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/video/generations":
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode Jimeng request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_exec_123","status":"in_queue"}}`))
|
||||
case "/v1/videos/task_jimeng_exec_123":
|
||||
http.NotFound(w, r)
|
||||
case "/v1/video/generations/task_jimeng_exec_123":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_exec_123","status":"done","code":10000,"result_url":"` + outputURL + `","progress":"100%"}}`))
|
||||
case "/jimeng-output.mp4":
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
_, _ = w.Write(videoBytes)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
outputURL = srv.URL + "/jimeng-output.mp4"
|
||||
|
||||
cfg := Config{
|
||||
NewAPIBaseURL: srv.URL,
|
||||
SQLitePath: filepath.Join(t.TempDir(), "popiart.db"),
|
||||
SkillhubDir: makeEmptySkillhub(t),
|
||||
SessionSecret: "test-secret",
|
||||
}
|
||||
server, err := NewWithConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithConfig: %v", err)
|
||||
}
|
||||
|
||||
token, _, ok, err := server.store.createSession("sk-test-upstream")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected session creation to succeed")
|
||||
}
|
||||
current, exists, err := server.store.session(token)
|
||||
if err != nil {
|
||||
t.Fatalf("load session: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("expected stored session")
|
||||
}
|
||||
|
||||
record, _, err := server.store.createJob(
|
||||
"",
|
||||
"video.image2video",
|
||||
"jimeng_dreamactor_m20_gen_video",
|
||||
routeExecMode("video.image2video"),
|
||||
map[string]any{
|
||||
"images": []any{"https://example.com/face.jpg"},
|
||||
"videos": []any{"https://example.com/action.mp4"},
|
||||
"metadata": map[string]any{
|
||||
"action": "actionGenerate",
|
||||
},
|
||||
},
|
||||
"",
|
||||
"normal",
|
||||
"",
|
||||
current,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("createJob: %v", err)
|
||||
}
|
||||
|
||||
server.executeImageToVideoJob(record)
|
||||
|
||||
if gotBody["model"] != "jimeng_dreamactor_m20_gen_video" {
|
||||
t.Fatalf("unexpected Jimeng model: %#v", gotBody["model"])
|
||||
}
|
||||
images := gotBody["images"].([]any)
|
||||
videos := gotBody["videos"].([]any)
|
||||
if images[0] != "https://example.com/face.jpg" || videos[0] != "https://example.com/action.mp4" {
|
||||
t.Fatalf("unexpected Jimeng media payload: images=%#v videos=%#v", gotBody["images"], gotBody["videos"])
|
||||
}
|
||||
|
||||
done, exists, err := server.store.getJob(current.User.ID, record.JobID)
|
||||
if err != nil {
|
||||
t.Fatalf("getJob: %v", err)
|
||||
}
|
||||
if !exists || done.Status != "done" {
|
||||
t.Fatalf("expected completed Jimeng job, exists=%v record=%#v", exists, done)
|
||||
}
|
||||
if done.NewAPITaskID != "task_jimeng_exec_123" {
|
||||
t.Fatalf("expected Jimeng task id, got %q", done.NewAPITaskID)
|
||||
}
|
||||
if len(done.ArtifactIDs) != 1 {
|
||||
t.Fatalf("expected one Jimeng artifact, got %#v", done.ArtifactIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVideoSizeUsesViduResolutionLabels(t *testing.T) {
|
||||
if got := resolveVideoSize("viduq2", map[string]any{"aspect_ratio": "16:9"}, imageEditReference{}); got != "720p" {
|
||||
t.Fatalf("expected vidu 16:9 to map to 720p, got %q", got)
|
||||
|
||||
Reference in New Issue
Block a user