feat: update voucher purchase urls to match established patterns

This commit is contained in:
fromme committed 2026-10-05 09:10:19 +02:00
1 parent e42f836d9b
commit 627b190fcb
7 files changed
+167 -45

No files matched your search

+33 -36
View File
@@ -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', [
+4 -4
View File
@@ -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 {
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Voucher;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherTemplate;
use App\BusProNet\Model\VoucherTemplates;
use App\Controller\Voucher\IndexController;
use App\Exception\VoucherShopUnavailableException;
use App\Form\Model\VoucherPurchaseDto;
use App\Model\PurchaseFlow;
use App\Service\PurchaseFlowResolver;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherTemplateProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class IndexControllerTest extends TestCase
{
private VoucherPurchaseSessionManager $sessionManager;
private PurchaseFlowResolver $purchaseFlowResolver;
protected function setUp(): void
{
$this->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<array{string, mixed}>
*/
public array $flashes = [];
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = [$type, $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse
{
return new RedirectResponse('/'.$route, $status);
}
}
@@ -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);