package clients import ( "context" "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "os" "reflect" "strings" "sync/atomic" "testing" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) func TestGeminiVeoRunUsesOfficialLongRunningProtocol(t *testing.T) { const operationName = "models/veo-3.1-generate-preview/operations/test-operation" videoPayload := []byte("test mp4 payload") var server *httptest.Server var pollCount atomic.Int32 server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("x-goog-api-key") != "test-gemini-key" { t.Errorf("missing Gemini API key header for %s", r.URL.Path) } switch { case r.Method == http.MethodPost && r.URL.Path == "/v1beta/models/veo-3.1-generate-preview:predictLongRunning": var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode Veo body: %v", err) } instances := body["instances"].([]any) if prompt := instances[0].(map[string]any)["prompt"]; prompt != "a paper boat on a lake" { t.Errorf("unexpected prompt: %v", prompt) } parameters := body["parameters"].(map[string]any) if parameters["durationSeconds"] != float64(4) || parameters["resolution"] != "720p" || parameters["aspectRatio"] != "16:9" { t.Errorf("unexpected parameters: %+v", parameters) } _ = json.NewEncoder(w).Encode(map[string]any{"name": operationName}) case r.Method == http.MethodGet && r.URL.Path == "/v1beta/"+operationName: if pollCount.Add(1) == 1 { _ = json.NewEncoder(w).Encode(map[string]any{"name": operationName, "done": false}) return } _ = json.NewEncoder(w).Encode(map[string]any{ "name": operationName, "done": true, "response": map[string]any{"generateVideoResponse": map[string]any{"generatedSamples": []any{ map[string]any{"video": map[string]any{"uri": server.URL + "/v1beta/files/generated-video:download", "mimeType": "video/mp4"}}, }}}, }) case r.Method == http.MethodGet && r.URL.Path == "/v1beta/files/generated-video:download": w.Header().Set("Content-Type", "video/mp4") _, _ = w.Write(videoPayload) default: http.NotFound(w, r) } })) defer server.Close() var submitted string polled := 0 response, err := (GeminiClient{}).Run(context.Background(), Request{ Kind: "videos.generations", ModelType: "video_generate", Model: "veo-3.1", Body: map[string]any{ "prompt": "a paper boat on a lake", "duration": 4, "resolution": "720p", "aspect_ratio": "16:9", }, Candidate: testGeminiVeoCandidate(server.URL), OnRemoteTaskSubmitted: func(remoteTaskID string, _ map[string]any) error { submitted = remoteTaskID return nil }, OnRemoteTaskPolled: func(remoteTaskID string, _ map[string]any) error { if remoteTaskID != operationName { t.Errorf("unexpected polled operation: %s", remoteTaskID) } polled++ return nil }, }) if err != nil { t.Fatalf("run Gemini Veo: %v", err) } if submitted != operationName || polled != 2 || pollCount.Load() != 2 { t.Fatalf("unexpected async callbacks: submitted=%q polled=%d requests=%d", submitted, polled, pollCount.Load()) } if response.UpstreamProtocol != ProtocolGeminiVeo || response.RequestID != operationName { t.Fatalf("unexpected response metadata: %+v", response) } data := response.Result["data"].([]any) item := data[0].(map[string]any) if got := item["video_bytes"].([]byte); !reflect.DeepEqual(got, videoPayload) { t.Fatalf("unexpected video payload: %q", got) } if _, exposed := item["uri"]; exposed { t.Fatalf("upstream authenticated URI must not be exposed: %+v", item) } } func TestGeminiVeoRunResumesOperationWithoutSubmittingAgain(t *testing.T) { const operationName = "models/veo-3.1-generate-preview/operations/resumed" var postCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { postCount.Add(1) http.Error(w, "unexpected submit", http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(map[string]any{ "name": operationName, "done": true, "response": map[string]any{"generateVideoResponse": map[string]any{"generatedSamples": []any{ map[string]any{"video": map[string]any{"videoBytes": base64.StdEncoding.EncodeToString([]byte("resumed video")), "mimeType": "video/mp4"}}, }}}, }) })) defer server.Close() response, err := (GeminiClient{}).Run(context.Background(), Request{ Kind: "videos.generations", ModelType: "video_generate", Model: "veo-3.1", Body: map[string]any{"prompt": "ignored on resume"}, Candidate: testGeminiVeoCandidate(server.URL), RemoteTaskID: operationName, }) if err != nil { t.Fatalf("resume Gemini Veo: %v", err) } if postCount.Load() != 0 || response.Result["id"] != operationName { t.Fatalf("resume submitted a new operation or returned wrong ID: posts=%d result=%+v", postCount.Load(), response.Result) } } func TestGeminiVeoBodyMapsImageAndParameters(t *testing.T) { first := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("first")) last := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString([]byte("last")) reference := "data:image/webp;base64," + base64.StdEncoding.EncodeToString([]byte("reference")) body, err := geminiVeoBody(context.Background(), Request{Body: map[string]any{ "prompt": "animate", "first_frame": first, "last_frame": last, "reference_images": []any{reference}, "duration": 8, "resolution": "2160p", "aspect_ratio": "9:16", "negative_prompt": "text", "person_generation": "allow_adult", "enhance_prompt": false, }}) if err != nil { t.Fatalf("build Gemini Veo body: %v", err) } instance := body["instances"].([]any)[0].(map[string]any) image := instance["image"].(map[string]any) lastFrame := instance["lastFrame"].(map[string]any) references := instance["referenceImages"].([]any) if image["bytesBase64Encoded"] != base64.StdEncoding.EncodeToString([]byte("first")) || image["mimeType"] != "image/png" { t.Fatalf("unexpected first frame: %+v", image) } if lastFrame["mimeType"] != "image/jpeg" || len(references) != 1 { t.Fatalf("unexpected last/reference images: last=%+v references=%+v", lastFrame, references) } parameters := body["parameters"].(map[string]any) if parameters["resolution"] != "4k" || parameters["durationSeconds"] != 8 || parameters["enhancePrompt"] != false { t.Fatalf("unexpected Gemini Veo parameters: %+v", parameters) } } func TestGeminiVeoDownloadsImageURLWhenBuildingOfficialPayload(t *testing.T) { payload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4} fetchCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fetchCount++ w.Header().Set("Content-Type", "image/png") _, _ = w.Write(payload) })) defer server.Close() body, err := geminiVeoBody(context.Background(), Request{Body: map[string]any{ "prompt": "animate", "first_frame": server.URL + "/input.png", "duration": 8, }}) if err != nil { t.Fatalf("build Gemini Veo body from URL: %v", err) } instance := body["instances"].([]any)[0].(map[string]any) image := instance["image"].(map[string]any) if fetchCount != 1 || image["bytesBase64Encoded"] != base64.StdEncoding.EncodeToString(payload) || image["mimeType"] != "image/png" { t.Fatalf("Veo URL should be fetched during official payload construction: fetches=%d image=%+v", fetchCount, image) } } func TestGeminiOfficialAPIClassification(t *testing.T) { tests := []struct { baseURL string want bool }{ {baseURL: "", want: true}, {baseURL: "https://generativelanguage.googleapis.com", want: true}, {baseURL: "https://generativelanguage.googleapis.com/v1beta", want: true}, {baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", want: false}, {baseURL: "https://gemini-compatible.example.com/v1beta", want: false}, } for _, test := range tests { if got := GeminiUsesOfficialAPI(store.RuntimeModelCandidate{BaseURL: test.baseURL}); got != test.want { t.Fatalf("GeminiUsesOfficialAPI(%q) = %t, want %t", test.baseURL, got, test.want) } } } func TestGeminiCompatibleConvertsFileDataURLToInlineBase64(t *testing.T) { payload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/png") _, _ = w.Write(payload) })) defer server.Close() body := map[string]any{"contents": []any{map[string]any{"parts": []any{map[string]any{ "fileData": map[string]any{"fileUri": server.URL + "/source.png", "mimeType": "image/png"}, }}}}} prepared, err := prepareCompatibleGeminiInlineImages(context.Background(), body) if err != nil { t.Fatalf("prepare compatible Gemini inline image: %v", err) } contents := prepared["contents"].([]any) parts := contents[0].(map[string]any)["parts"].([]any) part := parts[0].(map[string]any) inline := part["inlineData"].(map[string]any) if _, exists := part["fileData"]; exists || inline["mimeType"] != "image/png" || inline["data"] != base64.StdEncoding.EncodeToString(payload) { t.Fatalf("compatible Gemini should use inlineData: %+v", part) } } func TestGeminiOfficialImageUploadUsesFilesAPI(t *testing.T) { imagePayload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4} imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/png") _, _ = w.Write(imagePayload) })) defer imageServer.Close() var uploadServer *httptest.Server startCount := 0 uploadCount := 0 uploadServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/upload/v1beta/files": startCount++ if r.Header.Get("x-goog-api-key") != "gemini-key" || r.Header.Get("X-Goog-Upload-Protocol") != "resumable" || r.Header.Get("X-Goog-Upload-Header-Content-Type") != "image/png" { t.Fatalf("unexpected Gemini Files start headers: %+v", r.Header) } w.Header().Set("X-Goog-Upload-URL", uploadServer.URL+"/upload/session") w.WriteHeader(http.StatusOK) case "/upload/session": uploadCount++ raw, err := io.ReadAll(r.Body) if err != nil { t.Fatalf("read Gemini Files upload: %v", err) } if string(raw) != string(imagePayload) || r.Header.Get("X-Goog-Upload-Command") != "upload, finalize" { t.Fatalf("unexpected Gemini Files upload body=%v headers=%+v", raw, r.Header) } _ = json.NewEncoder(w).Encode(map[string]any{"file": map[string]any{ "uri": "https://generativelanguage.googleapis.com/v1beta/files/easyai-image", "mimeType": "image/png", }}) default: http.NotFound(w, r) } })) defer uploadServer.Close() fileData, err := uploadOfficialGeminiImageFile(context.Background(), uploadServer.Client(), store.RuntimeModelCandidate{BaseURL: uploadServer.URL + "/v1beta"}, "gemini-key", imageServer.URL+"/source.png", "image/png") if err != nil { t.Fatalf("upload official Gemini image: %v", err) } if startCount != 1 || uploadCount != 1 || fileData["fileUri"] != "https://generativelanguage.googleapis.com/v1beta/files/easyai-image" || fileData["mimeType"] != "image/png" { t.Fatalf("unexpected Gemini Files result: starts=%d uploads=%d fileData=%+v", startCount, uploadCount, fileData) } } func TestGeminiVeoRejectsInvalidDurationBeforeSubmission(t *testing.T) { _, err := geminiVeoBody(context.Background(), Request{Body: map[string]any{"prompt": "test", "duration": 5}}) if err == nil || ErrorCode(err) != "invalid_parameter" || ErrorParam(err) != "duration" || !strings.Contains(err.Error(), "4, 6, or 8") { t.Fatalf("unexpected invalid duration error: %v", err) } } func TestGeminiVeoDownloadURLOnlyTrustsProviderAndGoogleAPIs(t *testing.T) { for _, test := range []struct { uri string trusted bool }{ {uri: "https://gemini-proxy.example.com/v1beta/files/video", trusted: true}, {uri: "https://generativelanguage.googleapis.com/v1beta/files/video", trusted: true}, {uri: "https://storage.googleapis.com/signed/video", trusted: false}, {uri: "https://attacker.example.com/video", trusted: false}, } { _, trusted, err := geminiVeoDownloadURL("https://gemini-proxy.example.com/v1beta", test.uri) if err != nil { t.Fatalf("parse %s: %v", test.uri, err) } if trusted != test.trusted { t.Fatalf("unexpected trust for %s: got %t want %t", test.uri, trusted, test.trusted) } } } func TestGeminiVeoLive(t *testing.T) { apiKey := strings.TrimSpace(os.Getenv("GEMINI_VEO_LIVE_API_KEY")) if apiKey == "" { t.Skip("GEMINI_VEO_LIVE_API_KEY is not configured") } baseURL := strings.TrimSpace(os.Getenv("GEMINI_VEO_LIVE_BASE_URL")) model := strings.TrimSpace(os.Getenv("GEMINI_VEO_LIVE_MODEL")) if model == "" { model = "veo-3.1-generate-preview" } ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute) defer cancel() operationName := strings.TrimSpace(os.Getenv("GEMINI_VEO_LIVE_OPERATION")) response, err := (GeminiClient{}).Run(ctx, Request{ Kind: "videos.generations", ModelType: "video_generate", Model: model, Body: map[string]any{ "prompt": "A small red paper boat gently floating on calm water, static camera, natural daylight.", "duration": 4, "resolution": "720p", "aspect_ratio": "16:9", }, Candidate: store.RuntimeModelCandidate{ Provider: "gemini", BaseURL: baseURL, ProviderModelName: model, Credentials: map[string]any{"apiKey": apiKey}, PlatformConfig: map[string]any{"pollIntervalMs": 10000, "pollTimeoutMs": 660000}, }, RemoteTaskID: operationName, OnRemoteTaskSubmitted: func(remoteTaskID string, _ map[string]any) error { t.Logf("live Gemini Veo submitted operation %s", remoteTaskID) return nil }, }) if err != nil { t.Fatalf("live Gemini Veo request failed: %v", err) } data, _ := response.Result["data"].([]any) if len(data) != 1 { t.Fatalf("live Gemini Veo returned %d videos", len(data)) } item, _ := data[0].(map[string]any) payload, _ := item["video_bytes"].([]byte) if len(payload) < 1024 { t.Fatalf("live Gemini Veo video is unexpectedly small: %d bytes", len(payload)) } if mimeType := strings.TrimSpace(stringFromAny(item["mime_type"])); !strings.HasPrefix(mimeType, "video/") { t.Fatalf("live Gemini Veo returned unexpected MIME type: %q", mimeType) } t.Logf("live Gemini Veo accepted operation %s and downloaded %d video bytes", response.RequestID, len(payload)) } func testGeminiVeoCandidate(baseURL string) store.RuntimeModelCandidate { return store.RuntimeModelCandidate{ Provider: "gemini", BaseURL: baseURL, ProviderModelName: "veo-3.1-generate-preview", Credentials: map[string]any{"apiKey": "test-gemini-key"}, PlatformConfig: map[string]any{"pollIntervalMs": 1, "pollTimeoutMs": 1000}, } }