createMock(UserRepository::class); $userRepository ->expects(self::once()) ->method('findOneByNormalizedEmail') ->with('customer@example.com') ->willReturn(null); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager ->expects(self::once()) ->method('persist') ->with(self::callback(static function (User $user) use (&$persistedUser): bool { $persistedUser = $user; return true; })); $entityManager ->expects(self::once()) ->method('flush'); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $passwordHasher ->expects(self::once()) ->method('hashPassword') ->with(self::isInstanceOf(User::class), self::callback(static function (string $password) use (&$plainPassword): bool { $plainPassword = $password; return '' !== $password; })) ->willReturn('hashed-password'); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $urlGenerator ->expects(self::once()) ->method('generate') ->willReturnCallback(static function (string $route, array $parameters, int $referenceType) use (&$generatedRoute, &$generatedParameters, &$generatedReferenceType): string { $generatedRoute = $route; $generatedParameters = $parameters; $generatedReferenceType = $referenceType; return 'https://example.test/welcome/activate?'.http_build_query($parameters); }); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->willReturnCallback(static function (array $context, array $options) use (&$capturedContext, &$capturedOptions): void { $capturedContext = $context; $capturedOptions = $options; }); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer); $uriSigner = new UriSigner('test-secret'); self::assertTrue($manager->provisionGuestAccount(' Customer@Example.COM ', 'Alice', 'Example', 12345, 67890)); self::assertSame('app_guest_account_activate', $generatedRoute); self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $generatedReferenceType); self::assertSame('customer@example.com', $generatedParameters['email']); self::assertSame($persistedUser?->getWelcomeActivationNonce(), $generatedParameters['nonce']); self::assertTrue($uriSigner->check((string) $capturedContext['activationUrl'])); self::assertSame('customer@example.com', $capturedOptions['to']); self::assertSame('Dein MyE&P Konto ist bereit', $capturedOptions['subject']); self::assertSame('email/booking_guest_account_welcome.html.twig', $capturedOptions['template']); self::assertSame('Alice', $capturedContext['firstName']); self::assertSame('Example', $capturedContext['lastName']); self::assertMatchesRegularExpression('/^[a-f0-9]{48}$/', (string) $plainPassword); self::assertInstanceOf(User::class, $persistedUser); self::assertSame('customer@example.com', $persistedUser->getEmail()); self::assertSame(12345, $persistedUser->getAddressId()); self::assertSame(67890, $persistedUser->getPersonId()); self::assertSame(['ROLE_USER', 'ROLE_CUSTOMER'], $persistedUser->getRoles()); self::assertSame([], $persistedUser->getHotelCodes()); self::assertTrue($persistedUser->isProfileComplete()); self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $persistedUser->getWelcomeActivationNonce()); self::assertInstanceOf(\DateTimeImmutable::class, $persistedUser->getWelcomeActivationExpiresAt()); self::assertNull($persistedUser->getWelcomeActivatedAt()); } public function testProvisionGuestAccountReturnsFalseWhenLocalUserAlreadyExists(): void { $userRepository = $this->createMock(UserRepository::class); $userRepository ->expects(self::once()) ->method('findOneByNormalizedEmail') ->with('customer@example.com') ->willReturn(new User('customer@example.com')); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('persist'); $entityManager->expects(self::never())->method('flush'); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $passwordHasher->expects(self::never())->method('hashPassword'); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $urlGenerator->expects(self::never())->method('generate'); $mailer = $this->createMock(Mailer::class); $mailer->expects(self::never())->method('createAndSendEmail'); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer); self::assertFalse($manager->provisionGuestAccount('customer@example.com', 'Alice', 'Example')); } public function testRequestPortalAccessProvisionsGuestAccountWhenBookingMatches(): void { $persistedUser = null; $capturedContext = null; $capturedOptions = null; $booking = $this->createLookupResult('customer@example.com', 123, 'Alice', 'Example'); $bpnConnectClient = $this->createMock(BpnConnectClient::class); $bpnConnectClient ->expects(self::once()) ->method('lookupBooking') ->with('customer@example.com', 123) ->willReturn($booking); $userRepository = $this->createMock(UserRepository::class); $userRepository ->expects(self::exactly(2)) ->method('findOneByNormalizedEmail') ->with('customer@example.com') ->willReturnOnConsecutiveCalls(null, null); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager ->expects(self::once()) ->method('persist') ->with(self::callback(static function (User $user) use (&$persistedUser): bool { $persistedUser = $user; return true; })); $entityManager->expects(self::once())->method('flush'); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $passwordHasher ->expects(self::once()) ->method('hashPassword') ->willReturn('hashed-password'); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $urlGenerator ->expects(self::once()) ->method('generate') ->willReturn('https://example.test/welcome/activate?email=customer@example.com&nonce=current&expires=9999999999'); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->willReturnCallback(static function (array $context, array $options) use (&$capturedContext, &$capturedOptions): void { $capturedContext = $context; $capturedOptions = $options; }); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient); self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_SENT, $manager->requestPortalAccess('Customer@Example.COM', 123)); self::assertInstanceOf(User::class, $persistedUser); self::assertSame('customer@example.com', $persistedUser->getEmail()); self::assertSame(12345, $persistedUser->getAddressId()); self::assertSame(67890, $persistedUser->getPersonId()); self::assertSame('customer@example.com', $capturedOptions['to']); self::assertSame('Alice', $capturedContext['firstName']); self::assertSame('Example', $capturedContext['lastName']); self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $persistedUser->getWelcomeActivationNonce()); self::assertInstanceOf(\DateTimeImmutable::class, $persistedUser->getWelcomeActivationExpiresAt()); self::assertNull($persistedUser->getWelcomeActivatedAt()); } public function testRequestPortalAccessResendsPendingInviteWhenBookingMatches(): void { $user = new User('customer@example.com'); $user->setAddressId(12345); $user->setPersonId(67890); $user->setWelcomeActivationNonce('current-nonce'); $user->setWelcomeActivationExpiresAt(new \DateTimeImmutable('+30 minutes')); $booking = $this->createLookupResult('customer@example.com', 123, 'Alice', 'Example'); $bpnConnectClient = $this->createMock(BpnConnectClient::class); $bpnConnectClient ->expects(self::once()) ->method('lookupBooking') ->with('customer@example.com', 123) ->willReturn($booking); $userRepository = $this->createMock(UserRepository::class); $userRepository ->expects(self::once()) ->method('findOneByNormalizedEmail') ->with('customer@example.com') ->willReturn($user); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once())->method('flush'); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $passwordHasher->expects(self::never())->method('hashPassword'); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $urlGenerator ->expects(self::once()) ->method('generate') ->willReturn('https://example.test/welcome/activate?email=customer@example.com&nonce=new-nonce&expires=9999999999'); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail'); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient); self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_SENT, $manager->requestPortalAccess('customer@example.com', 123)); self::assertNotSame('current-nonce', $user->getWelcomeActivationNonce()); self::assertNull($user->getWelcomeActivatedAt()); self::assertInstanceOf(\DateTimeImmutable::class, $user->getWelcomeActivationExpiresAt()); } public function testRequestPortalAccessUsesExistingUserIdsAndDoesNotMailActivatedAccount(): void { $user = new User('customer@example.com'); $user->setAddressId(12345); $user->setPersonId(67890); $user->markWelcomeActivated(); $booking = $this->createLookupResult('customer@example.com', 123, 'Alice', 'Example'); $bpnConnectClient = $this->createMock(BpnConnectClient::class); $bpnConnectClient ->expects(self::once()) ->method('lookupBooking') ->with('customer@example.com', 123) ->willReturn($booking); $userRepository = $this->createMock(UserRepository::class); $userRepository ->expects(self::once()) ->method('findOneByNormalizedEmail') ->with('customer@example.com') ->willReturn($user); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('persist'); $entityManager->expects(self::never())->method('flush'); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $passwordHasher->expects(self::never())->method('hashPassword'); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $urlGenerator->expects(self::never())->method('generate'); $mailer = $this->createMock(Mailer::class); $mailer->expects(self::never())->method('createAndSendEmail'); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient); self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_ALREADY_ACTIVE, $manager->requestPortalAccess('customer@example.com', 123)); } public function testProvisionGuestAccountForBookingFetchesBookingIdentifiers(): void { $persistedUser = null; $booking = $this->createLookupResult('customer@example.com', 123, 'Alice', 'Example'); $bpnConnectClient = $this->createMock(BpnConnectClient::class); $bpnConnectClient ->expects(self::once()) ->method('lookupBooking') ->with('customer@example.com', 123) ->willReturn($booking); $userRepository = $this->createMock(UserRepository::class); $userRepository ->expects(self::once()) ->method('findOneByNormalizedEmail') ->with('customer@example.com') ->willReturn(null); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager ->expects(self::once()) ->method('persist') ->with(self::callback(static function (User $user) use (&$persistedUser): bool { $persistedUser = $user; return true; })); $entityManager->expects(self::once())->method('flush'); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $passwordHasher ->expects(self::once()) ->method('hashPassword') ->willReturn('hashed-password'); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $urlGenerator ->expects(self::once()) ->method('generate') ->willReturn('https://example.test/welcome/activate?email=customer@example.com&nonce=current&expires=9999999999'); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail'); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient); self::assertTrue($manager->provisionGuestAccountForBooking('Customer@Example.COM', 123)); self::assertInstanceOf(User::class, $persistedUser); self::assertSame('customer@example.com', $persistedUser->getEmail()); self::assertSame(12345, $persistedUser->getAddressId()); self::assertSame(67890, $persistedUser->getPersonId()); } public function testRequestPortalAccessRejectsMismatchedBookingGenerically(): void { $booking = $this->createLookupResult('other@example.com', 123, 'Alice', 'Example'); $bpnConnectClient = $this->createMock(BpnConnectClient::class); $bpnConnectClient ->expects(self::once()) ->method('lookupBooking') ->with('customer@example.com', 123) ->willReturn($booking); $userRepository = $this->createMock(UserRepository::class); $userRepository ->expects(self::once()) ->method('findOneByNormalizedEmail') ->with('customer@example.com') ->willReturn(null); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('persist'); $entityManager->expects(self::never())->method('flush'); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $passwordHasher->expects(self::never())->method('hashPassword'); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $urlGenerator->expects(self::never())->method('generate'); $mailer = $this->createMock(Mailer::class); $mailer->expects(self::never())->method('createAndSendEmail'); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient); self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_NOT_SENT, $manager->requestPortalAccess('customer@example.com', 123)); } public function testConsumeActivationMarksInviteAsActivated(): void { $user = new User('customer@example.com'); $user->setWelcomeActivationNonce('current-nonce'); $user->setWelcomeActivationExpiresAt(new \DateTimeImmutable('+30 minutes')); $manager = $this->createValidationManager($user); $manager->consumeActivation($user); self::assertNull($user->getWelcomeActivationNonce()); self::assertNull($user->getWelcomeActivationExpiresAt()); self::assertInstanceOf(\DateTimeImmutable::class, $user->getWelcomeActivatedAt()); } public function testValidateActivationRequestAcceptsValidSignedRequestAndRejectsReplay(): void { $user = new User('customer@example.com'); $user->setWelcomeActivationNonce('current-nonce'); $manager = $this->createValidationManager($user); $request = Request::create( $this->buildActivationUrl('customer@example.com', 'current-nonce', time() + 3600), 'GET' ); self::assertSame($user, $manager->validateActivationRequest($request)); $manager->consumeActivation($user); $this->expectException(NotFoundHttpException::class); $manager->validateActivationRequest($request); } public function testValidateActivationRequestRejectsExpiredLink(): void { $user = new User('customer@example.com'); $user->setWelcomeActivationNonce('current-nonce'); $manager = $this->createValidationManager($user); $request = Request::create( $this->buildActivationUrl('customer@example.com', 'current-nonce', time() - 60), 'GET' ); $this->expectException(GuestAccountActivationExpiredException::class); $manager->validateActivationRequest($request); } public function testValidateActivationUrlForResendAcceptsExpiredLink(): void { $user = new User('customer@example.com'); $user->setWelcomeActivationNonce('current-nonce'); $manager = $this->createValidationManager($user); $activationUrl = $this->buildActivationUrl('customer@example.com', 'current-nonce', time() - 60); self::assertSame($user, $manager->validateActivationUrlForResend($activationUrl)); } public function testResendWelcomeEmailRotatesNonceAndSendsActivationMail(): void { $user = new User('customer@example.com'); $user->setWelcomeActivationNonce('old-nonce'); $generatedRoute = null; $generatedParameters = null; $generatedReferenceType = null; $capturedContext = null; $capturedOptions = null; $userRepository = $this->createMock(UserRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $mailer = $this->createMock(Mailer::class); $entityManager->expects(self::once())->method('flush'); $passwordHasher->expects(self::never())->method('hashPassword'); $userRepository->expects(self::never())->method('findOneByNormalizedEmail'); $urlGenerator ->expects(self::once()) ->method('generate') ->willReturnCallback(static function (string $route, array $parameters, int $referenceType) use (&$generatedRoute, &$generatedParameters, &$generatedReferenceType): string { $generatedRoute = $route; $generatedParameters = $parameters; $generatedReferenceType = $referenceType; return 'https://example.test/welcome/activate?'.http_build_query($parameters); }); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->willReturnCallback(static function (array $context, array $options) use (&$capturedContext, &$capturedOptions): void { $capturedContext = $context; $capturedOptions = $options; }); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer); $manager->resendWelcomeEmail($user); self::assertSame('app_guest_account_activate', $generatedRoute); self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $generatedReferenceType); self::assertSame('customer@example.com', $generatedParameters['email']); self::assertSame($user->getWelcomeActivationNonce(), $generatedParameters['nonce']); self::assertSame('customer@example.com', $capturedOptions['to']); self::assertSame('Dein MyE&P Konto ist bereit', $capturedOptions['subject']); self::assertSame('email/booking_guest_account_welcome.html.twig', $capturedOptions['template']); self::assertTrue((new UriSigner('test-secret'))->check((string) $capturedContext['activationUrl'])); self::assertNotSame('old-nonce', $user->getWelcomeActivationNonce()); self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $user->getWelcomeActivationNonce()); } public function testProvisionGuestAccountKeepsPersistedUserWhenMailSendFails(): void { $persistedUser = null; $userRepository = $this->createMock(UserRepository::class); $userRepository ->expects(self::once()) ->method('findOneByNormalizedEmail') ->with('customer@example.com') ->willReturn(null); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager ->expects(self::once()) ->method('persist') ->with(self::callback(static function (User $user) use (&$persistedUser): bool { $persistedUser = $user; return true; })); $entityManager->expects(self::once())->method('flush'); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $passwordHasher ->expects(self::once()) ->method('hashPassword') ->willReturn('hashed-password'); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $urlGenerator ->expects(self::once()) ->method('generate') ->willReturn('https://example.test/welcome/activate?email=customer@example.com&nonce=current&expires=9999999999'); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->willThrowException(new TransportException('smtp down')); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer); $this->expectException(TransportException::class); try { $manager->provisionGuestAccount('customer@example.com', 'Alice', 'Example'); } finally { self::assertInstanceOf(User::class, $persistedUser); self::assertSame('customer@example.com', $persistedUser->getEmail()); self::assertSame(['ROLE_USER', 'ROLE_CUSTOMER'], $persistedUser->getRoles()); self::assertTrue($persistedUser->isProfileComplete()); self::assertNotNull($persistedUser->getWelcomeActivationNonce()); } } private function createManager( UserRepository $userRepository, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher, UrlGeneratorInterface $urlGenerator, Mailer $mailer, ?BpnConnectClient $bpnConnectClient = null, ): BookingGuestAccountManager { $bpnConnectClient ??= $this->createMock(BpnConnectClient::class); return new BookingGuestAccountManager( $bpnConnectClient, $userRepository, $entityManager, new UserBootstrapper(), $passwordHasher, $urlGenerator, new UriSigner('test-secret'), $mailer, new NullLogger(), ); } private function createValidationManager(User $user): BookingGuestAccountManager { $userRepository = $this->createMock(UserRepository::class); $userRepository ->method('findOneByNormalizedEmail') ->willReturnCallback(static function (string $email) use ($user): ?User { return 'customer@example.com' === $email ? $user : null; }); $entityManager = $this->createMock(EntityManagerInterface::class); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $mailer = $this->createMock(Mailer::class); $bpnConnectClient = $this->createMock(BpnConnectClient::class); return $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient); } private function buildActivationUrl(string $email, string $nonce, int $expires): string { $baseUrl = 'https://example.test/welcome/activate?'.http_build_query([ 'email' => $email, 'nonce' => $nonce, 'expires' => $expires, ]); return (new UriSigner('test-secret'))->sign($baseUrl); } private function createLookupResult(string $email, int $bookingNumber, string $firstName, string $lastName): BookingLookupResult { return new BookingLookupResult( bookingNumber: $bookingNumber, bookingId: 98765, addressId: 12345, personId: 67890, email: $email, firstName: $firstName, lastName: $lastName, ); } }