*/ public static function fibonacciDelayProvider(): iterable { yield '0 attempts = no delay' => [0, 0]; yield '1 attempt = no delay' => [1, 0]; yield '2 attempts = 1s' => [2, 1]; yield '3 attempts = 1s' => [3, 1]; yield '4 attempts = 2s' => [4, 2]; yield '5 attempts = 3s' => [5, 3]; yield '6 attempts = 5s' => [6, 5]; yield '7 attempts = 8s' => [7, 8]; yield '8 attempts = 13s' => [8, 13]; yield '9 attempts = 21s' => [9, 21]; yield '10 attempts = 34s' => [10, 34]; yield '11 attempts = 55s' => [11, 55]; yield '12 attempts = 89s (max)' => [12, 89]; yield '20 attempts = 89s (capped)' => [20, 89]; yield '100 attempts = 89s (capped)' => [100, 89]; } #[Test] public function allowedResultHasCorrectProperties(): void { $result = LoginRateLimitResult::allowed( attempts: 3, delaySeconds: 1, requiresCaptcha: false, ); self::assertTrue($result->isAllowed); self::assertSame(3, $result->attempts); self::assertSame(1, $result->delaySeconds); self::assertFalse($result->requiresCaptcha); self::assertFalse($result->ipBlocked); self::assertSame(1, $result->retryAfter); } #[Test] public function allowedWithZeroDelayHasNullRetryAfter(): void { $result = LoginRateLimitResult::allowed( attempts: 1, delaySeconds: 0, requiresCaptcha: false, ); self::assertNull($result->retryAfter); } #[Test] public function blockedResultHasCorrectProperties(): void { $result = LoginRateLimitResult::blocked(retryAfter: 900); self::assertFalse($result->isAllowed); self::assertSame(0, $result->attempts); self::assertSame(900, $result->delaySeconds); self::assertFalse($result->requiresCaptcha); self::assertTrue($result->ipBlocked); self::assertSame(900, $result->retryAfter); } #[Test] public function toHeadersIncludesAllRelevantHeaders(): void { $result = LoginRateLimitResult::allowed( attempts: 6, delaySeconds: 5, requiresCaptcha: true, ); $headers = $result->toHeaders(); self::assertSame('6', $headers['X-Login-Attempts']); self::assertSame('5', $headers['X-Login-Delay']); self::assertSame('5', $headers['Retry-After']); self::assertSame('true', $headers['X-Captcha-Required']); self::assertArrayNotHasKey('X-IP-Blocked', $headers); } #[Test] public function toHeadersForBlockedIp(): void { $result = LoginRateLimitResult::blocked(retryAfter: 600); $headers = $result->toHeaders(); self::assertSame('true', $headers['X-IP-Blocked']); self::assertSame('600', $headers['Retry-After']); } #[Test] public function getFormattedDelayFormatsSeconds(): void { $result = LoginRateLimitResult::allowed(attempts: 2, delaySeconds: 1, requiresCaptcha: false); self::assertSame('1 seconde', $result->getFormattedDelay()); $result = LoginRateLimitResult::allowed(attempts: 6, delaySeconds: 5, requiresCaptcha: false); self::assertSame('5 secondes', $result->getFormattedDelay()); $result = LoginRateLimitResult::allowed(attempts: 8, delaySeconds: 13, requiresCaptcha: false); self::assertSame('13 secondes', $result->getFormattedDelay()); } #[Test] public function getFormattedDelayFormatsMinutes(): void { $result = LoginRateLimitResult::blocked(retryAfter: 60); self::assertSame('1 minute', $result->getFormattedDelay()); $result = LoginRateLimitResult::blocked(retryAfter: 900); self::assertSame('15 minutes', $result->getFormattedDelay()); } #[Test] public function getFormattedDelayReturnsEmptyForZero(): void { $result = LoginRateLimitResult::allowed(attempts: 1, delaySeconds: 0, requiresCaptcha: false); self::assertSame('', $result->getFormattedDelay()); } }