createMock(UserReadRepositoryInterface::class); $userReadRepository->expects($this->once())->method('find')->with(9)->willReturn(null); $service = $this->newService(userReadRepository: $userReadRepository); $result = $service->sendVerification(9); $this->assertFalse($result['ok']); $this->assertSame('user_not_found', $result['error']); } public function testSendVerificationReturnsEmailRequiredWhenEmailIsMissing(): void { $userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $userReadRepository->expects($this->once())->method('find')->with(10)->willReturn([ 'id' => 10, 'email' => '', ]); $service = $this->newService(userReadRepository: $userReadRepository); $result = $service->sendVerification(10); $this->assertFalse($result['ok']); $this->assertSame('email_required', $result['error']); } public function testSendVerificationReturnsCreateFailedWhenRepositoryCreateFails(): void { $userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $userReadRepository->expects($this->once())->method('find')->with(11)->willReturn([ 'id' => 11, 'email' => 'user@example.com', 'locale' => 'en', 'first_name' => 'Ada', 'last_name' => 'Lovelace', ]); $verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class); $verificationRepository->expects($this->once())->method('invalidateForUser')->with(11); $verificationRepository->expects($this->once()) ->method('create') ->with( 11, $this->callback(static fn (string $hash): bool => (password_get_info($hash)['algo'] ?? null) !== null), $this->callback(static fn (string $expiresAt): bool => strtotime($expiresAt . ' UTC') !== false) ) ->willReturn(null); $mailService = $this->createMock(MailService::class); $mailService->expects($this->never())->method('sendTemplate'); $service = $this->newService( userReadRepository: $userReadRepository, emailVerificationRepository: $verificationRepository, mailService: $mailService ); $result = $service->sendVerification(11, 'en'); $this->assertFalse($result['ok']); $this->assertSame('create_failed', $result['error']); } public function testSendVerificationCreatesCodeAndSendsMail(): void { $userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $userReadRepository->expects($this->once())->method('find')->with(12)->willReturn([ 'id' => 12, 'email' => 'user@example.com', 'locale' => 'en', 'first_name' => 'Ada', 'last_name' => 'Lovelace', ]); $verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class); $verificationRepository->expects($this->once())->method('invalidateForUser')->with(12); $verificationRepository->expects($this->once())->method('create')->willReturn(120); $mailService = $this->createMock(MailService::class); $mailService->expects($this->once()) ->method('sendTemplate') ->with( 'email_verification', $this->callback(static function (array $vars): bool { return preg_match('/^\d{6}$/', (string) ($vars['code'] ?? '')) === 1 && (int) ($vars['expires_minutes'] ?? 0) === 30 && str_contains((string) ($vars['verify_url'] ?? ''), 'verify-email'); }), 'user@example.com', $this->isString(), 'en' ) ->willReturn(['ok' => true]); $service = $this->newService( userReadRepository: $userReadRepository, emailVerificationRepository: $verificationRepository, mailService: $mailService ); $result = $service->sendVerification(12, 'en'); $this->assertTrue($result['ok']); } public function testVerifyCodeRejectsEmptyInput(): void { $service = $this->newService(); $result = $service->verifyCode('', ''); $this->assertFalse($result['ok']); $this->assertSame('invalid', $result['error']); } public function testVerifyCodeReturnsAlreadyVerifiedWhenUserIsAlreadyVerified(): void { $userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $userReadRepository->method('findByEmail')->willReturn([ 'id' => 13, 'email_verified_at' => gmdate('Y-m-d H:i:s'), ]); $service = $this->newService(userReadRepository: $userReadRepository); $result = $service->verifyCode('user@example.com', '123456'); $this->assertFalse($result['ok']); $this->assertSame('already_verified', $result['error']); } public function testVerifyCodeReturnsTooManyAttemptsWhenLimitReached(): void { $userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $userReadRepository->method('findByEmail')->willReturn([ 'id' => 14, 'email_verified_at' => null, ]); $verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class); $verificationRepository->method('findActiveByUserId')->willReturn([ 'id' => 140, 'attempts' => 5, 'code_hash' => password_hash('123456', PASSWORD_DEFAULT), ]); $verificationRepository->expects($this->never())->method('incrementAttempts'); $service = $this->newService( userReadRepository: $userReadRepository, emailVerificationRepository: $verificationRepository ); $result = $service->verifyCode('user@example.com', '123456'); $this->assertFalse($result['ok']); $this->assertSame('too_many_attempts', $result['error']); } public function testVerifyCodeIncrementsAttemptsOnWrongCode(): void { $userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $userReadRepository->method('findByEmail')->willReturn([ 'id' => 15, 'email_verified_at' => null, ]); $verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class); $verificationRepository->method('findActiveByUserId')->willReturn([ 'id' => 150, 'attempts' => 0, 'code_hash' => password_hash('654321', PASSWORD_DEFAULT), ]); $verificationRepository->expects($this->once())->method('incrementAttempts')->with(150)->willReturn(true); $service = $this->newService( userReadRepository: $userReadRepository, emailVerificationRepository: $verificationRepository ); $result = $service->verifyCode('user@example.com', '123456'); $this->assertFalse($result['ok']); $this->assertSame('invalid', $result['error']); } public function testVerifyCodeMarksRequestUsedAndUserAsVerifiedOnSuccess(): void { $userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $userReadRepository->method('findByEmail')->willReturn([ 'id' => 16, 'email_verified_at' => null, ]); $verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class); $verificationRepository->method('findActiveByUserId')->willReturn([ 'id' => 160, 'attempts' => 1, 'code_hash' => password_hash('123456', PASSWORD_DEFAULT), ]); $verificationRepository->expects($this->once())->method('markUsed')->with(160)->willReturn(true); $userWriteRepository = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepository->expects($this->once())->method('setEmailVerified')->with(16)->willReturn(true); $service = $this->newService( userReadRepository: $userReadRepository, userWriteRepository: $userWriteRepository, emailVerificationRepository: $verificationRepository ); $result = $service->verifyCode('user@example.com', '123456'); $this->assertTrue($result['ok']); $this->assertSame(16, (int) ($result['user_id'] ?? 0)); } public function testResendVerificationRejectsEmptyEmail(): void { $service = $this->newService(); $result = $service->resendVerification(' '); $this->assertFalse($result['ok']); $this->assertSame('email_required', $result['error']); } public function testResendVerificationReturnsOkForUnknownEmailWithoutLeakingIdentity(): void { $userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $userReadRepository->expects($this->once())->method('findByEmail')->with('missing@example.com')->willReturn(null); $verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class); $verificationRepository->expects($this->never())->method('create'); $service = $this->newService( userReadRepository: $userReadRepository, emailVerificationRepository: $verificationRepository ); $result = $service->resendVerification('missing@example.com'); $this->assertTrue($result['ok']); } public function testResendVerificationReturnsAlreadyVerifiedWhenUserIsAlreadyVerified(): void { $userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $userReadRepository->method('findByEmail')->willReturn([ 'id' => 17, 'email_verified_at' => gmdate('Y-m-d H:i:s'), ]); $service = $this->newService(userReadRepository: $userReadRepository); $result = $service->resendVerification('user@example.com'); $this->assertFalse($result['ok']); $this->assertSame('already_verified', $result['error']); } public function testResendVerificationDelegatesToSendVerificationForUnverifiedUsers(): void { $userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $userReadRepository->expects($this->once())->method('findByEmail')->with('user@example.com')->willReturn([ 'id' => 18, 'email_verified_at' => null, ]); $userReadRepository->expects($this->once())->method('find')->with(18)->willReturn([ 'id' => 18, 'email' => 'user@example.com', 'locale' => 'en', ]); $verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class); $verificationRepository->expects($this->once())->method('invalidateForUser')->with(18); $verificationRepository->expects($this->once())->method('create')->willReturn(180); $mailService = $this->createMock(MailService::class); $mailService->expects($this->once())->method('sendTemplate')->willReturn(['ok' => true]); $service = $this->newService( userReadRepository: $userReadRepository, emailVerificationRepository: $verificationRepository, mailService: $mailService ); $result = $service->resendVerification('user@example.com', 'en'); $this->assertTrue($result['ok']); } private function newService( ?UserReadRepositoryInterface $userReadRepository = null, ?UserWriteRepositoryInterface $userWriteRepository = null, ?EmailVerificationRepositoryInterface $emailVerificationRepository = null, ?MailService $mailService = null ): EmailVerificationService { $userReadRepository ??= $this->createMock(UserReadRepositoryInterface::class); $userWriteRepository ??= $this->createMock(UserWriteRepositoryInterface::class); $emailVerificationRepository ??= $this->createMock(EmailVerificationRepositoryInterface::class); $mailService ??= $this->createMock(MailService::class); return new EmailVerificationService( $userReadRepository, $userWriteRepository, $emailVerificationRepository, $mailService, 'TestApp', 'http://localhost', ); } }