Files
sub2api/backend/internal/repository/redeem_cache_integration_test.go
T
TT 666a5c6f87 fix(redeem): use a fixed 10-minute failure window
The redeem failure counter used INCR followed by Expire(24h) on every
attempt, so each failed attempt refreshed the key TTL and the window
never actually closed for a user who kept retrying within 24h.

- Count attempts in a fixed 10-minute window via a small Lua script
  (INCR, then PEXPIRE only when the key is created or has no TTL), which
  is atomic and also repairs keys that lost their TTL.
- Move the counter to the redeem:ratelimit:v2: key space so pre-existing
  24h counters do not seed the new window.
- Rename the service threshold to redeemMaxFailedAttempts (30) to reflect
  that it bounds attempts per window, not per hour.

Public redeem behavior is unchanged apart from the window semantics:
the check fails open when the counter read fails, and a failed
increment never masks the underlying domain error.
2026-09-05 23:38:50 +08:00

132 lines
4.5 KiB
Go

//go:build integration
package repository
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type RedeemCacheSuite struct {
IntegrationRedisSuite
cache *redeemCache
}
func (s *RedeemCacheSuite) SetupTest() {
s.IntegrationRedisSuite.SetupTest()
s.cache = NewRedeemCache(s.rdb).(*redeemCache)
}
func (s *RedeemCacheSuite) TestGetRedeemAttemptCount_Missing() {
missingUserID := int64(99999)
count, err := s.cache.GetRedeemAttemptCount(s.ctx, missingUserID)
require.NoError(s.T(), err, "expected nil error for missing rate-limit key")
require.Equal(s.T(), 0, count, "expected zero count for missing key")
}
func (s *RedeemCacheSuite) TestIncrementAndGetRedeemAttemptCount() {
userID := int64(1)
key := fmt.Sprintf("%s%d", redeemRateLimitKeyPrefix, userID)
require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID), "IncrementRedeemAttemptCount")
count, err := s.cache.GetRedeemAttemptCount(s.ctx, userID)
require.NoError(s.T(), err, "GetRedeemAttemptCount")
require.Equal(s.T(), 1, count, "count mismatch")
ttl, err := s.rdb.TTL(s.ctx, key).Result()
require.NoError(s.T(), err, "TTL")
s.AssertTTLWithin(ttl, redeemRateLimitWindow-time.Second, redeemRateLimitWindow)
}
func (s *RedeemCacheSuite) TestIncrementDoesNotRefreshFixedWindow() {
userID := int64(3)
key := redeemRateLimitKey(userID)
shortTTL := 5 * time.Minute
require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID))
require.NoError(s.T(), s.rdb.PExpire(s.ctx, key, shortTTL).Err())
require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID))
ttl, err := s.rdb.PTTL(s.ctx, key).Result()
require.NoError(s.T(), err)
s.AssertTTLWithin(ttl, shortTTL-time.Second, shortTTL)
}
func (s *RedeemCacheSuite) TestIncrementRepairsMissingTTL() {
userID := int64(4)
key := redeemRateLimitKey(userID)
require.NoError(s.T(), s.rdb.Set(s.ctx, key, 5, 0).Err())
ttl, err := s.rdb.PTTL(s.ctx, key).Result()
require.NoError(s.T(), err)
require.Equal(s.T(), time.Duration(-1), ttl)
require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID))
count, err := s.cache.GetRedeemAttemptCount(s.ctx, userID)
require.NoError(s.T(), err)
require.Equal(s.T(), 6, count)
ttl, err = s.rdb.PTTL(s.ctx, key).Result()
require.NoError(s.T(), err)
s.AssertTTLWithin(ttl, redeemRateLimitWindow-time.Second, redeemRateLimitWindow)
}
func (s *RedeemCacheSuite) TestMultipleIncrements() {
userID := int64(2)
require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID))
require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID))
require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID))
count, err := s.cache.GetRedeemAttemptCount(s.ctx, userID)
require.NoError(s.T(), err)
require.Equal(s.T(), 3, count, "count after 3 increments")
}
func (s *RedeemCacheSuite) TestAcquireAndReleaseRedeemLock() {
ok, err := s.cache.AcquireRedeemLock(s.ctx, "CODE", 10*time.Second)
require.NoError(s.T(), err, "AcquireRedeemLock")
require.True(s.T(), ok)
// Second acquire should fail
ok, err = s.cache.AcquireRedeemLock(s.ctx, "CODE", 10*time.Second)
require.NoError(s.T(), err, "AcquireRedeemLock 2")
require.False(s.T(), ok, "expected lock to be held")
// Release
require.NoError(s.T(), s.cache.ReleaseRedeemLock(s.ctx, "CODE"), "ReleaseRedeemLock")
// Now acquire should succeed
ok, err = s.cache.AcquireRedeemLock(s.ctx, "CODE", 10*time.Second)
require.NoError(s.T(), err, "AcquireRedeemLock after release")
require.True(s.T(), ok)
}
func (s *RedeemCacheSuite) TestAcquireRedeemLock_TTL() {
lockKey := redeemLockKeyPrefix + "CODE2"
lockTTL := 15 * time.Second
ok, err := s.cache.AcquireRedeemLock(s.ctx, "CODE2", lockTTL)
require.NoError(s.T(), err, "AcquireRedeemLock CODE2")
require.True(s.T(), ok)
ttl, err := s.rdb.TTL(s.ctx, lockKey).Result()
require.NoError(s.T(), err, "TTL lock key")
s.AssertTTLWithin(ttl, 1*time.Second, lockTTL)
}
func (s *RedeemCacheSuite) TestReleaseRedeemLock_Idempotent() {
// Release a lock that doesn't exist should not error
require.NoError(s.T(), s.cache.ReleaseRedeemLock(s.ctx, "NONEXISTENT"))
// Acquire, release, release again
ok, err := s.cache.AcquireRedeemLock(s.ctx, "IDEMPOTENT", 10*time.Second)
require.NoError(s.T(), err)
require.True(s.T(), ok)
require.NoError(s.T(), s.cache.ReleaseRedeemLock(s.ctx, "IDEMPOTENT"))
require.NoError(s.T(), s.cache.ReleaseRedeemLock(s.ctx, "IDEMPOTENT"), "second release should be idempotent")
}
func TestRedeemCacheSuite(t *testing.T) {
suite.Run(t, new(RedeemCacheSuite))
}