446 lines
13 KiB
Python
446 lines
13 KiB
Python
"""
|
|
ⒸAngelaMos | 2025
|
|
test_algorithms.py
|
|
|
|
Tests for rate limiting algorithms and the factory function
|
|
|
|
Tests:
|
|
create_algorithm() factory mapping
|
|
SlidingWindowAlgorithm - first request, limit enforcement, keys
|
|
TokenBucketAlgorithm - bursting, refill, capacity
|
|
FixedWindowAlgorithm - counting, boundary behavior
|
|
Cross-algorithm behavioral comparison
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
|
|
import pytest
|
|
|
|
from fastapi_420.algorithms import create_algorithm
|
|
from fastapi_420.algorithms.sliding_window import SlidingWindowAlgorithm
|
|
from fastapi_420.algorithms.token_bucket import TokenBucketAlgorithm
|
|
from fastapi_420.algorithms.fixed_window import FixedWindowAlgorithm
|
|
from fastapi_420.storage import MemoryStorage
|
|
from fastapi_420.types import Algorithm
|
|
|
|
from tests.conftest import (
|
|
WINDOW_MINUTE,
|
|
RuleFactory,
|
|
)
|
|
|
|
|
|
class TestAlgorithmFactory:
|
|
"""
|
|
Tests for algorithm factory function
|
|
"""
|
|
def test_create_sliding_window(self) -> None:
|
|
algo = create_algorithm(Algorithm.SLIDING_WINDOW)
|
|
assert isinstance(algo, SlidingWindowAlgorithm)
|
|
|
|
def test_create_token_bucket(self) -> None:
|
|
algo = create_algorithm(Algorithm.TOKEN_BUCKET)
|
|
assert isinstance(algo, TokenBucketAlgorithm)
|
|
|
|
def test_create_fixed_window(self) -> None:
|
|
algo = create_algorithm(Algorithm.FIXED_WINDOW)
|
|
assert isinstance(algo, FixedWindowAlgorithm)
|
|
|
|
def test_create_from_string(self) -> None:
|
|
algo = create_algorithm(Algorithm.SLIDING_WINDOW)
|
|
assert algo.name == Algorithm.SLIDING_WINDOW.value
|
|
|
|
|
|
class TestSlidingWindowAlgorithm:
|
|
"""
|
|
Tests for sliding window counter algorithm
|
|
"""
|
|
@pytest.mark.asyncio
|
|
async def test_algorithm_name(self) -> None:
|
|
algo = SlidingWindowAlgorithm()
|
|
assert algo.name == "sliding_window"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_first_request_allowed(self) -> None:
|
|
algo = SlidingWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
|
|
result = await algo.check(
|
|
storage = storage,
|
|
key = "test_key",
|
|
rule = rule,
|
|
)
|
|
|
|
assert result.allowed is True
|
|
assert result.remaining == 99
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_requests(self) -> None:
|
|
algo = SlidingWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
|
|
for _i in range(50):
|
|
result = await algo.check(storage, "multi_key", rule)
|
|
assert result.allowed is True
|
|
|
|
assert result.remaining == 50
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_limit_exceeded(self) -> None:
|
|
algo = SlidingWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.create(
|
|
requests = 5,
|
|
window_seconds = WINDOW_MINUTE
|
|
)
|
|
|
|
for _ in range(5):
|
|
result = await algo.check(storage, "limit_key", rule)
|
|
assert result.allowed is True
|
|
|
|
result = await algo.check(storage, "limit_key", rule)
|
|
assert result.allowed is False
|
|
assert result.retry_after is not None
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_keys_independent(self) -> None:
|
|
algo = SlidingWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.create(
|
|
requests = 5,
|
|
window_seconds = WINDOW_MINUTE
|
|
)
|
|
|
|
for _ in range(5):
|
|
await algo.check(storage, "key_a", rule)
|
|
|
|
result_a = await algo.check(storage, "key_a", rule)
|
|
result_b = await algo.check(storage, "key_b", rule)
|
|
|
|
assert result_a.allowed is False
|
|
assert result_b.allowed is True
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_current_usage_empty(self) -> None:
|
|
algo = SlidingWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute()
|
|
|
|
usage = await algo.get_current_usage(storage, "empty_key", rule)
|
|
assert usage == 0
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_current_usage_after_requests(self) -> None:
|
|
algo = SlidingWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
|
|
for _ in range(25):
|
|
await algo.check(storage, "usage_key", rule)
|
|
|
|
usage = await algo.get_current_usage(storage, "usage_key", rule)
|
|
assert usage >= 24
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_timestamp(self) -> None:
|
|
algo = SlidingWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
fixed_time = 1000000.0
|
|
|
|
result = await algo.check(
|
|
storage = storage,
|
|
key = "timestamp_key",
|
|
rule = rule,
|
|
timestamp = fixed_time,
|
|
)
|
|
|
|
assert result.allowed is True
|
|
|
|
await storage.close()
|
|
|
|
|
|
class TestTokenBucketAlgorithm:
|
|
"""
|
|
Tests for token bucket algorithm
|
|
"""
|
|
@pytest.mark.asyncio
|
|
async def test_algorithm_name(self) -> None:
|
|
algo = TokenBucketAlgorithm()
|
|
assert algo.name == "token_bucket"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_first_request_allowed(self) -> None:
|
|
algo = TokenBucketAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
|
|
result = await algo.check(
|
|
storage = storage,
|
|
key = "bucket_test",
|
|
rule = rule,
|
|
)
|
|
|
|
assert result.allowed is True
|
|
assert result.remaining == 99
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_burst_consumption(self) -> None:
|
|
algo = TokenBucketAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.create(
|
|
requests = 10,
|
|
window_seconds = WINDOW_MINUTE
|
|
)
|
|
|
|
for i in range(10):
|
|
result = await algo.check(storage, "burst_key", rule)
|
|
assert result.allowed is True
|
|
assert result.remaining == 10 - (i + 1)
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bucket_exhausted(self) -> None:
|
|
algo = TokenBucketAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.create(
|
|
requests = 5,
|
|
window_seconds = WINDOW_MINUTE
|
|
)
|
|
|
|
for _ in range(5):
|
|
await algo.check(storage, "exhaust_key", rule)
|
|
|
|
result = await algo.check(storage, "exhaust_key", rule)
|
|
assert result.allowed is False
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_keys_independent(self) -> None:
|
|
algo = TokenBucketAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.create(
|
|
requests = 5,
|
|
window_seconds = WINDOW_MINUTE
|
|
)
|
|
|
|
for _ in range(5):
|
|
await algo.check(storage, "bucket_a", rule)
|
|
|
|
result_a = await algo.check(storage, "bucket_a", rule)
|
|
result_b = await algo.check(storage, "bucket_b", rule)
|
|
|
|
assert result_a.allowed is False
|
|
assert result_b.allowed is True
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_current_usage_empty(self) -> None:
|
|
algo = TokenBucketAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
|
|
usage = await algo.get_current_usage(storage, "empty_bucket", rule)
|
|
assert usage == 0
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_current_usage_after_consumption(self) -> None:
|
|
algo = TokenBucketAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
|
|
for _ in range(25):
|
|
await algo.check(storage, "usage_bucket", rule)
|
|
|
|
usage = await algo.get_current_usage(storage, "usage_bucket", rule)
|
|
assert usage >= 24
|
|
|
|
await storage.close()
|
|
|
|
|
|
class TestFixedWindowAlgorithm:
|
|
"""
|
|
Tests for fixed window counter algorithm
|
|
"""
|
|
@pytest.mark.asyncio
|
|
async def test_algorithm_name(self) -> None:
|
|
algo = FixedWindowAlgorithm()
|
|
assert algo.name == "fixed_window"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_first_request_allowed(self) -> None:
|
|
algo = FixedWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
|
|
result = await algo.check(
|
|
storage = storage,
|
|
key = "fixed_test",
|
|
rule = rule,
|
|
)
|
|
|
|
assert result.allowed is True
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_requests(self) -> None:
|
|
algo = FixedWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
|
|
for _ in range(50):
|
|
result = await algo.check(storage, "multi_fixed", rule)
|
|
assert result.allowed is True
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_limit_exceeded(self) -> None:
|
|
algo = FixedWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.create(
|
|
requests = 5,
|
|
window_seconds = WINDOW_MINUTE
|
|
)
|
|
|
|
for _ in range(5):
|
|
result = await algo.check(storage, "limit_fixed", rule)
|
|
assert result.allowed is True
|
|
|
|
result = await algo.check(storage, "limit_fixed", rule)
|
|
assert result.allowed is False
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_keys_independent(self) -> None:
|
|
algo = FixedWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.create(
|
|
requests = 5,
|
|
window_seconds = WINDOW_MINUTE
|
|
)
|
|
|
|
for _ in range(5):
|
|
await algo.check(storage, "fixed_a", rule)
|
|
|
|
result_a = await algo.check(storage, "fixed_a", rule)
|
|
result_b = await algo.check(storage, "fixed_b", rule)
|
|
|
|
assert result_a.allowed is False
|
|
assert result_b.allowed is True
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_current_usage_empty(self) -> None:
|
|
algo = FixedWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
|
|
usage = await algo.get_current_usage(storage, "empty_fixed", rule)
|
|
assert usage == 0
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_current_usage_after_requests(self) -> None:
|
|
algo = FixedWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
|
|
for _ in range(25):
|
|
await algo.check(storage, "usage_fixed", rule)
|
|
|
|
usage = await algo.get_current_usage(storage, "usage_fixed", rule)
|
|
assert usage >= 0
|
|
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_timestamp(self) -> None:
|
|
algo = FixedWindowAlgorithm()
|
|
storage = MemoryStorage()
|
|
rule = RuleFactory.per_minute(100)
|
|
fixed_time = 1000000.0
|
|
|
|
result = await algo.check(
|
|
storage = storage,
|
|
key = "timestamp_fixed",
|
|
rule = rule,
|
|
timestamp = fixed_time,
|
|
)
|
|
|
|
assert result.allowed is True
|
|
|
|
await storage.close()
|
|
|
|
|
|
class TestAlgorithmComparison:
|
|
"""
|
|
Comparative tests between algorithms
|
|
"""
|
|
@pytest.mark.asyncio
|
|
async def test_all_algorithms_allow_first_request(self) -> None:
|
|
algorithms = [
|
|
SlidingWindowAlgorithm(),
|
|
TokenBucketAlgorithm(),
|
|
FixedWindowAlgorithm(),
|
|
]
|
|
rule = RuleFactory.per_minute(100)
|
|
|
|
for algo in algorithms:
|
|
storage = MemoryStorage()
|
|
result = await algo.check(storage, "compare_key", rule)
|
|
assert result.allowed is True, f"{algo.name} failed first request"
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_algorithms_enforce_limit(self) -> None:
|
|
algorithms = [
|
|
SlidingWindowAlgorithm(),
|
|
TokenBucketAlgorithm(),
|
|
FixedWindowAlgorithm(),
|
|
]
|
|
rule = RuleFactory.create(
|
|
requests = 5,
|
|
window_seconds = WINDOW_MINUTE
|
|
)
|
|
|
|
for algo in algorithms:
|
|
storage = MemoryStorage()
|
|
for _ in range(5):
|
|
await algo.check(storage, "enforce_key", rule)
|
|
|
|
result = await algo.check(storage, "enforce_key", rule)
|
|
assert result.allowed is False, f"{algo.name} didn't enforce limit"
|
|
await storage.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_algorithms_have_correct_names(self) -> None:
|
|
assert SlidingWindowAlgorithm(
|
|
).name == Algorithm.SLIDING_WINDOW.value
|
|
assert TokenBucketAlgorithm().name == Algorithm.TOKEN_BUCKET.value
|
|
assert FixedWindowAlgorithm().name == Algorithm.FIXED_WINDOW.value
|