account_handler_batch_delete_test.go 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. package admin
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "net/http"
  8. "net/http/httptest"
  9. "sync"
  10. "testing"
  11. "time"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/Wei-Shaw/sub2api/internal/service"
  15. )
  16. type batchDeleteAdminService struct {
  17. *stubAdminService
  18. mu sync.Mutex
  19. active int
  20. maxActive int
  21. deletedIDs []int64
  22. deleteErrorsByID map[int64]error
  23. accountsByID map[int64]*service.Account
  24. }
  25. func (s *batchDeleteAdminService) GetAccountsByIDs(_ context.Context, ids []int64) ([]*service.Account, error) {
  26. accounts := make([]*service.Account, 0, len(ids))
  27. for _, id := range ids {
  28. if s.accountsByID != nil {
  29. if account, ok := s.accountsByID[id]; ok {
  30. accounts = append(accounts, account)
  31. }
  32. continue
  33. }
  34. accounts = append(accounts, &service.Account{ID: id})
  35. }
  36. return accounts, nil
  37. }
  38. func (s *batchDeleteAdminService) DeleteAccount(ctx context.Context, id int64) error {
  39. s.mu.Lock()
  40. s.active++
  41. if s.active > s.maxActive {
  42. s.maxActive = s.active
  43. }
  44. s.mu.Unlock()
  45. select {
  46. case <-ctx.Done():
  47. s.mu.Lock()
  48. s.active--
  49. s.mu.Unlock()
  50. return ctx.Err()
  51. case <-time.After(10 * time.Millisecond):
  52. }
  53. s.mu.Lock()
  54. defer s.mu.Unlock()
  55. s.active--
  56. s.deletedIDs = append(s.deletedIDs, id)
  57. return s.deleteErrorsByID[id]
  58. }
  59. func setupAccountBatchDeleteRouter(adminSvc *batchDeleteAdminService) *gin.Engine {
  60. gin.SetMode(gin.TestMode)
  61. router := gin.New()
  62. handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  63. router.POST("/api/v1/admin/accounts/batch-delete", handler.BatchDelete)
  64. return router
  65. }
  66. func TestAccountHandlerBatchDeleteReturnsStablePerAccountResults(t *testing.T) {
  67. adminSvc := &batchDeleteAdminService{
  68. stubAdminService: newStubAdminService(),
  69. deleteErrorsByID: map[int64]error{
  70. 3: errors.New("delete failed"),
  71. },
  72. }
  73. router := setupAccountBatchDeleteRouter(adminSvc)
  74. rec := httptest.NewRecorder()
  75. req := httptest.NewRequest(
  76. http.MethodPost,
  77. "/api/v1/admin/accounts/batch-delete",
  78. bytes.NewBufferString(`{"account_ids":[5,4,3,2,1,2,0,-1]}`),
  79. )
  80. req.Header.Set("Content-Type", "application/json")
  81. router.ServeHTTP(rec, req)
  82. require.Equal(t, http.StatusOK, rec.Code)
  83. var payload struct {
  84. Data struct {
  85. Total int `json:"total"`
  86. Success int `json:"success"`
  87. Failed int `json:"failed"`
  88. SuccessIDs []int64 `json:"success_ids"`
  89. FailedIDs []int64 `json:"failed_ids"`
  90. Errors []struct {
  91. AccountID int64 `json:"account_id"`
  92. Error string `json:"error"`
  93. } `json:"errors"`
  94. } `json:"data"`
  95. }
  96. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  97. require.Equal(t, 5, payload.Data.Total)
  98. require.Equal(t, 4, payload.Data.Success)
  99. require.Equal(t, 1, payload.Data.Failed)
  100. require.Equal(t, []int64{1, 2, 4, 5}, payload.Data.SuccessIDs)
  101. require.Equal(t, []int64{3}, payload.Data.FailedIDs)
  102. require.Equal(t, int64(3), payload.Data.Errors[0].AccountID)
  103. require.Equal(t, "delete failed", payload.Data.Errors[0].Error)
  104. require.LessOrEqual(t, adminSvc.maxActive, 5)
  105. require.Greater(t, adminSvc.maxActive, 1)
  106. }
  107. func TestAccountHandlerBatchDeleteDoesNotRaceSelectedShadowWithParent(t *testing.T) {
  108. parentID := int64(1)
  109. adminSvc := &batchDeleteAdminService{
  110. stubAdminService: newStubAdminService(),
  111. accountsByID: map[int64]*service.Account{
  112. 1: {ID: 1},
  113. 2: {ID: 2, ParentAccountID: &parentID},
  114. 3: {ID: 3},
  115. },
  116. }
  117. router := setupAccountBatchDeleteRouter(adminSvc)
  118. rec := httptest.NewRecorder()
  119. req := httptest.NewRequest(
  120. http.MethodPost,
  121. "/api/v1/admin/accounts/batch-delete",
  122. bytes.NewBufferString(`{"account_ids":[1,2,3]}`),
  123. )
  124. req.Header.Set("Content-Type", "application/json")
  125. router.ServeHTTP(rec, req)
  126. require.Equal(t, http.StatusOK, rec.Code)
  127. var payload struct {
  128. Data struct {
  129. SuccessIDs []int64 `json:"success_ids"`
  130. FailedIDs []int64 `json:"failed_ids"`
  131. } `json:"data"`
  132. }
  133. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  134. require.Equal(t, []int64{1, 2, 3}, payload.Data.SuccessIDs)
  135. require.Empty(t, payload.Data.FailedIDs)
  136. require.ElementsMatch(t, []int64{1, 3}, adminSvc.deletedIDs)
  137. }
  138. func TestAccountHandlerBatchDeleteRejectsEmptyNormalizedIDs(t *testing.T) {
  139. adminSvc := &batchDeleteAdminService{
  140. stubAdminService: newStubAdminService(),
  141. }
  142. router := setupAccountBatchDeleteRouter(adminSvc)
  143. rec := httptest.NewRecorder()
  144. req := httptest.NewRequest(
  145. http.MethodPost,
  146. "/api/v1/admin/accounts/batch-delete",
  147. bytes.NewBufferString(`{"account_ids":[0,-1]}`),
  148. )
  149. req.Header.Set("Content-Type", "application/json")
  150. router.ServeHTTP(rec, req)
  151. require.Equal(t, http.StatusBadRequest, rec.Code)
  152. }