package httpapi import ( "encoding/json" "net/http" "net/http/httptest" "runtime" "strings" "sync" "testing" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/publicerror" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) func TestEasyAITaskResultURLResponseIsCompact(t *testing.T) { task := store.GatewayTask{ ID: "image-url-compact", Kind: "images.generations", Status: "succeeded", Result: map[string]any{"data": []any{map[string]any{ "type": "image", "url": "https://cdn.example/result.png", "mime_type": "image/png", "provider_payload": strings.Repeat("x", 4096), "upload": map[string]any{"objectKey": "secret"}, }}}, } runtime.GC() var before runtime.MemStats runtime.ReadMemStats(&before) response := easyAITaskResultResponse(task) payload, err := json.Marshal(response) if err != nil { t.Fatal(err) } var after runtime.MemStats runtime.ReadMemStats(&after) if len(payload) >= maxAsyncMediaResultResponseBytes { t.Fatalf("URL response bytes=%d", len(payload)) } if allocated := after.TotalAlloc - before.TotalAlloc; allocated >= 1<<20 { t.Fatalf("URL response allocated %d bytes", allocated) } if !json.Valid(payload) { t.Fatal("URL response is not valid JSON") } if strings.Contains(string(payload), "provider_payload") || strings.Contains(string(payload), "objectKey") { t.Fatalf("internal provider or object-storage fields leaked: %s", payload) } } func TestWriteAsyncMediaResultJSONSetsURLAndDeprecationHeaders(t *testing.T) { recorder := httptest.NewRecorder() writeAsyncMediaResultJSON(recorder, map[string]any{ "status": "success", "data": []any{map[string]any{"url": "https://cdn.example/result.png"}}, }, nil) if recorder.Code != http.StatusOK { t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) } if recorder.Header().Get("X-Gateway-Response-Format") != "url" || recorder.Header().Get("Deprecation") != "true" { t.Fatalf("unexpected headers: %#v", recorder.Header()) } } func TestWriteAsyncMediaResultJSONRejectsOversizedPayload(t *testing.T) { recorder := httptest.NewRecorder() writeAsyncMediaResultJSON(recorder, map[string]any{"data": strings.Repeat("x", maxAsyncMediaResultResponseBytes)}, nil) if recorder.Code != http.StatusInternalServerError || !strings.Contains(recorder.Body.String(), "result_response_too_large") { t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) } } func TestConcurrentURLResultResponsesRemainBounded(t *testing.T) { task := store.GatewayTask{ ID: "image-url-load", Kind: "images.generations", Status: "succeeded", Result: map[string]any{"data": []any{map[string]any{ "type": "image", "url": "https://cdn.example/result.png", "mime_type": "image/png", }}}, } runtime.GC() var before runtime.MemStats runtime.ReadMemStats(&before) var wait sync.WaitGroup errors := make(chan string, 1000) for index := 0; index < 1000; index++ { wait.Add(1) go func() { defer wait.Done() payload, err := json.Marshal(easyAITaskResultResponse(task)) if err != nil { errors <- err.Error() return } if len(payload) >= maxAsyncMediaResultResponseBytes { errors <- "response exceeded URL-only size gate" } }() } wait.Wait() close(errors) for message := range errors { t.Fatal(message) } var after runtime.MemStats runtime.ReadMemStats(&after) if allocated := after.TotalAlloc - before.TotalAlloc; allocated >= 256<<20 { t.Fatalf("1000 concurrent URL responses allocated %d bytes", allocated) } } func TestEasyAITaskAcceptedResponseKeepsGatewayFieldsAndAddsLegacyFields(t *testing.T) { task := store.GatewayTask{ ID: "task-accepted-1", Kind: "speech.generations", Status: "queued", AsyncMode: true, } got := easyAITaskAcceptedResponse(task) if got["status"] != "submitted" || got["task_id"] != task.ID || got["taskId"] != task.ID { t.Fatalf("unexpected EasyAI submission identity: %+v", got) } if _, ok := got["task"].(store.GatewayTask); !ok { t.Fatalf("Gateway task envelope was removed: %+v", got) } next, _ := got["next"].(map[string]string) if next["detail"] != "/api/v1/tasks/"+task.ID || next["events"] != "/api/v1/tasks/"+task.ID+"/events" || next["result"] != "/api/v1/ai/result/"+task.ID { t.Fatalf("unexpected next links: %+v", next) } } func TestEasyAITaskResultStatus(t *testing.T) { for _, item := range []struct { input string want string }{ {input: "queued", want: "submitted"}, {input: "pending", want: "submitted"}, {input: "running", want: "process"}, {input: "processing", want: "process"}, {input: "succeeded", want: "success"}, {input: "completed", want: "success"}, {input: "failed", want: "failed"}, {input: "cancelled", want: "failed"}, } { t.Run(item.input, func(t *testing.T) { if got := easyAITaskResultStatus(item.input); got != item.want { t.Fatalf("easyAITaskResultStatus(%q)=%q, want %q", item.input, got, item.want) } }) } } func TestEasyAITaskResultResponseNormalizesMediaOutputs(t *testing.T) { createdAt := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC) for _, item := range []struct { name string task store.GatewayTask wantType string wantURL string wantCount int }{ { name: "image alias", task: store.GatewayTask{ ID: "image-1", Kind: "images.generations", Status: "succeeded", CreatedAt: createdAt, Result: map[string]any{"data": []any{map[string]any{ "image_url": "https://example.com/output.png", }}}, }, wantType: "image", wantURL: "https://example.com/output.png", wantCount: 1, }, { name: "video raw content", task: store.GatewayTask{ ID: "video-1", Kind: "videos.generations", Status: "succeeded", CreatedAt: createdAt, Result: map[string]any{"raw": map[string]any{"content": map[string]any{ "video_url": "https://example.com/output.mp4", "duration": 5, }}}, }, wantType: "video", wantURL: "https://example.com/output.mp4", wantCount: 1, }, { name: "audio alias", task: store.GatewayTask{ ID: "audio-1", Kind: "speech.generations", Status: "succeeded", CreatedAt: createdAt, Result: map[string]any{"data": []any{map[string]any{ "audio_url": "https://example.com/output.wav", }}}, }, wantType: "audio", wantURL: "https://example.com/output.wav", wantCount: 1, }, { name: "voice clone without media", task: store.GatewayTask{ ID: "voice-1", Kind: "voice.clone", Status: "succeeded", CreatedAt: createdAt, Result: map[string]any{"voice_id": "voice-test-1", "cloned_voice": map[string]any{"voiceId": "voice-test-1"}}, }, wantCount: 0, }, } { t.Run(item.name, func(t *testing.T) { got := easyAITaskResultResponse(item.task) if got["status"] != "success" || got["task_id"] != item.task.ID || got["created"] != createdAt.UnixMilli() { t.Fatalf("unexpected task result identity: %+v", got) } data, _ := got["data"].([]any) if len(data) != item.wantCount { t.Fatalf("unexpected output count: got=%d response=%+v", len(data), got) } outputContent, _ := got["output_content"].([]any) if len(outputContent) != item.wantCount { t.Fatalf("output_content is not synchronized: %+v", got) } if item.wantCount == 0 { if got["voice_id"] != nil || got["cloned_voice"] != nil { t.Fatalf("provider-specific fields leaked: %+v", got) } return } output, _ := data[0].(map[string]any) if output["type"] != item.wantType || output["url"] != item.wantURL { t.Fatalf("unexpected normalized output: %+v", output) } if _, exposed := output["b64_json"]; exposed { t.Fatalf("base64 output was exposed: %+v", output) } urls, _ := got["output"].([]string) if len(urls) != 1 || urls[0] != item.wantURL { t.Fatalf("unexpected output URL list: %+v", got["output"]) } }) } } func TestEasyAITaskResultResponseDropsBase64ImageOutput(t *testing.T) { base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII=" task := store.GatewayTask{ ID: "image-base64-1", Kind: "images.generations", Status: "succeeded", Result: map[string]any{"data": []any{map[string]any{ "b64_json": base64Payload, "mime_type": "image/png", }}}, } got := easyAITaskResultResponse(task) data, _ := got["data"].([]any) if len(data) != 0 { t.Fatalf("Base64 output leaked into URL-only response: %+v", got) } urls, _ := got["output"].([]string) if len(urls) != 0 { t.Fatalf("base64-only output must not create URL aliases: %+v", urls) } } func TestEasyAITaskResultResponseDropsImageDataURL(t *testing.T) { base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII=" task := store.GatewayTask{ ID: "image-data-url-1", Kind: "images.generations", Status: "succeeded", Result: map[string]any{"data": []any{map[string]any{ "url": "data:image/png;base64," + base64Payload, }}}, } got := easyAITaskResultResponse(task) data, _ := got["data"].([]any) if len(data) != 0 { t.Fatalf("data URL leaked into URL-only response: %+v", got) } } func TestEasyAITaskResultResponseForwardsSafeUpstreamParameterMessage(t *testing.T) { raw := "Duplicate parameter: 'image'. Use image[]= for multiple values." legacy := publicerror.Error{ Code: "upstream_invalid_request", Message: "The upstream service rejected the request parameters.", Category: "upstream", Source: "upstream", HTTPStatus: http.StatusBadRequest, Version: "v1", } response := easyAITaskResultResponse(store.GatewayTask{ ID: "image-error-1", Kind: "images.edits", Status: "failed", ErrorCode: "http_400", ErrorMessage: raw, PublicError: &legacy, }) standard, ok := response["error"].(publicerror.Error) if !ok { t.Fatalf("unexpected EasyAI error payload: %+v", response) } upstream, _ := standard.Details["upstreamError"].(map[string]any) if response["message"] != raw || standard.Message != raw || upstream["message"] != raw { t.Fatalf("EasyAI result did not forward safe upstream diagnostics: %+v", response) } } func TestEasyAITaskResultResponseDropsAudioDataURL(t *testing.T) { base64Payload := "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/" task := store.GatewayTask{ ID: "audio-data-url-1", Kind: "speech.generations", Status: "succeeded", Result: map[string]any{"data": []any{map[string]any{ "audio_url": "data:audio/mpeg;base64," + base64Payload, }}}, } got := easyAITaskResultResponse(task) data, _ := got["data"].([]any) if len(data) != 0 { t.Fatalf("audio data URL leaked into URL-only response: %+v", got) } urls, _ := got["output"].([]string) if len(urls) != 0 { t.Fatalf("base64-only audio must not create URL aliases: %+v", urls) } } func TestEasyAITaskResultResponseMapsVectorFormats(t *testing.T) { for _, item := range []struct { format string wantType string }{ {format: "svg", wantType: "image"}, {format: "png", wantType: "image"}, {format: "pdf", wantType: "file"}, {format: "eps", wantType: "file"}, } { t.Run(item.format, func(t *testing.T) { task := store.GatewayTask{ ID: "vector-" + item.format, Kind: "images.vectorize", Status: "succeeded", Request: map[string]any{"format": item.format}, Result: map[string]any{"data": []any{map[string]any{"url": "https://example.com/output." + item.format}}}, } got := easyAITaskResultResponse(task) data, _ := got["data"].([]any) output, _ := data[0].(map[string]any) if output["type"] != item.wantType { t.Fatalf("format %s mapped to %v, want %s", item.format, output["type"], item.wantType) } }) } } func TestEasyAITaskResultResponseClearsFailedOutputs(t *testing.T) { task := store.GatewayTask{ ID: "failed-1", Kind: "videos.upscales", Status: "cancelled", ErrorCode: "cancelled", ErrorMessage: "任务已取消", Result: map[string]any{"data": []any{map[string]any{ "url": "https://example.com/partial.mp4", }}}, } got := easyAITaskResultResponse(task) data, _ := got["data"].([]any) outputContent, _ := got["output_content"].([]any) if got["status"] != "failed" || got["code"] != "cancelled" || got["message"] != "任务已取消" || len(data) != 0 || len(outputContent) != 0 { t.Fatalf("unexpected failed response: %+v", got) } } func TestEasyAISynchronousVectorAndFileUploadResponsesAreAdditive(t *testing.T) { vectorTask := store.GatewayTask{ID: "vector-sync-1", Kind: "images.vectorize"} vector := easyAISynchronousTaskResponse(vectorTask, map[string]any{ "status": "success", "data": []any{map[string]any{"url": "https://example.com/output.svg"}}, }) if vector["status"] != "success" || vector["taskId"] != vectorTask.ID || vector["task_id"] != vectorTask.ID { t.Fatalf("unexpected vector response: %+v", vector) } upload := easyAIFileUploadResponse(map[string]any{ "id": "file-1", "url": "https://example.com/upload.png", "filename": "upload.png", }) data, _ := upload["data"].(map[string]any) if upload["status"] != "success" || upload["url"] != data["url"] || upload["id"] != data["id"] { t.Fatalf("unexpected upload response: %+v", upload) } } func TestEasyAIAsyncMediaRequestRequiresExplicitHeader(t *testing.T) { syncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil) if easyAIAsyncMediaRequest("images.generations", syncRequest) { t.Fatal("image request without X-Async must stay synchronous") } asyncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil) asyncRequest.Header.Set("X-Async", "true") if !easyAIAsyncMediaRequest("images.generations", asyncRequest) { t.Fatal("image request with X-Async must enable EasyAI async compatibility") } if easyAIAsyncMediaRequest("chat.completions", asyncRequest) { t.Fatal("chat completions must not enter EasyAI media async compatibility") } } func TestWriteEasyAIAsyncErrorKeepsNestedGatewayError(t *testing.T) { recorder := httptest.NewRecorder() writeEasyAIAsyncError(recorder, http.StatusBadRequest, "invalid request", map[string]any{"param": "model"}, "invalid_parameter") var got map[string]any if err := json.NewDecoder(recorder.Body).Decode(&got); err != nil { t.Fatal(err) } errorPayload, _ := got["error"].(map[string]any) data, _ := got["data"].([]any) if got["status"] != "failed" || got["code"] != "invalid_parameter" || errorPayload["message"] != "invalid request" || len(data) != 0 { t.Fatalf("unexpected async error response: %+v", got) } }