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:
wtgoku
2026-05-21 16:53:07 +08:00
parent 7054436a99
commit baba209519
31 changed files with 6035 additions and 86 deletions
+393
View File
@@ -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)