package main import ( "testing" "time" ) // TestRateLimiter_AllowsUpToCapAndBlocksRest verifies the WO-018 contract: // 10 calls from the same IP succeed; the 11th is denied. func TestRateLimiter_AllowsUpToCapAndBlocksRest(t *testing.T) { rl := NewRateLimiter(10) for i := 1; i <= 10; i++ { if !rl.Allow("203.0.113.1") { t.Fatalf("call %d unexpectedly denied", i) } } if rl.Allow("203.0.113.1") { t.Fatalf("11th call must be denied") } } // TestRateLimiter_IsolatesByIP confirms that buckets are keyed per-IP. func TestRateLimiter_IsolatesByIP(t *testing.T) { rl := NewRateLimiter(2) for i := range 2 { if !rl.Allow("a") { t.Fatalf("call %d from 'a' should succeed", i+1) } } if rl.Allow("a") { t.Fatalf("third call from 'a' should be denied") } if !rl.Allow("b") { t.Fatalf("first call from 'b' should succeed even after 'a' is full") } } // TestRateLimiter_WindowResetsAfterHour verifies the fixed-window semantics: // once a bucket's resetsAt is in the past, the next Allow() call starts a // fresh window with count=1. This guards against a regression where the // limiter forgets to clear/reset stale buckets and locks out legitimate // traffic indefinitely. // // We exercise this by mutating the bucket's resetsAt directly to a time in // the past — equivalent to fast-forwarding the clock without waiting an hour. // The helper accesses the unexported buckets map; this is safe because // _test.go files compile inside the same package. func TestRateLimiter_WindowResetsAfterHour(t *testing.T) { rl := NewRateLimiter(3) const ip = "198.51.100.7" // Fill the bucket. for i := range 3 { if !rl.Allow(ip) { t.Fatalf("call %d should succeed", i+1) } } if rl.Allow(ip) { t.Fatalf("4th call should be denied (bucket full)") } // Fast-forward the clock by pushing resetsAt into the past. rl.mu.Lock() b, ok := rl.buckets[ip] if !ok { rl.mu.Unlock() t.Fatalf("bucket missing for ip %s after filling", ip) } b.resetsAt = time.Now().Add(-time.Hour - time.Second) rl.mu.Unlock() // Next call must reset the window and succeed. if !rl.Allow(ip) { t.Fatalf("Allow() must reset the bucket after window expiry — got false") } // Bucket count must be 1 (fresh window), not 4. rl.mu.Lock() got := rl.buckets[ip].count rl.mu.Unlock() if got != 1 { t.Errorf("bucket count after reset: got %d, want 1 (fresh window)", got) } }