batch_image_provider_vertex_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  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. "time"
  12. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  13. "github.com/stretchr/testify/require"
  14. )
  15. func TestBatchImageProviderRegistry_ReturnsVertex(t *testing.T) {
  16. registry := NewDefaultBatchImageProviderRegistry()
  17. provider, ok := registry.Get(BatchImageProviderVertex)
  18. require.True(t, ok)
  19. require.Equal(t, BatchImageProviderVertex, provider.Name())
  20. }
  21. func TestVertexProvider_SupportsOnlyGeminiServiceAccount(t *testing.T) {
  22. provider := newTestVertexProvider(&fakeVertexBatchClient{}, &fakeVertexObjectStore{})
  23. require.True(t, provider.SupportsAccount(vertexServiceAccount()))
  24. require.False(t, provider.SupportsAccount(&Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk"}}))
  25. require.False(t, provider.SupportsAccount(&Account{Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "tok"}}))
  26. require.False(t, provider.SupportsAccount(&Account{Platform: PlatformAnthropic, Type: AccountTypeServiceAccount, Credentials: vertexServiceAccount().Credentials}))
  27. require.False(t, provider.SupportsAccount(&Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{}}))
  28. }
  29. func TestVertexProvider_MissingServiceAccountRejected(t *testing.T) {
  30. provider := newTestVertexProvider(&fakeVertexBatchClient{}, &fakeVertexObjectStore{})
  31. _, err := provider.Submit(context.Background(), nil, &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{}}, validVertexBatchInput())
  32. require.ErrorIs(t, err, ErrBatchImageProviderMissingServiceAccount)
  33. }
  34. func TestVertexProvider_MissingManagedGCSBucketRejected(t *testing.T) {
  35. provider := NewVertexBatchImageProvider(VertexBatchImageProviderOptions{ProjectID: "proj", Environment: "test"}, &fakeVertexBatchClient{}, &fakeVertexObjectStore{}, &fakeGeminiTokenCache{token: "token"})
  36. _, err := provider.Submit(context.Background(), nil, vertexServiceAccount(), validVertexBatchInput())
  37. require.Error(t, err)
  38. require.Equal(t, "VERTEX_MANAGED_GCS_BUCKET_MISSING", infraerrors.Reason(err))
  39. }
  40. func TestBuildVertexBatchJSONL_WritesValidLinesAndPreservesCustomID(t *testing.T) {
  41. input := validVertexBatchInput()
  42. input.Items = append(input.Items, BatchImageInputItem{CustomID: "cover_002", Prompt: "Second prompt"})
  43. jsonl, err := BuildVertexBatchJSONL(input)
  44. require.NoError(t, err)
  45. lines := strings.Split(strings.TrimSpace(string(jsonl)), "\n")
  46. require.Len(t, lines, 2)
  47. requireVertexJSONLLine(t, lines[0], "cover_001", "A clean product hero image")
  48. requireVertexJSONLLine(t, lines[1], "cover_002", "Second prompt")
  49. }
  50. func TestBuildVertexBatchJSONL_RejectsDuplicateCustomIDs(t *testing.T) {
  51. input := validVertexBatchInput()
  52. input.Items = append(input.Items, BatchImageInputItem{CustomID: "cover_001", Prompt: "Duplicate"})
  53. _, err := BuildVertexBatchJSONL(input)
  54. require.ErrorIs(t, err, ErrBatchImageProviderInvalidInput)
  55. }
  56. func TestBuildVertexBatchJSONL_RejectsEmptyPrompt(t *testing.T) {
  57. input := validVertexBatchInput()
  58. input.Items[0].Prompt = " "
  59. _, err := BuildVertexBatchJSONL(input)
  60. require.ErrorIs(t, err, ErrBatchImageProviderInvalidInput)
  61. }
  62. func TestBuildVertexBatchJSONL_WritesReferenceImages(t *testing.T) {
  63. input := validVertexBatchInput()
  64. input.Items[0].ReferenceImages = []BatchImageReference{
  65. {MimeType: "image/png", Data: []byte("png-bytes")},
  66. {MimeType: "image/jpeg", FileURI: "gs://bucket/refs/style.jpg"},
  67. }
  68. jsonl, err := BuildVertexBatchJSONL(input)
  69. require.NoError(t, err)
  70. lines := strings.Split(strings.TrimSpace(string(jsonl)), "\n")
  71. require.Len(t, lines, 1)
  72. var got map[string]any
  73. require.NoError(t, json.Unmarshal([]byte(lines[0]), &got))
  74. request := got["request"].(map[string]any)
  75. contents := request["contents"].([]any)
  76. parts := contents[0].(map[string]any)["parts"].([]any)
  77. require.Len(t, parts, 3)
  78. require.Equal(t, "A clean product hero image", parts[0].(map[string]any)["text"])
  79. inlineData := parts[1].(map[string]any)["inlineData"].(map[string]any)
  80. require.Equal(t, "image/png", inlineData["mimeType"])
  81. require.Equal(t, "cG5nLWJ5dGVz", inlineData["data"])
  82. fileData := parts[2].(map[string]any)["fileData"].(map[string]any)
  83. require.Equal(t, "image/jpeg", fileData["mimeType"])
  84. require.Equal(t, "gs://bucket/refs/style.jpg", fileData["fileUri"])
  85. }
  86. func TestNormalizeVertexBatchModelPath(t *testing.T) {
  87. require.Equal(t, "publishers/google/models/gemini-3.1-flash-image", NormalizeVertexBatchModelPath("gemini-3.1-flash-image"))
  88. require.Equal(t, "publishers/google/models/gemini-2.5-flash-image", NormalizeVertexBatchModelPath("publishers/google/models/gemini-2.5-flash-image"))
  89. require.Equal(t, "projects/p/locations/global/models/m", NormalizeVertexBatchModelPath("projects/p/locations/global/models/m"))
  90. }
  91. func TestBuildVertexBatchPredictionJobsEndpoint(t *testing.T) {
  92. global, err := BuildVertexBatchPredictionJobsEndpoint("", "my-project", "global")
  93. require.NoError(t, err)
  94. require.Equal(t, "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/batchPredictionJobs", global)
  95. regional, err := BuildVertexBatchPredictionJobsEndpoint("", "my-project", "asia-northeast1")
  96. require.NoError(t, err)
  97. require.Equal(t, "https://asia-northeast1-aiplatform.googleapis.com/v1/projects/my-project/locations/asia-northeast1/batchPredictionJobs", regional)
  98. }
  99. func TestVertexProvider_SubmitUploadsJSONLAndCreatesBatchPredictionJob(t *testing.T) {
  100. vertexClient := &fakeVertexBatchClient{created: &VertexBatchPredictionJob{Name: "projects/proj/locations/global/batchPredictionJobs/job-1", State: "JOB_STATE_PENDING"}}
  101. store := &fakeVertexObjectStore{}
  102. provider := newTestVertexProvider(vertexClient, store)
  103. got, err := provider.Submit(context.Background(), &BatchImageJob{BatchID: "imgbatch_abc123", Model: "gemini-3.1-flash-image"}, vertexServiceAccount(), validVertexBatchInput())
  104. require.NoError(t, err)
  105. require.Equal(t, "gs://managed-bucket/batch-image/test/imgbatch_abc123/input/requests.jsonl", store.uploadURI)
  106. require.Equal(t, "projects/proj/locations/global/batchPredictionJobs/job-1", got.ProviderJobName)
  107. require.Equal(t, store.uploadURI, got.ProviderInputRef)
  108. require.Equal(t, "gs://managed-bucket/batch-image/test/imgbatch_abc123/output/", got.ProviderOutputRef)
  109. require.Equal(t, "jsonl", vertexClient.createdReq.InputConfig.InstancesFormat)
  110. require.Equal(t, "jsonl", vertexClient.createdReq.OutputConfig.PredictionsFormat)
  111. require.Equal(t, got.ProviderOutputRef, vertexClient.createdReq.OutputConfig.GCSDestination.OutputURIPrefix)
  112. require.Equal(t, "key", vertexClient.createdReq.InstanceConfig.KeyField)
  113. require.NotContains(t, string(vertexClient.createdPayloadForAssert(t)), "serviceAccount")
  114. require.NotContains(t, string(vertexClient.createdPayloadForAssert(t)), "encryptionSpec")
  115. require.NotContains(t, got.ProviderInputRef+got.ProviderOutputRef+got.ProviderJobName, "A clean product hero image")
  116. require.NotContains(t, string(store.uploadedJSONL), "private_key")
  117. }
  118. func TestVertexProvider_GetMapsStates(t *testing.T) {
  119. tests := []struct {
  120. name string
  121. state string
  122. err *VertexBatchJobError
  123. wantState BatchProviderInternalState
  124. wantDone bool
  125. wantCode string
  126. }{
  127. {name: "pending", state: "JOB_STATE_PENDING", wantState: BatchProviderStateQueued},
  128. {name: "queued", state: "JOB_STATE_QUEUED", wantState: BatchProviderStateQueued},
  129. {name: "running", state: "JOB_STATE_RUNNING", wantState: BatchProviderStateRunning},
  130. {name: "succeeded", state: "JOB_STATE_SUCCEEDED", wantState: BatchProviderStateSucceeded, wantDone: true},
  131. {name: "failed", state: "JOB_STATE_FAILED", err: &VertexBatchJobError{Status: "INVALID_ARGUMENT", Message: "bad request"}, wantState: BatchProviderStateFailed, wantDone: true, wantCode: "INVALID_ARGUMENT"},
  132. {name: "cancelled", state: "JOB_STATE_CANCELLED", wantState: BatchProviderStateCancelled, wantDone: true, wantCode: "VERTEX_BATCH_CANCELLED"},
  133. }
  134. for _, tt := range tests {
  135. t.Run(tt.name, func(t *testing.T) {
  136. output := "gs://managed-bucket/batch-image/test/imgbatch_abc123/output/"
  137. provider := newTestVertexProvider(&fakeVertexBatchClient{got: &VertexBatchPredictionJob{
  138. Name: "projects/proj/locations/global/batchPredictionJobs/job-1",
  139. State: tt.state,
  140. Error: tt.err,
  141. OutputConfig: VertexBatchOutputConfig{GCSDestination: VertexBatchGCSDestination{OutputURIPrefix: output}},
  142. }}, &fakeVertexObjectStore{})
  143. got, err := provider.Get(context.Background(), vertexJobWithName("projects/proj/locations/global/batchPredictionJobs/job-1"), vertexServiceAccount())
  144. require.NoError(t, err)
  145. require.Equal(t, tt.wantState, got.InternalState)
  146. require.Equal(t, tt.wantDone, got.Done)
  147. require.Equal(t, output, got.ProviderOutputRef)
  148. require.Equal(t, tt.wantCode, got.ErrorCode)
  149. })
  150. }
  151. }
  152. func TestVertexProvider_OpenResultReturnsCombinedJSONLStream(t *testing.T) {
  153. output := "gs://managed-bucket/batch-image/test/imgbatch_abc123/output/"
  154. store := &fakeVertexObjectStore{
  155. listed: []string{
  156. output + "predictions_2.jsonl",
  157. output + "predictions_1.jsonl",
  158. },
  159. objects: map[string]string{
  160. output + "predictions_1.jsonl": `{"key":"1"}` + "\n",
  161. output + "predictions_2.jsonl": `{"key":"2"}` + "\n",
  162. },
  163. }
  164. provider := newTestVertexProvider(&fakeVertexBatchClient{}, store)
  165. r, contentType, err := provider.OpenResult(context.Background(), &BatchImageJob{ProviderOutputRef: &output}, vertexServiceAccount())
  166. require.NoError(t, err)
  167. defer r.Close()
  168. body, err := io.ReadAll(r)
  169. require.NoError(t, err)
  170. require.Equal(t, "application/jsonl", contentType)
  171. require.Equal(t, "{\"key\":\"1\"}\n\n{\"key\":\"2\"}\n", string(body))
  172. }
  173. func TestVertexProvider_OpenResultMissingObjectsReturnsTypedError(t *testing.T) {
  174. output := "gs://managed-bucket/batch-image/test/imgbatch_abc123/output/"
  175. provider := newTestVertexProvider(&fakeVertexBatchClient{}, &fakeVertexObjectStore{})
  176. _, _, err := provider.OpenResult(context.Background(), &BatchImageJob{ProviderOutputRef: &output}, vertexServiceAccount())
  177. require.Error(t, err)
  178. require.Equal(t, "VERTEX_RESULT_OBJECTS_MISSING", infraerrors.Reason(err))
  179. }
  180. func TestVertexProvider_CancelCallsClient(t *testing.T) {
  181. vertexClient := &fakeVertexBatchClient{}
  182. provider := newTestVertexProvider(vertexClient, &fakeVertexObjectStore{})
  183. err := provider.Cancel(context.Background(), vertexJobWithName("projects/proj/locations/global/batchPredictionJobs/job-1"), vertexServiceAccount())
  184. require.NoError(t, err)
  185. require.Equal(t, "projects/proj/locations/global/batchPredictionJobs/job-1", vertexClient.cancelledName)
  186. }
  187. func TestVertexProvider_CleanupDeletesOnlyManagedPaths(t *testing.T) {
  188. input := "gs://managed-bucket/batch-image/test/imgbatch_abc123/input/requests.jsonl"
  189. output := "gs://managed-bucket/batch-image/test/imgbatch_abc123/output/"
  190. store := &fakeVertexObjectStore{}
  191. provider := newTestVertexProvider(&fakeVertexBatchClient{}, store)
  192. err := provider.Cleanup(context.Background(), &BatchImageJob{BatchID: "imgbatch_abc123", ProviderInputRef: &input, ProviderOutputRef: &output}, vertexServiceAccount(), CleanupTargetAll)
  193. require.NoError(t, err)
  194. require.Equal(t, []string{input}, store.deletedObjects)
  195. require.Equal(t, []string{output}, store.deletedPrefixes)
  196. }
  197. func TestVertexProvider_CleanupRejectsUnsafePath(t *testing.T) {
  198. input := "gs://other-bucket/batch-image/test/imgbatch_abc123/input/requests.jsonl"
  199. provider := newTestVertexProvider(&fakeVertexBatchClient{}, &fakeVertexObjectStore{})
  200. err := provider.Cleanup(context.Background(), &BatchImageJob{BatchID: "imgbatch_abc123", ProviderInputRef: &input}, vertexServiceAccount(), CleanupTargetInput)
  201. require.ErrorIs(t, err, ErrBatchImageProviderUnsafeCleanupPath)
  202. }
  203. func TestVertexProvider_ErrorsDoNotExposeServiceAccountSecrets(t *testing.T) {
  204. privateKey := "-----BEGIN PRIVATE KEY-----secret-----END PRIVATE KEY-----"
  205. account := vertexServiceAccount()
  206. account.Credentials["service_account_json"] = map[string]any{
  207. "type": "service_account",
  208. "project_id": "proj",
  209. "private_key": privateKey,
  210. "client_email": "svc@proj.iam.gserviceaccount.com",
  211. }
  212. provider := newTestVertexProvider(&fakeVertexBatchClient{createErr: &VertexAPIError{StatusCode: 403, Message: "do not expose " + privateKey}}, &fakeVertexObjectStore{})
  213. _, err := provider.Submit(context.Background(), nil, account, validVertexBatchInput())
  214. require.Error(t, err)
  215. require.Equal(t, "VERTEX_PERMISSION_DENIED", infraerrors.Reason(err))
  216. require.NotContains(t, err.Error(), privateKey)
  217. require.NotContains(t, err.Error(), "svc@proj")
  218. }
  219. func TestVertexProvider_MetadataDoesNotStoreImageBytesOrBase64(t *testing.T) {
  220. vertexClient := &fakeVertexBatchClient{created: &VertexBatchPredictionJob{Name: "projects/proj/locations/global/batchPredictionJobs/job-1", State: "JOB_STATE_PENDING"}}
  221. provider := newTestVertexProvider(vertexClient, &fakeVertexObjectStore{})
  222. got, err := provider.Submit(context.Background(), nil, vertexServiceAccount(), validVertexBatchInput())
  223. require.NoError(t, err)
  224. metadata := got.ProviderJobName + got.ProviderInputRef + got.ProviderOutputRef
  225. require.NotContains(t, metadata, "iVBOR")
  226. require.NotContains(t, metadata, "base64")
  227. require.NotContains(t, metadata, "A clean product hero image")
  228. }
  229. func validVertexBatchInput() BatchImageInput {
  230. return BatchImageInput{
  231. BatchID: "imgbatch_abc123",
  232. Model: "gemini-3.1-flash-image",
  233. DisplayName: "test vertex batch",
  234. Items: []BatchImageInputItem{{
  235. CustomID: "cover_001",
  236. Prompt: "A clean product hero image",
  237. }},
  238. }
  239. }
  240. func requireVertexJSONLLine(t *testing.T, line, wantKey, wantPrompt string) {
  241. t.Helper()
  242. var got map[string]any
  243. require.NoError(t, json.Unmarshal([]byte(line), &got))
  244. require.Equal(t, wantKey, got["key"])
  245. request := got["request"].(map[string]any)
  246. contents := request["contents"].([]any)
  247. require.Equal(t, "user", contents[0].(map[string]any)["role"])
  248. parts := contents[0].(map[string]any)["parts"].([]any)
  249. require.Equal(t, wantPrompt, parts[0].(map[string]any)["text"])
  250. config := request["generationConfig"].(map[string]any)
  251. require.Equal(t, []any{"TEXT", "IMAGE"}, config["responseModalities"])
  252. }
  253. func newTestVertexProvider(client *fakeVertexBatchClient, store *fakeVertexObjectStore) *VertexBatchImageProvider {
  254. return NewVertexBatchImageProvider(VertexBatchImageProviderOptions{
  255. ProjectID: "proj",
  256. Location: "global",
  257. ManagedGCSBucket: "managed-bucket",
  258. ManagedGCSPrefix: "batch-image/{env}/{batch_id}",
  259. Environment: "test",
  260. }, client, store, &fakeGeminiTokenCache{token: "ya29.test-token"})
  261. }
  262. func vertexServiceAccount() *Account {
  263. return &Account{
  264. Platform: PlatformGemini,
  265. Type: AccountTypeServiceAccount,
  266. Credentials: map[string]any{
  267. "service_account_json": map[string]any{
  268. "type": "service_account",
  269. "project_id": "proj",
  270. "private_key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n",
  271. "client_email": "svc@proj.iam.gserviceaccount.com",
  272. },
  273. },
  274. }
  275. }
  276. func vertexJobWithName(name string) *BatchImageJob {
  277. return &BatchImageJob{ProviderJobName: &name}
  278. }
  279. type fakeVertexBatchClient struct {
  280. created *VertexBatchPredictionJob
  281. got *VertexBatchPredictionJob
  282. createErr error
  283. getErr error
  284. cancelErr error
  285. createdReq VertexCreateBatchPredictionJobRequest
  286. cancelledName string
  287. }
  288. func (f *fakeVertexBatchClient) CreateBatchPredictionJob(_ context.Context, accessToken string, req VertexCreateBatchPredictionJobRequest) (*VertexBatchPredictionJob, error) {
  289. if strings.TrimSpace(accessToken) == "" {
  290. return nil, errors.New("missing token")
  291. }
  292. f.createdReq = req
  293. if f.createErr != nil {
  294. return nil, f.createErr
  295. }
  296. if f.created != nil {
  297. return f.created, nil
  298. }
  299. return &VertexBatchPredictionJob{Name: "projects/proj/locations/global/batchPredictionJobs/job-1", State: "JOB_STATE_PENDING"}, nil
  300. }
  301. func (f *fakeVertexBatchClient) GetBatchPredictionJob(_ context.Context, _ string, _ string) (*VertexBatchPredictionJob, error) {
  302. if f.getErr != nil {
  303. return nil, f.getErr
  304. }
  305. return f.got, nil
  306. }
  307. func (f *fakeVertexBatchClient) CancelBatchPredictionJob(_ context.Context, _ string, name string) error {
  308. f.cancelledName = name
  309. return f.cancelErr
  310. }
  311. func (f *fakeVertexBatchClient) createdPayloadForAssert(t *testing.T) []byte {
  312. t.Helper()
  313. b, err := json.Marshal(f.createdReq)
  314. require.NoError(t, err)
  315. return b
  316. }
  317. type fakeVertexObjectStore struct {
  318. uploadURI string
  319. uploadedJSONL []byte
  320. uploadErr error
  321. listed []string
  322. objects map[string]string
  323. listErr error
  324. openErr error
  325. deleteErr error
  326. deletedObjects []string
  327. deletedPrefixes []string
  328. }
  329. func (f *fakeVertexObjectStore) UploadJSONL(_ context.Context, _ string, uri string, r io.Reader) error {
  330. f.uploadURI = uri
  331. f.uploadedJSONL, _ = io.ReadAll(r)
  332. return f.uploadErr
  333. }
  334. func (f *fakeVertexObjectStore) ListJSONLObjects(_ context.Context, _ string, _ string) ([]string, error) {
  335. if f.listErr != nil {
  336. return nil, f.listErr
  337. }
  338. out := make([]string, 0, len(f.listed))
  339. for _, item := range f.listed {
  340. if strings.HasSuffix(item, ".jsonl") {
  341. out = append(out, item)
  342. }
  343. }
  344. return out, nil
  345. }
  346. func (f *fakeVertexObjectStore) OpenObject(_ context.Context, _ string, uri string) (io.ReadCloser, string, error) {
  347. if f.openErr != nil {
  348. return nil, "", f.openErr
  349. }
  350. return io.NopCloser(bytes.NewBufferString(f.objects[uri])), "application/jsonl", nil
  351. }
  352. func (f *fakeVertexObjectStore) DeleteObject(_ context.Context, _ string, uri string) error {
  353. f.deletedObjects = append(f.deletedObjects, uri)
  354. return f.deleteErr
  355. }
  356. func (f *fakeVertexObjectStore) DeletePrefix(_ context.Context, _ string, uri string) error {
  357. f.deletedPrefixes = append(f.deletedPrefixes, uri)
  358. return f.deleteErr
  359. }
  360. type fakeGeminiTokenCache struct {
  361. token string
  362. }
  363. func (f *fakeGeminiTokenCache) GetAccessToken(context.Context, string) (string, error) {
  364. if strings.TrimSpace(f.token) == "" {
  365. return "", errors.New("missing token")
  366. }
  367. return f.token, nil
  368. }
  369. func (f *fakeGeminiTokenCache) SetAccessToken(context.Context, string, string, time.Duration) error {
  370. return nil
  371. }
  372. func (f *fakeGeminiTokenCache) DeleteAccessToken(context.Context, string) error {
  373. return nil
  374. }
  375. func (f *fakeGeminiTokenCache) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) {
  376. return false, nil
  377. }
  378. func (f *fakeGeminiTokenCache) ReleaseRefreshLock(context.Context, string) error {
  379. return nil
  380. }