batch_image_provider_gemini_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "context"
  6. "encoding/json"
  7. "errors"
  8. "io"
  9. "strings"
  10. "testing"
  11. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  12. "github.com/stretchr/testify/require"
  13. )
  14. func TestBatchImageProviderRegistry_ReturnsGeminiAPI(t *testing.T) {
  15. registry := NewDefaultBatchImageProviderRegistry()
  16. provider, ok := registry.Get(BatchImageProviderGeminiAPI)
  17. require.True(t, ok)
  18. require.Equal(t, BatchImageProviderGeminiAPI, provider.Name())
  19. must, err := registry.MustGet(BatchImageProviderGeminiAPI)
  20. require.NoError(t, err)
  21. require.Same(t, provider, must)
  22. _, err = registry.MustGet("unknown_provider")
  23. require.ErrorIs(t, err, ErrBatchImageInvalidProvider)
  24. }
  25. func TestGeminiProvider_SupportsOnlyGeminiAPIKeyWithSecret(t *testing.T) {
  26. provider := NewGeminiAPIBatchImageProvider(&fakeGeminiBatchClient{})
  27. require.True(t, provider.SupportsAccount(geminiAPIKeyAccount("sk-gemini")))
  28. require.False(t, provider.SupportsAccount(&Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{}}))
  29. require.False(t, provider.SupportsAccount(&Account{Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{"api_key": "sk"}}))
  30. require.False(t, provider.SupportsAccount(&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk"}}))
  31. require.False(t, provider.SupportsAccount(nil))
  32. }
  33. func TestGeminiProvider_MissingAPIKeyRejected(t *testing.T) {
  34. provider := NewGeminiAPIBatchImageProvider(&fakeGeminiBatchClient{})
  35. _, err := provider.Submit(context.Background(), nil, &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey}, validGeminiBatchInput())
  36. require.ErrorIs(t, err, ErrBatchImageProviderMissingAPIKey)
  37. }
  38. func TestBuildGeminiBatchJSONL_WritesValidLinesAndPreservesCustomID(t *testing.T) {
  39. input := validGeminiBatchInput()
  40. input.Items = append(input.Items, BatchImageInputItem{CustomID: "cover_002", Prompt: "Second prompt"})
  41. jsonl, err := BuildGeminiBatchJSONL(input)
  42. require.NoError(t, err)
  43. lines := strings.Split(strings.TrimSpace(string(jsonl)), "\n")
  44. require.Len(t, lines, 2)
  45. requireJSONLLine(t, lines[0], "cover_001", "A clean product hero image")
  46. requireJSONLLine(t, lines[1], "cover_002", "Second prompt")
  47. }
  48. func TestBuildGeminiBatchJSONL_RejectsDuplicateCustomIDs(t *testing.T) {
  49. input := validGeminiBatchInput()
  50. input.Items = append(input.Items, BatchImageInputItem{CustomID: "cover_001", Prompt: "Duplicate"})
  51. _, err := BuildGeminiBatchJSONL(input)
  52. require.ErrorIs(t, err, ErrBatchImageProviderInvalidInput)
  53. }
  54. func TestBuildGeminiBatchJSONL_RejectsEmptyPrompt(t *testing.T) {
  55. input := validGeminiBatchInput()
  56. input.Items[0].Prompt = " "
  57. _, err := BuildGeminiBatchJSONL(input)
  58. require.ErrorIs(t, err, ErrBatchImageProviderInvalidInput)
  59. }
  60. func TestBuildGeminiBatchJSONL_WritesReferenceImages(t *testing.T) {
  61. input := validGeminiBatchInput()
  62. input.Items[0].ReferenceImages = []BatchImageReference{
  63. {MimeType: "image/webp", Data: []byte("webp-bytes")},
  64. {MimeType: "image/jpeg", FileURI: "gs://bucket/refs/style.jpg"},
  65. }
  66. jsonl, err := BuildGeminiBatchJSONL(input)
  67. require.NoError(t, err)
  68. lines := strings.Split(strings.TrimSpace(string(jsonl)), "\n")
  69. require.Len(t, lines, 1)
  70. var got map[string]any
  71. require.NoError(t, json.Unmarshal([]byte(lines[0]), &got))
  72. request := got["request"].(map[string]any)
  73. contents := request["contents"].([]any)
  74. parts := contents[0].(map[string]any)["parts"].([]any)
  75. require.Len(t, parts, 3)
  76. require.Equal(t, "A clean product hero image", parts[0].(map[string]any)["text"])
  77. inlineData := parts[1].(map[string]any)["inlineData"].(map[string]any)
  78. require.Equal(t, "image/webp", inlineData["mimeType"])
  79. require.Equal(t, "d2VicC1ieXRlcw==", inlineData["data"])
  80. fileData := parts[2].(map[string]any)["fileData"].(map[string]any)
  81. require.Equal(t, "image/jpeg", fileData["mimeType"])
  82. require.Equal(t, "gs://bucket/refs/style.jpg", fileData["fileUri"])
  83. }
  84. func TestGeminiProvider_SubmitUploadsJSONLThenCreatesBatch(t *testing.T) {
  85. client := &fakeGeminiBatchClient{
  86. uploaded: &GeminiUploadedFile{Name: "files/input-jsonl"},
  87. created: &GeminiBatchJob{Name: "batches/job-123", State: "JOB_STATE_PENDING"},
  88. }
  89. provider := NewGeminiAPIBatchImageProvider(client)
  90. got, err := provider.Submit(context.Background(), &BatchImageJob{BatchID: "imgbatch_123", Model: "gemini-3.1-flash-image"}, geminiAPIKeyAccount("sk-secret"), validGeminiBatchInput())
  91. require.NoError(t, err)
  92. require.Equal(t, []string{"upload", "create"}, client.calls)
  93. require.Equal(t, "files/input-jsonl", got.ProviderInputRef)
  94. require.Equal(t, "batches/job-123", got.ProviderJobName)
  95. require.Empty(t, got.ProviderOutputRef)
  96. require.NotContains(t, got.ProviderInputRef, "A clean product hero image")
  97. require.NotContains(t, string(client.uploadedJSONL), "sk-secret")
  98. }
  99. func TestGeminiProvider_GetMapsStates(t *testing.T) {
  100. tests := []struct {
  101. name string
  102. job *GeminiBatchJob
  103. wantState BatchProviderInternalState
  104. wantDone bool
  105. wantRef string
  106. wantCode string
  107. }{
  108. {name: "running", job: &GeminiBatchJob{Name: "batches/1", State: "JOB_STATE_RUNNING"}, wantState: BatchProviderStateRunning},
  109. {name: "succeeded_dest_fileName", job: &GeminiBatchJob{Name: "batches/1", State: "JOB_STATE_SUCCEEDED", Dest: &GeminiBatchDest{FileName: "files/out"}}, wantState: BatchProviderStateSucceeded, wantDone: true, wantRef: "files/out"},
  110. {name: "failed", job: &GeminiBatchJob{Name: "batches/1", State: "JOB_STATE_FAILED", Error: &GeminiBatchError{Code: "BAD_PROMPT", Message: "bad prompt"}}, wantState: BatchProviderStateFailed, wantDone: true, wantCode: "BAD_PROMPT"},
  111. {name: "cancelled", job: &GeminiBatchJob{Name: "batches/1", State: "JOB_STATE_CANCELLED"}, wantState: BatchProviderStateCancelled, wantDone: true, wantCode: "GEMINI_BATCH_CANCELLED"},
  112. {name: "expired", job: &GeminiBatchJob{Name: "batches/1", State: "JOB_STATE_EXPIRED"}, wantState: BatchProviderStateExpired, wantDone: true, wantCode: "GEMINI_BATCH_EXPIRED"},
  113. }
  114. for _, tt := range tests {
  115. t.Run(tt.name, func(t *testing.T) {
  116. provider := NewGeminiAPIBatchImageProvider(&fakeGeminiBatchClient{got: tt.job})
  117. got, err := provider.Get(context.Background(), jobWithProviderName("batches/1"), geminiAPIKeyAccount("sk-secret"))
  118. require.NoError(t, err)
  119. require.Equal(t, tt.wantState, got.InternalState)
  120. require.Equal(t, tt.wantDone, got.Done)
  121. require.Equal(t, tt.wantRef, got.ProviderOutputRef)
  122. require.Equal(t, tt.wantCode, got.ErrorCode)
  123. require.NotContains(t, got.ErrorMessage, "sk-secret")
  124. })
  125. }
  126. }
  127. func TestGeminiProvider_GetExtractsResponsesFileReference(t *testing.T) {
  128. provider := NewGeminiAPIBatchImageProvider(&fakeGeminiBatchClient{
  129. got: &GeminiBatchJob{
  130. Name: "batches/1",
  131. State: "JOB_STATE_SUCCEEDED",
  132. Response: &GeminiBatchResponse{ResponsesFile: "files/responses-jsonl"},
  133. },
  134. })
  135. got, err := provider.Get(context.Background(), jobWithProviderName("batches/1"), geminiAPIKeyAccount("sk-secret"))
  136. require.NoError(t, err)
  137. require.Equal(t, BatchProviderStateSucceeded, got.InternalState)
  138. require.Equal(t, "files/responses-jsonl", got.ProviderOutputRef)
  139. }
  140. func TestGeminiProvider_GetRejectsInlineResultShape(t *testing.T) {
  141. provider := NewGeminiAPIBatchImageProvider(&fakeGeminiBatchClient{
  142. got: &GeminiBatchJob{
  143. Name: "batches/1",
  144. State: "JOB_STATE_SUCCEEDED",
  145. Response: &GeminiBatchResponse{InlinedResponses: []any{map[string]any{"response": "large"}}},
  146. },
  147. })
  148. _, err := provider.Get(context.Background(), jobWithProviderName("batches/1"), geminiAPIKeyAccount("sk-secret"))
  149. require.ErrorIs(t, err, ErrBatchImageProviderInlineResultUnsupported)
  150. }
  151. func TestGeminiProvider_OpenResultStreamsResultFile(t *testing.T) {
  152. client := &fakeGeminiBatchClient{downloadBody: "line1\n", downloadContentType: "application/jsonl"}
  153. provider := NewGeminiAPIBatchImageProvider(client)
  154. outputRef := "files/output-jsonl"
  155. r, contentType, err := provider.OpenResult(context.Background(), &BatchImageJob{ProviderOutputRef: &outputRef}, geminiAPIKeyAccount("sk-secret"))
  156. require.NoError(t, err)
  157. defer r.Close()
  158. body, err := io.ReadAll(r)
  159. require.NoError(t, err)
  160. require.Equal(t, "line1\n", string(body))
  161. require.Equal(t, "application/jsonl", contentType)
  162. require.Equal(t, "files/output-jsonl", client.downloadedFile)
  163. }
  164. func TestGeminiProvider_CancelCallsClient(t *testing.T) {
  165. client := &fakeGeminiBatchClient{}
  166. provider := NewGeminiAPIBatchImageProvider(client)
  167. require.NoError(t, provider.Cancel(context.Background(), jobWithProviderName("batches/1"), geminiAPIKeyAccount("sk-secret")))
  168. require.Equal(t, "batches/1", client.cancelledBatch)
  169. }
  170. func TestGeminiProvider_CleanupDeletesRefsOnlyWhenPresent(t *testing.T) {
  171. inputRef := "files/input"
  172. outputRef := "files/output"
  173. client := &fakeGeminiBatchClient{}
  174. provider := NewGeminiAPIBatchImageProvider(client)
  175. err := provider.Cleanup(context.Background(), &BatchImageJob{ProviderInputRef: &inputRef, ProviderOutputRef: &outputRef}, geminiAPIKeyAccount("sk-secret"), CleanupTargetAll)
  176. require.NoError(t, err)
  177. require.Equal(t, []string{"files/input", "files/output"}, client.deletedFiles)
  178. err = provider.Cleanup(context.Background(), &BatchImageJob{}, geminiAPIKeyAccount("sk-secret"), CleanupTargetAll)
  179. require.NoError(t, err)
  180. require.Equal(t, []string{"files/input", "files/output"}, client.deletedFiles)
  181. }
  182. func TestGeminiProvider_ErrorsDoNotExposeAPIKey(t *testing.T) {
  183. apiKey := "sk-top-secret"
  184. client := &fakeGeminiBatchClient{uploadErr: &GeminiAPIError{StatusCode: 401, Message: "upstream body should be hidden " + apiKey}}
  185. provider := NewGeminiAPIBatchImageProvider(client)
  186. _, err := provider.Submit(context.Background(), nil, geminiAPIKeyAccount(apiKey), validGeminiBatchInput())
  187. require.Error(t, err)
  188. require.Equal(t, "GEMINI_AUTH_FAILED", infraerrors.Reason(err))
  189. require.NotContains(t, err.Error(), apiKey)
  190. }
  191. func TestGeminiProvider_MetadataDoesNotStoreImageBytesOrBase64(t *testing.T) {
  192. client := &fakeGeminiBatchClient{
  193. uploaded: &GeminiUploadedFile{Name: "files/input-jsonl"},
  194. created: &GeminiBatchJob{Name: "batches/job-123", State: "JOB_STATE_PENDING"},
  195. }
  196. provider := NewGeminiAPIBatchImageProvider(client)
  197. got, err := provider.Submit(context.Background(), nil, geminiAPIKeyAccount("sk-secret"), validGeminiBatchInput())
  198. require.NoError(t, err)
  199. require.NotContains(t, got.ProviderJobName, "base64")
  200. require.NotContains(t, got.ProviderInputRef, "base64")
  201. require.NotContains(t, got.ProviderOutputRef, "base64")
  202. require.NotContains(t, got.ProviderJobName+got.ProviderInputRef+got.ProviderOutputRef, "iVBOR")
  203. require.NotContains(t, got.ProviderJobName+got.ProviderInputRef+got.ProviderOutputRef, "A clean product hero image")
  204. }
  205. func requireJSONLLine(t *testing.T, line, wantKey, wantPrompt string) {
  206. t.Helper()
  207. var got map[string]any
  208. require.NoError(t, json.Unmarshal([]byte(line), &got))
  209. require.Equal(t, wantKey, got["key"])
  210. request := got["request"].(map[string]any)
  211. config := request["generationConfig"].(map[string]any)
  212. require.Equal(t, []any{"TEXT", "IMAGE"}, config["responseModalities"])
  213. contents := request["contents"].([]any)
  214. parts := contents[0].(map[string]any)["parts"].([]any)
  215. require.Equal(t, wantPrompt, parts[0].(map[string]any)["text"])
  216. }
  217. func validGeminiBatchInput() BatchImageInput {
  218. return BatchImageInput{
  219. BatchID: "imgbatch_123",
  220. Model: "gemini-3.1-flash-image",
  221. DisplayName: "test batch",
  222. Items: []BatchImageInputItem{{
  223. CustomID: "cover_001",
  224. Prompt: "A clean product hero image",
  225. }},
  226. }
  227. }
  228. func geminiAPIKeyAccount(apiKey string) *Account {
  229. return &Account{
  230. Platform: PlatformGemini,
  231. Type: AccountTypeAPIKey,
  232. Credentials: map[string]any{"api_key": apiKey},
  233. }
  234. }
  235. func jobWithProviderName(name string) *BatchImageJob {
  236. return &BatchImageJob{ProviderJobName: &name}
  237. }
  238. type fakeGeminiBatchClient struct {
  239. calls []string
  240. uploaded *GeminiUploadedFile
  241. created *GeminiBatchJob
  242. got *GeminiBatchJob
  243. uploadErr error
  244. createErr error
  245. getErr error
  246. cancelErr error
  247. downloadErr error
  248. deleteErr error
  249. uploadedJSONL []byte
  250. createdFile string
  251. cancelledBatch string
  252. downloadedFile string
  253. downloadBody string
  254. downloadContentType string
  255. deletedFiles []string
  256. }
  257. func (f *fakeGeminiBatchClient) UploadJSONL(_ context.Context, apiKey string, _ string, r io.Reader) (*GeminiUploadedFile, error) {
  258. if strings.TrimSpace(apiKey) == "" {
  259. return nil, errors.New("missing api key")
  260. }
  261. f.calls = append(f.calls, "upload")
  262. f.uploadedJSONL, _ = io.ReadAll(r)
  263. if f.uploadErr != nil {
  264. return nil, f.uploadErr
  265. }
  266. if f.uploaded != nil {
  267. return f.uploaded, nil
  268. }
  269. return &GeminiUploadedFile{Name: "files/input-jsonl"}, nil
  270. }
  271. func (f *fakeGeminiBatchClient) CreateBatch(_ context.Context, _ string, _ string, fileName string, _ string) (*GeminiBatchJob, error) {
  272. f.calls = append(f.calls, "create")
  273. f.createdFile = fileName
  274. if f.createErr != nil {
  275. return nil, f.createErr
  276. }
  277. if f.created != nil {
  278. return f.created, nil
  279. }
  280. return &GeminiBatchJob{Name: "batches/job-123", State: "JOB_STATE_PENDING"}, nil
  281. }
  282. func (f *fakeGeminiBatchClient) GetBatch(_ context.Context, _ string, _ string) (*GeminiBatchJob, error) {
  283. f.calls = append(f.calls, "get")
  284. if f.getErr != nil {
  285. return nil, f.getErr
  286. }
  287. return f.got, nil
  288. }
  289. func (f *fakeGeminiBatchClient) CancelBatch(_ context.Context, _ string, batchName string) error {
  290. f.calls = append(f.calls, "cancel")
  291. f.cancelledBatch = batchName
  292. return f.cancelErr
  293. }
  294. func (f *fakeGeminiBatchClient) DownloadFile(_ context.Context, _ string, fileName string) (io.ReadCloser, string, error) {
  295. f.calls = append(f.calls, "download")
  296. f.downloadedFile = fileName
  297. if f.downloadErr != nil {
  298. return nil, "", f.downloadErr
  299. }
  300. contentType := f.downloadContentType
  301. if contentType == "" {
  302. contentType = "application/octet-stream"
  303. }
  304. return io.NopCloser(bytes.NewBufferString(f.downloadBody)), contentType, nil
  305. }
  306. func (f *fakeGeminiBatchClient) DeleteFile(_ context.Context, _ string, fileName string) error {
  307. f.calls = append(f.calls, "delete")
  308. f.deletedFiles = append(f.deletedFiles, fileName)
  309. return f.deleteErr
  310. }