createController( apiClient: $this->createMock(ApiClient::class), newsletterManager: $this->createMock(NewsletterManager::class), ); $controller->apiClient ->expects(self::once()) ->method('getPersonalData') ->with('customer@example.com', 'secret') ->willReturn($this->createPersonalData('Mia', 'Muster')); $controller->newsletterManager ->expects(self::once()) ->method('hasConfirmedOptIn') ->with('customer@example.com') ->willReturn(false); $controller->newsletterManager ->expects(self::once()) ->method('hasPendingConfirmation') ->with('customer@example.com') ->willReturn(false); $response = $controller->index(Request::create('/personal-data', 'GET')); self::assertSame(200, $response->getStatusCode()); self::assertSame('account/personal_data.html.twig', $controller->renderedView); self::assertSame(false, $controller->renderedParameters['newsletterSubscribed']); self::assertSame(false, $controller->renderedParameters['newsletterPendingConfirmation']); } public function testIndexStillTracksPendingNewsletterConfirmationWhenSubscribed(): void { $controller = $this->createController( apiClient: $this->createMock(ApiClient::class), newsletterManager: $this->createMock(NewsletterManager::class), ); $controller->apiClient ->expects(self::once()) ->method('getPersonalData') ->with('customer@example.com', 'secret') ->willReturn($this->createPersonalData('Mia', 'Muster')); $controller->newsletterManager ->expects(self::once()) ->method('hasConfirmedOptIn') ->with('customer@example.com') ->willReturn(true); $controller->newsletterManager ->expects(self::once()) ->method('hasPendingConfirmation') ->with('customer@example.com') ->willReturn(false); $response = $controller->index(Request::create('/personal-data', 'GET')); self::assertSame(200, $response->getStatusCode()); self::assertSame(true, $controller->renderedParameters['newsletterSubscribed']); self::assertSame(false, $controller->renderedParameters['newsletterPendingConfirmation']); } public function testNewsletterActionReturnsHtmxRedirectAndFlash(): void { $controller = $this->createController( apiClient: $this->createMock(ApiClient::class), newsletterManager: $this->createMock(NewsletterManager::class), ); $controller->apiClient ->expects(self::once()) ->method('getPersonalData') ->with('customer@example.com', 'secret') ->willReturn($this->createPersonalData('Mia', 'Muster')); $controller->newsletterManager ->expects(self::once()) ->method('hasPendingConfirmation') ->with('customer@example.com') ->willReturn(false); $controller->newsletterManager ->expects(self::once()) ->method('requestDefaultListSubscription') ->with('customer@example.com', 'Mia', 'Muster') ->willReturn(new NewsletterSubscriptionRequestResult( 'customer@example.com', [10321569], NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED, true, [ 10321569 => NewsletterSubscriptionRequestResult::LIST_STATE_PENDING, ], )); $response = $controller->newsletter(Request::create('/personal-data/newsletter', 'POST', [], [], [], [ 'HTTP_HX_REQUEST' => 'true', ])); self::assertInstanceOf(Response::class, $response); self::assertSame(200, $response->getStatusCode()); self::assertSame('/personal-data', $response->headers->get('HX-Redirect')); self::assertSame([ ['success', 'Bitte bestätige deine Newsletter-Anmeldung über den Link in der E-Mail.'], ], $controller->flashes); } public function testNewsletterActionResendsPendingConfirmation(): void { $controller = $this->createController( apiClient: $this->createMock(ApiClient::class), newsletterManager: $this->createMock(NewsletterManager::class), ); $controller->apiClient ->expects(self::once()) ->method('getPersonalData') ->with('customer@example.com', 'secret') ->willReturn($this->createPersonalData('Mia', 'Muster')); $controller->newsletterManager ->expects(self::once()) ->method('hasPendingConfirmation') ->with('customer@example.com') ->willReturn(true); $controller->newsletterManager ->expects(self::once()) ->method('requestConfirmation') ->with('customer@example.com', 'Mia', 'Muster'); $controller->newsletterManager->expects(self::never())->method('requestDefaultListSubscription'); $response = $controller->newsletter(Request::create('/personal-data/newsletter', 'POST', [], [], [], [ 'HTTP_HX_REQUEST' => 'true', ])); self::assertInstanceOf(Response::class, $response); self::assertSame(200, $response->getStatusCode()); self::assertSame([ ['success', 'Wir haben dir eine neue Bestätigungs-E-Mail gesendet.'], ], $controller->flashes); } /** * The e-mail lives on the BPN address and doubles as the login identity of every person on it, * so this form must never accept a submitted value for it. */ public function testIndexLocksTheEmailField(): void { $controller = $this->createController(); $controller->apiClient ->method('getPersonalData') ->willReturn($this->createPersonalData('Mia', 'Muster')); $controller->index(Request::create('/personal-data', 'GET')); self::assertFalse($controller->createFormOptions['email_editable']); } /** * A failed load yields an empty PersonalData. Handing that to BPN would blank every field we * never read - including the e-mail, which would lock the customer out of the portal. */ public function testIndexDoesNotSubmitTheFormWhenPersonalDataCouldNotBeLoaded(): void { $controller = $this->createController( apiClient: $this->createMock(ApiClient::class), form: $this->createMock(FormInterface::class), ); $controller->apiClient ->expects(self::once()) ->method('getPersonalData') ->willReturn(new Notification(500, 'no such customer')); $controller->apiClient ->expects(self::never()) ->method('updatePersonalData'); $controller->form ->expects(self::never()) ->method('handleRequest'); $response = $controller->index(Request::create('/personal-data', 'POST', [ 'personal_data' => ['email' => 'attacker@example.com'], ])); self::assertSame(200, $response->getStatusCode()); self::assertSame('account/personal_data.html.twig', $controller->renderedView); } /** * Collaborators default to stubs; a test passes its own mock for whatever it sets * expectations on, and reaches it again through the controller's public property. */ private function createController( ?ApiClient $apiClient = null, ?NewsletterManager $newsletterManager = null, ?FormInterface $form = null, ): TestablePersonalDataController { $user = new User('customer@example.com'); $user->setPassword('secret'); $crypt = $this->createMock(Crypt::class); $crypt ->method('decrypt') ->with('secret') ->willReturn('secret'); $form ??= $this->createStub(FormInterface::class); $form ->method('handleRequest') ->willReturnSelf(); $form ->method('isSubmitted') ->willReturn(false); return new TestablePersonalDataController( $apiClient ?? $this->createStub(ApiClient::class), $crypt, $this->createStub(BookingEditDataLoader::class), $this->createStub(ProfileCompletenessChecker::class), $this->createStub(EntityManagerInterface::class), $newsletterManager ?? $this->createStub(NewsletterManager::class), $this->createStub(LoggerInterface::class), $user, $form, ); } private function createPersonalData(string $firstName, string $lastName): PersonalData { $personalData = new PersonalData(); $personalData->addressId = 4711; $personalData->personId = 815; $personalData->firstName = $firstName; $personalData->name = $lastName; $personalData->communication->email = 'customer@example.com'; return $personalData; } } final class TestablePersonalDataController extends PersonalDataController { /** * @var array */ public array $flashes = []; /** * @var array */ public array $renderedParameters = []; public string $renderedView = ''; /** * @var array */ public array $createFormOptions = []; public function __construct( public readonly ApiClient $apiClient, Crypt $crypt, BookingEditDataLoader $dataLoader, ProfileCompletenessChecker $completenessChecker, EntityManagerInterface $entityManager, public readonly NewsletterManager $newsletterManager, LoggerInterface $logger, private readonly User $user, public readonly FormInterface $form, ) { parent::__construct( $apiClient, $crypt, $dataLoader, $completenessChecker, $entityManager, $newsletterManager, $logger, ); } public function createForm(string $type, mixed $data = null, array $options = []): FormInterface { $this->createFormOptions = $options; return $this->form; } protected function getUser(): UserInterface { return $this->user; } protected function addFlash(string $type, mixed $message): void { $this->flashes[] = [$type, (string) $message]; } protected function render(string $view, array $parameters = [], ?Response $response = null): Response { $this->renderedView = $view; $this->renderedParameters = $parameters; return new Response('ok'); } protected function generateUrl(string $route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string { return '/personal-data'; } }