batch_image_public_test.go 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "io"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  13. "github.com/stretchr/testify/require"
  14. )
  15. func TestBatchImagePublicService_Submit(t *testing.T) {
  16. ctx := context.Background()
  17. t.Run("rejects when disabled", func(t *testing.T) {
  18. svc, _, _, _, _ := newTestBatchImagePublicService(false)
  19. _, err := svc.Submit(ctx, testBatchImageOwner(), validBatchImageSubmitRequest(), "")
  20. require.ErrorIs(t, err, ErrBatchImageDisabled)
  21. })
  22. t.Run("accepts valid request stores refs and enqueues once", func(t *testing.T) {
  23. svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true)
  24. req := validBatchImageSubmitRequest()
  25. req.SessionID = batchImageStringPtr("batch-session-123")
  26. got, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
  27. require.NoError(t, err)
  28. require.Equal(t, "image.batch", got.Object)
  29. require.Equal(t, "queued", got.Status)
  30. require.Equal(t, BatchImageProviderGeminiAPI, got.Provider)
  31. require.Equal(t, 2, got.ItemCount)
  32. require.Equal(t, 0.25, got.EstimatedCost)
  33. require.Len(t, repo.jobs, 1)
  34. require.Len(t, gemini.submits, 1)
  35. require.Equal(t, []string{got.ID}, queue.enqueued)
  36. billing := svc.BillingRepo.(*fakeBatchImageBillingRepo)
  37. require.Len(t, billing.reserves, 1)
  38. require.Equal(t, BatchImageHoldRequestID(got.ID), billing.reserves[0].RequestID)
  39. require.InDelta(t, 0.3, billing.reserves[0].HoldAmount, 1e-12)
  40. require.Empty(t, billing.releases)
  41. authCache := svc.AuthCache.(*fakeBatchImageAuthCacheInvalidator)
  42. require.Equal(t, []int64{11}, authCache.userIDs)
  43. job := repo.jobs[got.ID]
  44. require.Equal(t, BatchImageJobStatusSubmitted, job.Status)
  45. require.Equal(t, "providers/gemini_api/job", batchImageDerefString(job.ProviderJobName))
  46. require.Equal(t, "files/gemini_api/input", batchImageDerefString(job.ProviderInputRef))
  47. require.Equal(t, "files/gemini_api/output", batchImageDerefString(job.ProviderOutputRef))
  48. require.NotNil(t, job.AccountID)
  49. require.Equal(t, int64(202), *job.AccountID)
  50. require.Equal(t, 1, job.PricingSnapshotVersion)
  51. require.InDelta(t, 0.25, job.BaseUnitPrice, 1e-12)
  52. require.InDelta(t, 1.0, job.GroupRateMultiplier, 1e-12)
  53. require.InDelta(t, 1.0, job.AccountRateMultiplier, 1e-12)
  54. require.InDelta(t, 0.5, job.BatchDiscountMultiplier, 1e-12)
  55. require.InDelta(t, 0.6, job.HoldMultiplier, 1e-12)
  56. require.InDelta(t, 0.125, job.BillableUnitPrice, 1e-12)
  57. require.InDelta(t, 0.15, job.HoldUnitPrice, 1e-12)
  58. require.Equal(t, "batch-session-123", batchImageDerefString(job.SessionID))
  59. })
  60. t.Run("combines user group image rate account rate discount and hold margin", func(t *testing.T) {
  61. svc, repo, _, _, _ := newTestBatchImagePublicService(true)
  62. groupID := int64(7)
  63. accountMultiplier := 1.25
  64. accountRepo := svc.AccountRepo.(*publicBatchImageAccountRepo)
  65. accountRepo.accounts[1].RateMultiplier = &accountMultiplier
  66. svc.GroupRepo = &publicBatchImageGroupRepo{groups: map[int64]*Group{
  67. groupID: {
  68. ID: groupID,
  69. Platform: PlatformGemini,
  70. RateMultiplier: 2.0,
  71. AllowImageGeneration: true,
  72. AllowBatchImageGeneration: true,
  73. ImageRateIndependent: false,
  74. BatchImageDiscountMultiplier: 0.8,
  75. BatchImageHoldMultiplier: 0.6,
  76. },
  77. }}
  78. userRate := 0.5
  79. svc.UserGroupRateRepo = &publicBatchImageUserGroupRateRepo{rates: map[int64]*float64{groupID: &userRate}}
  80. got, err := svc.Submit(ctx, BatchImageOwner{UserID: 11, APIKeyID: 22, GroupID: &groupID}, validBatchImageSubmitRequest(), "")
  81. require.NoError(t, err)
  82. require.InDelta(t, 0.25, got.EstimatedCost, 1e-12)
  83. job := repo.jobs[got.ID]
  84. require.InDelta(t, 0.25, job.BaseUnitPrice, 1e-12)
  85. require.InDelta(t, 0.5, job.GroupRateMultiplier, 1e-12)
  86. require.InDelta(t, 1.25, job.AccountRateMultiplier, 1e-12)
  87. require.InDelta(t, 0.8, job.BatchDiscountMultiplier, 1e-12)
  88. // 配置的 hold(0.6) < discount(0.8) 属于会导致结算死锁的脏数据,
  89. // 快照时被钳制为 discount,保证 holdAmount >= 实际成本上限。
  90. require.InDelta(t, 0.8, job.HoldMultiplier, 1e-12)
  91. require.InDelta(t, 0.125, job.BillableUnitPrice, 1e-12)
  92. require.InDelta(t, 0.125, job.HoldUnitPrice, 1e-12)
  93. require.InDelta(t, 0.25, *job.HoldAmount, 1e-12)
  94. })
  95. t.Run("uses configured group 1k image price for batch image base price", func(t *testing.T) {
  96. svc, repo, _, _, _ := newTestBatchImagePublicService(true)
  97. groupID := int64(7)
  98. imagePrice := 0.134
  99. svc.GroupRepo = &publicBatchImageGroupRepo{groups: map[int64]*Group{
  100. groupID: {
  101. ID: groupID,
  102. Platform: PlatformGemini,
  103. RateMultiplier: 1.0,
  104. AllowImageGeneration: true,
  105. AllowBatchImageGeneration: true,
  106. ImagePrice1K: &imagePrice,
  107. BatchImageDiscountMultiplier: 0.5,
  108. BatchImageHoldMultiplier: 0.6,
  109. },
  110. }}
  111. got, err := svc.Submit(ctx, BatchImageOwner{UserID: 11, APIKeyID: 22, GroupID: &groupID}, validBatchImageSubmitRequest(), "")
  112. require.NoError(t, err)
  113. require.InDelta(t, 0.134, got.EstimatedCost, 1e-12)
  114. job := repo.jobs[got.ID]
  115. require.InDelta(t, 0.134, job.BaseUnitPrice, 1e-12)
  116. require.InDelta(t, 0.067, job.BillableUnitPrice, 1e-12)
  117. require.InDelta(t, 0.0804, job.HoldUnitPrice, 1e-12)
  118. require.InDelta(t, 0.1608, *job.HoldAmount, 1e-12)
  119. })
  120. t.Run("pricing missing rejects before provider submit", func(t *testing.T) {
  121. svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true)
  122. svc.Pricing = &fakeBatchImagePricingResolver{err: ErrBatchImageSettlementPricingMissing}
  123. _, err := svc.Submit(ctx, testBatchImageOwner(), validBatchImageSubmitRequest(), "")
  124. require.ErrorIs(t, err, ErrBatchImageSettlementPricingMissing)
  125. require.Empty(t, repo.jobs)
  126. require.Empty(t, queue.enqueued)
  127. require.Empty(t, gemini.submits)
  128. })
  129. t.Run("group batch image disabled rejects before provider submit", func(t *testing.T) {
  130. svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true)
  131. groupID := int64(7)
  132. svc.GroupRepo = &publicBatchImageGroupRepo{groups: map[int64]*Group{
  133. groupID: {
  134. ID: groupID,
  135. Platform: PlatformGemini,
  136. RateMultiplier: 1,
  137. AllowBatchImageGeneration: false,
  138. BatchImageDiscountMultiplier: 0.5,
  139. BatchImageHoldMultiplier: 0.6,
  140. },
  141. }}
  142. _, err := svc.Submit(ctx, BatchImageOwner{UserID: 11, APIKeyID: 22, GroupID: &groupID}, validBatchImageSubmitRequest(), "")
  143. require.ErrorIs(t, err, ErrBatchImageGroupDisabled)
  144. require.Empty(t, repo.jobs)
  145. require.Empty(t, queue.enqueued)
  146. require.Empty(t, gemini.submits)
  147. })
  148. t.Run("group pricing load failure rejects before provider submit", func(t *testing.T) {
  149. svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true)
  150. groupID := int64(404)
  151. _, err := svc.Submit(ctx, BatchImageOwner{UserID: 11, APIKeyID: 22, GroupID: &groupID}, validBatchImageSubmitRequest(), "")
  152. require.ErrorIs(t, err, ErrBatchImageSettlementPricingMissing)
  153. require.Empty(t, repo.jobs)
  154. require.Empty(t, queue.enqueued)
  155. require.Empty(t, gemini.submits)
  156. })
  157. t.Run("generates custom ids deterministically", func(t *testing.T) {
  158. svc, _, _, gemini, _ := newTestBatchImagePublicService(true)
  159. req := validBatchImageSubmitRequest()
  160. req.Items[0].CustomID = ""
  161. req.Items[1].CustomID = ""
  162. _, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
  163. require.NoError(t, err)
  164. require.Len(t, gemini.submits, 1)
  165. require.Equal(t, "item_000001", gemini.submits[0].Items[0].CustomID)
  166. require.Equal(t, "item_000002", gemini.submits[0].Items[1].CustomID)
  167. })
  168. t.Run("expands output count into separate billable items", func(t *testing.T) {
  169. svc, repo, _, gemini, _ := newTestBatchImagePublicService(true)
  170. req := validBatchImageSubmitRequest()
  171. req.Items = []BatchImageSubmitItem{
  172. {CustomID: "cover", Prompt: "hero", OutputCount: 3, ReferenceImages: []BatchImageReferenceInput{{MimeType: "image/png", Data: []byte("ref")}}},
  173. }
  174. got, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
  175. require.NoError(t, err)
  176. require.Equal(t, 3, got.ItemCount)
  177. require.InDelta(t, 0.375, got.EstimatedCost, 1e-12)
  178. require.Len(t, gemini.submits, 1)
  179. require.Len(t, gemini.submits[0].Items, 3)
  180. require.Equal(t, []string{"cover_01", "cover_02", "cover_03"}, []string{
  181. gemini.submits[0].Items[0].CustomID,
  182. gemini.submits[0].Items[1].CustomID,
  183. gemini.submits[0].Items[2].CustomID,
  184. })
  185. require.Len(t, gemini.submits[0].Items[0].ReferenceImages, 1)
  186. require.Len(t, repo.items[got.ID], 3)
  187. })
  188. t.Run("validates request fields", func(t *testing.T) {
  189. tests := []struct {
  190. name string
  191. mutate func(*BatchImageSubmitRequest)
  192. want error
  193. }{
  194. {name: "missing_model", mutate: func(r *BatchImageSubmitRequest) { r.Model = "" }, want: ErrBatchImageInvalidModel},
  195. {name: "empty_items", mutate: func(r *BatchImageSubmitRequest) { r.Items = nil }, want: ErrBatchImageInvalidItems},
  196. {name: "duplicate_custom_ids", mutate: func(r *BatchImageSubmitRequest) { r.Items[1].CustomID = r.Items[0].CustomID }, want: ErrBatchImageDuplicateCustomIDInRequest},
  197. {name: "empty_prompt", mutate: func(r *BatchImageSubmitRequest) { r.Items[0].Prompt = " " }, want: ErrBatchImageInvalidItems},
  198. {name: "prompt_too_long", mutate: func(r *BatchImageSubmitRequest) { r.Items[0].Prompt = strings.Repeat("x", 9) }, want: ErrBatchImagePromptTooLong},
  199. {name: "unsupported_provider", mutate: func(r *BatchImageSubmitRequest) { r.Provider = "other" }, want: ErrBatchImageUnsupportedProvider},
  200. {name: "vertex_rejects_2k", mutate: func(r *BatchImageSubmitRequest) { r.Provider = BatchImageProviderVertex; r.ImageSize = "2K" }, want: ErrBatchImageInvalidItems},
  201. {name: "too_many_outputs_per_item", mutate: func(r *BatchImageSubmitRequest) {
  202. r.Items[0].OutputCount = 5
  203. }, want: ErrBatchImageInvalidItems},
  204. {name: "too_many_reference_images_for_flash", mutate: func(r *BatchImageSubmitRequest) {
  205. r.Model = "gemini-2.5-flash-image"
  206. r.Items[0].ReferenceImages = []BatchImageReferenceInput{
  207. {MimeType: "image/png", Data: []byte("1")},
  208. {MimeType: "image/png", Data: []byte("2")},
  209. {MimeType: "image/png", Data: []byte("3")},
  210. {MimeType: "image/png", Data: []byte("4")},
  211. }
  212. }, want: ErrBatchImageTooManyReferenceImages},
  213. {name: "bad_reference_mime", mutate: func(r *BatchImageSubmitRequest) {
  214. r.Items[0].ReferenceImages = []BatchImageReferenceInput{{MimeType: "application/octet-stream", Data: []byte("x")}}
  215. }, want: ErrBatchImageInvalidReferenceImage},
  216. {name: "reference_requires_data_or_file_uri", mutate: func(r *BatchImageSubmitRequest) {
  217. r.Items[0].ReferenceImages = []BatchImageReferenceInput{{MimeType: "image/png"}}
  218. }, want: ErrBatchImageInvalidReferenceImage},
  219. }
  220. for _, tt := range tests {
  221. t.Run(tt.name, func(t *testing.T) {
  222. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  223. req := validBatchImageSubmitRequest()
  224. tt.mutate(&req)
  225. _, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
  226. require.ErrorIs(t, err, tt.want)
  227. })
  228. }
  229. })
  230. t.Run("rejects too many items", func(t *testing.T) {
  231. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  232. req := validBatchImageSubmitRequest()
  233. req.Items = append(req.Items, BatchImageSubmitItem{CustomID: "too_many", Prompt: "x"})
  234. _, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
  235. require.ErrorIs(t, err, ErrBatchImageInvalidItems)
  236. })
  237. t.Run("rejects too many output images", func(t *testing.T) {
  238. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  239. svc.Config.BatchImage.MaxOutputImagesPerJob = 3
  240. req := validBatchImageSubmitRequest()
  241. req.Items[0].OutputCount = 2
  242. req.Items[1].OutputCount = 2
  243. _, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
  244. require.ErrorIs(t, err, ErrBatchImageTooManyOutputImages)
  245. })
  246. t.Run("rejects too many reference images across request", func(t *testing.T) {
  247. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  248. svc.Config.BatchImage.MaxReferenceImagesPerJob = 3
  249. req := validBatchImageSubmitRequest()
  250. req.Model = "gemini-2.5-flash-image"
  251. req.Items[0].ReferenceImages = []BatchImageReferenceInput{
  252. {MimeType: "image/png", Data: []byte("1")},
  253. {MimeType: "image/png", Data: []byte("2")},
  254. }
  255. req.Items[1].ReferenceImages = []BatchImageReferenceInput{
  256. {MimeType: "image/png", Data: []byte("3")},
  257. {MimeType: "image/png", Data: []byte("4")},
  258. }
  259. _, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
  260. require.ErrorIs(t, err, ErrBatchImageTooManyReferenceImages)
  261. })
  262. t.Run("rejects too much inline reference image data across request", func(t *testing.T) {
  263. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  264. svc.Config.BatchImage.MaxReferenceImagesPerJob = 10
  265. svc.Config.BatchImage.MaxReferenceInlineBytesPerJob = 4
  266. req := validBatchImageSubmitRequest()
  267. req.Model = "gemini-2.5-flash-image"
  268. req.Items[0].ReferenceImages = []BatchImageReferenceInput{{MimeType: "image/png", Data: []byte("123")}}
  269. req.Items[1].ReferenceImages = []BatchImageReferenceInput{{MimeType: "image/png", Data: []byte("456")}}
  270. _, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
  271. require.ErrorIs(t, err, ErrBatchImageReferenceImagesTooLarge)
  272. })
  273. t.Run("selects requested provider", func(t *testing.T) {
  274. svc, _, _, gemini, vertex := newTestBatchImagePublicService(true)
  275. req := validBatchImageSubmitRequest()
  276. req.Provider = BatchImageProviderVertex
  277. got, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
  278. require.NoError(t, err)
  279. require.Equal(t, BatchImageProviderVertex, got.Provider)
  280. require.Empty(t, gemini.submits)
  281. require.Len(t, vertex.submits, 1)
  282. })
  283. t.Run("insufficient balance rejects before provider submit", func(t *testing.T) {
  284. svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true)
  285. billing := &fakeBatchImageBillingRepo{err: ErrBatchImageInsufficientBalance}
  286. svc.BillingRepo = billing
  287. _, err := svc.Submit(ctx, testBatchImageOwner(), validBatchImageSubmitRequest(), "")
  288. require.ErrorIs(t, err, ErrBatchImageInsufficientBalance)
  289. require.Empty(t, queue.enqueued)
  290. require.Empty(t, gemini.submits)
  291. require.Len(t, billing.reserves, 1)
  292. require.Empty(t, billing.releases)
  293. require.Len(t, repo.jobs, 1)
  294. for _, job := range repo.jobs {
  295. require.Equal(t, BatchImageJobStatusFailed, job.Status)
  296. require.Equal(t, "INSUFFICIENT_BALANCE", batchImageDerefString(job.LastErrorCode))
  297. require.NotNil(t, job.UserDeletedAt)
  298. }
  299. })
  300. t.Run("provider failure marks failed and does not enqueue", func(t *testing.T) {
  301. svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true)
  302. gemini.submitErr = errors.New("projects/secret-provider-job failed")
  303. billing := svc.BillingRepo.(*fakeBatchImageBillingRepo)
  304. _, err := svc.Submit(ctx, testBatchImageOwner(), validBatchImageSubmitRequest(), "")
  305. require.ErrorIs(t, err, ErrBatchImageProviderSubmitFailed)
  306. require.Empty(t, queue.enqueued)
  307. require.Len(t, billing.reserves, 1)
  308. require.Len(t, billing.releases, 1)
  309. require.Equal(t, BatchImageReleaseRequestID(billing.reserves[0].BatchID), billing.releases[0].RequestID)
  310. require.Len(t, repo.jobs, 1)
  311. for _, job := range repo.jobs {
  312. require.Equal(t, BatchImageJobStatusFailed, job.Status)
  313. require.Equal(t, "PROVIDER_SUBMIT_FAILED", batchImageDerefString(job.LastErrorCode))
  314. require.Equal(t, "upstream provider operation failed", batchImageDerefString(job.LastErrorMessage))
  315. require.NotNil(t, job.UserDeletedAt)
  316. }
  317. })
  318. t.Run("provider failure with release failure enqueues billing retry", func(t *testing.T) {
  319. svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true)
  320. gemini.submitErr = errors.New("projects/secret-provider-job failed")
  321. billing := svc.BillingRepo.(*fakeBatchImageBillingRepo)
  322. billing.releaseErr = errors.New("billing database timeout")
  323. _, err := svc.Submit(ctx, testBatchImageOwner(), validBatchImageSubmitRequest(), "")
  324. require.ErrorIs(t, err, ErrBatchImageBillingHoldFailed)
  325. require.Len(t, billing.reserves, 1)
  326. require.Len(t, billing.releases, 1)
  327. require.Len(t, repo.jobs, 1)
  328. for _, job := range repo.jobs {
  329. require.Equal(t, BatchImageJobStatusFailed, job.Status)
  330. require.Equal(t, "BILLING_RELEASE_FAILED", batchImageDerefString(job.LastErrorCode))
  331. require.Equal(t, []string{job.BatchID}, queue.enqueued)
  332. }
  333. })
  334. t.Run("queue failure is recorded after provider submit", func(t *testing.T) {
  335. svc, repo, queue, _, _ := newTestBatchImagePublicService(true)
  336. queue.err = errors.New("redis unavailable")
  337. billing := svc.BillingRepo.(*fakeBatchImageBillingRepo)
  338. _, err := svc.Submit(ctx, testBatchImageOwner(), validBatchImageSubmitRequest(), "")
  339. require.ErrorIs(t, err, ErrBatchImageQueueFailed)
  340. require.Len(t, billing.reserves, 1)
  341. require.Empty(t, billing.releases)
  342. require.Len(t, repo.jobs, 1)
  343. for _, job := range repo.jobs {
  344. require.Equal(t, BatchImageJobStatusSubmitted, job.Status)
  345. require.Equal(t, "QUEUE_FAILED", batchImageDerefString(job.LastErrorCode))
  346. require.Contains(t, repo.events[job.BatchID], "queue_failed")
  347. }
  348. })
  349. t.Run("idempotency returns same batch without provider resubmit", func(t *testing.T) {
  350. svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true)
  351. req := validBatchImageSubmitRequest()
  352. req.SessionID = batchImageStringPtr("original-session")
  353. first, err := svc.Submit(ctx, testBatchImageOwner(), req, "client-key")
  354. require.NoError(t, err)
  355. req.SessionID = batchImageStringPtr("retry-session")
  356. second, err := svc.Submit(ctx, testBatchImageOwner(), req, "client-key")
  357. require.NoError(t, err)
  358. require.Equal(t, first.ID, second.ID)
  359. require.Equal(t, "original-session", batchImageDerefString(repo.jobs[first.ID].SessionID))
  360. require.Len(t, gemini.submits, 1)
  361. require.Equal(t, []string{first.ID}, queue.enqueued)
  362. })
  363. t.Run("idempotency conflict rejects changed request", func(t *testing.T) {
  364. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  365. req := validBatchImageSubmitRequest()
  366. first, err := svc.Submit(ctx, testBatchImageOwner(), req, "client-key")
  367. require.NoError(t, err)
  368. req.Items[0].Prompt = "diff"
  369. second, err := svc.Submit(ctx, testBatchImageOwner(), req, "client-key")
  370. require.Nil(t, second)
  371. require.ErrorIs(t, err, ErrBatchImageIdempotencyConflict)
  372. require.NotEmpty(t, first.ID)
  373. })
  374. t.Run("public response does not expose internals", func(t *testing.T) {
  375. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  376. got, err := svc.Submit(ctx, testBatchImageOwner(), validBatchImageSubmitRequest(), "")
  377. require.NoError(t, err)
  378. body, err := json.Marshal(got)
  379. require.NoError(t, err)
  380. requireBatchImagePublicJSONHasNoInternals(t, string(body))
  381. })
  382. }
  383. func TestBatchImagePublicService_List(t *testing.T) {
  384. ctx := context.Background()
  385. svc, repo, _, _, _ := newTestBatchImagePublicService(true)
  386. visibleKeyID := int64(22)
  387. otherKeyID := int64(23)
  388. repo.jobs["visible-1"] = &BatchImageJob{
  389. BatchID: "visible-1",
  390. UserID: 11,
  391. APIKeyID: &visibleKeyID,
  392. Status: BatchImageJobStatusCompleted,
  393. Provider: BatchImageProviderVertex,
  394. Model: "gemini-3.1-flash-lite-image",
  395. ItemCount: 1,
  396. CreatedAt: time.Now(),
  397. }
  398. repo.jobs["hidden-other-key"] = &BatchImageJob{
  399. BatchID: "hidden-other-key",
  400. UserID: 11,
  401. APIKeyID: &otherKeyID,
  402. Status: BatchImageJobStatusCompleted,
  403. Provider: BatchImageProviderVertex,
  404. Model: "gemini-3.1-flash-lite-image",
  405. ItemCount: 1,
  406. CreatedAt: time.Now(),
  407. }
  408. got, err := svc.List(ctx, BatchImageOwner{UserID: 11, APIKeyID: visibleKeyID}, BatchImageJobsQuery{Limit: 20})
  409. require.NoError(t, err)
  410. require.Equal(t, "list", got.Object)
  411. require.Len(t, got.Data, 1)
  412. require.Equal(t, "visible-1", got.Data[0].ID)
  413. require.False(t, got.HasMore)
  414. }
  415. func TestBatchImagePublicService_ListModels(t *testing.T) {
  416. ctx := context.Background()
  417. t.Run("requires explicit account model mapping", func(t *testing.T) {
  418. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  419. got, err := svc.ListModels(ctx, testBatchImageOwner())
  420. require.NoError(t, err)
  421. require.Equal(t, "list", got.Object)
  422. require.Empty(t, got.Data)
  423. })
  424. t.Run("returns priced models from selected account group", func(t *testing.T) {
  425. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  426. groupID := int64(7)
  427. svc.GroupRepo = &publicBatchImageGroupRepo{groups: map[int64]*Group{
  428. groupID: {
  429. ID: groupID,
  430. Platform: PlatformGemini,
  431. RateMultiplier: 1,
  432. AllowImageGeneration: true,
  433. AllowBatchImageGeneration: true,
  434. BatchImageDiscountMultiplier: 0.5,
  435. BatchImageHoldMultiplier: 0.6,
  436. },
  437. }}
  438. accountRepo := svc.AccountRepo.(*publicBatchImageAccountRepo)
  439. accountRepo.accounts = []Account{testBatchImageMappedAccount(303, AccountTypeAPIKey, map[string]any{
  440. "gemini-2.5-flash-image": "gemini-2.5-flash-image",
  441. })}
  442. got, err := svc.ListModels(ctx, BatchImageOwner{UserID: 11, APIKeyID: 22, GroupID: &groupID})
  443. require.NoError(t, err)
  444. require.Equal(t, []BatchImagePublicModel{{
  445. ID: "gemini-2.5-flash-image",
  446. Object: "image.batch.model",
  447. Provider: BatchImageProviderGeminiAPI,
  448. }, {
  449. ID: "gemini-2.5-flash-image",
  450. Object: "image.batch.model",
  451. Provider: BatchImageProviderVertex,
  452. }}, got.Data)
  453. })
  454. t.Run("expands wildcard mappings against batch image candidates", func(t *testing.T) {
  455. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  456. accountRepo := svc.AccountRepo.(*publicBatchImageAccountRepo)
  457. accountRepo.accounts = []Account{testBatchImageMappedAccount(303, AccountTypeAPIKey, map[string]any{
  458. "gemini-3.1-*": "gemini-3.1-flash-lite-image",
  459. })}
  460. got, err := svc.ListModels(ctx, testBatchImageOwner())
  461. require.NoError(t, err)
  462. require.NotEmpty(t, got.Data)
  463. ids := make([]string, 0, len(got.Data))
  464. for _, model := range got.Data {
  465. ids = append(ids, model.ID)
  466. }
  467. require.Contains(t, ids, "gemini-3.1-flash-image")
  468. require.Contains(t, ids, "gemini-3.1-flash-lite-image")
  469. require.NotContains(t, ids, "gemini-2.5-flash-image")
  470. })
  471. t.Run("filters models without batch image pricing", func(t *testing.T) {
  472. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  473. svc.Pricing = &fakeBatchImagePricingResolver{
  474. unitPrice: 0.25,
  475. missingModels: map[string]bool{"gemini-3.1-flash-lite-image": true},
  476. }
  477. accountRepo := svc.AccountRepo.(*publicBatchImageAccountRepo)
  478. accountRepo.accounts = []Account{testBatchImageMappedAccount(303, AccountTypeAPIKey, map[string]any{
  479. "gemini-2.5-flash-image": "gemini-2.5-flash-image",
  480. "gemini-3.1-flash-lite-image": "gemini-3.1-flash-lite-image",
  481. })}
  482. got, err := svc.ListModels(ctx, testBatchImageOwner())
  483. require.NoError(t, err)
  484. ids := make([]string, 0, len(got.Data))
  485. for _, model := range got.Data {
  486. ids = append(ids, model.ID)
  487. }
  488. require.Contains(t, ids, "gemini-2.5-flash-image")
  489. require.NotContains(t, ids, "gemini-3.1-flash-lite-image")
  490. })
  491. t.Run("rejects when group disables batch image", func(t *testing.T) {
  492. svc, _, _, _, _ := newTestBatchImagePublicService(true)
  493. groupID := int64(7)
  494. svc.GroupRepo = &publicBatchImageGroupRepo{groups: map[int64]*Group{
  495. groupID: {ID: groupID, AllowBatchImageGeneration: false},
  496. }}
  497. _, err := svc.ListModels(ctx, BatchImageOwner{UserID: 11, APIKeyID: 22, GroupID: &groupID})
  498. require.ErrorIs(t, err, ErrBatchImageGroupDisabled)
  499. })
  500. }
  501. func TestBatchImagePublicService_StatusItemsAndCancel(t *testing.T) {
  502. ctx := context.Background()
  503. t.Run("status is owner scoped and maps public status", func(t *testing.T) {
  504. svc, repo, _, _, _ := newTestBatchImagePublicService(true)
  505. apiKeyID := int64(22)
  506. accountID := int64(101)
  507. repo.jobs["imgbatch_status"] = &BatchImageJob{
  508. BatchID: "imgbatch_status",
  509. UserID: 11,
  510. APIKeyID: &apiKeyID,
  511. AccountID: &accountID,
  512. Provider: BatchImageProviderGeminiAPI,
  513. Model: "gemini-2.5-flash-image",
  514. Status: BatchImageJobStatusIndexing,
  515. ProviderJobName: batchImageStringPtr("providers/internal/job"),
  516. CreatedAt: time.Now(),
  517. }
  518. got, err := svc.Get(ctx, testBatchImageOwner(), "imgbatch_status")
  519. require.NoError(t, err)
  520. require.Equal(t, "processing_results", got.Status)
  521. body, err := json.Marshal(got)
  522. require.NoError(t, err)
  523. requireBatchImagePublicJSONHasNoInternals(t, string(body))
  524. _, err = svc.Get(ctx, BatchImageOwner{UserID: 11, APIKeyID: 999}, "imgbatch_status")
  525. require.ErrorIs(t, err, ErrBatchImageJobNotFound)
  526. })
  527. t.Run("items are filtered paginated and sanitized", func(t *testing.T) {
  528. svc, repo, _, _, _ := newTestBatchImagePublicService(true)
  529. apiKeyID := int64(22)
  530. repo.jobs["imgbatch_items"] = &BatchImageJob{
  531. BatchID: "imgbatch_items",
  532. UserID: 11,
  533. APIKeyID: &apiKeyID,
  534. Provider: BatchImageProviderGeminiAPI,
  535. Model: "gemini-2.5-flash-image",
  536. Status: BatchImageJobStatusCompleted,
  537. CreatedAt: time.Now(),
  538. }
  539. sourceObject := "gs://bucket/internal/output.jsonl"
  540. mime := "image/png"
  541. ext := "png"
  542. code := "SAFETY_BLOCKED"
  543. msg := "blocked in gs://bucket/internal/output.jsonl"
  544. repo.items["imgbatch_items"] = []CreateBatchImageItemParams{
  545. {JobID: "imgbatch_items", CustomID: "ok_1", Status: BatchImageItemStatusSuccess, ProviderSourceObject: &sourceObject, MimeType: &mime, FileExtension: &ext, ImageCount: 1},
  546. {JobID: "imgbatch_items", CustomID: "bad_1", Status: BatchImageItemStatusFailed, ProviderSourceObject: &sourceObject, ErrorCode: &code, ErrorMessage: &msg},
  547. {JobID: "imgbatch_items", CustomID: "ok_2", Status: BatchImageItemStatusSuccess, MimeType: &mime, FileExtension: &ext, ImageCount: 1},
  548. }
  549. page, err := svc.ListItems(ctx, testBatchImageOwner(), "imgbatch_items", BatchImageItemsQuery{Limit: 1})
  550. require.NoError(t, err)
  551. require.True(t, page.HasMore)
  552. require.Len(t, page.Data, 1)
  553. require.Equal(t, "ok_1", page.Data[0].CustomID)
  554. filtered, err := svc.ListItems(ctx, testBatchImageOwner(), "imgbatch_items", BatchImageItemsQuery{Status: "failed", Limit: 100})
  555. require.NoError(t, err)
  556. require.False(t, filtered.HasMore)
  557. require.Len(t, filtered.Data, 1)
  558. require.Equal(t, "failed", filtered.Data[0].Status)
  559. require.NotNil(t, filtered.Data[0].Error)
  560. require.Equal(t, "upstream provider operation failed", filtered.Data[0].Error.Message)
  561. body, err := json.Marshal(filtered)
  562. require.NoError(t, err)
  563. requireBatchImagePublicJSONHasNoInternals(t, string(body))
  564. require.NotContains(t, string(body), "download_url")
  565. _, err = svc.ListItems(ctx, BatchImageOwner{UserID: 12, APIKeyID: 22}, "imgbatch_items", BatchImageItemsQuery{})
  566. require.ErrorIs(t, err, ErrBatchImageJobNotFound)
  567. })
  568. t.Run("cancel active job calls provider and waits for confirmed terminal state", func(t *testing.T) {
  569. svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true)
  570. apiKeyID := int64(22)
  571. accountID := int64(101)
  572. holdAmount := 0.5
  573. holdID := BatchImageHoldRequestID("imgbatch_cancel")
  574. repo.jobs["imgbatch_cancel"] = &BatchImageJob{
  575. BatchID: "imgbatch_cancel",
  576. UserID: 11,
  577. APIKeyID: &apiKeyID,
  578. AccountID: &accountID,
  579. Provider: BatchImageProviderGeminiAPI,
  580. Model: "gemini-2.5-flash-image",
  581. Status: BatchImageJobStatusSubmitted,
  582. ProviderJobName: batchImageStringPtr("providers/internal/job"),
  583. EstimatedCost: holdAmount,
  584. HoldAmount: &holdAmount,
  585. HoldID: &holdID,
  586. CreatedAt: time.Now(),
  587. }
  588. got, err := svc.Cancel(ctx, testBatchImageOwner(), "imgbatch_cancel")
  589. require.NoError(t, err)
  590. require.Equal(t, "queued", got.Status)
  591. require.Equal(t, 1, gemini.cancelCount)
  592. billing := svc.BillingRepo.(*fakeBatchImageBillingRepo)
  593. require.Empty(t, billing.releases)
  594. require.Equal(t, []string{"imgbatch_cancel"}, queue.enqueued)
  595. require.Equal(t, BatchImageJobStatusSubmitted, repo.jobs["imgbatch_cancel"].Status)
  596. require.Contains(t, repo.events["imgbatch_cancel"], "job_cancel_requested")
  597. })
  598. t.Run("cancel terminal job is idempotent", func(t *testing.T) {
  599. svc, repo, _, gemini, _ := newTestBatchImagePublicService(true)
  600. apiKeyID := int64(22)
  601. repo.jobs["imgbatch_done"] = &BatchImageJob{
  602. BatchID: "imgbatch_done",
  603. UserID: 11,
  604. APIKeyID: &apiKeyID,
  605. Provider: BatchImageProviderGeminiAPI,
  606. Model: "gemini-2.5-flash-image",
  607. Status: BatchImageJobStatusCompleted,
  608. CreatedAt: time.Now(),
  609. }
  610. got, err := svc.Cancel(ctx, testBatchImageOwner(), "imgbatch_done")
  611. require.NoError(t, err)
  612. require.Equal(t, "completed", got.Status)
  613. require.Zero(t, gemini.cancelCount)
  614. })
  615. t.Run("cancel hides provider raw errors behind public error", func(t *testing.T) {
  616. svc, repo, _, gemini, _ := newTestBatchImagePublicService(true)
  617. gemini.cancelErr = errors.New("projects/secret-provider-job not found")
  618. apiKeyID := int64(22)
  619. accountID := int64(101)
  620. repo.jobs["imgbatch_cancel_error"] = &BatchImageJob{
  621. BatchID: "imgbatch_cancel_error",
  622. UserID: 11,
  623. APIKeyID: &apiKeyID,
  624. AccountID: &accountID,
  625. Provider: BatchImageProviderGeminiAPI,
  626. Model: "gemini-2.5-flash-image",
  627. Status: BatchImageJobStatusSubmitted,
  628. ProviderJobName: batchImageStringPtr("providers/internal/job"),
  629. CreatedAt: time.Now(),
  630. }
  631. _, err := svc.Cancel(ctx, testBatchImageOwner(), "imgbatch_cancel_error")
  632. require.ErrorIs(t, err, ErrBatchImageCancelFailed)
  633. require.Equal(t, "BATCH_IMAGE_CANCEL_FAILED", infraerrors.Reason(err))
  634. require.NotContains(t, infraerrors.Message(err), "projects/")
  635. })
  636. }
  637. func newTestBatchImagePublicService(enabled bool) (*BatchImagePublicService, *fakeBatchImageRepository, *publicBatchImageQueue, *publicBatchImageProvider, *publicBatchImageProvider) {
  638. repo := newFakeBatchImageRepository()
  639. queue := &publicBatchImageQueue{}
  640. gemini := &publicBatchImageProvider{name: BatchImageProviderGeminiAPI}
  641. vertex := &publicBatchImageProvider{name: BatchImageProviderVertex}
  642. svc := &BatchImagePublicService{
  643. Repo: repo,
  644. AccountRepo: &publicBatchImageAccountRepo{accounts: []Account{testBatchImageAccount(101, AccountTypeAPIKey), testBatchImageAccount(202, AccountTypeServiceAccount)}},
  645. Queue: queue,
  646. ProviderRegistry: NewBatchImageProviderRegistry(
  647. gemini,
  648. vertex,
  649. ),
  650. Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25},
  651. BillingRepo: &fakeBatchImageBillingRepo{},
  652. AuthCache: &fakeBatchImageAuthCacheInvalidator{},
  653. Config: &config.Config{BatchImage: config.BatchImageConfig{
  654. Enabled: enabled,
  655. MaxItemsPerJobDefault: 2,
  656. MaxPromptCharsPerItem: 8,
  657. DefaultResponseMimeType: "image/png",
  658. DefaultImageSize: "1K",
  659. }},
  660. }
  661. return svc, repo, queue, gemini, vertex
  662. }
  663. func testBatchImageOwner() BatchImageOwner {
  664. return BatchImageOwner{UserID: 11, APIKeyID: 22}
  665. }
  666. type fakeBatchImageAuthCacheInvalidator struct {
  667. keys []string
  668. userIDs []int64
  669. groupIDs []int64
  670. }
  671. func (f *fakeBatchImageAuthCacheInvalidator) InvalidateAuthCacheByKey(_ context.Context, key string) {
  672. f.keys = append(f.keys, key)
  673. }
  674. func (f *fakeBatchImageAuthCacheInvalidator) InvalidateAuthCacheByUserID(_ context.Context, userID int64) {
  675. f.userIDs = append(f.userIDs, userID)
  676. }
  677. func (f *fakeBatchImageAuthCacheInvalidator) InvalidateAuthCacheByGroupID(_ context.Context, groupID int64) {
  678. f.groupIDs = append(f.groupIDs, groupID)
  679. }
  680. func validBatchImageSubmitRequest() BatchImageSubmitRequest {
  681. return BatchImageSubmitRequest{
  682. Model: "gemini-2.5-flash-image",
  683. Provider: BatchImageProviderGeminiAPI,
  684. ResponseMimeType: "image/png",
  685. AspectRatio: "1:1",
  686. ImageSize: "1K",
  687. Metadata: map[string]string{"project": "campaign-a", "secret": strings.Repeat("x", 300)},
  688. Items: []BatchImageSubmitItem{
  689. {CustomID: "cover_001", Prompt: "hero"},
  690. {CustomID: "cover_002", Prompt: "clean"},
  691. },
  692. }
  693. }
  694. func testBatchImageAccount(id int64, accountType string) Account {
  695. return Account{
  696. ID: id,
  697. Platform: PlatformGemini,
  698. Type: accountType,
  699. Status: StatusActive,
  700. Schedulable: true,
  701. Priority: int(id),
  702. Credentials: map[string]any{"api_key": "test-secret"},
  703. Concurrency: 1,
  704. RateLimitedAt: nil,
  705. }
  706. }
  707. func testBatchImageMappedAccount(id int64, accountType string, mapping map[string]any) Account {
  708. account := testBatchImageAccount(id, accountType)
  709. account.Credentials["model_mapping"] = mapping
  710. return account
  711. }
  712. func requireBatchImagePublicJSONHasNoInternals(t *testing.T, body string) {
  713. t.Helper()
  714. for _, forbidden := range []string{
  715. "provider_job_name",
  716. "provider_input_ref",
  717. "provider_output_ref",
  718. "gcs_input_uri",
  719. "gcs_output_uri",
  720. "account_id",
  721. "service_account",
  722. "api_key",
  723. "download_url",
  724. "providers/",
  725. "files/",
  726. "gs://",
  727. } {
  728. require.NotContains(t, body, forbidden)
  729. }
  730. }
  731. type publicBatchImageAccountRepo struct {
  732. accounts []Account
  733. }
  734. func (r *publicBatchImageAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) {
  735. for i := range r.accounts {
  736. if r.accounts[i].ID == id {
  737. return &r.accounts[i], nil
  738. }
  739. }
  740. return nil, errors.New("account not found")
  741. }
  742. func (r *publicBatchImageAccountRepo) ListSchedulableByPlatform(_ context.Context, platform string) ([]Account, error) {
  743. out := make([]Account, 0, len(r.accounts))
  744. for _, account := range r.accounts {
  745. if account.Platform == platform {
  746. out = append(out, account)
  747. }
  748. }
  749. return out, nil
  750. }
  751. func (r *publicBatchImageAccountRepo) ListSchedulableByGroupIDAndPlatform(ctx context.Context, _ int64, platform string) ([]Account, error) {
  752. return r.ListSchedulableByPlatform(ctx, platform)
  753. }
  754. type publicBatchImageQueue struct {
  755. enqueued []string
  756. err error
  757. }
  758. func (q *publicBatchImageQueue) Enqueue(_ context.Context, batchID string) error {
  759. if q.err != nil {
  760. return q.err
  761. }
  762. for _, existing := range q.enqueued {
  763. if existing == batchID {
  764. return ErrBatchImageAlreadyQueued
  765. }
  766. }
  767. q.enqueued = append(q.enqueued, batchID)
  768. return nil
  769. }
  770. func (q *publicBatchImageQueue) Reserve(context.Context, time.Duration) (ReservedBatchImageJob, error) {
  771. return ReservedBatchImageJob{}, ErrBatchImageQueueEmpty
  772. }
  773. func (q *publicBatchImageQueue) RequeueAfter(context.Context, string, time.Duration) error {
  774. return nil
  775. }
  776. func (q *publicBatchImageQueue) Ack(context.Context, string) error {
  777. return nil
  778. }
  779. func (q *publicBatchImageQueue) Heartbeat(context.Context, string) error {
  780. return nil
  781. }
  782. func (q *publicBatchImageQueue) MoveDueDelayedToReady(context.Context, int) (int, error) {
  783. return 0, nil
  784. }
  785. func (q *publicBatchImageQueue) RecoverStaleActive(context.Context, time.Duration, int) (int, error) {
  786. return 0, nil
  787. }
  788. func (q *publicBatchImageQueue) TryAcquireJobLock(context.Context, string, time.Duration) (BatchImageJobLock, bool, error) {
  789. return nil, false, nil
  790. }
  791. type publicBatchImageProvider struct {
  792. name string
  793. submits []BatchImageInput
  794. submitErr error
  795. cancelCount int
  796. cancelErr error
  797. result string
  798. cleanupTargets []CleanupTarget
  799. cleanupErr error
  800. }
  801. func (p *publicBatchImageProvider) Name() string { return p.name }
  802. func (p *publicBatchImageProvider) SupportsAccount(*Account) bool { return true }
  803. func (p *publicBatchImageProvider) Submit(_ context.Context, _ *BatchImageJob, _ *Account, input BatchImageInput) (*BatchProviderJob, error) {
  804. p.submits = append(p.submits, input)
  805. if p.submitErr != nil {
  806. return nil, p.submitErr
  807. }
  808. return &BatchProviderJob{
  809. ProviderJobName: "providers/" + p.name + "/job",
  810. ProviderInputRef: "files/" + p.name + "/input",
  811. ProviderOutputRef: "files/" + p.name + "/output",
  812. }, nil
  813. }
  814. func (p *publicBatchImageProvider) Get(context.Context, *BatchImageJob, *Account) (*BatchProviderStatus, error) {
  815. return &BatchProviderStatus{InternalState: BatchProviderStateQueued}, nil
  816. }
  817. func (p *publicBatchImageProvider) Cancel(context.Context, *BatchImageJob, *Account) error {
  818. p.cancelCount++
  819. return p.cancelErr
  820. }
  821. func (p *publicBatchImageProvider) OpenResult(context.Context, *BatchImageJob, *Account) (io.ReadCloser, string, error) {
  822. return io.NopCloser(strings.NewReader(p.result)), "application/jsonl", nil
  823. }
  824. func (p *publicBatchImageProvider) Cleanup(_ context.Context, _ *BatchImageJob, _ *Account, target CleanupTarget) error {
  825. p.cleanupTargets = append(p.cleanupTargets, target)
  826. return p.cleanupErr
  827. }
  828. var _ BatchImageAccountSelectionRepository = (*publicBatchImageAccountRepo)(nil)
  829. var _ BatchImageQueue = (*publicBatchImageQueue)(nil)
  830. var _ BatchImageProvider = (*publicBatchImageProvider)(nil)
  831. type publicBatchImageGroupRepo struct {
  832. groups map[int64]*Group
  833. }
  834. func (r *publicBatchImageGroupRepo) GetByIDLite(_ context.Context, id int64) (*Group, error) {
  835. if r != nil && r.groups != nil {
  836. if group, ok := r.groups[id]; ok {
  837. return group, nil
  838. }
  839. }
  840. return nil, ErrGroupNotFound
  841. }
  842. type publicBatchImageUserGroupRateRepo struct {
  843. rates map[int64]*float64
  844. }
  845. func (r *publicBatchImageUserGroupRateRepo) GetByUserAndGroup(_ context.Context, _ int64, groupID int64) (*float64, error) {
  846. if r != nil && r.rates != nil {
  847. return r.rates[groupID], nil
  848. }
  849. return nil, nil
  850. }
  851. var _ BatchImageGroupPricingRepository = (*publicBatchImageGroupRepo)(nil)
  852. var _ BatchImageUserGroupRateRepository = (*publicBatchImageUserGroupRateRepo)(nil)