"""Tests for token bucket rate limiter.""" import asyncio import time import pytest from fastapi import FastAPI, Depends, Request from fastapi.testclient import TestClient from src.network.rate_limiter import RateLimiter, TokenBucket, rate_limit from src.network.auth import API_KEY_HEADER class TestTokenBucket: """Tests for TokenBucket.""" def test_initial_tokens(self): bucket = TokenBucket(rate=10.0, capacity=20) assert bucket.available == 20.0 def test_consume_reduces_tokens(self): bucket = TokenBucket(rate=10.0, capacity=20) assert bucket.consume(1) # available may refill a tiny amount based on timing assert bucket.available == pytest.approx(19.0, abs=0.1) def test_consume_multiple(self): bucket = TokenBucket(rate=10.0, capacity=20) assert bucket.consume(5) assert bucket.available == pytest.approx(15.0, abs=0.1) def test_cannot_consume_more_than_available(self): bucket = TokenBucket(rate=10.0, capacity=5) # Consume all for _ in range(5): assert bucket.consume(1) # No more assert not bucket.consume(1) def test_refill_over_time(self): bucket = TokenBucket(rate=100.0, capacity=100) # Drain all tokens for _ in range(100): assert bucket.consume(1) assert bucket.available < 1.0 # Wait for refill time.sleep(0.05) # 50ms → ~5 tokens at 100/s assert bucket.available >= 4.0 # allow slight timing variance def test_capacity_cap(self): bucket = TokenBucket(rate=1000.0, capacity=10) time.sleep(0.1) # 100ms → 100 tokens, but capped at 10 assert bucket.available <= 10.0 + 0.01 # small epsilon def test_zero_rate(self): bucket = TokenBucket(rate=0.0, capacity=10) for _ in range(10): assert bucket.consume(1) # No refill assert not bucket.consume(1) class TestRateLimiter: """Tests for RateLimiter.""" @pytest.fixture def limiter(self): return RateLimiter(rate=100.0, capacity=200, eviction_timeout=0.5) @pytest.mark.asyncio async def test_check_rest_allows_first_request(self, limiter): """First request from an IP should be allowed.""" # We can't easily test this without a FastAPI request, # but we can test the bucket directly. # The check_rest method needs a Request object, so we test # via FastAPI integration below. pass class TestRateLimitIntegration: """Integration tests for rate limiting with FastAPI.""" @pytest.fixture def app(self): limiter = RateLimiter(rate=50.0, capacity=100) app = FastAPI() @app.get("/limited") async def limited(_: None = Depends(rate_limit(limiter))): return {"ok": True} @app.get("/unlimited") async def unlimited(): return {"ok": True} return app @pytest.fixture def client(self, app): return TestClient(app) def test_unlimited_endpoint(self, client): resp = client.get("/unlimited") assert resp.status_code == 200 def test_limited_endpoint_allows_requests(self, client): """Rate-limited endpoint should allow normal traffic.""" for _ in range(10): resp = client.get("/limited") assert resp.status_code == 200 def test_rate_limit_headers_on_client(self, client): """Basic sanity — requests should work.""" resp = client.get("/limited") assert resp.status_code == 200 assert resp.json() == {"ok": True} def test_multiple_requests_within_limit(self, client): """Multiple requests within the rate limit should all succeed.""" for i in range(50): resp = client.get("/limited") assert resp.status_code == 200, f"Request {i} failed" class TestRateLimiterStats: """Tests for rate limiter statistics.""" def test_initial_stats(self): limiter = RateLimiter(rate=50.0, capacity=100) assert limiter.active_connections == 0 stats = limiter.stats assert stats["active_clients"] == 0 assert stats["rest_rate"] == 50.0 assert stats["rest_capacity"] == 100 def test_ws_rates(self): limiter = RateLimiter(ws_rate=500.0, ws_capacity=1000) stats = limiter.stats assert stats["ws_rate"] == 500.0 assert stats["ws_capacity"] == 1000