From 627b190fcb556b6288156d10659a1f14d6a1c193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 5 Oct 2026 09:09:17 +0200 Subject: [PATCH] feat: update voucher purchase urls to match established patterns --- src/Controller/Voucher/IndexController.php | 69 +++++----- src/Controller/Voucher/Step1Controller.php | 8 +- src/Controller/Voucher/Step2Controller.php | 2 +- src/Controller/Voucher/Step3Controller.php | 2 +- src/Controller/Voucher/SuccessController.php | 2 +- .../Voucher/IndexControllerTest.php | 125 ++++++++++++++++++ .../Voucher/Step3ControllerTest.php | 4 +- 7 files changed, 167 insertions(+), 45 deletions(-) create mode 100644 tests/Controller/Voucher/IndexControllerTest.php diff --git a/src/Controller/Voucher/IndexController.php b/src/Controller/Voucher/IndexController.php index 1b47bc95..09fb9a3a 100644 --- a/src/Controller/Voucher/IndexController.php +++ b/src/Controller/Voucher/IndexController.php @@ -33,52 +33,49 @@ class IndexController extends AbstractController } /** - * Renders a loading page that posts to init, as the booking flow does. + * Entry point: starts a purchase, or resumes the unfinished one, and sends the visitor to the + * login page (sign in or continue as guest). + * + * A GET with side effects, kept safe for speculative fetches by being idempotent: an existing + * draft is never reset, only its return URL is updated when one is given. Starting over goes through cancel, and + * a completed purchase clears the draft. /voucher is an English alias for links. */ #[Route('/gutschein', name: 'app_voucher', methods: ['GET'])] - public function index(#[MapQueryParameter('r')] ?string $returnUrl = null): Response + #[Route('/voucher', name: 'app_voucher_alias', methods: ['GET'])] + public function index(Request $request, #[MapQueryParameter('r')] ?string $returnUrl = null): Response { - return $this->render('voucher/index.html.twig', [ - 'returnUrl' => $returnUrl, - ]); - } - - /** - * Starts a fresh purchase and sends the visitor to the login page (sign in or continue as guest). - * - * POST-only for the same reason as app_booking_create_init: it resets session state and calls - * BusPro, so nothing that speculatively fetches URLs may trigger it. - */ - #[Route('/gutschein/init', name: 'app_voucher_init', methods: ['POST'])] - public function init(Request $request, #[MapQueryParameter('r')] ?string $returnUrl = null): Response - { - $this->sessionManager->clear($request); - $this->sessionManager->storeReturnUrl($request, $returnUrl); - - try { - $templates = $this->templateProvider->getTemplates(); - $paymentMethod = $this->templateProvider->getPaymentMethod(); - } catch (VoucherShopUnavailableException) { - $this->addFlash('error', 'Gutscheine können gerade nicht gekauft werden. Bitte versuche es später noch einmal.'); - - return $this->htmxRedirect($request, $this->generateUrl('app_voucher_error')); + $resumed = null !== $this->sessionManager->find($request); + if (!$resumed || null !== $returnUrl) { + $this->sessionManager->storeReturnUrl($request, $returnUrl); } - $dto = new VoucherPurchaseDto(); - $dto->minAmount = $templates->minAmount; - $dto->maxAmount = $templates->maxAmount; - $dto->paymentMethod = $paymentMethod; - if (1 === \count($templates->templates)) { - $dto->template = $templates->templates[0]; + if (!$resumed) { + try { + $templates = $this->templateProvider->getTemplates(); + $paymentMethod = $this->templateProvider->getPaymentMethod(); + } catch (VoucherShopUnavailableException) { + $this->addFlash('error', 'Gutscheine können gerade nicht gekauft werden. Bitte versuche es später noch einmal.'); + + return $this->redirectToRoute('app_voucher_error'); + } + + $dto = new VoucherPurchaseDto(); + $dto->minAmount = $templates->minAmount; + $dto->maxAmount = $templates->maxAmount; + $dto->paymentMethod = $paymentMethod; + if (1 === \count($templates->templates)) { + $dto->template = $templates->templates[0]; + } + + $this->sessionManager->save($request, $dto); } - $this->sessionManager->save($request, $dto); $this->purchaseFlowResolver->markStarted($request, PurchaseFlow::Voucher); - return $this->htmxRedirect($request, $this->generateUrl('app_login')); + return $this->redirectToRoute('app_login'); } - #[Route('/gutschein/abbrechen', name: 'app_voucher_cancel', methods: ['GET', 'POST'])] + #[Route('/vouchers/cancel', name: 'app_voucher_cancel', methods: ['GET', 'POST'])] public function cancel(Request $request): Response { if (Request::METHOD_POST !== $request->getMethod()) { @@ -100,7 +97,7 @@ class IndexController extends AbstractController return $this->htmxRedirect($request, $returnUrl); } - #[Route('/gutschein/fehler', name: 'app_voucher_error', methods: ['GET'])] + #[Route('/vouchers/create/error', name: 'app_voucher_error', methods: ['GET'])] public function error(Request $request): Response { return $this->render('booking/create/error.html.twig', [ diff --git a/src/Controller/Voucher/Step1Controller.php b/src/Controller/Voucher/Step1Controller.php index 48d3506a..3868ec24 100644 --- a/src/Controller/Voucher/Step1Controller.php +++ b/src/Controller/Voucher/Step1Controller.php @@ -38,7 +38,7 @@ class Step1Controller extends AbstractVoucherPurchaseController parent::__construct($sessionManager, $thumbnailResolver); } - #[Route('/gutschein/auswahl', name: 'app_voucher_step_1')] + #[Route('/vouchers/create/design', name: 'app_voucher_step_1')] public function index(Request $request): Response { $dto = $this->sessionManager->find($request); @@ -67,7 +67,7 @@ class Step1Controller extends AbstractVoucherPurchaseController /** * Takes over the current input without validating it and refreshes the summary. */ - #[Route('/gutschein/auswahl/aktualisieren', name: 'app_voucher_step_1_refresh', methods: ['POST'])] + #[Route('/vouchers/create/refresh', name: 'app_voucher_step_1_refresh', methods: ['POST'])] public function refresh(Request $request): Response { $dto = $this->sessionManager->find($request); @@ -94,7 +94,7 @@ class Step1Controller extends AbstractVoucherPurchaseController * Takes over the current input like refresh() and opens the preview modal, whose frame loads * the PDF from previewPdf(). */ - #[Route('/gutschein/auswahl/vorschau', name: 'app_voucher_preview', methods: ['POST'])] + #[Route('/vouchers/create/preview', name: 'app_voucher_preview', methods: ['POST'])] public function preview(Request $request): Response { $dto = $this->sessionManager->find($request); @@ -118,7 +118,7 @@ class Step1Controller extends AbstractVoucherPurchaseController ]); } - #[Route('/gutschein/vorschau.pdf', name: 'app_voucher_preview_pdf', methods: ['GET'])] + #[Route('/vouchers/create/preview.pdf', name: 'app_voucher_preview_pdf', methods: ['GET'])] public function previewPdf(Request $request): Response { try { diff --git a/src/Controller/Voucher/Step2Controller.php b/src/Controller/Voucher/Step2Controller.php index 3622f22a..f4cba767 100644 --- a/src/Controller/Voucher/Step2Controller.php +++ b/src/Controller/Voucher/Step2Controller.php @@ -27,7 +27,7 @@ class Step2Controller extends AbstractVoucherPurchaseController parent::__construct($sessionManager, $thumbnailResolver); } - #[Route('/gutschein/kaeufer', name: 'app_voucher_step_2')] + #[Route('/vouchers/create/buyer', name: 'app_voucher_step_2')] public function index(Request $request): Response { $dto = $this->sessionManager->find($request); diff --git a/src/Controller/Voucher/Step3Controller.php b/src/Controller/Voucher/Step3Controller.php index 78ad16f1..7fa856f1 100644 --- a/src/Controller/Voucher/Step3Controller.php +++ b/src/Controller/Voucher/Step3Controller.php @@ -39,7 +39,7 @@ class Step3Controller extends AbstractVoucherPurchaseController parent::__construct($sessionManager, $thumbnailResolver); } - #[Route('/gutschein/uebersicht', name: 'app_voucher_step_3')] + #[Route('/vouchers/create/confirmation', name: 'app_voucher_step_3')] public function index(Request $request): Response { $dto = $this->sessionManager->find($request); diff --git a/src/Controller/Voucher/SuccessController.php b/src/Controller/Voucher/SuccessController.php index b9174fba..359a67dc 100644 --- a/src/Controller/Voucher/SuccessController.php +++ b/src/Controller/Voucher/SuccessController.php @@ -22,7 +22,7 @@ class SuccessController extends AbstractController ) { } - #[Route('/gutschein/erfolg', name: 'app_voucher_success')] + #[Route('/vouchers/create/success', name: 'app_voucher_success')] public function success(Request $request): Response { $returnUrl = $this->sessionManager->getReturnUrl($request); diff --git a/tests/Controller/Voucher/IndexControllerTest.php b/tests/Controller/Voucher/IndexControllerTest.php new file mode 100644 index 00000000..050fe353 --- /dev/null +++ b/tests/Controller/Voucher/IndexControllerTest.php @@ -0,0 +1,125 @@ +sessionManager = new VoucherPurchaseSessionManager(); + $this->purchaseFlowResolver = new PurchaseFlowResolver(); + } + + public function testStartsAFreshPurchaseAndSendsTheVisitorToTheLogin(): void + { + $request = $this->createRequest(); + $template = new VoucherTemplate(3, 'K', 'Allgemein'); + + $templateProvider = $this->createStub(VoucherTemplateProvider::class); + $templateProvider->method('getTemplates')->willReturn(new VoucherTemplates(10, 5000, [$template])); + $templateProvider->method('getPaymentMethod')->willReturn(new VoucherPaymentMethod(2, 'Überweisung', 'UEBERW')); + + $response = $this->createController($templateProvider)->index($request, 'https://www.ep-reisen.de/gutscheine'); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_login', $response->getTargetUrl()); + $dto = $this->sessionManager->find($request); + self::assertSame(10, $dto?->minAmount); + self::assertSame(5000, $dto->maxAmount); + self::assertSame($template, $dto->template); + self::assertSame('https://www.ep-reisen.de/gutscheine', $this->sessionManager->getReturnUrl($request)); + self::assertSame(PurchaseFlow::Voucher, $this->purchaseFlowResolver->resolvePending($request)); + } + + /** + * Idempotent so that a speculative fetch in the visitor's own browser cannot wipe their draft. + */ + public function testResumesAnUnfinishedPurchaseAndOnlyUpdatesTheReturnUrl(): void + { + $request = $this->createRequest(); + $draft = new VoucherPurchaseDto(); + $draft->currentStep = 2; + $draft->amount = 111; + $this->sessionManager->save($request, $draft); + + $templateProvider = $this->createMock(VoucherTemplateProvider::class); + $templateProvider->expects(self::never())->method('getTemplates'); + + $response = $this->createController($templateProvider)->index($request, 'https://www.snowzone.net/'); + + self::assertSame('/app_login', $response->getTargetUrl()); + self::assertSame($draft, $this->sessionManager->find($request)); + self::assertSame(2, $draft->currentStep); + self::assertSame('https://www.snowzone.net/', $this->sessionManager->getReturnUrl($request)); + } + + public function testAnUnavailableShopShowsTheErrorPage(): void + { + $request = $this->createRequest(); + + $templateProvider = $this->createStub(VoucherTemplateProvider::class); + $templateProvider->method('getTemplates')->willThrowException(new VoucherShopUnavailableException()); + + $controller = $this->createController($templateProvider); + $response = $controller->index($request); + + self::assertSame('/app_voucher_error', $response->getTargetUrl()); + self::assertNull($this->sessionManager->find($request)); + self::assertSame('error', $controller->flashes[0][0] ?? null); + } + + private function createRequest(): Request + { + $request = Request::create('/gutschein'); + $request->setSession(new Session(new MockArraySessionStorage())); + + return $request; + } + + private function createController(VoucherTemplateProvider $templateProvider): TestableIndexController + { + return new TestableIndexController($this->sessionManager, $templateProvider, $this->purchaseFlowResolver); + } +} + +final class TestableIndexController extends IndexController +{ + /** + * @var list + */ + public array $flashes = []; + + protected function addFlash(string $type, mixed $message): void + { + $this->flashes[] = [$type, $message]; + } + + /** + * @param array $parameters + */ + protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse + { + return new RedirectResponse('/'.$route, $status); + } +} diff --git a/tests/Controller/Voucher/Step3ControllerTest.php b/tests/Controller/Voucher/Step3ControllerTest.php index c47022c1..d5a12260 100644 --- a/tests/Controller/Voucher/Step3ControllerTest.php +++ b/tests/Controller/Voucher/Step3ControllerTest.php @@ -140,7 +140,7 @@ class Step3ControllerTest extends TestCase */ public function testASecondSubmitCannotBuyTwice(): void { - $request = Request::create('/gutschein/uebersicht', 'POST'); + $request = Request::create('/vouchers/create/confirmation', 'POST'); $request->setSession(new Session(new MockArraySessionStorage())); $voucherClient = $this->createMock(VoucherClient::class); @@ -172,7 +172,7 @@ class Step3ControllerTest extends TestCase private function createRequest(string $method, VoucherPurchaseDto $dto): Request { - $request = Request::create('/gutschein/uebersicht', $method); + $request = Request::create('/vouchers/create/confirmation', $method); $request->setSession(new Session(new MockArraySessionStorage())); $this->sessionManager->save($request, $dto);