redeem_rate_limit_test.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "testing"
  7. "time"
  8. "github.com/stretchr/testify/require"
  9. )
  10. // redeemRateLimitCacheStub 实现 RedeemCache,记录各方法调用次数并允许注入错误。
  11. type redeemRateLimitCacheStub struct {
  12. count int
  13. getErr error
  14. incrementErr error
  15. getCalls int
  16. incrementCalls int
  17. acquireCalls int
  18. releaseCalls int
  19. }
  20. func (c *redeemRateLimitCacheStub) GetRedeemAttemptCount(context.Context, int64) (int, error) {
  21. c.getCalls++
  22. return c.count, c.getErr
  23. }
  24. func (c *redeemRateLimitCacheStub) IncrementRedeemAttemptCount(context.Context, int64) error {
  25. c.incrementCalls++
  26. if c.incrementErr != nil {
  27. return c.incrementErr
  28. }
  29. c.count++
  30. return nil
  31. }
  32. func (c *redeemRateLimitCacheStub) AcquireRedeemLock(context.Context, string, time.Duration) (bool, error) {
  33. c.acquireCalls++
  34. return true, nil
  35. }
  36. func (c *redeemRateLimitCacheStub) ReleaseRedeemLock(context.Context, string) error {
  37. c.releaseCalls++
  38. return nil
  39. }
  40. func TestPublicRedeemAllowsLastAttemptBeforeLimit(t *testing.T) {
  41. cache := &redeemRateLimitCacheStub{count: redeemMaxFailedAttempts - 1}
  42. redeemRepo := &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}}
  43. svc := &RedeemService{redeemRepo: redeemRepo, cache: cache}
  44. result, err := svc.Redeem(context.Background(), 42, "MISSING")
  45. require.Nil(t, result)
  46. require.ErrorIs(t, err, ErrRedeemCodeNotFound)
  47. require.Equal(t, redeemMaxFailedAttempts, cache.count)
  48. require.Equal(t, 1, cache.getCalls)
  49. require.Equal(t, 1, cache.incrementCalls)
  50. require.Equal(t, 1, cache.acquireCalls)
  51. require.Equal(t, 1, cache.releaseCalls)
  52. }
  53. func TestPublicRedeemCountsOnlyConfiguredDomainFailures(t *testing.T) {
  54. past := time.Now().Add(-time.Minute)
  55. tests := []struct {
  56. name string
  57. code RedeemCode
  58. input string
  59. wantErr error
  60. }{
  61. {
  62. name: "not found",
  63. code: RedeemCode{Code: "OTHER"},
  64. input: "MISSING",
  65. wantErr: ErrRedeemCodeNotFound,
  66. },
  67. {
  68. name: "expired",
  69. code: RedeemCode{
  70. ID: 1, Code: "EXPIRED", Type: RedeemTypeBalance, Value: 10,
  71. Status: StatusUnused, ExpiresAt: &past,
  72. },
  73. input: "EXPIRED",
  74. wantErr: ErrRedeemCodeExpired,
  75. },
  76. {
  77. name: "used",
  78. code: RedeemCode{
  79. ID: 2, Code: "USED", Type: RedeemTypeBalance, Value: 10,
  80. Status: StatusUsed,
  81. },
  82. input: "USED",
  83. wantErr: ErrRedeemCodeUsed,
  84. },
  85. }
  86. for _, tt := range tests {
  87. t.Run(tt.name, func(t *testing.T) {
  88. cache := &redeemRateLimitCacheStub{}
  89. svc := &RedeemService{
  90. redeemRepo: &redeemRejectRepo{code: tt.code},
  91. cache: cache,
  92. }
  93. result, err := svc.Redeem(context.Background(), 42, tt.input)
  94. require.Nil(t, result)
  95. require.ErrorIs(t, err, tt.wantErr)
  96. require.Equal(t, 1, cache.getCalls)
  97. require.Equal(t, 1, cache.incrementCalls)
  98. require.Equal(t, 1, cache.count)
  99. })
  100. }
  101. }
  102. func TestPublicRedeemRateLimitFailsOpenWhenCounterReadFails(t *testing.T) {
  103. cache := &redeemRateLimitCacheStub{
  104. count: redeemMaxFailedAttempts,
  105. getErr: errors.New("redis unavailable"),
  106. }
  107. svc := &RedeemService{
  108. redeemRepo: &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}},
  109. cache: cache,
  110. }
  111. result, err := svc.Redeem(context.Background(), 42, "MISSING")
  112. require.Nil(t, result)
  113. require.ErrorIs(t, err, ErrRedeemCodeNotFound)
  114. require.Equal(t, 1, cache.getCalls)
  115. require.Equal(t, 1, cache.incrementCalls)
  116. }
  117. func TestPublicRedeemCounterWriteFailurePreservesDomainError(t *testing.T) {
  118. cache := &redeemRateLimitCacheStub{incrementErr: errors.New("redis unavailable")}
  119. svc := &RedeemService{
  120. redeemRepo: &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}},
  121. cache: cache,
  122. }
  123. result, err := svc.Redeem(context.Background(), 42, "MISSING")
  124. require.Nil(t, result)
  125. require.ErrorIs(t, err, ErrRedeemCodeNotFound)
  126. require.Equal(t, 1, cache.incrementCalls)
  127. require.Zero(t, cache.count)
  128. }
  129. func TestSuccessfulPublicRedeemDoesNotMutateFailureCounter(t *testing.T) {
  130. ctx := context.Background()
  131. client := newPaymentConfigServiceTestClient(t)
  132. userID := int64(42)
  133. code := &RedeemCode{
  134. ID: 101, Code: "PUBLIC-SUCCESS", Type: RedeemTypeBalance, Value: 10, Status: StatusUnused,
  135. }
  136. redeemRepo := &paymentOrderLifecycleRedeemRepo{
  137. codesByCode: map[string]*RedeemCode{code.Code: code},
  138. }
  139. userRepo := &mockUserRepo{getByIDUser: &User{ID: userID}}
  140. userRepo.updateBalanceFn = func(context.Context, int64, float64) error { return nil }
  141. cache := &redeemRateLimitCacheStub{count: 7}
  142. svc := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, nil)
  143. result, err := svc.Redeem(ctx, userID, code.Code)
  144. require.NoError(t, err)
  145. require.NotNil(t, result)
  146. require.Equal(t, 1, cache.getCalls)
  147. require.Zero(t, cache.incrementCalls)
  148. require.Equal(t, 7, cache.count)
  149. require.Equal(t, 1, cache.acquireCalls)
  150. require.Equal(t, 1, cache.releaseCalls)
  151. require.Len(t, redeemRepo.useCalls, 1)
  152. }