Files
hammy-backend/internal/ratelimit/ratelimit_test.go
T
2026-09-06 15:15:21 +02:00

209 lines
5.2 KiB
Go

package ratelimit
import (
"context"
"errors"
"testing"
"time"
)
func TestMemoryWindow(t *testing.T) {
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
m := NewMemory()
m.Now = func() time.Time { return now }
ctx := context.Background()
for i := 1; i <= 3; i++ {
res, err := m.Allow(ctx, "k", 3, time.Minute)
if err != nil || !res.Allowed {
t.Fatalf("request %d: allowed=%v err=%v", i, res.Allowed, err)
}
if res.Remaining != 3-i {
t.Errorf("request %d: remaining %d, want %d", i, res.Remaining, 3-i)
}
}
res, _ := m.Allow(ctx, "k", 3, time.Minute)
if res.Allowed {
t.Error("4th request allowed past a limit of 3")
}
if res.RetryAfter <= 0 {
t.Error("RetryAfter not set on a rejection")
}
// Window rolls over.
now = now.Add(time.Minute + time.Second)
if res, _ := m.Allow(ctx, "k", 3, time.Minute); !res.Allowed {
t.Error("request rejected after the window expired")
}
}
func TestMemoryKeysAreIndependent(t *testing.T) {
m := NewMemory()
ctx := context.Background()
for i := 0; i < 5; i++ {
m.Allow(ctx, "a", 5, time.Minute)
}
if res, _ := m.Allow(ctx, "a", 5, time.Minute); res.Allowed {
t.Error("key a not limited")
}
if res, _ := m.Allow(ctx, "b", 5, time.Minute); !res.Allowed {
t.Error("key b affected by key a")
}
}
func TestMemoryZeroLimitIsUnlimited(t *testing.T) {
m := NewMemory()
for i := 0; i < 1000; i++ {
if res, _ := m.Allow(context.Background(), "k", 0, time.Minute); !res.Allowed {
t.Fatalf("zero limit rejected at %d", i)
}
}
}
func TestMemorySweep(t *testing.T) {
now := time.Now()
m := NewMemory()
m.Now = func() time.Time { return now }
m.Allow(context.Background(), "a", 5, time.Minute)
m.Allow(context.Background(), "b", 5, time.Hour)
if n := m.Sweep(); n != 0 {
t.Errorf("swept %d live windows", n)
}
now = now.Add(2 * time.Minute)
if n := m.Sweep(); n != 1 {
t.Errorf("swept %d, want 1 (a expired, b did not)", n)
}
}
func TestMemoryIsConcurrencySafe(t *testing.T) {
m := NewMemory()
ctx := context.Background()
const goroutines, each = 20, 50
done := make(chan int, goroutines)
for g := 0; g < goroutines; g++ {
go func() {
allowed := 0
for i := 0; i < each; i++ {
if res, _ := m.Allow(ctx, "shared", 100, time.Minute); res.Allowed {
allowed++
}
}
done <- allowed
}()
}
total := 0
for g := 0; g < goroutines; g++ {
total += <-done
}
// Exactly 100 of the 1000 attempts may pass. A check-then-increment race
// would let more through.
if total != 100 {
t.Errorf("%d requests allowed under concurrency, want exactly 100", total)
}
}
// fakeRunner stands in for Redis so the script result handling can be tested.
type fakeRunner struct {
ret any
err error
keys []string
args []any
}
func (f *fakeRunner) Eval(_ context.Context, _ string, keys []string, args ...any) (any, error) {
f.keys, f.args = keys, args
return f.ret, f.err
}
func TestRedisResultParsing(t *testing.T) {
ctx := context.Background()
t.Run("under limit", func(t *testing.T) {
f := &fakeRunner{ret: []any{int64(3), int64(45000)}}
r := NewRedis(f)
res, err := r.Allow(ctx, "key:1", 10, time.Minute)
if err != nil {
t.Fatal(err)
}
if !res.Allowed || res.Remaining != 7 {
t.Errorf("allowed=%v remaining=%d, want true/7", res.Allowed, res.Remaining)
}
if res.RetryAfter != 45*time.Second {
t.Errorf("RetryAfter %v, want 45s", res.RetryAfter)
}
if len(f.keys) != 1 || f.keys[0] != "rl:key:1" {
t.Errorf("keys %v, want [rl:key:1]", f.keys)
}
})
t.Run("over limit", func(t *testing.T) {
r := NewRedis(&fakeRunner{ret: []any{int64(11), int64(1000)}})
res, _ := r.Allow(ctx, "k", 10, time.Minute)
if res.Allowed || res.Remaining != 0 {
t.Errorf("allowed=%v remaining=%d, want false/0", res.Allowed, res.Remaining)
}
})
t.Run("exactly at limit is allowed", func(t *testing.T) {
r := NewRedis(&fakeRunner{ret: []any{int64(10), int64(1000)}})
if res, _ := r.Allow(ctx, "k", 10, time.Minute); !res.Allowed {
t.Error("request at exactly the limit was rejected")
}
})
t.Run("plain int accepted", func(t *testing.T) {
r := NewRedis(&fakeRunner{ret: []any{1, 1000}})
if _, err := r.Allow(ctx, "k", 10, time.Minute); err != nil {
t.Errorf("int result rejected: %v", err)
}
})
t.Run("missing ttl falls back to the window", func(t *testing.T) {
r := NewRedis(&fakeRunner{ret: []any{int64(11), int64(-1)}})
res, _ := r.Allow(ctx, "k", 10, time.Minute)
if res.RetryAfter != time.Minute {
t.Errorf("RetryAfter %v, want 1m", res.RetryAfter)
}
})
t.Run("eval error propagates", func(t *testing.T) {
r := NewRedis(&fakeRunner{err: errors.New("nope")})
if _, err := r.Allow(ctx, "k", 10, time.Minute); err == nil {
t.Error("expected an error so the caller can fail open")
}
})
t.Run("garbage result is an error not a panic", func(t *testing.T) {
r := NewRedis(&fakeRunner{ret: "not a list"})
if _, err := r.Allow(ctx, "k", 10, time.Minute); err == nil {
t.Error("expected an error")
}
})
t.Run("zero limit skips redis entirely", func(t *testing.T) {
f := &fakeRunner{ret: []any{int64(1), int64(1)}}
r := NewRedis(f)
res, _ := r.Allow(ctx, "k", 0, time.Minute)
if !res.Allowed {
t.Error("zero limit rejected")
}
if f.keys != nil {
t.Error("redis was called for an unlimited tier")
}
})
}