batch_image_cleanup_test.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "strings"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  12. "github.com/stretchr/testify/require"
  13. )
  14. func TestBatchImageCleanupService_DeleteOutputsForOwner(t *testing.T) {
  15. ctx := context.Background()
  16. t.Run("deletes completed output and returns public dto", func(t *testing.T) {
  17. svc, repo, provider := newTestBatchImageCleanupService()
  18. got, err := svc.DeleteOutputsForOwner(ctx, testBatchImageOwner(), "imgbatch_cleanup")
  19. require.NoError(t, err)
  20. require.Equal(t, "output_deleted", got.Status)
  21. require.NotNil(t, got.OutputDeletedAt)
  22. require.Equal(t, []CleanupTarget{CleanupTargetOutput}, provider.cleanupTargets)
  23. require.NotNil(t, repo.jobs["imgbatch_cleanup"].OutputDeletedAt)
  24. require.Equal(t, BatchImageJobStatusOutputDeleted, repo.jobs["imgbatch_cleanup"].Status)
  25. body := mustJSON(t, got)
  26. requireBatchImagePublicJSONHasNoInternals(t, body)
  27. })
  28. t.Run("repeated delete is idempotent", func(t *testing.T) {
  29. svc, repo, provider := newTestBatchImageCleanupService()
  30. deletedAt := time.Now()
  31. repo.jobs["imgbatch_cleanup"].Status = BatchImageJobStatusOutputDeleted
  32. repo.jobs["imgbatch_cleanup"].OutputDeletedAt = &deletedAt
  33. got, err := svc.DeleteOutputsForOwner(ctx, testBatchImageOwner(), "imgbatch_cleanup")
  34. require.NoError(t, err)
  35. require.Equal(t, "output_deleted", got.Status)
  36. require.Empty(t, provider.cleanupTargets)
  37. })
  38. t.Run("not completed returns not ready", func(t *testing.T) {
  39. svc, repo, _ := newTestBatchImageCleanupService()
  40. repo.jobs["imgbatch_cleanup"].Status = BatchImageJobStatusRunning
  41. _, err := svc.DeleteOutputsForOwner(ctx, testBatchImageOwner(), "imgbatch_cleanup")
  42. require.ErrorIs(t, err, ErrBatchImageOutputDeleteNotReady)
  43. })
  44. t.Run("non owner returns not found", func(t *testing.T) {
  45. svc, _, _ := newTestBatchImageCleanupService()
  46. _, err := svc.DeleteOutputsForOwner(ctx, BatchImageOwner{UserID: 11, APIKeyID: 999}, "imgbatch_cleanup")
  47. require.ErrorIs(t, err, ErrBatchImageJobNotFound)
  48. })
  49. t.Run("provider not found is success", func(t *testing.T) {
  50. svc, repo, provider := newTestBatchImageCleanupService()
  51. provider.cleanupErr = infraerrors.New(404, "PROVIDER_NOT_FOUND", "provider file not found: gs://hidden")
  52. got, err := svc.DeleteOutputsForOwner(ctx, testBatchImageOwner(), "imgbatch_cleanup")
  53. require.NoError(t, err)
  54. require.Equal(t, "output_deleted", got.Status)
  55. require.NotNil(t, repo.jobs["imgbatch_cleanup"].OutputDeletedAt)
  56. })
  57. t.Run("provider transient error is sanitized and records failure", func(t *testing.T) {
  58. svc, repo, provider := newTestBatchImageCleanupService()
  59. provider.cleanupErr = errors.New("temporary cleanup failed for gs://secret-output")
  60. _, err := svc.DeleteOutputsForOwner(ctx, testBatchImageOwner(), "imgbatch_cleanup")
  61. require.ErrorIs(t, err, ErrBatchImageProviderCleanupFailed)
  62. require.Equal(t, "BATCH_IMAGE_PROVIDER_CLEANUP_FAILED", infraerrors.Reason(err))
  63. require.NotContains(t, infraerrors.Message(err), "gs://")
  64. require.Equal(t, "BATCH_IMAGE_PROVIDER_CLEANUP_FAILED", batchImageDerefString(repo.jobs["imgbatch_cleanup"].LastErrorCode))
  65. require.Equal(t, "upstream provider operation failed", batchImageDerefString(repo.jobs["imgbatch_cleanup"].LastErrorMessage))
  66. })
  67. t.Run("unsafe cleanup path is not swallowed", func(t *testing.T) {
  68. svc, repo, provider := newTestBatchImageCleanupService()
  69. provider.cleanupErr = ErrBatchImageProviderUnsafeCleanupPath
  70. _, err := svc.DeleteOutputsForOwner(ctx, testBatchImageOwner(), "imgbatch_cleanup")
  71. require.ErrorIs(t, err, ErrBatchImageCleanupUnsafePath)
  72. require.Equal(t, "BATCH_IMAGE_CLEANUP_UNSAFE_PATH", batchImageDerefString(repo.jobs["imgbatch_cleanup"].LastErrorCode))
  73. })
  74. }
  75. func TestBatchImageCleanupService_InputOutputAndWorker(t *testing.T) {
  76. ctx := context.Background()
  77. now := time.Now()
  78. t.Run("input cleanup marks input only", func(t *testing.T) {
  79. svc, repo, provider := newTestBatchImageCleanupService()
  80. err := svc.CleanupInput(ctx, "imgbatch_cleanup")
  81. require.NoError(t, err)
  82. require.Equal(t, []CleanupTarget{CleanupTargetInput}, provider.cleanupTargets)
  83. require.NotNil(t, repo.jobs["imgbatch_cleanup"].InputDeletedAt)
  84. require.Equal(t, BatchImageJobStatusCompleted, repo.jobs["imgbatch_cleanup"].Status)
  85. err = svc.CleanupInput(ctx, "imgbatch_cleanup")
  86. require.NoError(t, err)
  87. require.Len(t, provider.cleanupTargets, 1)
  88. })
  89. t.Run("output cleanup for failed job keeps status", func(t *testing.T) {
  90. svc, repo, _ := newTestBatchImageCleanupService()
  91. repo.jobs["imgbatch_cleanup"].Status = BatchImageJobStatusFailed
  92. err := svc.CleanupOutput(ctx, "imgbatch_cleanup", "ttl")
  93. require.NoError(t, err)
  94. require.Equal(t, BatchImageJobStatusFailed, repo.jobs["imgbatch_cleanup"].Status)
  95. require.NotNil(t, repo.jobs["imgbatch_cleanup"].OutputDeletedAt)
  96. })
  97. t.Run("worker processes due jobs and continues after failure", func(t *testing.T) {
  98. svc, repo, provider := newTestBatchImageCleanupService()
  99. provider.cleanupErr = nil
  100. old := now.Add(-48 * time.Hour)
  101. expired := now.Add(-time.Minute)
  102. future := now.Add(time.Hour)
  103. repo.jobs["imgbatch_cleanup"].FinishedAt = &old
  104. repo.jobs["imgbatch_cleanup"].OutputExpiresAt = &expired
  105. repo.jobs["imgbatch_running"] = cleanupTestJob("imgbatch_running", BatchImageJobStatusRunning)
  106. repo.jobs["imgbatch_running"].FinishedAt = &old
  107. repo.jobs["imgbatch_running"].OutputExpiresAt = &expired
  108. repo.jobs["imgbatch_future"] = cleanupTestJob("imgbatch_future", BatchImageJobStatusCompleted)
  109. repo.jobs["imgbatch_future"].FinishedAt = &old
  110. repo.jobs["imgbatch_future"].OutputExpiresAt = &future
  111. result, err := svc.RunOnce(ctx, now)
  112. require.NoError(t, err)
  113. require.Equal(t, 2, result.InputCleaned)
  114. require.Equal(t, 1, result.OutputCleaned)
  115. require.Equal(t, BatchImageJobStatusRunning, repo.jobs["imgbatch_running"].Status)
  116. require.Nil(t, repo.jobs["imgbatch_future"].OutputDeletedAt)
  117. require.NotContains(t, strings.Join(repo.events["imgbatch_running"], ","), "cleanup")
  118. })
  119. }
  120. func TestBatchImageSettlementOutputExpiration(t *testing.T) {
  121. repo := newFakeBatchImageRepository()
  122. job := testSettlingBatchImageJob("imgbatch_expire")
  123. repo.jobs[job.BatchID] = job
  124. billing := &fakeBatchImageBillingRepo{}
  125. svc := &BatchImageSettlementService{
  126. Repo: repo,
  127. BillingRepo: billing,
  128. Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25},
  129. Config: &config.Config{BatchImage: config.BatchImageConfig{OutputRetentionAfterTerminalHours: 5}},
  130. }
  131. _, err := svc.Settle(context.Background(), job.BatchID)
  132. require.NoError(t, err)
  133. require.NotNil(t, repo.jobs[job.BatchID].OutputExpiresAt)
  134. require.WithinDuration(t, time.Now().Add(5*time.Hour), *repo.jobs[job.BatchID].OutputExpiresAt, time.Minute)
  135. existing := time.Now().Add(time.Hour)
  136. second := testSettlingBatchImageJob("imgbatch_keep_expire")
  137. second.OutputExpiresAt = &existing
  138. repo.jobs[second.BatchID] = second
  139. _, err = svc.Settle(context.Background(), second.BatchID)
  140. require.NoError(t, err)
  141. require.Equal(t, existing, *repo.jobs[second.BatchID].OutputExpiresAt)
  142. }
  143. func TestBatchImageDownloadAfterOutputDeletedReturnsGone(t *testing.T) {
  144. svc, repo, _ := newTestBatchImageDownloadService()
  145. now := time.Now()
  146. repo.jobs["imgbatch_download"].Status = BatchImageJobStatusOutputDeleted
  147. repo.jobs["imgbatch_download"].OutputDeletedAt = &now
  148. stream, err := svc.OpenItemContent(context.Background(), testBatchImageOwner(), "imgbatch_download", "cover/../001", 0)
  149. require.Nil(t, stream)
  150. require.ErrorIs(t, err, ErrBatchImageOutputDeleted)
  151. var out strings.Builder
  152. result, err := svc.StreamZip(context.Background(), testBatchImageOwner(), "imgbatch_download", BatchImageZipOptions{}, &out)
  153. require.Nil(t, result)
  154. require.ErrorIs(t, err, ErrBatchImageOutputDeleted)
  155. }
  156. func newTestBatchImageCleanupService() (*BatchImageCleanupService, *fakeBatchImageRepository, *publicBatchImageProvider) {
  157. repo := newFakeBatchImageRepository()
  158. repo.jobs["imgbatch_cleanup"] = cleanupTestJob("imgbatch_cleanup", BatchImageJobStatusCompleted)
  159. provider := &publicBatchImageProvider{name: BatchImageProviderGeminiAPI}
  160. accountID := int64(101)
  161. svc := &BatchImageCleanupService{
  162. Repo: repo,
  163. ProviderRegistry: NewBatchImageProviderRegistry(provider),
  164. AccountResolver: &fakeBatchImageAccountResolver{account: &Account{ID: accountID, Platform: PlatformGemini, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true}},
  165. Config: &config.Config{BatchImage: config.BatchImageConfig{CleanupBatchSize: 10, InputRetentionAfterTerminalHours: 24}},
  166. }
  167. return svc, repo, provider
  168. }
  169. func cleanupTestJob(batchID, status string) *BatchImageJob {
  170. apiKeyID := int64(22)
  171. accountID := int64(101)
  172. now := time.Now().Add(-48 * time.Hour)
  173. return &BatchImageJob{
  174. BatchID: batchID,
  175. UserID: 11,
  176. APIKeyID: &apiKeyID,
  177. AccountID: &accountID,
  178. Provider: BatchImageProviderGeminiAPI,
  179. Model: "gemini-2.5-flash-image",
  180. Status: status,
  181. ProviderJobName: batchImageStringPtr("providers/internal/job"),
  182. ProviderInputRef: batchImageStringPtr("files/internal/input"),
  183. ProviderOutputRef: batchImageStringPtr("files/internal/output"),
  184. ItemCount: 1,
  185. SuccessCount: 1,
  186. CreatedAt: now,
  187. UpdatedAt: now,
  188. FinishedAt: &now,
  189. SettledAt: &now,
  190. }
  191. }
  192. func mustJSON(t *testing.T, v any) string {
  193. t.Helper()
  194. b, err := json.Marshal(v)
  195. require.NoError(t, err)
  196. return string(b)
  197. }