diff --git a/tests/DTO/RateLimitStatusDTOTest.php b/tests/DTO/RateLimitStatusDTOTest.php index 55730f8..7b317c6 100644 --- a/tests/DTO/RateLimitStatusDTOTest.php +++ b/tests/DTO/RateLimitStatusDTOTest.php @@ -29,6 +29,7 @@ public function testConstructorAndToArray(): void 'blocked' => true, 'backoff_seconds' => 15, 'next_allowed_at' => '2025-01-01 12:00:00', + 'source' => null, ]; $this->assertSame($expected, $dto->toArray()); @@ -50,6 +51,7 @@ public function testNullableFields(): void 'blocked' => false, 'backoff_seconds' => null, 'next_allowed_at' => null, + 'source' => null, ]; $this->assertSame($expected, $dto->toArray()); diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php index a0dc8b5..af9935a 100644 --- a/tests/MiddlewareTest.php +++ b/tests/MiddlewareTest.php @@ -1,15 +1,5 @@ redis` works as expected. - * - * ✅ Example: - * ```php - * $resolver = new RateLimiterResolver(['driver' => 'redis']); - * $driver = $resolver->resolve(); - * ``` - */ - public function testResolverCreatesRedisDriver(): void + public function testResolverSelectsDriver(): void { - // ⚙️ Load Redis configuration dynamically - $redisHost = getenv('REDIS_HOST') ?: ($_ENV['REDIS_HOST'] ?? '127.0.0.1'); - $redisPort = getenv('REDIS_PORT') ?: ($_ENV['REDIS_PORT'] ?? '6379'); - $redisPassword = getenv('REDIS_PASSWORD') ?: ($_ENV['REDIS_PASSWORD'] ?? ''); + $mockDriver = $this->createMock(RateLimiterInterface::class); - // 🧩 Initialize Redis driver via resolver $resolver = new RateLimiterResolver([ - 'driver' => 'redis', - 'redis_host' => $redisHost, - 'redis_port' => $redisPort, - 'redis_password' => $redisPassword, - ]); + 'redis' => $mockDriver, + ], 'redis'); + $driver = $resolver->resolve(); - $this->assertInstanceOf( - \Maatify\RateLimiter\Contracts\RateLimiterInterface::class, - $driver, - 'Resolver must return a valid RateLimiterInterface implementation for Redis.' - ); + $this->assertInstanceOf(EnforcingRateLimiter::class, $driver); } - /** - * 🔍 Test that enums return correct string values. - * - * 🎯 Verifies that `RateLimitActionEnum` and `PlatformEnum` - * correctly return their string identifiers through `value()`. - */ public function testActionEnums(): void { $this->assertSame( diff --git a/tests/Phase5/ActionLimiterTest.php b/tests/Phase5/ActionLimiterTest.php new file mode 100644 index 0000000..5a529ac --- /dev/null +++ b/tests/Phase5/ActionLimiterTest.php @@ -0,0 +1,154 @@ +driver = $this->createMock(RateLimiterInterface::class); + $this->backoffPolicy = $this->createMock(BackoffPolicyInterface::class); + $this->resolver = new EnforcingRateLimiter($this->driver, $this->backoffPolicy); + } + + public function testActionLimiterExecutesIfGlobalPasses(): void + { + $action = $this->createMock(RateLimitActionInterface::class); + $platform = $this->createMock(PlatformInterface::class); + + $callCount = 0; + + $this->driver->expects($this->exactly(2)) + ->method('attempt') + ->willReturnCallback(function (string $key, RateLimitActionInterface $act, PlatformInterface $plt) use (&$callCount) { + $callCount++; + if ($callCount === 1) { + // Global passes + return new RateLimitStatusDTO(100, 99, 60, null, false, null, null, 'global'); + } + + // Action passes + return new RateLimitStatusDTO(10, 5, 60, null, false, null, null, 'action'); + }); + + $status = $this->resolver->attempt('key', $action, $platform); + + $this->assertSame('action', $status->source); + $this->assertFalse($status->blocked); + } + + public function testActionLimiterBlock(): void + { + $action = $this->createMock(RateLimitActionInterface::class); + $platform = $this->createMock(PlatformInterface::class); + + $callCount = 0; + + $this->driver->expects($this->exactly(2)) + ->method('attempt') + ->willReturnCallback(function (string $key, RateLimitActionInterface $act, PlatformInterface $plt) use (&$callCount) { + $callCount++; + if ($callCount === 1) { + // Global passes + return new RateLimitStatusDTO(100, 99, 60, null, false, null, null, 'global'); + } + + // Action blocks + $dto = new RateLimitStatusDTO(10, 0, 60, null, true, null, null, 'action'); + throw new TooManyRequestsException('Action limit', 429, $dto); + }); + + $this->backoffPolicy->expects($this->once()) + ->method('calculateDelay') + ->willReturn(15); + + try { + $this->resolver->attempt('key', $action, $platform); + $this->fail('Expected TooManyRequestsException was not thrown'); + } catch (TooManyRequestsException $e) { + $status = $e->status; + $this->assertNotNull($status); + $this->assertSame('action', $status->source); + $this->assertSame(15, $status->retryAfter); + $this->assertTrue($status->blocked); + $this->assertNotNull($status->nextAllowedAt); + } + } + + public function testSourceIsSetIfNotProvidedByDriver(): void + { + $action = $this->createMock(RateLimitActionInterface::class); + $platform = $this->createMock(PlatformInterface::class); + + $callCount = 0; + + $this->driver->expects($this->exactly(2)) + ->method('attempt') + ->willReturnCallback(function (string $key, RateLimitActionInterface $act, PlatformInterface $plt) use (&$callCount) { + $callCount++; + if ($callCount === 1) { + return new RateLimitStatusDTO(100, 99, 60); // No source + } + return new RateLimitStatusDTO(10, 5, 60); // No source + }); + + $status = $this->resolver->attempt('key', $action, $platform); + + $this->assertSame('action', $status->source); + } + + public function testResetResetsGlobalAndAction(): void + { + $action = $this->createMock(RateLimitActionInterface::class); + $platform = $this->createMock(PlatformInterface::class); + + $callCount = 0; + + $this->driver->expects($this->exactly(2)) + ->method('reset') + ->willReturnCallback(function (string $key, RateLimitActionInterface $act, PlatformInterface $plt) use (&$callCount) { + $callCount++; + if ($callCount === 1) { + $this->assertSame('global', $act->value()); + return true; + } + return true; + }); + + $this->assertTrue($this->resolver->reset('key', $action, $platform)); + } + + public function testStatusProxiesDriverAndEnrichesSource(): void + { + $action = $this->createMock(RateLimitActionInterface::class); + $platform = $this->createMock(PlatformInterface::class); + + $this->driver->expects($this->once()) + ->method('status') + ->willReturn(new RateLimitStatusDTO(10, 5, 60)); + + $status = $this->resolver->status('key', $action, $platform); + + $this->assertSame('action', $status->source); + } +} diff --git a/tests/Phase5/BackoffPolicyTest.php b/tests/Phase5/BackoffPolicyTest.php new file mode 100644 index 0000000..48862f8 --- /dev/null +++ b/tests/Phase5/BackoffPolicyTest.php @@ -0,0 +1,64 @@ + over 1 -> 2^1 = 2 + $status1 = new RateLimitStatusDTO(10, -1, 60); + $this->assertSame(2, $policy->calculateDelay($status1)); + + // Limit 10, remaining -2 -> over 2 -> 2^2 = 4 + $status2 = new RateLimitStatusDTO(10, -2, 60); + $this->assertSame(4, $policy->calculateDelay($status2)); + + // Limit 10, remaining -3 -> over 3 -> 2^3 = 8 + $status3 = new RateLimitStatusDTO(10, -3, 60); + $this->assertSame(8, $policy->calculateDelay($status3)); + } + + public function testBackoffCappedByResetWindow(): void + { + $policy = new ExponentialBackoffPolicy(base: 2); + + // Limit 10, remaining -10 -> over 10 -> 2^10 = 1024 + // Reset after 60 + // Should be capped at 60 + $status = new RateLimitStatusDTO(10, -10, 60); + $this->assertSame(60, $policy->calculateDelay($status)); + } + + public function testBackoffCappedByMaxDelay(): void + { + $policy = new ExponentialBackoffPolicy(base: 2, maxDelay: 100); + + // Limit 10, remaining -10 -> over 10 -> 2^10 = 1024 + // Reset after 2000 + // Should be capped at 100 + $status = new RateLimitStatusDTO(10, -10, 2000); + $this->assertSame(100, $policy->calculateDelay($status)); + } + + public function testZeroDelayIfNotOverLimit(): void + { + $policy = new ExponentialBackoffPolicy(base: 2); + + // Remaining 0 -> over 0 -> delay 0 + $status = new RateLimitStatusDTO(10, 0, 60); + $this->assertSame(0, $policy->calculateDelay($status)); + + // Remaining 5 -> over 0 -> delay 0 + $status2 = new RateLimitStatusDTO(10, 5, 60); + $this->assertSame(0, $policy->calculateDelay($status2)); + } +} diff --git a/tests/Phase5/ExceptionPropagationTest.php b/tests/Phase5/ExceptionPropagationTest.php new file mode 100644 index 0000000..8676d67 --- /dev/null +++ b/tests/Phase5/ExceptionPropagationTest.php @@ -0,0 +1,37 @@ +assertSame($dto, $exception->status); + } + + public function testExceptionExposesRetryAfter(): void + { + $dto = new RateLimitStatusDTO(10, 0, 60, 30); + $exception = new TooManyRequestsException('Error', 429, $dto); + + $this->assertSame(30, $exception->getRetryAfter()); + } + + public function testExceptionExposesNextAllowedAt(): void + { + $timestamp = '2025-01-01 12:00:00'; + $dto = new RateLimitStatusDTO(10, 0, 60, 30, true, null, $timestamp); + $exception = new TooManyRequestsException('Error', 429, $dto); + + $this->assertSame($timestamp, $exception->getNextAllowedAt()); + } +} diff --git a/tests/Phase5/GlobalLimiterTest.php b/tests/Phase5/GlobalLimiterTest.php new file mode 100644 index 0000000..fdaca3d --- /dev/null +++ b/tests/Phase5/GlobalLimiterTest.php @@ -0,0 +1,94 @@ +driver = $this->createMock(RateLimiterInterface::class); + $this->backoffPolicy = $this->createMock(BackoffPolicyInterface::class); + $this->resolver = new EnforcingRateLimiter($this->driver, $this->backoffPolicy); + } + + public function testGlobalLimiterExecutesBeforeActionLimiter(): void + { + $action = $this->createMock(RateLimitActionInterface::class); + $platform = $this->createMock(PlatformInterface::class); + + $callCount = 0; + + $this->driver->expects($this->exactly(2)) + ->method('attempt') + ->willReturnCallback(function (string $key, RateLimitActionInterface $act, PlatformInterface $plt) use (&$callCount, $action, $platform) { + $callCount++; + if ($callCount === 1) { + $this->assertSame('global', $act->value(), 'First call must be global action'); + $this->assertSame('global', $plt->value(), 'First call must be global platform'); + return new RateLimitStatusDTO(10, 9, 60, null, false, null, null, 'global'); + } + + $this->assertSame($action, $act, 'Second call must be the requested action'); + $this->assertSame($platform, $plt, 'Second call must be the requested platform'); + return new RateLimitStatusDTO(10, 9, 60, null, false, null, null, 'action'); + }); + + $status = $this->resolver->attempt('key', $action, $platform); + + $this->assertSame('action', $status->source); + } + + public function testGlobalLimiterBlockPreventsActionLimiter(): void + { + $action = $this->createMock(RateLimitActionInterface::class); + $platform = $this->createMock(PlatformInterface::class); + + // Global attempt fails + $exceptionDTO = new RateLimitStatusDTO(10, 0, 60, null, true, null, null, 'global'); + + $this->driver->expects($this->once()) + ->method('attempt') + ->with( + 'key', + $this->callback(fn(RateLimitActionInterface $a) => $a->value() === 'global'), + $this->callback(fn(PlatformInterface $p) => $p->value() === 'global') + ) + ->willThrowException(new TooManyRequestsException('Global limit', 429, $exceptionDTO)); + + $this->backoffPolicy->expects($this->once()) + ->method('calculateDelay') + ->willReturn(30); + + try { + $this->resolver->attempt('key', $action, $platform); + $this->fail('Expected TooManyRequestsException was not thrown'); + } catch (TooManyRequestsException $e) { + $status = $e->status; + $this->assertNotNull($status); + $this->assertSame('global', $status->source); + $this->assertSame(30, $status->retryAfter); + $this->assertTrue($status->blocked); + $this->assertNotNull($status->nextAllowedAt); + } + } +} diff --git a/tests/Phase5/RateLimitStatusDTOTest.php b/tests/Phase5/RateLimitStatusDTOTest.php new file mode 100644 index 0000000..4ce5566 --- /dev/null +++ b/tests/Phase5/RateLimitStatusDTOTest.php @@ -0,0 +1,61 @@ +assertSame(10, $dto->limit); + $this->assertSame(-1, $dto->remaining); + $this->assertSame(60, $dto->resetAfter); + $this->assertSame(30, $dto->retryAfter); + $this->assertTrue($dto->blocked); + $this->assertSame(30, $dto->backoffSeconds); + $this->assertSame('2025-01-01 12:00:00', $dto->nextAllowedAt); + $this->assertSame('global', $dto->source); + } + + public function testToArrayAndFromArray(): void + { + $dto = new RateLimitStatusDTO( + limit: 10, + remaining: 5, + resetAfter: 60, + retryAfter: null, + blocked: false, + backoffSeconds: null, + nextAllowedAt: null, + source: 'action' + ); + + $array = $dto->toArray(); + + $this->assertSame(10, $array['limit']); + $this->assertSame(5, $array['remaining']); + $this->assertSame(60, $array['reset_after']); + $this->assertNull($array['retry_after']); + $this->assertFalse($array['blocked']); + $this->assertSame('action', $array['source']); + + $newDto = RateLimitStatusDTO::fromArray($array); + + $this->assertEquals($dto, $newDto); + } +} diff --git a/tests/Resolver/RateLimiterResolverTest.php b/tests/Resolver/RateLimiterResolverTest.php index 1ec75b0..b98d8bd 100644 --- a/tests/Resolver/RateLimiterResolverTest.php +++ b/tests/Resolver/RateLimiterResolverTest.php @@ -5,6 +5,7 @@ namespace Maatify\RateLimiter\Tests\Resolver; use Maatify\RateLimiter\Contracts\RateLimiterInterface; +use Maatify\RateLimiter\Resolver\EnforcingRateLimiter; use Maatify\RateLimiter\Resolver\RateLimiterResolver; use PHPUnit\Framework\TestCase; use InvalidArgumentException; @@ -16,7 +17,7 @@ public function testResolveRedis(): void $redisLimiter = $this->createMock(RateLimiterInterface::class); $resolver = new RateLimiterResolver(['redis' => $redisLimiter]); - $this->assertSame($redisLimiter, $resolver->resolve()); + $this->assertInstanceOf(EnforcingRateLimiter::class, $resolver->resolve()); } public function testResolveMongo(): void @@ -24,7 +25,7 @@ public function testResolveMongo(): void $mongoLimiter = $this->createMock(RateLimiterInterface::class); $resolver = new RateLimiterResolver(['mongo' => $mongoLimiter], 'mongo'); - $this->assertSame($mongoLimiter, $resolver->resolve()); + $this->assertInstanceOf(EnforcingRateLimiter::class, $resolver->resolve()); } public function testResolveMySQL(): void @@ -32,7 +33,7 @@ public function testResolveMySQL(): void $mysqlLimiter = $this->createMock(RateLimiterInterface::class); $resolver = new RateLimiterResolver(['mysql' => $mysqlLimiter], 'mysql'); - $this->assertSame($mysqlLimiter, $resolver->resolve()); + $this->assertInstanceOf(EnforcingRateLimiter::class, $resolver->resolve()); } public function testResolveThrowsExceptionForUnknownDriver(): void