ci / verify (pull_request) Successful in 15m34s
新增图片矢量化、视频超分、每日用量、计价与任务隔离能力,并通过环境变量解析平台凭据。 已通过 Go 全量门禁、迁移检查、镜像构建以及 Vectorizer 五格式和 Topaz 3 秒视频真实 DEV 验收。
283 lines
12 KiB
Go
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
|
|
}
|