summaryrefslogtreecommitdiffstats
path: root/internal/bot/ratelimit_test.go
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 15:18:21 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 15:18:21 -0300
commit3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 (patch)
treeaa964675c588b80e4f81182d34130ba2d7d984e1 /internal/bot/ratelimit_test.go
downloadtelegram-507b9f5009ff8720e0ea9a43c2f6bf888fd53ddb.tar.gz
Initial importv0.1.0
Diffstat (limited to 'internal/bot/ratelimit_test.go')
-rw-r--r--internal/bot/ratelimit_test.go128
1 files changed, 128 insertions, 0 deletions
diff --git a/internal/bot/ratelimit_test.go b/internal/bot/ratelimit_test.go
new file mode 100644
index 0000000..7f09787
--- /dev/null
+++ b/internal/bot/ratelimit_test.go
@@ -0,0 +1,128 @@
+package bot
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+type fakeClock struct {
+ mu sync.Mutex
+ now time.Time
+}
+
+func (c *fakeClock) Now() time.Time {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.now
+}
+
+func (c *fakeClock) Advance(d time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.now = c.now.Add(d)
+}
+
+func TestLimiterAllowsTheBurstThenStops(t *testing.T) {
+ clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
+ limiter := NewLimiterWith(3, time.Second, 30*time.Second, clock.Now)
+
+ for i := 0; i < 3; i++ {
+ if decision := limiter.Allow(1); !decision.Allowed {
+ t.Fatalf("request %d was refused inside the burst", i+1)
+ }
+ }
+ decision := limiter.Allow(1)
+ if decision.Allowed {
+ t.Fatal("the fourth request should be refused")
+ }
+ if !decision.Warn {
+ t.Fatal("the first refusal should warn the user")
+ }
+ if decision.RetryAfter <= 0 {
+ t.Fatalf("retry after = %v, want a positive hint", decision.RetryAfter)
+ }
+}
+
+func TestLimiterWarnsOncePerWindow(t *testing.T) {
+ clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
+ limiter := NewLimiterWith(1, time.Minute, 30*time.Second, clock.Now)
+
+ limiter.Allow(1)
+ if !limiter.Allow(1).Warn {
+ t.Fatal("the first refusal should warn")
+ }
+ if limiter.Allow(1).Warn {
+ t.Fatal("a second refusal inside the window should stay silent")
+ }
+
+ clock.Advance(31 * time.Second)
+ if !limiter.Allow(1).Warn {
+ t.Fatal("a refusal after the window should warn again")
+ }
+}
+
+func TestLimiterRefills(t *testing.T) {
+ clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
+ limiter := NewLimiterWith(2, time.Second, time.Minute, clock.Now)
+
+ limiter.Allow(1)
+ limiter.Allow(1)
+ if limiter.Allow(1).Allowed {
+ t.Fatal("the bucket should be empty")
+ }
+
+ clock.Advance(time.Second)
+ if !limiter.Allow(1).Allowed {
+ t.Fatal("one second should have bought one token")
+ }
+ if limiter.Allow(1).Allowed {
+ t.Fatal("only one token should have been refilled")
+ }
+}
+
+func TestLimiterNeverExceedsTheBurst(t *testing.T) {
+ clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
+ limiter := NewLimiterWith(2, time.Second, time.Minute, clock.Now)
+
+ limiter.Allow(1)
+ clock.Advance(time.Hour)
+
+ if !limiter.Allow(1).Allowed || !limiter.Allow(1).Allowed {
+ t.Fatal("the bucket should be full again")
+ }
+ if limiter.Allow(1).Allowed {
+ t.Fatal("an idle hour must not bank more than the burst")
+ }
+}
+
+func TestLimiterKeysAreIndependent(t *testing.T) {
+ clock := &fakeClock{now: time.Unix(1_700_000_000, 0)}
+ limiter := NewLimiterWith(1, time.Minute, time.Minute, clock.Now)
+
+ if !limiter.Allow(1).Allowed {
+ t.Fatal("first user refused")
+ }
+ if limiter.Allow(1).Allowed {
+ t.Fatal("first user should now be limited")
+ }
+ if !limiter.Allow(2).Allowed {
+ t.Fatal("one user's flood must not silence another")
+ }
+}
+
+func TestLimiterIsConcurrencySafe(t *testing.T) {
+ limiter := NewLimiterWith(100, time.Millisecond, time.Second, nil)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func(key int64) {
+ defer wg.Done()
+ for j := 0; j < 20; j++ {
+ limiter.Allow(key % 5)
+ }
+ }(int64(i))
+ }
+ wg.Wait()
+}