package limiter import ( "sync" "sync/atomic" "testing" "time" ) func TestBurstThenDeny(t *testing.T) { s := NewStore(Limit{Rate: 1, Burst: 3}, nil) for i := 0; i < 3; i++ { if !s.Allow("k", 1).Allowed { t.Fatalf("request %d within burst should be allowed", i) } } d := s.Allow("k", 1) if d.Allowed { t.Fatal("4th immediate request should be denied") } if d.RetryAfter <= 0 || d.RetryAfter > time.Second { t.Fatalf("retry-after should be ~1s at rate 1, got %v", d.RetryAfter) } } func TestRefill(t *testing.T) { s := NewStore(Limit{Rate: 20, Burst: 1}, nil) if !s.Allow("k", 1).Allowed { t.Fatal("initial token missing") } if s.Allow("k", 1).Allowed { t.Fatal("should be empty immediately after") } time.Sleep(120 * time.Millisecond) if !s.Allow("k", 1).Allowed { t.Fatal("should have refilled") } } func TestBurstIsCapped(t *testing.T) { s := NewStore(Limit{Rate: 1000, Burst: 2}, nil) time.Sleep(50 * time.Millisecond) allowed := 0 for i := 0; i < 10; i++ { if s.Allow("k", 1).Allowed { allowed++ } } if allowed > 2 { t.Fatalf("idle time built credit beyond burst: %d", allowed) } } func TestKeysAreIsolatedAndOverridden(t *testing.T) { s := NewStore(Limit{Rate: 0.0001, Burst: 1}, map[string]Limit{"vip": {Rate: 0.0001, Burst: 5}}) if !s.Allow("a", 1).Allowed || s.Allow("a", 1).Allowed { t.Fatal("key a should allow exactly one") } if !s.Allow("b", 1).Allowed { t.Fatal("key b must have its own bucket") } for i := 0; i < 5; i++ { if !s.Allow("vip", 1).Allowed { t.Fatalf("vip request %d should be allowed (burst 5)", i) } } if s.Allow("vip", 1).Allowed { t.Fatal("vip 6th should be denied") } if got := s.Stats().Keys; got != 3 { t.Fatalf("expected 3 keys, got %d", got) } } func TestAllowN(t *testing.T) { s := NewStore(Limit{Rate: 0.0001, Burst: 10}, nil) if !s.Allow("k", 7).Allowed { t.Fatal("7 of 10 should be allowed") } if s.Allow("k", 4).Allowed { t.Fatal("4 more (3 left) should be denied") } if !s.Allow("k", 3).Allowed { t.Fatal("exactly 3 left should be allowed") } } func TestConcurrentNoOverAdmission(t *testing.T) { s := NewStore(Limit{Rate: 0.0001, Burst: 100}, nil) var allowed atomic.Int64 var wg sync.WaitGroup for i := 0; i < 50; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 10; j++ { if s.Allow("shared", 1).Allowed { allowed.Add(1) } } }() } wg.Wait() if got := allowed.Load(); got != 100 { t.Fatalf("expected exactly 100 admissions, got %d", got) } st := s.Stats() if st.Allowed != 100 || st.Denied != 400 { t.Fatalf("stats mismatch: %+v", st) } } func TestSweepEvictsIdleOnly(t *testing.T) { s := NewStore(Limit{Rate: 1000, Burst: 1}, nil) s.Allow("old", 1) time.Sleep(30 * time.Millisecond) s.Allow("fresh", 1) if n := s.Sweep(20 * time.Millisecond); n != 1 { t.Fatalf("expected 1 evicted, got %d", n) } if got := s.Stats().Keys; got != 1 { t.Fatalf("expected 1 key left, got %d", got) } // evicted key comes back full, which is what an idle bucket would be anyway if !s.Allow("old", 1).Allowed { t.Fatal("recreated bucket should start full") } }