Files
michaelschiemer/tests/Unit/Framework/RateLimit/TokenBucketRateLimiterTest.php
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

450 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\RateLimit;
use App\Framework\RateLimit\RateLimiter;
use App\Framework\RateLimit\RateLimitResult;
use App\Framework\RateLimit\Storage\StorageInterface;
use App\Framework\RateLimit\TimeProvider\TimeProviderInterface;
use App\Framework\RateLimit\TokenBucket;
describe('Token Bucket Rate Limiter', function () {
beforeEach(function () {
$this->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
});
});
});