Files
easyai-ai-gateway/apps/api/internal/clients/advanced_media_test.go
T
easyai 3056cf8fca
ci / verify (pull_request) Successful in 15m34s
feat(gateway): 补齐桌面端高级媒体直连接口
新增图片矢量化、视频超分、每日用量、计价与任务隔离能力,并通过环境变量解析平台凭据。

已通过 Go 全量门禁、迁移检查、镜像构建以及 Vectorizer 五格式和 Topaz 3 秒视频真实 DEV 验收。
2026-07-22 14:02:53 +08:00

283 lines
12 KiB
Go

package clients
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestVectorizerClientMultipartAndPrivateResumeState(t *testing.T) {
var receivedPath string
var receivedFields map[string]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
if username, password, ok := r.BasicAuth(); !ok || username != "id" || password != "secret" {
t.Fatalf("unexpected vectorizer auth")
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("parse multipart: %v", err)
}
receivedFields = map[string]string{}
for key, values := range r.MultipartForm.Value {
receivedFields[key] = values[0]
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("X-Image-Token", "private-image-token")
w.Header().Set("X-Receipt", "private-receipt")
_, _ = io.WriteString(w, `<svg xmlns="http://www.w3.org/2000/svg"/>`)
}))
defer server.Close()
privateState := map[string]any{}
response, err := (VectorizerClient{LookupIP: func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
}}).Run(context.Background(), Request{
Kind: "images.vectorize", Model: "easy-image-vectorizer-1",
Body: map[string]any{"source": map[string]any{"url": "https://example.com/input.png"}, "format": "svg", "maxColors": 16, "cleanupLevel": "strong"},
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"accessKey": "id", "secretKey": "secret"}),
OnRemoteTaskSubmitted: func(_ string, payload map[string]any) error { privateState = payload; return nil },
})
if err != nil {
t.Fatalf("run vectorizer: %v", err)
}
if receivedPath != "/vectorize" || receivedFields["image.url"] == "" || receivedFields["output.file_format"] != "svg" || receivedFields["processing.max_colors"] != "16" {
t.Fatalf("unexpected multipart request: path=%s fields=%+v", receivedPath, receivedFields)
}
if privateState["imageToken"] != "private-image-token" || privateState["receipt"] != "private-receipt" {
t.Fatalf("private resume state missing: %+v", privateState)
}
serialized := strings.ToLower(toJSONForTest(response.Result))
if strings.Contains(serialized, "private-image-token") || strings.Contains(serialized, "private-receipt") {
t.Fatalf("private vectorizer state leaked into response: %s", serialized)
}
}
func TestVectorizerClientRejectsPrivateSourceBeforeProviderCall(t *testing.T) {
providerCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { providerCalls++ }))
defer server.Close()
_, err := (VectorizerClient{LookupIP: func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
}}).Run(context.Background(), Request{
Body: map[string]any{"source": map[string]any{"url": "https://assets.example.test/input.png"}},
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"apiKey": "key"}),
})
if err == nil || !strings.Contains(err.Error(), "blocked network") {
t.Fatalf("expected blocked source error, got %v", err)
}
if providerCalls != 0 {
t.Fatalf("provider received %d calls for blocked source", providerCalls)
}
}
func TestVectorizerClientReusesImageTokenForExtraFormat(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/download" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatal(err)
}
if r.FormValue("image.token") != "token" || r.FormValue("receipt") != "receipt" || r.FormValue("output.file_format") != "pdf" {
t.Fatalf("unexpected reuse form: %+v", r.MultipartForm.Value)
}
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("%PDF-1.7\n"))
}))
defer server.Close()
response, err := (VectorizerClient{}).Run(context.Background(), Request{
Body: map[string]any{"_vectorizer_image_token": "token", "_vectorizer_receipt": "receipt", "format": "pdf"},
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"apiKey": "key"}),
})
if err != nil {
t.Fatal(err)
}
item := response.Result["data"].([]any)[0].(map[string]any)
if item["type"] != "file" || item["mime_type"] != "application/pdf" {
t.Fatalf("unexpected pdf result: %+v", item)
}
}
func TestTopazClientUploadsPartsPollsAndValidatesOutput(t *testing.T) {
var mu sync.Mutex
called := map[string]int{}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
called[r.Method+" "+r.URL.Path]++
mu.Unlock()
switch {
case r.Method == http.MethodGet && r.URL.Path == "/source.mp4":
_, _ = w.Write([]byte("small-video-source"))
case r.Method == http.MethodPost && r.URL.Path == "/video/":
if r.Header.Get("X-API-Key") != "topaz-key" {
t.Fatalf("missing Topaz API key")
}
_, _ = io.WriteString(w, `{"requestId":"job-1"}`)
case r.Method == http.MethodPatch && r.URL.Path == "/video/job-1/accept":
_, _ = io.WriteString(w, `{"urls":["`+server.URL+`/upload/1","`+server.URL+`/upload/2"]}`)
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/upload/"):
w.Header().Set("ETag", `"etag-`+strings.TrimPrefix(r.URL.Path, "/upload/")+`"`)
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPatch && r.URL.Path == "/video/job-1/complete-upload/":
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{}`)
case r.Method == http.MethodGet && r.URL.Path == "/video/job-1/status":
_, _ = io.WriteString(w, `{"status":"completed","download":{"url":"`+server.URL+`/output.mp4"}}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
phases := []string{}
client := TopazClient{Probe: func(_ context.Context, target string) (TopazVideoMetadata, error) {
if strings.HasPrefix(target, "http") {
return TopazVideoMetadata{Width: 640, Height: 360, Duration: 3, FrameRate: 24, HasAudio: true}, nil
}
return TopazVideoMetadata{Width: 320, Height: 180, Duration: 3, FrameRate: 24, FrameCount: 72, HasAudio: true}, nil
}}
response, err := client.Run(context.Background(), Request{
Model: "easy-proteus-standard-4",
Body: map[string]any{"video_url": server.URL + "/source.mp4", "output_width": 640, "output_height": 360, "preserve_audio": true},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{"allowPrivateSourceDownloads": true, "pollIntervalMs": 1, "pollTimeoutMs": 100}),
OnRemoteTaskSubmitted: func(_ string, payload map[string]any) error {
phases = append(phases, payload["phase"].(string))
return nil
},
OnRemoteTaskPolled: func(_ string, payload map[string]any) error {
phases = append(phases, payload["phase"].(string))
return nil
},
})
if err != nil {
t.Fatalf("run Topaz: %v", err)
}
if response.RequestID != "job-1" || !containsStringForTest(phases, "created") || !containsStringForTest(phases, "uploaded") {
t.Fatalf("unexpected Topaz response/phases: response=%+v phases=%+v", response, phases)
}
if called["PUT /upload/1"] != 1 || called["PUT /upload/2"] != 1 || called["PATCH /video/job-1/complete-upload/"] != 1 {
t.Fatalf("upload lifecycle incomplete: %+v", called)
}
}
func TestTopazClientResumesUploadedTaskWithoutDownloadingSource(t *testing.T) {
var sourceCalls int
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/source.mp4" {
sourceCalls++
}
if r.URL.Path == "/video/job-resume/status" {
_, _ = io.WriteString(w, `{"status":"completed","download":{"url":"`+server.URL+`/result.mp4"}}`)
return
}
http.NotFound(w, r)
}))
defer server.Close()
_, err := (TopazClient{Probe: func(context.Context, string) (TopazVideoMetadata, error) {
return TopazVideoMetadata{Width: 640, Height: 360, Duration: 3, FrameRate: 24}, nil
}}).Run(context.Background(), Request{
RemoteTaskID: "job-resume", RemoteTaskPayload: map[string]any{"phase": "uploaded", "targetResolution": map[string]any{"width": 640, "height": 360}},
Body: map[string]any{"video_url": server.URL + "/source.mp4"},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{"pollIntervalMs": 1, "pollTimeoutMs": 100}),
})
if err != nil {
t.Fatal(err)
}
if sourceCalls != 0 {
t.Fatalf("resume downloaded source %d times", sourceCalls)
}
}
func TestTopazClientReportsProviderFailureAndPollingTimeout(t *testing.T) {
tests := []struct {
name string
statusBody string
timeoutMS int
want string
}{
{name: "provider failure", statusBody: `{"status":"failed","message":"upstream rejected"}`, timeoutMS: 100, want: "upstream rejected"},
{name: "poll timeout", statusBody: `{"status":"processing"}`, timeoutMS: 3, want: "polling timed out"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/video/job/status" {
http.NotFound(w, r)
return
}
_, _ = io.WriteString(w, test.statusBody)
}))
defer server.Close()
_, err := (TopazClient{}).Run(context.Background(), Request{
RemoteTaskID: "job", RemoteTaskPayload: map[string]any{"phase": "uploaded", "targetResolution": map[string]any{"width": 640, "height": 360}},
Body: map[string]any{"video_url": "https://assets.example.test/source.mp4"},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{
"pollIntervalMs": 1, "pollTimeoutMs": test.timeoutMS,
}),
})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("expected %q error, got %v", test.want, err)
}
})
}
}
func TestTopazClientRejectsOversizedInputBeforeProbe(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/source.mp4" {
_, _ = io.WriteString(w, "too-large")
return
}
http.NotFound(w, r)
}))
defer server.Close()
probeCalls := 0
_, err := (TopazClient{Probe: func(context.Context, string) (TopazVideoMetadata, error) {
probeCalls++
return TopazVideoMetadata{}, nil
}}).Run(context.Background(), Request{
Body: map[string]any{"video_url": server.URL + "/source.mp4"},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{
"allowPrivateSourceDownloads": true, "maxInputBytes": 3,
}),
})
if err == nil || !strings.Contains(err.Error(), "size limit") {
t.Fatalf("expected input size limit error, got %v", err)
}
if probeCalls != 0 {
t.Fatalf("oversized input was probed %d times", probeCalls)
}
}
func storeCandidate(baseURL, specType string, credentials map[string]any) store.RuntimeModelCandidate {
return storeCandidateWithConfig(baseURL, specType, credentials, nil)
}
func storeCandidateWithConfig(baseURL, specType string, credentials, config map[string]any) store.RuntimeModelCandidate {
return store.RuntimeModelCandidate{BaseURL: baseURL, SpecType: specType, Provider: specType, Credentials: credentials, PlatformConfig: config}
}
func toJSONForTest(value any) string {
raw, _ := json.Marshal(value)
return string(raw)
}
func containsStringForTest(values []string, expected string) bool {
for _, value := range values {
if value == expected {
return true
}
}
return false
}