currentTime = 1000; // Create test time provider $this->timeProvider = new class($this->currentTime) implements TimeProviderInterface { public function __construct(private int &$time) {} public function getCurrentTime(): int { return $this->time; } }; // Create in-memory storage $this->storage = new class implements StorageInterface { private array $requests = []; private array $buckets = []; private array $baselines = []; public function getRequestsInWindow(string $key, int $windowStart, int $windowEnd): array { if (!isset($this->requests[$key])) { return []; } return array_filter( $this->requests[$key], fn($timestamp) => $timestamp >= $windowStart && $timestamp <= $windowEnd ); } public function addRequest(string $key, int $timestamp, int $ttl): void { if (!isset($this->requests[$key])) { $this->requests[$key] = []; } $this->requests[$key][] = $timestamp; } public function getTokenBucket(string $key): ?TokenBucket { return $this->buckets[$key] ?? null; } public function saveTokenBucket(string $key, TokenBucket $bucket): void { $this->buckets[$key] = $bucket; } public function clear(string $key): void { unset($this->requests[$key]); unset($this->buckets[$key]); unset($this->baselines[$key]); } public function getBaseline(string $key): ?array { return $this->baselines[$key] ?? null; } public function updateBaseline(string $key, int $rate): void { if (!isset($this->baselines[$key])) { $this->baselines[$key] = []; } $this->baselines[$key][] = $rate; } }; $this->rateLimiter = new RateLimiter($this->storage, $this->timeProvider); }); describe('TokenBucket Value Object', function () { it('creates token bucket with capacity and tokens', function () { $bucket = new TokenBucket( capacity: 100, tokens: 75, lastRefill: 1000 ); expect($bucket->capacity)->toBe(100); expect($bucket->tokens)->toBe(75); expect($bucket->lastRefill)->toBe(1000); }); it('checks if can consume tokens', function () { $bucket = new TokenBucket(100, 75, 1000); expect($bucket->canConsume(50))->toBeTrue(); expect($bucket->canConsume(75))->toBeTrue(); expect($bucket->canConsume(76))->toBeFalse(); expect($bucket->canConsume(100))->toBeFalse(); }); it('consumes tokens and returns new bucket', function () { $bucket = new TokenBucket(100, 75, 1000); $newBucket = $bucket->consume(25); // Original bucket unchanged (immutable) expect($bucket->tokens)->toBe(75); // New bucket has reduced tokens expect($newBucket->tokens)->toBe(50); expect($newBucket->capacity)->toBe(100); expect($newBucket->lastRefill)->toBe(1000); }); it('throws when consuming more tokens than available', function () { $bucket = new TokenBucket(100, 50, 1000); expect(fn() => $bucket->consume(51)) ->toThrow(\InvalidArgumentException::class, 'Not enough tokens available'); }); it('refills tokens up to capacity', function () { $bucket = new TokenBucket(100, 25, 1000); $refilled = $bucket->refill(50, 1050); // Tokens refilled but capped at capacity expect($refilled->tokens)->toBe(75); expect($refilled->lastRefill)->toBe(1050); }); it('caps refill at capacity', function () { $bucket = new TokenBucket(100, 80, 1000); $refilled = $bucket->refill(50, 1050); // Should cap at 100, not go to 130 expect($refilled->tokens)->toBe(100); expect($refilled->capacity)->toBe(100); }); it('allows zero token consumption', function () { $bucket = new TokenBucket(100, 50, 1000); $newBucket = $bucket->consume(0); expect($newBucket->tokens)->toBe(50); }); it('handles full bucket consumption', function () { $bucket = new TokenBucket(100, 100, 1000); $empty = $bucket->consume(100); expect($empty->tokens)->toBe(0); expect($empty->canConsume(1))->toBeFalse(); }); }); describe('RateLimiter Token Bucket Integration', function () { it('allows request when tokens available', function () { $result = $this->rateLimiter->checkTokenBucket( key: 'user:123', capacity: 10, refillRate: 1, tokens: 1 ); expect($result->isAllowed())->toBeTrue(); expect($result->getLimit())->toBe(10); expect($result->getCurrent())->toBe(9); // 10 - 1 consumed }); it('blocks request when tokens exhausted', function () { // Consume all tokens for ($i = 0; $i < 10; $i++) { $this->rateLimiter->checkTokenBucket('user:123', 10, 1, 1); } // Next request should be blocked $result = $this->rateLimiter->checkTokenBucket('user:123', 10, 1, 1); expect($result->isAllowed())->toBeFalse(); expect($result->isExceeded())->toBeTrue(); expect($result->getCurrent())->toBe(0); expect($result->getRetryAfter())->toBeGreaterThan(0); }); it('refills tokens over time', function () { // Consume 5 tokens $this->rateLimiter->checkTokenBucket('user:456', 10, 1, 5); // Advance time by 3 seconds (refill rate: 1 token/second) $this->currentTime += 3; // Should have refilled 3 tokens (5 consumed - 3 refilled = 2 consumed) $result = $this->rateLimiter->checkTokenBucket('user:456', 10, 1, 0); expect($result->isAllowed())->toBeTrue(); expect($result->getCurrent())->toBe(8); // 10 capacity - 2 net consumed }); it('calculates correct retry after when tokens exhausted', function () { // Exhaust all tokens $this->rateLimiter->checkTokenBucket('user:789', 10, 2, 10); // Try to consume 5 more tokens $result = $this->rateLimiter->checkTokenBucket('user:789', 10, 2, 5); // Need 5 tokens, refill rate is 2 tokens/second // intval(5/2) = 2 seconds (not ceil, but floor/truncate) expect($result->getRetryAfter())->toBe(2); }); it('allows consuming multiple tokens at once', function () { $result = $this->rateLimiter->checkTokenBucket( key: 'batch:user', capacity: 100, refillRate: 10, tokens: 25 ); expect($result->isAllowed())->toBeTrue(); expect($result->getCurrent())->toBe(75); // 100 - 25 }); it('handles concurrent requests correctly', function () { // First request consumes 3 tokens $result1 = $this->rateLimiter->checkTokenBucket('user:concurrent', 10, 1, 3); expect($result1->isAllowed())->toBeTrue(); // Second request (same time) consumes 4 tokens $result2 = $this->rateLimiter->checkTokenBucket('user:concurrent', 10, 1, 4); expect($result2->isAllowed())->toBeTrue(); expect($result2->getCurrent())->toBe(3); // 10 - 3 - 4 = 3 }); it('creates new bucket for first request', function () { $result = $this->rateLimiter->checkTokenBucket('new:user', 50, 5, 1); // First request should have full capacity minus consumed expect($result->isAllowed())->toBeTrue(); expect($result->getCurrent())->toBe(49); // 50 - 1 }); it('respects capacity limit during refill', function () { // Start with partial tokens $this->rateLimiter->checkTokenBucket('user:refill', 10, 1, 8); // Advance time significantly (should refill to capacity, not beyond) $this->currentTime += 100; $result = $this->rateLimiter->checkTokenBucket('user:refill', 10, 1, 1); // Should have capacity minus current consumption, not more expect($result->getCurrent())->toBe(9); // Capped at 10, then -1 }); it('handles zero refill rate edge case', function () { // Consume 5 tokens $this->rateLimiter->checkTokenBucket('user:zero', 10, 0, 5); // Advance time $this->currentTime += 10; // With refill rate 0, no tokens should refill $result = $this->rateLimiter->checkTokenBucket('user:zero', 10, 0, 0); expect($result->getCurrent())->toBe(5); // Still only 5 tokens }); it('calculates remaining requests correctly', function () { $this->rateLimiter->checkTokenBucket('user:remaining', 20, 2, 7); $result = $this->rateLimiter->checkTokenBucket('user:remaining', 20, 2, 0); expect($result->getRemainingRequests())->toBe(7); // 20 - 13 consumed }); }); describe('RateLimitResult', function () { it('creates allowed result', function () { $result = RateLimitResult::allowed(100, 75); expect($result->isAllowed())->toBeTrue(); expect($result->isExceeded())->toBeFalse(); expect($result->getLimit())->toBe(100); expect($result->getCurrent())->toBe(75); expect($result->getRemainingRequests())->toBe(25); expect($result->getRetryAfter())->toBeNull(); }); it('creates exceeded result', function () { $result = RateLimitResult::exceeded(100, 0, 60); expect($result->isAllowed())->toBeFalse(); expect($result->isExceeded())->toBeTrue(); expect($result->getLimit())->toBe(100); expect($result->getCurrent())->toBe(0); expect($result->getRemainingRequests())->toBe(100); expect($result->getRetryAfter())->toBe(60); }); it('handles zero remaining requests', function () { $result = RateLimitResult::allowed(10, 10); expect($result->getRemainingRequests())->toBe(0); }); it('prevents negative remaining requests', function () { $result = RateLimitResult::exceeded(10, 15, 60); // Should never return negative expect($result->getRemainingRequests())->toBe(0); }); }); describe('Edge Cases and Stress Testing', function () { it('handles rapid successive requests', function () { $key = 'stress:rapid'; $allowed = 0; $blocked = 0; // Make 100 rapid requests for ($i = 0; $i < 100; $i++) { $result = $this->rateLimiter->checkTokenBucket($key, 50, 10, 1); if ($result->isAllowed()) { $allowed++; } else { $blocked++; } } // First 50 should be allowed, rest blocked expect($allowed)->toBe(50); expect($blocked)->toBe(50); }); it('handles burst then recovery pattern', function () { $key = 'burst:recovery'; // Burst: consume all tokens $this->rateLimiter->checkTokenBucket($key, 20, 5, 20); // Immediate request: should fail $result1 = $this->rateLimiter->checkTokenBucket($key, 20, 5, 1); expect($result1->isAllowed())->toBeFalse(); // Wait for refill (4 seconds = 20 tokens at rate 5/sec) $this->currentTime += 4; // Should now succeed $result2 = $this->rateLimiter->checkTokenBucket($key, 20, 5, 1); expect($result2->isAllowed())->toBeTrue(); }); it('handles very large capacity buckets', function () { $result = $this->rateLimiter->checkTokenBucket( 'large:bucket', capacity: 1000000, refillRate: 10000, tokens: 50000 ); expect($result->isAllowed())->toBeTrue(); expect($result->getCurrent())->toBe(950000); }); it('handles very small refill rates', function () { // Rate of 1 token/sec (slow but not zero to avoid division by zero) $this->rateLimiter->checkTokenBucket('slow:refill', 10, 1, 10); // Advance 5 seconds (should refill 5 tokens) $this->currentTime += 5; $result = $this->rateLimiter->checkTokenBucket('slow:refill', 10, 1, 0); // Should have 5 tokens after refill expect($result->getCurrent())->toBe(5); }); it('maintains bucket state across multiple operations', function () { $key = 'stateful:bucket'; // Operation 1: consume 3 $r1 = $this->rateLimiter->checkTokenBucket($key, 10, 1, 3); expect($r1->getCurrent())->toBe(7); // Operation 2: consume 2 (no time passed) $r2 = $this->rateLimiter->checkTokenBucket($key, 10, 1, 2); expect($r2->getCurrent())->toBe(5); // Advance time: refill 3 tokens $this->currentTime += 3; // Operation 3: consume 1 (should have 5 + 3 - 1 = 7) $r3 = $this->rateLimiter->checkTokenBucket($key, 10, 1, 1); expect($r3->getCurrent())->toBe(7); }); }); describe('Different User Isolation', function () { it('maintains separate buckets for different users', function () { // User A consumes tokens $this->rateLimiter->checkTokenBucket('user:A', 10, 1, 8); // User B should have full bucket $resultB = $this->rateLimiter->checkTokenBucket('user:B', 10, 1, 1); expect($resultB->getCurrent())->toBe(9); // User A should still have reduced tokens $resultA = $this->rateLimiter->checkTokenBucket('user:A', 10, 1, 0); expect($resultA->getCurrent())->toBe(2); }); it('allows same capacity but different refill rates per user', function () { // Fast refill user $this->rateLimiter->checkTokenBucket('user:fast', 10, 10, 10); $this->currentTime += 1; $fastResult = $this->rateLimiter->checkTokenBucket('user:fast', 10, 10, 0); expect($fastResult->getCurrent())->toBe(10); // Fully refilled in 1 second // Slow refill user $this->rateLimiter->checkTokenBucket('user:slow', 10, 1, 10); $this->currentTime += 1; $slowResult = $this->rateLimiter->checkTokenBucket('user:slow', 10, 1, 0); expect($slowResult->getCurrent())->toBe(1); // Only 1 token refilled }); }); describe('Reset Functionality', function () { it('resets bucket to initial state', function () { $key = 'user:reset'; // Consume some tokens $this->rateLimiter->checkTokenBucket($key, 10, 1, 7); // Reset $this->rateLimiter->reset($key); // Should have full capacity again $result = $this->rateLimiter->checkTokenBucket($key, 10, 1, 1); expect($result->getCurrent())->toBe(9); // Full bucket minus current consumption }); }); });