diff --git a/composer.json b/composer.json index b41ea92..797656d 100644 --- a/composer.json +++ b/composer.json @@ -6,10 +6,10 @@ "require": { "php": "^8.2", - "ext-pdo": "*", "ext-json": "*", + "ext-pdo": "*", "ext-redis": "*", - + "maatify/exceptions": "^1.1", "maatify/shared-common": "^1.0" }, diff --git a/src/Application/Enum/VerificationErrorCodeEnum.php b/src/Application/Enum/VerificationErrorCodeEnum.php new file mode 100644 index 0000000..5706c21 --- /dev/null +++ b/src/Application/Enum/VerificationErrorCodeEnum.php @@ -0,0 +1,22 @@ +value; + } +} diff --git a/src/Application/Exception/VerificationAttemptsExceededException.php b/src/Application/Exception/VerificationAttemptsExceededException.php new file mode 100644 index 0000000..a1ced2b --- /dev/null +++ b/src/Application/Exception/VerificationAttemptsExceededException.php @@ -0,0 +1,33 @@ +generator->generate($identityType, $identity, $purpose); - - return $generated->plainCode; + try { + $generated = $this->generator->generate($identityType, $identity, $purpose); + return $generated->plainCode; + } catch (RuntimeException $e) { + $this->mapGenerationException($e); + } } public function verifyCode( @@ -32,10 +42,15 @@ public function verifyCode( string $identity, VerificationPurposeEnum $purpose, string $code - ): bool { - $result = $this->validator->validate($identityType, $identity, $purpose, $code); - - return $result->success; + ): void { + try { + $result = $this->validator->validate($identityType, $identity, $purpose, $code); + if (!$result->success) { + throw new RuntimeException($result->reason); + } + } catch (RuntimeException $e) { + $this->mapValidationException($e); + } } public function resendVerification( @@ -43,8 +58,55 @@ public function resendVerification( string $identity, VerificationPurposeEnum $purpose ): string { - $generated = $this->generator->generate($identityType, $identity, $purpose); + try { + $generated = $this->generator->generate($identityType, $identity, $purpose); + return $generated->plainCode; + } catch (RuntimeException $e) { + $this->mapGenerationException($e); + } + } + + /** + * @return never + */ + private function mapGenerationException(RuntimeException $e): void + { + $message = strtolower($e->getMessage()); + + if (str_contains($message, 'rate limit exceeded')) { + throw new VerificationRateLimitException($e->getMessage()); + } + + if (str_contains($message, 'too many codes')) { + throw new VerificationRateLimitException($e->getMessage()); + } + + if (str_contains($message, 'please wait')) { + throw new VerificationGenerationBlockedException($e->getMessage()); + } + + throw new VerificationInternalException($e->getMessage()); + } + + /** + * @return never + */ + private function mapValidationException(RuntimeException $e): void + { + $message = strtolower($e->getMessage()); + + if (str_contains($message, 'expired')) { + throw new VerificationCodeExpiredException($e->getMessage()); + } + + if (str_contains($message, 'attempts')) { + throw new VerificationAttemptsExceededException($e->getMessage()); + } + + if (str_contains($message, 'invalid')) { + throw new VerificationInvalidCodeException($e->getMessage()); + } - return $generated->plainCode; + throw new VerificationInternalException($e->getMessage()); } } diff --git a/src/Application/Verification/VerificationServiceInterface.php b/src/Application/Verification/VerificationServiceInterface.php index 1b42ab9..314387c 100644 --- a/src/Application/Verification/VerificationServiceInterface.php +++ b/src/Application/Verification/VerificationServiceInterface.php @@ -4,6 +4,7 @@ namespace Maatify\Verification\Application\Verification; +use Maatify\Verification\Application\Exception\VerificationException; use Maatify\Verification\Domain\Enum\IdentityTypeEnum; use Maatify\Verification\Domain\Enum\VerificationPurposeEnum; @@ -16,6 +17,7 @@ interface VerificationServiceInterface * @param string $identity * @param VerificationPurposeEnum $purpose * @return string The plain generated verification code. + * @throws VerificationException */ public function startVerification( IdentityTypeEnum $identityType, @@ -30,14 +32,15 @@ public function startVerification( * @param string $identity * @param VerificationPurposeEnum $purpose * @param string $code - * @return bool True if verification succeeds, false otherwise. + * @return void + * @throws VerificationException */ public function verifyCode( IdentityTypeEnum $identityType, string $identity, VerificationPurposeEnum $purpose, string $code - ): bool; + ): void; /** * Resends a verification code. @@ -46,6 +49,7 @@ public function verifyCode( * @param string $identity * @param VerificationPurposeEnum $purpose * @return string The plain generated verification code. + * @throws VerificationException */ public function resendVerification( IdentityTypeEnum $identityType, diff --git a/tests/Unit/Application/Verification/VerificationServiceTest.php b/tests/Unit/Application/Verification/VerificationServiceTest.php index dae1dd7..fffd59c 100644 --- a/tests/Unit/Application/Verification/VerificationServiceTest.php +++ b/tests/Unit/Application/Verification/VerificationServiceTest.php @@ -4,6 +4,13 @@ namespace Tests\Unit\Application\Verification; +use DateTimeImmutable; +use Maatify\Verification\Application\Exception\VerificationAttemptsExceededException; +use Maatify\Verification\Application\Exception\VerificationCodeExpiredException; +use Maatify\Verification\Application\Exception\VerificationGenerationBlockedException; +use Maatify\Verification\Application\Exception\VerificationInternalException; +use Maatify\Verification\Application\Exception\VerificationInvalidCodeException; +use Maatify\Verification\Application\Exception\VerificationRateLimitException; use Maatify\Verification\Application\Verification\VerificationService; use Maatify\Verification\Domain\Contracts\VerificationCodeGeneratorInterface; use Maatify\Verification\Domain\Contracts\VerificationCodeValidatorInterface; @@ -14,6 +21,7 @@ use Maatify\Verification\Domain\Enum\VerificationCodeStatus; use Maatify\Verification\Domain\Enum\VerificationPurposeEnum; use PHPUnit\Framework\TestCase; +use RuntimeException; class VerificationServiceTest extends TestCase { @@ -28,7 +36,10 @@ protected function setUp(): void $this->generator = $this->createMock(VerificationCodeGeneratorInterface::class); $this->validator = $this->createMock(VerificationCodeValidatorInterface::class); - $this->service = new VerificationService($this->generator, $this->validator); + $this->service = new VerificationService( + $this->generator, + $this->validator + ); } private function createDummyGeneratedCode(string $plainCode): GeneratedVerificationCode @@ -42,8 +53,8 @@ private function createDummyGeneratedCode(string $plainCode): GeneratedVerificat VerificationCodeStatus::ACTIVE, 0, 3, - new \DateTimeImmutable('+1 hour'), - new \DateTimeImmutable() + new DateTimeImmutable('+1 hour'), + new DateTimeImmutable() ); return new GeneratedVerificationCode($entity, $plainCode); @@ -66,7 +77,7 @@ public function testStartVerificationReturnsGeneratedCode(): void $this->assertSame('123456', $code); } - public function testVerifyCodeReturnsTrueOnValidCode(): void + public function testVerifyCodeReturnsNormallyOnValidCode(): void { $this->validator ->expects($this->once()) @@ -74,32 +85,82 @@ public function testVerifyCodeReturnsTrueOnValidCode(): void ->with(IdentityTypeEnum::User, 'user@example.com', VerificationPurposeEnum::EmailVerification, '123456') ->willReturn(VerificationResult::success(IdentityTypeEnum::User, 'user@example.com', VerificationPurposeEnum::EmailVerification)); - $result = $this->service->verifyCode( + $this->service->verifyCode( IdentityTypeEnum::User, 'user@example.com', VerificationPurposeEnum::EmailVerification, '123456' ); + $this->assertTrue(true); + } - $this->assertTrue($result); + public function testVerifyCodeThrowsInvalidCodeException(): void + { + $this->validator + ->expects($this->once()) + ->method('validate') + ->willReturn(VerificationResult::failure('Invalid code.')); + + $this->expectException(VerificationInvalidCodeException::class); + $this->expectExceptionMessage('Invalid code.'); + + $this->service->verifyCode( + IdentityTypeEnum::User, + 'user@example.com', + VerificationPurposeEnum::EmailVerification, + 'wrong' + ); } - public function testVerifyCodeReturnsFalseOnInvalidCode(): void + public function testVerifyCodeThrowsExpiredCodeException(): void { $this->validator ->expects($this->once()) ->method('validate') - ->with(IdentityTypeEnum::User, 'user@example.com', VerificationPurposeEnum::EmailVerification, 'wrong') - ->willReturn(VerificationResult::failure('Invalid code')); + ->willReturn(VerificationResult::failure('Verification code has expired.')); + + $this->expectException(VerificationCodeExpiredException::class); - $result = $this->service->verifyCode( + $this->service->verifyCode( IdentityTypeEnum::User, 'user@example.com', VerificationPurposeEnum::EmailVerification, 'wrong' ); + } + + public function testVerifyCodeThrowsAttemptsExceededException(): void + { + $this->validator + ->expects($this->once()) + ->method('validate') + ->willReturn(VerificationResult::failure('Maximum attempts exceeded.')); + + $this->expectException(VerificationAttemptsExceededException::class); - $this->assertFalse($result); + $this->service->verifyCode( + IdentityTypeEnum::User, + 'user@example.com', + VerificationPurposeEnum::EmailVerification, + 'wrong' + ); + } + + public function testVerifyCodeThrowsInternalException(): void + { + $this->validator + ->expects($this->once()) + ->method('validate') + ->willThrowException(new RuntimeException('Database failure')); + + $this->expectException(VerificationInternalException::class); + + $this->service->verifyCode( + IdentityTypeEnum::User, + 'user@example.com', + VerificationPurposeEnum::EmailVerification, + 'wrong' + ); } public function testResendVerificationGeneratesNewCode(): void @@ -118,4 +179,53 @@ public function testResendVerificationGeneratesNewCode(): void $this->assertSame('654321', $code); } + + public function testGenerationRateLimitExceptionForRedis(): void + { + $this->generator + ->expects($this->once()) + ->method('generate') + ->willThrowException(new RuntimeException('Rate limit exceeded for window 1h')); + + $this->expectException(VerificationRateLimitException::class); + $this->expectExceptionMessage('Rate limit exceeded for window 1h'); + + $this->service->startVerification(IdentityTypeEnum::User, 'user@example.com', VerificationPurposeEnum::EmailVerification); + } + + public function testGenerationRateLimitExceptionTooManyCodes(): void + { + $this->generator + ->expects($this->once()) + ->method('generate') + ->willThrowException(new RuntimeException('Too many codes generated in the current window.')); + + $this->expectException(VerificationRateLimitException::class); + + $this->service->startVerification(IdentityTypeEnum::User, 'user@example.com', VerificationPurposeEnum::EmailVerification); + } + + public function testGenerationBlockedExceptionCooldown(): void + { + $this->generator + ->expects($this->once()) + ->method('generate') + ->willThrowException(new RuntimeException('Please wait before requesting a new code.')); + + $this->expectException(VerificationGenerationBlockedException::class); + + $this->service->startVerification(IdentityTypeEnum::User, 'user@example.com', VerificationPurposeEnum::EmailVerification); + } + + public function testGenerationInternalException(): void + { + $this->generator + ->expects($this->once()) + ->method('generate') + ->willThrowException(new RuntimeException('Failed to generate secure random code.')); + + $this->expectException(VerificationInternalException::class); + + $this->service->startVerification(IdentityTypeEnum::User, 'user@example.com', VerificationPurposeEnum::EmailVerification); + } }