batch_image_mvp_smoke_test.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. //go:build unit
  2. package service
  3. import (
  4. "archive/zip"
  5. "bytes"
  6. "context"
  7. "encoding/json"
  8. "io"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/stretchr/testify/require"
  14. )
  15. func TestBatchImageMVPFlow(t *testing.T) {
  16. ctx := context.Background()
  17. repo := newFakeBatchImageRepository()
  18. queue := &publicBatchImageQueue{}
  19. provider := &batchImageSmokeProvider{
  20. name: BatchImageProviderGeminiAPI,
  21. states: []BatchProviderInternalState{
  22. BatchProviderStateRunning,
  23. BatchProviderStateSucceeded,
  24. },
  25. result: batchImageSmokeResultJSONL(),
  26. }
  27. accountID := int64(101)
  28. accountRepo := &publicBatchImageAccountRepo{accounts: []Account{testBatchImageAccount(accountID, AccountTypeAPIKey)}}
  29. cfg := &config.Config{BatchImage: config.BatchImageConfig{
  30. Enabled: true,
  31. MaxItemsPerJobDefault: 10,
  32. MaxPromptCharsPerItem: 8000,
  33. DefaultResponseMimeType: "image/png",
  34. DefaultImageSize: "1K",
  35. MaxDownloadItemsZip: 10,
  36. MaxDownloadDurationSeconds: 60,
  37. OutputRetentionAfterTerminalHours: 72,
  38. }}
  39. registry := NewBatchImageProviderRegistry(provider)
  40. billing := &fakeBatchImageBillingRepo{}
  41. pricing := &fakeBatchImagePricingResolver{unitPrice: 0.25}
  42. owner := testBatchImageOwner()
  43. publicSvc := &BatchImagePublicService{
  44. Repo: repo,
  45. AccountRepo: accountRepo,
  46. Queue: queue,
  47. ProviderRegistry: registry,
  48. Pricing: pricing,
  49. BillingRepo: billing,
  50. Config: cfg,
  51. }
  52. processor := &BatchImagePipelineProcessor{
  53. ProviderProcessor: &BatchImageProviderProcessor{
  54. Repo: repo,
  55. ProviderRegistry: registry,
  56. AccountResolver: &fakeBatchImageAccountResolver{account: &accountRepo.accounts[0]},
  57. BillingRepo: billing,
  58. },
  59. SettlementService: &BatchImageSettlementService{
  60. Repo: repo,
  61. BillingRepo: billing,
  62. Pricing: pricing,
  63. Config: cfg,
  64. },
  65. }
  66. downloadSvc := &BatchImageDownloadService{
  67. Repo: repo,
  68. ProviderRegistry: registry,
  69. AccountResolver: &fakeBatchImageAccountResolver{account: &accountRepo.accounts[0]},
  70. Limiter: &fakeBatchImageDownloadLimiter{},
  71. Config: cfg,
  72. }
  73. cleanupSvc := &BatchImageCleanupService{
  74. Repo: repo,
  75. ProviderRegistry: registry,
  76. AccountResolver: &fakeBatchImageAccountResolver{account: &accountRepo.accounts[0]},
  77. Config: cfg,
  78. }
  79. submitted, err := publicSvc.Submit(ctx, owner, validBatchImageSubmitRequest(), "")
  80. require.NoError(t, err)
  81. require.Equal(t, "image.batch", submitted.Object)
  82. require.True(t, strings.HasPrefix(submitted.ID, "imgbatch_"))
  83. require.Equal(t, "queued", submitted.Status)
  84. require.Equal(t, 2, submitted.ItemCount)
  85. require.Equal(t, []string{submitted.ID}, queue.enqueued)
  86. require.Len(t, provider.submits, 1)
  87. require.Len(t, billing.reserves, 1)
  88. require.Equal(t, BatchImageHoldRequestID(submitted.ID), billing.reserves[0].RequestID)
  89. require.InDelta(t, 0.3, billing.reserves[0].HoldAmount, 1e-12)
  90. requireBatchImagePublicJSONHasNoInternals(t, mustMarshalBatchImageSmokeJSON(t, submitted))
  91. firstProcess, err := processor.Process(ctx, submitted.ID)
  92. require.NoError(t, err)
  93. require.False(t, firstProcess.Terminal)
  94. require.Equal(t, BatchImageJobStatusRunning, repo.jobs[submitted.ID].Status)
  95. indexProcess, err := processor.Process(ctx, submitted.ID)
  96. require.NoError(t, err)
  97. require.False(t, indexProcess.Terminal)
  98. require.Equal(t, time.Millisecond, indexProcess.RequeueAfter)
  99. require.Equal(t, BatchImageJobStatusSettling, repo.jobs[submitted.ID].Status)
  100. require.Equal(t, BatchImageCounts{SuccessCount: 1, FailCount: 1}, repo.counts[submitted.ID])
  101. settleProcess, err := processor.Process(ctx, submitted.ID)
  102. require.NoError(t, err)
  103. require.True(t, settleProcess.Terminal)
  104. job := repo.jobs[submitted.ID]
  105. require.Equal(t, BatchImageJobStatusCompleted, job.Status)
  106. require.NotNil(t, job.OutputExpiresAt)
  107. require.Equal(t, 1, job.SuccessCount)
  108. require.Equal(t, 1, job.FailCount)
  109. require.Len(t, billing.captures, 1)
  110. require.Equal(t, BatchImageCaptureRequestID(submitted.ID), billing.captures[0].RequestID)
  111. require.InDelta(t, 0.3, billing.captures[0].HoldAmount, 1e-12)
  112. require.InDelta(t, 0.125, billing.captures[0].ActualAmount, 1e-12)
  113. secondSettlement, err := processor.SettlementService.Settle(ctx, submitted.ID)
  114. require.NoError(t, err)
  115. require.True(t, secondSettlement.AlreadySettled)
  116. require.Len(t, billing.captures, 1)
  117. status, err := publicSvc.Get(ctx, owner, submitted.ID)
  118. require.NoError(t, err)
  119. require.Equal(t, "completed", status.Status)
  120. require.Equal(t, 1, status.SuccessCount)
  121. require.Equal(t, 1, status.FailCount)
  122. require.NotNil(t, status.ActualCost)
  123. requireBatchImagePublicJSONHasNoInternals(t, mustMarshalBatchImageSmokeJSON(t, status))
  124. items, err := publicSvc.ListItems(ctx, owner, submitted.ID, BatchImageItemsQuery{Limit: 100})
  125. require.NoError(t, err)
  126. require.False(t, items.HasMore)
  127. require.Len(t, items.Data, 2)
  128. require.Equal(t, "cover_001", items.Data[0].CustomID)
  129. require.Equal(t, "succeeded", items.Data[0].Status)
  130. require.Equal(t, "cover_002", items.Data[1].CustomID)
  131. require.Equal(t, "failed", items.Data[1].Status)
  132. require.NotNil(t, items.Data[1].Error)
  133. require.Nil(t, repo.items[submitted.ID][1].BilledAmount)
  134. requireBatchImagePublicJSONHasNoInternals(t, mustMarshalBatchImageSmokeJSON(t, items))
  135. stream, err := downloadSvc.OpenItemContent(ctx, owner, submitted.ID, "cover_001", 0)
  136. require.NoError(t, err)
  137. body, err := io.ReadAll(stream.Reader)
  138. require.NoError(t, err)
  139. require.NoError(t, stream.Reader.Close())
  140. require.Equal(t, []byte("smoke-png"), body)
  141. require.Equal(t, "image/png", stream.ContentType)
  142. require.Equal(t, "cover_001.png", stream.Filename)
  143. var zipBuf bytes.Buffer
  144. zipResult, err := downloadSvc.StreamZip(ctx, owner, submitted.ID, BatchImageZipOptions{}, &zipBuf)
  145. require.NoError(t, err)
  146. require.Equal(t, 1, zipResult.FileCount)
  147. require.Equal(t, 1, zipResult.ErrorCount)
  148. zipFiles := readZipFiles(t, zipBuf.Bytes())
  149. require.Equal(t, []byte("smoke-png"), zipFiles["images/cover_001.png"])
  150. require.Contains(t, zipFiles, "manifest.json")
  151. require.Contains(t, zipFiles, "errors.json")
  152. requireBatchImagePublicJSONHasNoInternals(t, string(bytes.Join(mapValues(zipFiles), []byte("\n"))))
  153. zipReader, err := zip.NewReader(bytes.NewReader(zipBuf.Bytes()), int64(zipBuf.Len()))
  154. require.NoError(t, err)
  155. require.ElementsMatch(t, []string{"images/cover_001.png", "manifest.json", "errors.json"}, batchImageSmokeZipNames(zipReader))
  156. deleted, err := cleanupSvc.DeleteOutputsForOwner(ctx, owner, submitted.ID)
  157. require.NoError(t, err)
  158. require.Equal(t, "output_deleted", deleted.Status)
  159. require.Equal(t, []CleanupTarget{CleanupTargetOutput}, provider.cleanupTargets)
  160. requireBatchImagePublicJSONHasNoInternals(t, mustMarshalBatchImageSmokeJSON(t, deleted))
  161. deletedAgain, err := cleanupSvc.DeleteOutputsForOwner(ctx, owner, submitted.ID)
  162. require.NoError(t, err)
  163. require.Equal(t, "output_deleted", deletedAgain.Status)
  164. require.Equal(t, []CleanupTarget{CleanupTargetOutput}, provider.cleanupTargets)
  165. stream, err = downloadSvc.OpenItemContent(ctx, owner, submitted.ID, "cover_001", 0)
  166. require.Nil(t, stream)
  167. require.ErrorIs(t, err, ErrBatchImageOutputDeleted)
  168. var afterDelete bytes.Buffer
  169. zipResult, err = downloadSvc.StreamZip(ctx, owner, submitted.ID, BatchImageZipOptions{}, &afterDelete)
  170. require.Nil(t, zipResult)
  171. require.ErrorIs(t, err, ErrBatchImageOutputDeleted)
  172. require.Empty(t, afterDelete.Bytes())
  173. }
  174. func batchImageSmokeResultJSONL() string {
  175. return strings.Join([]string{
  176. `{"key":"cover_001","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"c21va2UtcG5n"}}]}}]}}`,
  177. `{"key":"cover_002","status":{"code":3,"message":"blocked by safety policy"}}`,
  178. }, "\n") + "\n"
  179. }
  180. func mustMarshalBatchImageSmokeJSON(t *testing.T, value any) string {
  181. t.Helper()
  182. body, err := json.Marshal(value)
  183. require.NoError(t, err)
  184. return string(body)
  185. }
  186. func batchImageSmokeZipNames(reader *zip.Reader) []string {
  187. names := make([]string, 0, len(reader.File))
  188. for _, file := range reader.File {
  189. names = append(names, file.Name)
  190. }
  191. return names
  192. }
  193. type batchImageSmokeProvider struct {
  194. name string
  195. states []BatchProviderInternalState
  196. submits []BatchImageInput
  197. result string
  198. cleanupTargets []CleanupTarget
  199. }
  200. func (p *batchImageSmokeProvider) Name() string { return p.name }
  201. func (p *batchImageSmokeProvider) SupportsAccount(account *Account) bool {
  202. return account != nil && account.IsSchedulable()
  203. }
  204. func (p *batchImageSmokeProvider) Submit(_ context.Context, _ *BatchImageJob, _ *Account, input BatchImageInput) (*BatchProviderJob, error) {
  205. p.submits = append(p.submits, input)
  206. return &BatchProviderJob{
  207. ProviderJobName: "providers/fake-provider-job/raw-id",
  208. ProviderInputRef: "files/fake-provider-job/input.jsonl",
  209. ProviderOutputRef: "files/fake-provider-job/output.jsonl",
  210. }, nil
  211. }
  212. func (p *batchImageSmokeProvider) Get(context.Context, *BatchImageJob, *Account) (*BatchProviderStatus, error) {
  213. state := BatchProviderStateSucceeded
  214. if len(p.states) > 0 {
  215. state = p.states[0]
  216. p.states = p.states[1:]
  217. }
  218. return &BatchProviderStatus{
  219. RawState: strings.ToUpper(string(state)),
  220. InternalState: state,
  221. Done: state == BatchProviderStateSucceeded,
  222. ProviderOutputRef: "files/fake-provider-job/output.jsonl",
  223. }, nil
  224. }
  225. func (p *batchImageSmokeProvider) Cancel(context.Context, *BatchImageJob, *Account) error {
  226. return nil
  227. }
  228. func (p *batchImageSmokeProvider) OpenResult(context.Context, *BatchImageJob, *Account) (io.ReadCloser, string, error) {
  229. return io.NopCloser(strings.NewReader(p.result)), "application/jsonl", nil
  230. }
  231. func (p *batchImageSmokeProvider) Cleanup(_ context.Context, _ *BatchImageJob, _ *Account, target CleanupTarget) error {
  232. p.cleanupTargets = append(p.cleanupTargets, target)
  233. return nil
  234. }
  235. var _ BatchImageProvider = (*batchImageSmokeProvider)(nil)