admin_service_update_balance_test.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "testing"
  7. "github.com/stretchr/testify/require"
  8. )
  9. type balanceUserRepoStub struct {
  10. *userRepoStub
  11. adjustErr error
  12. // changes 记录每次原子余额变更,顺序与调用顺序一致。
  13. changes []BalanceChange
  14. }
  15. func (s *balanceUserRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
  16. return s.apply(func(current float64) float64 { return current + delta })
  17. }
  18. func (s *balanceUserRepoStub) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
  19. return s.apply(func(float64) float64 { return value })
  20. }
  21. func (s *balanceUserRepoStub) apply(next func(current float64) float64) (BalanceChange, error) {
  22. if s.adjustErr != nil {
  23. return BalanceChange{}, s.adjustErr
  24. }
  25. if s.userRepoStub == nil || s.userRepoStub.user == nil {
  26. return BalanceChange{}, ErrUserNotFound
  27. }
  28. change := BalanceChange{Old: s.userRepoStub.user.Balance}
  29. change.New = next(change.Old)
  30. if change.New < 0 {
  31. return change, ErrBalanceNegative
  32. }
  33. s.userRepoStub.user.Balance = change.New
  34. s.changes = append(s.changes, change)
  35. return change, nil
  36. }
  37. type balanceRedeemRepoStub struct {
  38. *redeemRepoStub
  39. created []*RedeemCode
  40. }
  41. func (s *balanceRedeemRepoStub) Create(ctx context.Context, code *RedeemCode) error {
  42. if code == nil {
  43. return nil
  44. }
  45. clone := *code
  46. s.created = append(s.created, &clone)
  47. return nil
  48. }
  49. type authCacheInvalidatorStub struct {
  50. userIDs []int64
  51. groupIDs []int64
  52. keys []string
  53. }
  54. type adminRechargeAffiliateAccruerStub struct {
  55. calls []adminRechargeAffiliateAccrual
  56. rebate float64
  57. err error
  58. }
  59. type adminRechargeAffiliateAccrual struct {
  60. userID int64
  61. amount float64
  62. }
  63. func (s *adminRechargeAffiliateAccruerStub) AccrueInviteRebate(_ context.Context, userID int64, amount float64) (float64, error) {
  64. s.calls = append(s.calls, adminRechargeAffiliateAccrual{userID: userID, amount: amount})
  65. return s.rebate, s.err
  66. }
  67. func adminRechargeSettingService(enabled bool) *SettingService {
  68. values := map[string]string{}
  69. if enabled {
  70. values[SettingKeyAffiliateAdminRechargeEnabled] = "true"
  71. }
  72. return NewSettingService(&settingRepoStub{values: values}, nil)
  73. }
  74. func (s *authCacheInvalidatorStub) InvalidateAuthCacheByKey(ctx context.Context, key string) {
  75. s.keys = append(s.keys, key)
  76. }
  77. func (s *authCacheInvalidatorStub) InvalidateAuthCacheByUserID(ctx context.Context, userID int64) {
  78. s.userIDs = append(s.userIDs, userID)
  79. }
  80. func (s *authCacheInvalidatorStub) InvalidateAuthCacheByGroupID(ctx context.Context, groupID int64) {
  81. s.groupIDs = append(s.groupIDs, groupID)
  82. }
  83. // 管理员调账必须走原子的 AdjustBalance/SetBalance,而不是"读余额→算新值→整行写回",
  84. // 后者会把并发的计费扣款覆盖掉。userRepoStub.Update 对未预期的调用会 panic,
  85. // 因此这里同时证明它没被走到。
  86. func TestAdminService_UpdateUserBalance_UsesAtomicPrimitives(t *testing.T) {
  87. tests := []struct {
  88. name string
  89. operation string
  90. amount float64
  91. want BalanceChange
  92. }{
  93. {name: "add", operation: "add", amount: 5, want: BalanceChange{Old: 10, New: 15}},
  94. {name: "subtract", operation: "subtract", amount: 4, want: BalanceChange{Old: 10, New: 6}},
  95. {name: "set", operation: "set", amount: 2, want: BalanceChange{Old: 10, New: 2}},
  96. }
  97. for _, tt := range tests {
  98. t.Run(tt.name, func(t *testing.T) {
  99. repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 10}}}
  100. svc := &adminServiceImpl{
  101. userRepo: repo,
  102. redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
  103. }
  104. user, err := svc.UpdateUserBalance(context.Background(), 7, tt.amount, tt.operation, "")
  105. require.NoError(t, err)
  106. require.Equal(t, []BalanceChange{tt.want}, repo.changes)
  107. require.Equal(t, tt.want.New, user.Balance)
  108. })
  109. }
  110. }
  111. func TestAdminService_UpdateUserBalance_RejectsNegativeResult(t *testing.T) {
  112. repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 3}}}
  113. svc := &adminServiceImpl{
  114. userRepo: repo,
  115. redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
  116. }
  117. _, err := svc.UpdateUserBalance(context.Background(), 7, 4, "subtract", "")
  118. require.Error(t, err)
  119. require.Contains(t, err.Error(), "balance cannot be negative")
  120. require.Empty(t, repo.changes, "refused adjustment must not be applied")
  121. require.Equal(t, 3.0, repo.userRepoStub.user.Balance)
  122. }
  123. func TestAdminService_UpdateUserBalance_RejectsUnknownOperation(t *testing.T) {
  124. repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 10}}}
  125. svc := &adminServiceImpl{
  126. userRepo: repo,
  127. redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
  128. }
  129. _, err := svc.UpdateUserBalance(context.Background(), 7, 1, "multiply", "")
  130. require.Error(t, err)
  131. require.Empty(t, repo.changes)
  132. }
  133. func TestAdminService_UpdateUserBalance_InvalidatesAuthCache(t *testing.T) {
  134. baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
  135. repo := &balanceUserRepoStub{userRepoStub: baseRepo}
  136. redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
  137. invalidator := &authCacheInvalidatorStub{}
  138. svc := &adminServiceImpl{
  139. userRepo: repo,
  140. redeemCodeRepo: redeemRepo,
  141. authCacheInvalidator: invalidator,
  142. }
  143. _, err := svc.UpdateUserBalance(context.Background(), 7, 5, "add", "")
  144. require.NoError(t, err)
  145. require.Equal(t, []int64{7}, invalidator.userIDs)
  146. require.Len(t, redeemRepo.created, 1)
  147. }
  148. func TestAdminService_UpdateUserBalance_NoChangeNoInvalidate(t *testing.T) {
  149. baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
  150. repo := &balanceUserRepoStub{userRepoStub: baseRepo}
  151. redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
  152. invalidator := &authCacheInvalidatorStub{}
  153. svc := &adminServiceImpl{
  154. userRepo: repo,
  155. redeemCodeRepo: redeemRepo,
  156. authCacheInvalidator: invalidator,
  157. }
  158. _, err := svc.UpdateUserBalance(context.Background(), 7, 10, "set", "")
  159. require.NoError(t, err)
  160. require.Empty(t, invalidator.userIDs)
  161. require.Empty(t, redeemRepo.created)
  162. }
  163. func TestAdminService_UpdateUserBalance_AdminRechargeAffiliateRebate(t *testing.T) {
  164. tests := []struct {
  165. name string
  166. enabled bool
  167. operation string
  168. amount float64
  169. wantCalls []adminRechargeAffiliateAccrual
  170. }{
  171. {
  172. name: "disabled by default",
  173. operation: "add",
  174. amount: 5,
  175. },
  176. {
  177. name: "enabled add",
  178. enabled: true,
  179. operation: "add",
  180. amount: 0.1,
  181. wantCalls: []adminRechargeAffiliateAccrual{{userID: 7, amount: 0.1}},
  182. },
  183. {
  184. name: "enabled set increase",
  185. enabled: true,
  186. operation: "set",
  187. amount: 15,
  188. },
  189. {
  190. name: "enabled subtract",
  191. enabled: true,
  192. operation: "subtract",
  193. amount: 5,
  194. },
  195. }
  196. for _, tt := range tests {
  197. t.Run(tt.name, func(t *testing.T) {
  198. baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
  199. repo := &balanceUserRepoStub{userRepoStub: baseRepo}
  200. redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
  201. affiliate := &adminRechargeAffiliateAccruerStub{}
  202. svc := &adminServiceImpl{
  203. userRepo: repo,
  204. redeemCodeRepo: redeemRepo,
  205. settingService: adminRechargeSettingService(tt.enabled),
  206. affiliateService: affiliate,
  207. }
  208. _, err := svc.UpdateUserBalance(context.Background(), 7, tt.amount, tt.operation, "")
  209. require.NoError(t, err)
  210. require.Equal(t, tt.wantCalls, affiliate.calls)
  211. })
  212. }
  213. }
  214. func TestAdminService_UpdateUserBalance_AffiliateFailureDoesNotRollbackRecharge(t *testing.T) {
  215. baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
  216. repo := &balanceUserRepoStub{userRepoStub: baseRepo}
  217. redeemRepo := &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}}
  218. affiliate := &adminRechargeAffiliateAccruerStub{err: errors.New("affiliate unavailable")}
  219. svc := &adminServiceImpl{
  220. userRepo: repo,
  221. redeemCodeRepo: redeemRepo,
  222. settingService: adminRechargeSettingService(true),
  223. affiliateService: affiliate,
  224. }
  225. user, err := svc.UpdateUserBalance(context.Background(), 7, 5, "add", "")
  226. require.NoError(t, err)
  227. require.Equal(t, 15.0, user.Balance)
  228. require.Equal(t, []adminRechargeAffiliateAccrual{{userID: 7, amount: 5}}, affiliate.calls)
  229. require.Len(t, redeemRepo.created, 1)
  230. }