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] }