batch_image_processor_test.go 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "strings"
  9. "testing"
  10. "time"
  11. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  12. "github.com/stretchr/testify/require"
  13. )
  14. const batchImageTestData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
  15. func TestParseBatchImageResultLine_SuccessShapes(t *testing.T) {
  16. tests := []struct {
  17. name string
  18. line string
  19. wantID string
  20. wantMime string
  21. wantExt string
  22. wantCount int
  23. }{
  24. {
  25. name: "gemini_inlineData",
  26. line: `{"key":"cover_001","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"` + batchImageTestData + `"}}]}}]}}`,
  27. wantID: "cover_001", wantMime: "image/png", wantExt: "png", wantCount: 1,
  28. },
  29. {
  30. name: "snake_case_inline_data",
  31. line: `{"custom_id":"cover_002","response":{"candidates":[{"content":{"parts":[{"inline_data":{"mime_type":"image/jpeg","data":"` + batchImageTestData + `"}}]}}]}}`,
  32. wantID: "cover_002", wantMime: "image/jpeg", wantExt: "jpg", wantCount: 1,
  33. },
  34. {
  35. name: "vertex_top_level_response",
  36. line: `{"customId":"cover_003","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/webp","data":"` + batchImageTestData + `"}}]}}]}}`,
  37. wantID: "cover_003", wantMime: "image/webp", wantExt: "webp", wantCount: 1,
  38. },
  39. {
  40. name: "top_level_candidates",
  41. line: `{"request":{"key":"cover_004"},"candidates":[{"content":{"parts":[{"inline_data":{"mime_type":"image/png","data":"` + batchImageTestData + `"}},{"inlineData":{"mimeType":"image/png","data":"` + batchImageTestData + `"}}]}}]}`,
  42. wantID: "cover_004", wantMime: "image/png", wantExt: "png", wantCount: 2,
  43. },
  44. }
  45. for _, tt := range tests {
  46. t.Run(tt.name, func(t *testing.T) {
  47. got, err := ParseBatchImageResultLine([]byte(tt.line), 7)
  48. require.NoError(t, err)
  49. require.Equal(t, tt.wantID, got.CustomID)
  50. require.Equal(t, BatchImageParsedStatusSucceeded, got.Status)
  51. require.Equal(t, tt.wantMime, got.MimeType)
  52. require.Equal(t, tt.wantExt, got.FileExtension)
  53. require.Equal(t, tt.wantCount, got.ImageCount)
  54. require.Equal(t, 7, got.SourceLineNumber)
  55. require.NotContains(t, fmt.Sprintf("%+v", got), batchImageTestData)
  56. })
  57. }
  58. }
  59. func TestParseBatchImageResultLine_FailureShapes(t *testing.T) {
  60. tests := []struct {
  61. name string
  62. line string
  63. wantCode string
  64. }{
  65. {name: "status_row", line: `{"key":"cover_001","status":{"code":3,"message":"invalid argument: bad prompt"}}`, wantCode: "INVALID_ARGUMENT"},
  66. {name: "error_row", line: `{"key":"cover_002","error":{"code":"SAFETY","message":"blocked by safety policy"}}`, wantCode: "SAFETY_BLOCKED"},
  67. {name: "quota_row", line: `{"key":"cover_003","error":{"code":"RESOURCE_EXHAUSTED","message":"quota exceeded"}}`, wantCode: "PROVIDER_RATE_LIMITED"},
  68. {name: "empty_image_output", line: `{"key":"cover_004","response":{"candidates":[{"content":{"parts":[{"text":"no image"}]}}]}}`, wantCode: "EMPTY_IMAGE_OUTPUT"},
  69. }
  70. for _, tt := range tests {
  71. t.Run(tt.name, func(t *testing.T) {
  72. got, err := ParseBatchImageResultLine([]byte(tt.line), 1)
  73. require.NoError(t, err)
  74. require.Equal(t, BatchImageParsedStatusFailed, got.Status)
  75. require.Equal(t, tt.wantCode, got.ErrorCode)
  76. })
  77. }
  78. }
  79. func TestParseBatchImageResultLine_RejectsMissingCustomIDAndDoesNotLeakData(t *testing.T) {
  80. _, err := ParseBatchImageResultLine([]byte(`{"response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"`+batchImageTestData+`"}}]}}]}}`), 3)
  81. require.ErrorIs(t, err, ErrBatchImageIndexParseFailed)
  82. require.NotContains(t, err.Error(), batchImageTestData)
  83. }
  84. func TestBatchImageResultIndexer_WritesCountsAndReplacesItems(t *testing.T) {
  85. output := strings.Join([]string{
  86. `{"key":"ok","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"` + batchImageTestData + `"}}]}}]}}`,
  87. `{"key":"bad","error":{"code":"SAFETY","message":"blocked by safety policy"}}`,
  88. }, "\n") + "\n"
  89. repo := newFakeBatchImageRepository()
  90. outputRef := "files/output"
  91. job := &BatchImageJob{BatchID: "imgbatch_index", ProviderOutputRef: &outputRef}
  92. provider := &fakeProcessorProvider{result: output}
  93. result, err := (&BatchImageResultIndexer{Repo: repo}).Index(context.Background(), job, provider, &Account{})
  94. require.NoError(t, err)
  95. require.True(t, provider.openResultCalled)
  96. require.Equal(t, 1, result.SuccessCount)
  97. require.Equal(t, 1, result.FailCount)
  98. require.Equal(t, 2, result.TotalCount)
  99. require.Equal(t, 1, repo.replaceCalls)
  100. require.Len(t, repo.items[job.BatchID], 2)
  101. require.Equal(t, BatchImageItemStatusSuccess, repo.items[job.BatchID][0].Status)
  102. require.Equal(t, BatchImageItemStatusFailed, repo.items[job.BatchID][1].Status)
  103. require.Equal(t, BatchImageCounts{SuccessCount: 1, FailCount: 1}, repo.counts[job.BatchID])
  104. require.NotContains(t, fmt.Sprintf("%+v", repo.items[job.BatchID]), batchImageTestData)
  105. // 重新索引时与现有 custom_id 集对账:未知的 "ok2" 被丢弃,
  106. // 输出中缺失的 ok/bad 补为 PROVIDER_RESULT_MISSING 失败记录。
  107. provider.result = `{"key":"ok2","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/webp","data":"` + batchImageTestData + `"}}]}}]}}` + "\n"
  108. result, err = (&BatchImageResultIndexer{Repo: repo}).Index(context.Background(), job, provider, &Account{})
  109. require.NoError(t, err)
  110. require.Equal(t, 2, result.TotalCount)
  111. require.Equal(t, 0, result.SuccessCount)
  112. require.Equal(t, 2, result.FailCount)
  113. require.Len(t, repo.items[job.BatchID], 2)
  114. gotIDs := []string{repo.items[job.BatchID][0].CustomID, repo.items[job.BatchID][1].CustomID}
  115. require.ElementsMatch(t, []string{"ok", "bad"}, gotIDs)
  116. for _, item := range repo.items[job.BatchID] {
  117. require.Equal(t, BatchImageItemStatusFailed, item.Status)
  118. require.Equal(t, "PROVIDER_RESULT_MISSING", batchImageDerefString(item.ErrorCode))
  119. }
  120. }
  121. func TestBatchImageResultIndexer_ReconcilesMissingAndUnknownCustomIDs(t *testing.T) {
  122. repo := newFakeBatchImageRepository()
  123. outputRef := "files/output"
  124. job := &BatchImageJob{BatchID: "imgbatch_reconcile", ProviderOutputRef: &outputRef, ItemCount: 3}
  125. // 预创建提交时的 pending 条目(提交流程的行为)。
  126. require.NoError(t, repo.BulkCreateBatchImageItems(context.Background(), []CreateBatchImageItemParams{
  127. {JobID: job.BatchID, CustomID: "a", Status: BatchImageItemStatusPending},
  128. {JobID: job.BatchID, CustomID: "b", Status: BatchImageItemStatusPending},
  129. {JobID: job.BatchID, CustomID: "c", Status: BatchImageItemStatusPending},
  130. }))
  131. // provider 输出:a 成功,b 失败,c 漏掉,多出未知的 x。
  132. output := strings.Join([]string{
  133. `{"key":"a","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"` + batchImageTestData + `"}}]}}]}}`,
  134. `{"key":"b","error":{"code":"SAFETY","message":"blocked"}}`,
  135. `{"key":"x","error":{"code":"UNKNOWN","message":"not ours"}}`,
  136. }, "\n") + "\n"
  137. provider := &fakeProcessorProvider{result: output}
  138. result, err := (&BatchImageResultIndexer{Repo: repo}).Index(context.Background(), job, provider, &Account{})
  139. require.NoError(t, err)
  140. require.Equal(t, 3, result.TotalCount)
  141. require.Equal(t, 1, result.SuccessCount)
  142. require.Equal(t, 2, result.FailCount)
  143. require.Len(t, repo.items[job.BatchID], 3)
  144. byID := make(map[string]CreateBatchImageItemParams)
  145. for _, item := range repo.items[job.BatchID] {
  146. byID[item.CustomID] = item
  147. }
  148. require.NotContains(t, byID, "x")
  149. require.Equal(t, BatchImageItemStatusSuccess, byID["a"].Status)
  150. require.Equal(t, BatchImageItemStatusFailed, byID["b"].Status)
  151. require.Equal(t, BatchImageItemStatusFailed, byID["c"].Status)
  152. require.Equal(t, "PROVIDER_RESULT_MISSING", batchImageDerefString(byID["c"].ErrorCode))
  153. // 对账后 success+fail == item_count,结算计数校验可通过。
  154. require.Equal(t, job.ItemCount, result.SuccessCount+result.FailCount)
  155. }
  156. func TestBatchImageResultIndexer_EmptyInvalidAndDuplicateOutput(t *testing.T) {
  157. tests := []struct {
  158. name string
  159. body string
  160. want error
  161. }{
  162. {name: "empty", body: "\n", want: ErrBatchImageIndexNoResultLines},
  163. {name: "invalid_json", body: "{bad-json}\n", want: ErrBatchImageIndexParseFailed},
  164. {name: "duplicate_custom_id", body: `{"key":"dup","error":{"message":"one"}}` + "\n" + `{"key":"dup","error":{"message":"two"}}` + "\n", want: ErrBatchImageDuplicateCustomID},
  165. }
  166. for _, tt := range tests {
  167. t.Run(tt.name, func(t *testing.T) {
  168. repo := newFakeBatchImageRepository()
  169. _, err := (&BatchImageResultIndexer{Repo: repo}).Index(context.Background(), &BatchImageJob{BatchID: "imgbatch_bad"}, &fakeProcessorProvider{result: tt.body}, &Account{})
  170. require.ErrorIs(t, err, tt.want)
  171. require.Empty(t, repo.items["imgbatch_bad"])
  172. })
  173. }
  174. }
  175. func TestBatchImageProviderProcessor_ValidationAndTerminalCases(t *testing.T) {
  176. ctx := context.Background()
  177. accountID := int64(10)
  178. providerJob := "providers/job"
  179. t.Run("terminal job returns without provider call", func(t *testing.T) {
  180. repo := newFakeBatchImageRepository()
  181. repo.jobs["imgbatch_done"] = &BatchImageJob{BatchID: "imgbatch_done", Status: BatchImageJobStatusFailed}
  182. provider := &fakeProcessorProvider{}
  183. got, err := (&BatchImageProviderProcessor{
  184. Repo: repo, ProviderRegistry: NewBatchImageProviderRegistry(provider), AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}},
  185. }).Process(ctx, "imgbatch_done")
  186. require.NoError(t, err)
  187. require.True(t, got.Terminal)
  188. require.False(t, provider.getCalled)
  189. })
  190. t.Run("missing provider", func(t *testing.T) {
  191. repo := newFakeBatchImageRepository()
  192. repo.jobs["imgbatch_missing_provider"] = &BatchImageJob{BatchID: "imgbatch_missing_provider", Status: BatchImageJobStatusSubmitted, Provider: "missing", AccountID: &accountID, ProviderJobName: &providerJob}
  193. _, err := (&BatchImageProviderProcessor{Repo: repo, ProviderRegistry: NewBatchImageProviderRegistry(), AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}}}).Process(ctx, "imgbatch_missing_provider")
  194. require.ErrorIs(t, err, ErrBatchImageUnsupportedProvider)
  195. })
  196. t.Run("missing account id", func(t *testing.T) {
  197. repo := newFakeBatchImageRepository()
  198. repo.jobs["imgbatch_missing_account"] = &BatchImageJob{BatchID: "imgbatch_missing_account", Status: BatchImageJobStatusSubmitted, Provider: "fake", ProviderJobName: &providerJob}
  199. _, err := (&BatchImageProviderProcessor{Repo: repo, ProviderRegistry: NewBatchImageProviderRegistry(&fakeProcessorProvider{}), AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}}}).Process(ctx, "imgbatch_missing_account")
  200. require.ErrorIs(t, err, ErrBatchImageMissingAccountID)
  201. })
  202. t.Run("missing provider job name", func(t *testing.T) {
  203. repo := newFakeBatchImageRepository()
  204. repo.jobs["imgbatch_missing_name"] = &BatchImageJob{BatchID: "imgbatch_missing_name", Status: BatchImageJobStatusSubmitted, Provider: "fake", AccountID: &accountID}
  205. _, err := (&BatchImageProviderProcessor{Repo: repo, ProviderRegistry: NewBatchImageProviderRegistry(&fakeProcessorProvider{}), AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}}}).Process(ctx, "imgbatch_missing_name")
  206. require.ErrorIs(t, err, ErrBatchImageMissingProviderJobName)
  207. })
  208. }
  209. func TestBatchImageProviderProcessor_StatusFlow(t *testing.T) {
  210. ctx := context.Background()
  211. accountID := int64(10)
  212. providerJob := "providers/job"
  213. newJob := func(status string) *BatchImageJob {
  214. return &BatchImageJob{BatchID: "imgbatch_flow", Status: status, Provider: "fake", AccountID: &accountID, ProviderJobName: &providerJob}
  215. }
  216. t.Run("running status updates and requeues", func(t *testing.T) {
  217. repo := newFakeBatchImageRepository()
  218. repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusSubmitted)
  219. provider := &fakeProcessorProvider{status: &BatchProviderStatus{InternalState: BatchProviderStateRunning, RawState: "RUNNING", SuggestedRequeueAfter: 12 * time.Second}}
  220. got, err := newTestBatchImageProcessor(repo, provider).Process(ctx, "imgbatch_flow")
  221. require.NoError(t, err)
  222. require.False(t, got.Terminal)
  223. require.Equal(t, 12*time.Second, got.RequeueAfter)
  224. require.Equal(t, BatchImageJobStatusRunning, repo.jobs["imgbatch_flow"].Status)
  225. })
  226. t.Run("queued status requeues", func(t *testing.T) {
  227. repo := newFakeBatchImageRepository()
  228. repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusSubmitted)
  229. provider := &fakeProcessorProvider{status: &BatchProviderStatus{InternalState: BatchProviderStateQueued}}
  230. got, err := newTestBatchImageProcessor(repo, provider).Process(ctx, "imgbatch_flow")
  231. require.NoError(t, err)
  232. require.False(t, got.Terminal)
  233. require.Equal(t, defaultBatchImageProcessorRequeue, got.RequeueAfter)
  234. require.Equal(t, BatchImageJobStatusSubmitted, repo.jobs["imgbatch_flow"].Status)
  235. })
  236. t.Run("transient provider get error requeues", func(t *testing.T) {
  237. repo := newFakeBatchImageRepository()
  238. repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusSubmitted)
  239. provider := &fakeProcessorProvider{getErr: errors.New("temporary upstream failure")}
  240. got, err := newTestBatchImageProcessor(repo, provider).Process(ctx, "imgbatch_flow")
  241. require.NoError(t, err)
  242. require.False(t, got.Terminal)
  243. require.Equal(t, time.Minute, got.RequeueAfter)
  244. })
  245. t.Run("succeeded indexes and settles from submitted", func(t *testing.T) {
  246. repo := newFakeBatchImageRepository()
  247. repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusSubmitted)
  248. provider := &fakeProcessorProvider{
  249. status: &BatchProviderStatus{InternalState: BatchProviderStateSucceeded, RawState: "SUCCEEDED", ProviderOutputRef: "files/output"},
  250. result: `{"key":"ok","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"` + batchImageTestData + `"}}]}}]}}` + "\n",
  251. }
  252. got, err := newTestBatchImageProcessor(repo, provider).Process(ctx, "imgbatch_flow")
  253. require.NoError(t, err)
  254. require.False(t, got.Terminal)
  255. require.Equal(t, time.Millisecond, got.RequeueAfter)
  256. require.Equal(t, BatchImageJobStatusSettling, repo.jobs["imgbatch_flow"].Status)
  257. require.Equal(t, "files/output", batchImageDerefString(repo.jobs["imgbatch_flow"].ProviderOutputRef))
  258. require.Equal(t, []string{BatchImageJobStatusIndexing, BatchImageJobStatusSettling}, repo.transitions["imgbatch_flow"])
  259. require.Equal(t, BatchImageCounts{SuccessCount: 1}, repo.counts["imgbatch_flow"])
  260. })
  261. t.Run("failed provider marks job failed", func(t *testing.T) {
  262. repo := newFakeBatchImageRepository()
  263. repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusRunning)
  264. provider := &fakeProcessorProvider{status: &BatchProviderStatus{InternalState: BatchProviderStateFailed, RawState: "FAILED", ErrorCode: "BAD_PROMPT", ErrorMessage: "bad prompt"}}
  265. got, err := newTestBatchImageProcessor(repo, provider).Process(ctx, "imgbatch_flow")
  266. require.NoError(t, err)
  267. require.True(t, got.Terminal)
  268. require.Equal(t, BatchImageJobStatusFailed, repo.jobs["imgbatch_flow"].Status)
  269. require.Equal(t, "BAD_PROMPT", batchImageDerefString(repo.jobs["imgbatch_flow"].LastErrorCode))
  270. })
  271. t.Run("cancelled provider marks job cancelled", func(t *testing.T) {
  272. repo := newFakeBatchImageRepository()
  273. repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusRunning)
  274. apiKeyID := int64(22)
  275. holdAmount := 0.5
  276. repo.jobs["imgbatch_flow"].UserID = 11
  277. repo.jobs["imgbatch_flow"].APIKeyID = &apiKeyID
  278. repo.jobs["imgbatch_flow"].EstimatedCost = holdAmount
  279. repo.jobs["imgbatch_flow"].HoldAmount = &holdAmount
  280. provider := &fakeProcessorProvider{status: &BatchProviderStatus{InternalState: BatchProviderStateCancelled, RawState: "CANCELLED"}}
  281. processor := newTestBatchImageProcessor(repo, provider)
  282. billing := &fakeBatchImageBillingRepo{}
  283. processor.BillingRepo = billing
  284. got, err := processor.Process(ctx, "imgbatch_flow")
  285. require.NoError(t, err)
  286. require.True(t, got.Terminal)
  287. require.Equal(t, BatchImageJobStatusCancelled, repo.jobs["imgbatch_flow"].Status)
  288. require.Len(t, billing.releases, 1)
  289. require.Equal(t, BatchImageReleaseRequestID("imgbatch_flow"), billing.releases[0].RequestID)
  290. })
  291. }
  292. func TestCanTransitionBatchImageJob_PR5DirectIndexing(t *testing.T) {
  293. require.True(t, CanTransitionBatchImageJob(BatchImageJobStatusSubmitted, BatchImageJobStatusIndexing))
  294. require.True(t, CanTransitionBatchImageJob(BatchImageJobStatusSubmitted, BatchImageJobStatusFailed))
  295. require.True(t, CanTransitionBatchImageJob(BatchImageJobStatusIndexing, BatchImageJobStatusFailed))
  296. }
  297. func newTestBatchImageProcessor(repo *fakeBatchImageRepository, provider *fakeProcessorProvider) *BatchImageProviderProcessor {
  298. return &BatchImageProviderProcessor{
  299. Repo: repo,
  300. ProviderRegistry: NewBatchImageProviderRegistry(provider),
  301. AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}},
  302. Indexer: &BatchImageResultIndexer{Repo: repo},
  303. }
  304. }
  305. type fakeBatchImageAccountResolver struct {
  306. account *Account
  307. err error
  308. }
  309. func (r *fakeBatchImageAccountResolver) ResolveBatchImageAccount(context.Context, int64) (*Account, error) {
  310. if r.err != nil {
  311. return nil, r.err
  312. }
  313. return r.account, nil
  314. }
  315. type fakeProcessorProvider struct {
  316. status *BatchProviderStatus
  317. getErr error
  318. result string
  319. getCalled bool
  320. openResultCalled bool
  321. }
  322. func (p *fakeProcessorProvider) Name() string { return "fake" }
  323. func (p *fakeProcessorProvider) SupportsAccount(*Account) bool {
  324. return true
  325. }
  326. func (p *fakeProcessorProvider) Submit(context.Context, *BatchImageJob, *Account, BatchImageInput) (*BatchProviderJob, error) {
  327. panic("Submit must not be called by PR5 processor")
  328. }
  329. func (p *fakeProcessorProvider) Get(context.Context, *BatchImageJob, *Account) (*BatchProviderStatus, error) {
  330. p.getCalled = true
  331. if p.getErr != nil {
  332. return nil, p.getErr
  333. }
  334. if p.status == nil {
  335. return &BatchProviderStatus{InternalState: BatchProviderStateQueued}, nil
  336. }
  337. return p.status, nil
  338. }
  339. func (p *fakeProcessorProvider) Cancel(context.Context, *BatchImageJob, *Account) error { return nil }
  340. func (p *fakeProcessorProvider) OpenResult(context.Context, *BatchImageJob, *Account) (io.ReadCloser, string, error) {
  341. p.openResultCalled = true
  342. return io.NopCloser(strings.NewReader(p.result)), "application/jsonl", nil
  343. }
  344. func (p *fakeProcessorProvider) Cleanup(context.Context, *BatchImageJob, *Account, CleanupTarget) error {
  345. return nil
  346. }
  347. type fakeBatchImageRepository struct {
  348. jobs map[string]*BatchImageJob
  349. items map[string][]CreateBatchImageItemParams
  350. counts map[string]BatchImageCounts
  351. transitions map[string][]string
  352. events map[string][]string
  353. transitionErr error
  354. replaceCalls int
  355. }
  356. func newFakeBatchImageRepository() *fakeBatchImageRepository {
  357. return &fakeBatchImageRepository{
  358. jobs: make(map[string]*BatchImageJob),
  359. items: make(map[string][]CreateBatchImageItemParams),
  360. counts: make(map[string]BatchImageCounts),
  361. transitions: make(map[string][]string),
  362. events: make(map[string][]string),
  363. }
  364. }
  365. func (r *fakeBatchImageRepository) CreateBatchImageJob(_ context.Context, params CreateBatchImageJobParams) (*BatchImageJob, error) {
  366. job := &BatchImageJob{
  367. BatchID: params.BatchID,
  368. UserID: params.UserID,
  369. APIKeyID: params.APIKeyID,
  370. AccountID: params.AccountID,
  371. Status: params.Status,
  372. Provider: params.Provider,
  373. Model: params.Model,
  374. TaskName: params.TaskName,
  375. ProviderJobName: params.ProviderJobName,
  376. ItemCount: params.ItemCount,
  377. EstimatedCost: params.EstimatedCost,
  378. HoldAmount: params.HoldAmount,
  379. HoldID: params.HoldID,
  380. BaseUnitPrice: params.BaseUnitPrice,
  381. GroupRateMultiplier: params.GroupRateMultiplier,
  382. AccountRateMultiplier: params.AccountRateMultiplier,
  383. BatchDiscountMultiplier: params.BatchDiscountMultiplier,
  384. HoldMultiplier: params.HoldMultiplier,
  385. BillableUnitPrice: params.BillableUnitPrice,
  386. HoldUnitPrice: params.HoldUnitPrice,
  387. PricingSnapshotVersion: params.PricingSnapshotVersion,
  388. Currency: params.Currency,
  389. IdempotencyKey: params.IdempotencyKey,
  390. RequestHash: params.RequestHash,
  391. SessionID: params.SessionID,
  392. CreatedAt: time.Now(),
  393. }
  394. r.jobs[job.BatchID] = job
  395. return job, nil
  396. }
  397. func (r *fakeBatchImageRepository) GetBatchImageJobByBatchID(_ context.Context, batchID string) (*BatchImageJob, error) {
  398. job, ok := r.jobs[batchID]
  399. if !ok {
  400. return nil, ErrBatchImageJobNotFound
  401. }
  402. return job, nil
  403. }
  404. func (r *fakeBatchImageRepository) GetBatchImageJobByIdempotencyKey(_ context.Context, userID, apiKeyID int64, key string) (*BatchImageJob, error) {
  405. for _, job := range r.jobs {
  406. if job.UserID == userID && job.APIKeyID != nil && *job.APIKeyID == apiKeyID && batchImageDerefString(job.IdempotencyKey) == key {
  407. return job, nil
  408. }
  409. }
  410. return nil, ErrBatchImageJobNotFound
  411. }
  412. func (r *fakeBatchImageRepository) GetBatchImageJobByBatchIDForOwner(_ context.Context, userID, apiKeyID int64, batchID string) (*BatchImageJob, error) {
  413. job, ok := r.jobs[batchID]
  414. if !ok || job.UserID != userID || job.APIKeyID == nil || *job.APIKeyID != apiKeyID {
  415. return nil, ErrBatchImageJobNotFound
  416. }
  417. return job, nil
  418. }
  419. func (r *fakeBatchImageRepository) ListBatchImageJobsForOwner(_ context.Context, userID, apiKeyID int64, filter BatchImageJobFilter) ([]*BatchImageJob, error) {
  420. limit := filter.Limit
  421. if limit <= 0 || limit > 100 {
  422. limit = 20
  423. }
  424. offset := filter.Offset
  425. if offset < 0 {
  426. offset = 0
  427. }
  428. var jobs []*BatchImageJob
  429. for _, job := range r.jobs {
  430. if job.UserID != userID || job.APIKeyID == nil || *job.APIKeyID != apiKeyID {
  431. continue
  432. }
  433. if filter.Status != "" && job.Status != filter.Status {
  434. continue
  435. }
  436. if filter.TaskNameLike != "" && !strings.Contains(strings.ToLower(job.TaskName), strings.ToLower(filter.TaskNameLike)) {
  437. continue
  438. }
  439. if filter.ExcludeDeleted && job.UserDeletedAt != nil {
  440. continue
  441. }
  442. if filter.Downloaded != nil {
  443. downloaded := job.DownloadedAt != nil
  444. if downloaded != *filter.Downloaded {
  445. continue
  446. }
  447. }
  448. if filter.CreatedAfter != nil && job.CreatedAt.Before(*filter.CreatedAfter) {
  449. continue
  450. }
  451. if filter.CreatedBefore != nil && !job.CreatedAt.Before(*filter.CreatedBefore) {
  452. continue
  453. }
  454. if offset > 0 {
  455. offset--
  456. continue
  457. }
  458. jobs = append(jobs, job)
  459. if len(jobs) >= limit {
  460. break
  461. }
  462. }
  463. return jobs, nil
  464. }
  465. func (r *fakeBatchImageRepository) GetBatchImageJobByID(_ context.Context, id int64) (*BatchImageJob, error) {
  466. for _, job := range r.jobs {
  467. if job.ID == id {
  468. return job, nil
  469. }
  470. }
  471. return nil, ErrBatchImageJobNotFound
  472. }
  473. func (r *fakeBatchImageRepository) TransitionBatchImageJobStatus(_ context.Context, batchID, toStatus string, opts BatchImageTransitionOptions) error {
  474. job, ok := r.jobs[batchID]
  475. if !ok {
  476. return ErrBatchImageJobNotFound
  477. }
  478. if !CanTransitionBatchImageJob(job.Status, toStatus) {
  479. return ErrBatchImageInvalidTransition
  480. }
  481. if r.transitionErr != nil {
  482. return r.transitionErr
  483. }
  484. job.Status = toStatus
  485. job.LastErrorCode = opts.ErrorCode
  486. job.LastErrorMessage = opts.ErrorMessage
  487. r.transitions[batchID] = append(r.transitions[batchID], toStatus)
  488. if opts.EventType != "" {
  489. r.events[batchID] = append(r.events[batchID], opts.EventType)
  490. }
  491. return nil
  492. }
  493. func (r *fakeBatchImageRepository) TouchBatchImageJobSubmitting(_ context.Context, batchID string) error {
  494. job, ok := r.jobs[batchID]
  495. if !ok {
  496. return ErrBatchImageJobNotFound
  497. }
  498. if job.Status == BatchImageJobStatusCreated || job.Status == BatchImageJobStatusUploading {
  499. job.UpdatedAt = time.Now()
  500. }
  501. return nil
  502. }
  503. func (r *fakeBatchImageRepository) FailStaleUnsubmittedBatchImageJob(_ context.Context, batchID string, cutoff time.Time, code, message string) (bool, error) {
  504. job, ok := r.jobs[batchID]
  505. if !ok {
  506. return false, ErrBatchImageJobNotFound
  507. }
  508. if job.Status != BatchImageJobStatusCreated && job.Status != BatchImageJobStatusUploading {
  509. return false, nil
  510. }
  511. if batchImageDerefString(job.ProviderJobName) != "" || job.UpdatedAt.After(cutoff) {
  512. return false, nil
  513. }
  514. job.Status = BatchImageJobStatusFailed
  515. job.LastErrorCode = batchImageStringPtr(code)
  516. job.LastErrorMessage = batchImageStringPtr(message)
  517. job.UpdatedAt = time.Now()
  518. r.transitions[batchID] = append(r.transitions[batchID], BatchImageJobStatusFailed)
  519. r.events[batchID] = append(r.events[batchID], "billing_hold_recovery_failed_unsubmitted")
  520. return true, nil
  521. }
  522. func (r *fakeBatchImageRepository) UpdateBatchImageJobProviderOutputRef(_ context.Context, batchID, providerOutputRef string) error {
  523. job, ok := r.jobs[batchID]
  524. if !ok {
  525. return ErrBatchImageJobNotFound
  526. }
  527. job.ProviderOutputRef = &providerOutputRef
  528. return nil
  529. }
  530. func (r *fakeBatchImageRepository) UpdateBatchImageJobProviderSubmit(_ context.Context, params UpdateBatchImageJobProviderSubmitParams) error {
  531. job, ok := r.jobs[params.BatchID]
  532. if !ok {
  533. return ErrBatchImageJobNotFound
  534. }
  535. if !CanTransitionBatchImageJob(job.Status, BatchImageJobStatusSubmitted) {
  536. return ErrBatchImageInvalidTransition
  537. }
  538. job.Status = BatchImageJobStatusSubmitted
  539. job.ProviderJobName = batchImageOptionalStringPtr(params.ProviderJobName)
  540. job.ProviderInputRef = batchImageOptionalStringPtr(params.ProviderInputRef)
  541. job.ProviderOutputRef = batchImageOptionalStringPtr(params.ProviderOutputRef)
  542. job.GCSInputURI = batchImageOptionalStringPtr(params.GCSInputURI)
  543. job.GCSOutputURI = batchImageOptionalStringPtr(params.GCSOutputURI)
  544. now := time.Now()
  545. job.SubmittedAt = &now
  546. r.transitions[params.BatchID] = append(r.transitions[params.BatchID], BatchImageJobStatusSubmitted)
  547. r.events[params.BatchID] = append(r.events[params.BatchID], "provider_submitted")
  548. return nil
  549. }
  550. func (r *fakeBatchImageRepository) RecordBatchImageJobSubmitFailure(_ context.Context, batchID, code, message string, markFailed bool) error {
  551. job, ok := r.jobs[batchID]
  552. if !ok {
  553. return ErrBatchImageJobNotFound
  554. }
  555. if markFailed {
  556. job.Status = BatchImageJobStatusFailed
  557. }
  558. job.LastErrorCode = batchImageOptionalStringPtr(code)
  559. job.LastErrorMessage = batchImageOptionalStringPtr(message)
  560. eventType := "submit_failed"
  561. if !markFailed {
  562. eventType = "queue_failed"
  563. }
  564. r.events[batchID] = append(r.events[batchID], eventType)
  565. return nil
  566. }
  567. func (r *fakeBatchImageRepository) MarkBatchImageJobSettled(_ context.Context, params MarkBatchImageJobSettledParams) error {
  568. job, ok := r.jobs[params.BatchID]
  569. if !ok {
  570. return ErrBatchImageJobNotFound
  571. }
  572. if job.Status != BatchImageJobStatusSettling {
  573. if job.Status == BatchImageJobStatusCompleted {
  574. return ErrBatchImageAlreadySettled
  575. }
  576. return ErrBatchImageSettlementInvalidStatus
  577. }
  578. if batchImageDerefString(job.ManifestHash) != "" && batchImageDerefString(job.ManifestHash) != params.ManifestHash {
  579. return ErrBatchImageSettlementManifestConflict
  580. }
  581. now := time.Now()
  582. job.Status = BatchImageJobStatusCompleted
  583. job.ActualCost = &params.ActualCost
  584. job.ManifestHash = &params.ManifestHash
  585. job.SettledAt = &now
  586. if job.OutputExpiresAt == nil && params.OutputExpiresAt != nil {
  587. job.OutputExpiresAt = params.OutputExpiresAt
  588. }
  589. r.transitions[params.BatchID] = append(r.transitions[params.BatchID], BatchImageJobStatusCompleted)
  590. r.events[params.BatchID] = append(r.events[params.BatchID], "settlement_completed")
  591. return nil
  592. }
  593. func (r *fakeBatchImageRepository) SetBatchImageJobSettlementFailed(_ context.Context, batchID, code, message string) (int, error) {
  594. job, ok := r.jobs[batchID]
  595. if !ok {
  596. return 0, ErrBatchImageJobNotFound
  597. }
  598. job.LastErrorCode = batchImageStringPtr(code)
  599. job.LastErrorMessage = batchImageOptionalStringPtr(message)
  600. job.RetryCount++
  601. r.events[batchID] = append(r.events[batchID], "settlement_failed")
  602. return job.RetryCount, nil
  603. }
  604. func (r *fakeBatchImageRepository) CreateBatchImageItem(_ context.Context, params CreateBatchImageItemParams) (*BatchImageItem, error) {
  605. r.items[params.JobID] = append(r.items[params.JobID], params)
  606. return &BatchImageItem{JobID: params.JobID, CustomID: params.CustomID, Status: params.Status}, nil
  607. }
  608. func (r *fakeBatchImageRepository) BulkCreateBatchImageItems(ctx context.Context, params []CreateBatchImageItemParams) error {
  609. for _, param := range params {
  610. if _, err := r.CreateBatchImageItem(ctx, param); err != nil {
  611. return err
  612. }
  613. }
  614. return nil
  615. }
  616. func (r *fakeBatchImageRepository) ReplaceBatchImageItemsForJob(_ context.Context, batchID string, items []CreateBatchImageItemParams, counts BatchImageCounts) error {
  617. // 与真实实现一致:仅 indexing 状态允许重建 item 表(未注册的 job 保持宽松,
  618. // 供直接构造 job 的单测使用)。
  619. if job, ok := r.jobs[batchID]; ok && job.Status != BatchImageJobStatusIndexing {
  620. return ErrBatchImageIndexStateConflict
  621. }
  622. r.replaceCalls++
  623. copied := append([]CreateBatchImageItemParams(nil), items...)
  624. for idx := range copied {
  625. copied[idx].JobID = batchID
  626. }
  627. r.items[batchID] = copied
  628. r.counts[batchID] = counts
  629. if job, ok := r.jobs[batchID]; ok {
  630. job.SuccessCount = counts.SuccessCount
  631. job.FailCount = counts.FailCount
  632. job.ItemCount = len(copied)
  633. }
  634. return nil
  635. }
  636. func (r *fakeBatchImageRepository) ListBatchImageItems(_ context.Context, batchID string, filter BatchImageItemFilter) ([]*BatchImageItem, error) {
  637. limit := filter.Limit
  638. if limit <= 0 || limit > 500 {
  639. limit = 100
  640. }
  641. offset := filter.Offset
  642. if offset < 0 {
  643. offset = 0
  644. }
  645. var result []*BatchImageItem
  646. for _, item := range r.items[batchID] {
  647. if filter.Status != "" && item.Status != filter.Status {
  648. continue
  649. }
  650. if offset > 0 {
  651. offset--
  652. continue
  653. }
  654. result = append(result, &BatchImageItem{
  655. JobID: item.JobID,
  656. CustomID: item.CustomID,
  657. Status: item.Status,
  658. RequestHash: item.RequestHash,
  659. PromptPreview: item.PromptPreview,
  660. ProviderSourceObject: item.ProviderSourceObject,
  661. SourceLineNumber: item.SourceLineNumber,
  662. SourceByteOffset: item.SourceByteOffset,
  663. SourceByteLength: item.SourceByteLength,
  664. MimeType: item.MimeType,
  665. FileExtension: item.FileExtension,
  666. ImageCount: item.ImageCount,
  667. ErrorCode: item.ErrorCode,
  668. ErrorMessage: item.ErrorMessage,
  669. BilledAmount: item.BilledAmount,
  670. IndexedAt: item.IndexedAt,
  671. })
  672. if len(result) >= limit {
  673. break
  674. }
  675. }
  676. return result, nil
  677. }
  678. func (r *fakeBatchImageRepository) ListBatchImageItemsForOwner(ctx context.Context, userID, apiKeyID int64, batchID string, filter BatchImageItemFilter) ([]*BatchImageItem, error) {
  679. if _, err := r.GetBatchImageJobByBatchIDForOwner(ctx, userID, apiKeyID, batchID); err != nil {
  680. return nil, err
  681. }
  682. return r.ListBatchImageItems(ctx, batchID, filter)
  683. }
  684. func (r *fakeBatchImageRepository) GetBatchImageJobForDownload(ctx context.Context, userID, apiKeyID int64, batchID string) (*BatchImageJob, error) {
  685. return r.GetBatchImageJobByBatchIDForOwner(ctx, userID, apiKeyID, batchID)
  686. }
  687. func (r *fakeBatchImageRepository) GetBatchImageItemForDownload(_ context.Context, batchID, customID string) (*BatchImageItem, error) {
  688. for _, item := range r.items[batchID] {
  689. if item.CustomID != customID {
  690. continue
  691. }
  692. return &BatchImageItem{
  693. JobID: item.JobID,
  694. CustomID: item.CustomID,
  695. Status: item.Status,
  696. RequestHash: item.RequestHash,
  697. PromptPreview: item.PromptPreview,
  698. ProviderSourceObject: item.ProviderSourceObject,
  699. SourceLineNumber: item.SourceLineNumber,
  700. SourceByteOffset: item.SourceByteOffset,
  701. SourceByteLength: item.SourceByteLength,
  702. MimeType: item.MimeType,
  703. FileExtension: item.FileExtension,
  704. ImageCount: item.ImageCount,
  705. ErrorCode: item.ErrorCode,
  706. ErrorMessage: item.ErrorMessage,
  707. BilledAmount: item.BilledAmount,
  708. IndexedAt: item.IndexedAt,
  709. }, nil
  710. }
  711. return nil, ErrBatchImageItemNotFound
  712. }
  713. func (r *fakeBatchImageRepository) ListBatchImageItemsForDownload(ctx context.Context, batchID string, status string, limit int) ([]*BatchImageItem, error) {
  714. return r.ListBatchImageItems(ctx, batchID, BatchImageItemFilter{Status: status, Limit: limit})
  715. }
  716. func (r *fakeBatchImageRepository) ListBatchImageJobsDueForInputCleanup(_ context.Context, cutoff time.Time, limit int) ([]*BatchImageJob, error) {
  717. if limit <= 0 {
  718. limit = 100
  719. }
  720. var jobs []*BatchImageJob
  721. for _, job := range r.jobs {
  722. if job.InputDeletedAt != nil || batchImageDerefString(job.ProviderInputRef) == "" || !IsTerminalBatchImageJobStatus(job.Status) {
  723. continue
  724. }
  725. at := job.FinishedAt
  726. if at == nil {
  727. at = job.SettledAt
  728. }
  729. if at == nil {
  730. at = &job.UpdatedAt
  731. }
  732. if at != nil && at.After(cutoff) {
  733. continue
  734. }
  735. jobs = append(jobs, job)
  736. if len(jobs) >= limit {
  737. break
  738. }
  739. }
  740. return jobs, nil
  741. }
  742. func (r *fakeBatchImageRepository) ListBatchImageJobsDueForOutputCleanup(_ context.Context, now time.Time, limit int) ([]*BatchImageJob, error) {
  743. if limit <= 0 {
  744. limit = 100
  745. }
  746. var jobs []*BatchImageJob
  747. for _, job := range r.jobs {
  748. if job.OutputDeletedAt != nil || batchImageDerefString(job.ProviderOutputRef) == "" || job.Status != BatchImageJobStatusCompleted || job.OutputExpiresAt == nil || job.OutputExpiresAt.After(now) {
  749. continue
  750. }
  751. jobs = append(jobs, job)
  752. if len(jobs) >= limit {
  753. break
  754. }
  755. }
  756. return jobs, nil
  757. }
  758. func (r *fakeBatchImageRepository) ListStaleUnsubmittedBatchImageJobs(_ context.Context, cutoff time.Time, limit int) ([]*BatchImageJob, error) {
  759. if limit <= 0 {
  760. limit = 100
  761. }
  762. jobs := make([]*BatchImageJob, 0, limit)
  763. for _, job := range r.jobs {
  764. if len(jobs) >= limit {
  765. break
  766. }
  767. if job.Status != BatchImageJobStatusCreated && job.Status != BatchImageJobStatusUploading {
  768. continue
  769. }
  770. if batchImageDerefString(job.ProviderJobName) != "" {
  771. continue
  772. }
  773. holdAmount := job.EstimatedCost
  774. if job.HoldAmount != nil {
  775. holdAmount = *job.HoldAmount
  776. }
  777. if holdAmount <= 0 || job.UpdatedAt.After(cutoff) {
  778. continue
  779. }
  780. jobs = append(jobs, job)
  781. }
  782. return jobs, nil
  783. }
  784. func (r *fakeBatchImageRepository) MarkBatchImageInputDeleted(_ context.Context, batchID string, deletedAt time.Time) error {
  785. job, ok := r.jobs[batchID]
  786. if !ok {
  787. return ErrBatchImageJobNotFound
  788. }
  789. if job.InputDeletedAt == nil {
  790. job.InputDeletedAt = &deletedAt
  791. }
  792. r.events[batchID] = append(r.events[batchID], "input_cleanup_completed")
  793. return nil
  794. }
  795. func (r *fakeBatchImageRepository) MarkBatchImageOutputDeleted(_ context.Context, batchID string, deletedAt time.Time) error {
  796. job, ok := r.jobs[batchID]
  797. if !ok {
  798. return ErrBatchImageJobNotFound
  799. }
  800. if job.OutputDeletedAt == nil {
  801. job.OutputDeletedAt = &deletedAt
  802. }
  803. if job.Status == BatchImageJobStatusCompleted {
  804. job.Status = BatchImageJobStatusOutputDeleted
  805. }
  806. r.events[batchID] = append(r.events[batchID], "output_cleanup_completed")
  807. return nil
  808. }
  809. func (r *fakeBatchImageRepository) MarkBatchImageDownloaded(_ context.Context, batchID string, downloadedAt time.Time) error {
  810. job, ok := r.jobs[batchID]
  811. if !ok {
  812. return ErrBatchImageJobNotFound
  813. }
  814. if job.DownloadedAt == nil {
  815. job.DownloadedAt = &downloadedAt
  816. }
  817. r.events[batchID] = append(r.events[batchID], "download_completed")
  818. return nil
  819. }
  820. func (r *fakeBatchImageRepository) MarkBatchImageJobUserDeleted(_ context.Context, userID, apiKeyID int64, batchID string, deletedAt time.Time) error {
  821. job, ok := r.jobs[batchID]
  822. if !ok || job.UserID != userID || job.APIKeyID == nil || *job.APIKeyID != apiKeyID {
  823. return ErrBatchImageJobNotFound
  824. }
  825. if !isBatchImageProcessorDoneStatus(job.Status) {
  826. return ErrBatchImageRecordDeleteNotReady
  827. }
  828. if job.UserDeletedAt == nil {
  829. job.UserDeletedAt = &deletedAt
  830. }
  831. r.events[batchID] = append(r.events[batchID], "user_record_deleted")
  832. return nil
  833. }
  834. func (r *fakeBatchImageRepository) SetBatchImageOutputExpiresAt(_ context.Context, batchID string, expiresAt time.Time) error {
  835. job, ok := r.jobs[batchID]
  836. if !ok {
  837. return ErrBatchImageJobNotFound
  838. }
  839. if job.OutputExpiresAt == nil {
  840. job.OutputExpiresAt = &expiresAt
  841. }
  842. return nil
  843. }
  844. func (r *fakeBatchImageRepository) RecordBatchImageCleanupFailure(_ context.Context, batchID, code, message string) error {
  845. job, ok := r.jobs[batchID]
  846. if !ok {
  847. return ErrBatchImageJobNotFound
  848. }
  849. job.LastErrorCode = batchImageStringPtr(code)
  850. job.LastErrorMessage = batchImageOptionalStringPtr(message)
  851. r.events[batchID] = append(r.events[batchID], "output_cleanup_failed")
  852. return nil
  853. }
  854. func (r *fakeBatchImageRepository) AppendBatchImageEvent(_ context.Context, batchID, eventType string, _ any) error {
  855. r.events[batchID] = append(r.events[batchID], eventType)
  856. return nil
  857. }
  858. var _ BatchImageRepository = (*fakeBatchImageRepository)(nil)
  859. var _ BatchImageProvider = (*fakeProcessorProvider)(nil)
  860. var _ BatchImageAccountResolver = (*fakeBatchImageAccountResolver)(nil)
  861. var _ = infraerrors.Reason