2026-07-12 00:47:20 +08:00

255 lines
10 KiB
Go

package giftlimit
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
)
type redisScriptCall struct {
script string
keys []string
args []any
}
type fakeRedisScriptRunner struct {
result []any
results [][]any
errors []error
calls []redisScriptCall
}
func (f *fakeRedisScriptRunner) evalSlice(_ context.Context, script string, keys []string, args ...any) ([]any, error) {
f.calls = append(f.calls, redisScriptCall{
script: script,
keys: append([]string(nil), keys...),
args: append([]any(nil), args...),
})
if len(f.errors) > 0 {
err := f.errors[0]
f.errors = f.errors[1:]
if err != nil {
return nil, err
}
}
if len(f.results) > 0 {
result := f.results[0]
f.results = f.results[1:]
return result, nil
}
return f.result, nil
}
type blockingRedisScriptRunner struct{}
func (blockingRedisScriptRunner) evalSlice(ctx context.Context, _ string, _ []string, _ ...any) ([]any, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func TestRedisClientOptionsEnforceBoundedContextAwareIO(t *testing.T) {
options := redisClientOptions(" 127.0.0.1:6379 ", "secret", 3)
if options.Addr != "127.0.0.1:6379" || options.Password != "secret" || options.DB != 3 {
t.Fatalf("Redis identity options mismatch: %+v", options)
}
if !options.ContextTimeoutEnabled || options.MaxRetries != 1 || options.DialerRetries != 1 {
t.Fatalf("Redis timeout/retry guard mismatch: %+v", options)
}
if options.DialTimeout != redisDialTimeout || options.ReadTimeout != redisReadTimeout || options.WriteTimeout != redisWriteTimeout || options.PoolTimeout != redisPoolTimeout {
t.Fatalf("Redis socket/pool timeouts mismatch: %+v", options)
}
}
func TestRedisLimiterAcquireUsesThreeWeightedClusterSafeLayers(t *testing.T) {
runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}}
limiter := newRedisLimiter(runner, Config{
KeyPrefix: "test:gift:",
UserBucketCapacity: 40,
UserRefillTokensPerSecond: 15,
RoomBucketCapacity: 120,
RoomRefillTokensPerSecond: 40,
AppBucketCapacity: 1000,
AppRefillTokensPerSecond: 500,
MaxInFlight: 2,
InFlightLease: 10 * time.Second,
RedisOperationTimeout: time.Second,
})
decision, err := limiter.Acquire(context.Background(), Input{
AppCode: "LALU",
UserID: 42,
RoomID: "room:unsafe/value",
RequestID: "req-1",
Weight: 13,
})
if err != nil {
t.Fatalf("Acquire failed: %v", err)
}
if !decision.Allowed || decision.Lease.inFlightKey == "" || decision.Lease.member == "" {
t.Fatalf("allowed decision must include release lease: %+v", decision)
}
if len(runner.calls) != 3 {
t.Fatalf("Acquire must execute app, room and user layers: calls=%d", len(runner.calls))
}
if runner.calls[0].script != redisUserAcquireScript || runner.calls[1].script != redisTokenBucketScript || runner.calls[2].script != redisTokenBucketScript {
t.Fatalf("layer script order mismatch: %+v", runner.calls)
}
if len(runner.calls[0].keys) != 2 || len(runner.calls[1].keys) != 1 || len(runner.calls[2].keys) != 1 {
t.Fatalf("each Lua must stay inside one aggregation slot: %+v", runner.calls)
}
appTag := redisHashTag(runner.calls[2].keys[0])
roomTag := redisHashTag(runner.calls[1].keys[0])
userTag := redisHashTag(runner.calls[0].keys[0])
if appTag == "" || roomTag == "" || userTag == "" || appTag == roomTag || appTag == userTag || roomTag == userTag {
t.Fatalf("aggregation layers must use distinct hash slots: app=%q room=%q user=%q", appTag, roomTag, userTag)
}
if redisHashTag(runner.calls[0].keys[1]) != userTag {
t.Fatalf("user bucket and in-flight lease must share one hash slot: %v", runner.calls[0].keys)
}
for _, call := range runner.calls {
for _, key := range call.keys {
if strings.Contains(key, "room:unsafe") || strings.Contains(key, "LALU") {
t.Fatalf("untrusted scope leaked into Redis key: %q", key)
}
}
if got := call.args[2]; got != 13 {
t.Fatalf("target_count must weight every layer: got=%v call=%+v", got, call)
}
}
if runner.calls[0].args[3] != 2 || runner.calls[0].args[4] != int64(10_000) {
t.Fatalf("user concurrency/lease args mismatch: %v", runner.calls[0].args)
}
}
func TestRedisLimiterStopsAtRejectedLayerWithoutUnsafeRollback(t *testing.T) {
tests := []struct {
name string
results [][]any
wantCalls int
wantRetryMS int64
}{
{name: "user", results: [][]any{{int64(0), int64(101)}}, wantCalls: 1, wantRetryMS: 101},
{name: "room", results: [][]any{{int64(1), int64(0)}, {int64(0), int64(202)}, {int64(1)}}, wantCalls: 3, wantRetryMS: 202},
{name: "app", results: [][]any{{int64(1), int64(0)}, {int64(1), int64(0)}, {int64(0), int64(303)}, {int64(1)}}, wantCalls: 4, wantRetryMS: 303},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runner := &fakeRedisScriptRunner{results: test.results}
limiter := newRedisLimiter(runner, Config{})
decision, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-deny", Weight: 1})
if err != nil {
t.Fatalf("Acquire failed: %v", err)
}
if decision.Allowed || decision.RetryAfter != time.Duration(test.wantRetryMS)*time.Millisecond || len(runner.calls) != test.wantCalls {
t.Fatalf("denied layer mismatch: decision=%+v calls=%d", decision, len(runner.calls))
}
if test.name != "user" && runner.calls[len(runner.calls)-1].script != redisReleaseScript {
t.Fatalf("shared-layer rejection must immediately release user lease: calls=%+v", runner.calls)
}
})
}
}
func TestRedisLimiterInFlightDenialDoesNotConsumeRoomOrApp(t *testing.T) {
runner := &fakeRedisScriptRunner{result: []any{int64(0), int64(250)}}
limiter := newRedisLimiter(runner, Config{})
decision, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-concurrent", Weight: 13})
if err != nil || decision.Allowed || decision.RetryAfter != 250*time.Millisecond {
t.Fatalf("user in-flight denial mismatch: decision=%+v err=%v", decision, err)
}
if len(runner.calls) != 1 || runner.calls[0].script != redisUserAcquireScript || len(runner.calls[0].keys) != 2 {
t.Fatalf("in-flight denial must stop before room/app: calls=%+v", runner.calls)
}
}
func TestRedisScopeKeysAggregateDifferentUsersInSameRoom(t *testing.T) {
left := buildRedisScopeKeys("test:", "lalu", Input{UserID: 42, RoomID: "room-1", RequestID: "req-left"})
right := buildRedisScopeKeys("test:", "lalu", Input{UserID: 43, RoomID: "room-1", RequestID: "req-right"})
if left.appBucket != right.appBucket || left.roomBucket != right.roomBucket {
t.Fatalf("different users in one room must share app and room buckets: left=%+v right=%+v", left, right)
}
if left.userBucket == right.userBucket || left.inFlight == right.inFlight {
t.Fatalf("different users must keep independent user buckets and leases: left=%+v right=%+v", left, right)
}
}
func TestRedisLimiterReleaseUsesLeaseIdentity(t *testing.T) {
runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}}
limiter := newRedisLimiter(runner, Config{})
decision, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-release", Weight: 1})
if err != nil {
t.Fatalf("Acquire failed: %v", err)
}
wantKey := decision.Lease.inFlightKey
wantMember := decision.Lease.member
if err := limiter.Release(context.Background(), decision.Lease); err != nil {
t.Fatalf("Release failed: %v", err)
}
lastCall := runner.calls[len(runner.calls)-1]
if !reflect.DeepEqual(lastCall.keys, []string{wantKey}) || !reflect.DeepEqual(lastCall.args, []any{wantMember}) || lastCall.script != redisReleaseScript {
t.Fatalf("release identity mismatch: %+v", lastCall)
}
}
func TestRedisLimiterAcquireDeadlineFailsClosedQuickly(t *testing.T) {
limiter := newRedisLimiter(blockingRedisScriptRunner{}, Config{RedisOperationTimeout: 30 * time.Millisecond})
startedAt := time.Now()
_, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-slow", Weight: 1})
elapsed := time.Since(startedAt)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("blocked Redis must fail closed with deadline: %v", err)
}
if elapsed < 20*time.Millisecond || elapsed > 200*time.Millisecond {
t.Fatalf("blocked Redis escaped short operation budget: elapsed=%s", elapsed)
}
}
func TestRedisLimiterReleaseDeadlineIsBounded(t *testing.T) {
limiter := newRedisLimiter(blockingRedisScriptRunner{}, Config{})
startedAt := time.Now()
err := limiter.Release(context.Background(), Lease{inFlightKey: "test:{user}:inflight", member: "req"})
elapsed := time.Since(startedAt)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("blocked release must honor deadline: %v", err)
}
if elapsed < 900*time.Millisecond || elapsed > 1500*time.Millisecond {
t.Fatalf("release deadline is not the configured 1s bound: elapsed=%s", elapsed)
}
}
func TestRedisLimiterPropagatesLayerAndProtocolErrors(t *testing.T) {
wantErr := errors.New("redis unavailable")
runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}, errors: []error{nil, wantErr, nil}}
limiter := newRedisLimiter(runner, Config{})
input := Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-error", Weight: 1}
if _, err := limiter.Acquire(context.Background(), input); !errors.Is(err, wantErr) || len(runner.calls) != 3 || runner.calls[2].script != redisReleaseScript {
t.Fatalf("room-layer backend error mismatch: err=%v calls=%d", err, len(runner.calls))
}
limiter = newRedisLimiter(&fakeRedisScriptRunner{result: []any{int64(1)}}, Config{})
if _, err := limiter.Acquire(context.Background(), input); err == nil {
t.Fatal("invalid Lua response must fail closed")
}
}
func TestRedisLimiterRejectsWeightAboveConfiguredCapacity(t *testing.T) {
runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}}
limiter := newRedisLimiter(runner, Config{UserBucketCapacity: 2})
_, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-heavy", Weight: 3})
if err == nil || len(runner.calls) != 0 {
t.Fatalf("impossible weight must fail before Redis: err=%v calls=%d", err, len(runner.calls))
}
}
func redisHashTag(key string) string {
start := strings.IndexByte(key, '{')
end := strings.IndexByte(key, '}')
if start < 0 || end <= start {
return ""
}
return key[start+1 : end]
}