feat: voucher purchase flow

This commit is contained in:
fromme committed 2026-10-01 15:03:27 +02:00
1 parent a628e96d06
commit f3dd122678
70 files changed
+3672 -122

No files matched your search

+59 -3
View File
@@ -7,8 +7,12 @@ namespace App\Tests\Controller;
use App\BusProNet\Model\Travel;
use App\Controller\SecurityController;
use App\Form\Model\BookingDto;
use App\Form\Model\VoucherPurchaseDto;
use App\Model\PurchaseFlow;
use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler;
use App\Service\PurchaseFlowResolver;
use App\Service\VoucherPurchaseSessionManager;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use League\Bundle\OAuth2ServerBundle\Model\Client;
use PHPUnit\Framework\TestCase;
@@ -98,6 +102,7 @@ class SecurityControllerTest extends TestCase
->willReturn(new Client('EPHub', 'abc123', null));
$session = $this->createSession(self::AUTHORIZE_PATH);
$session->set(BookingSessionManager::BOOKING_CREATE_KEY, $this->createBookingDto());
$response = $controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH, $session));
self::assertSame(200, $response->getStatusCode());
@@ -111,15 +116,65 @@ class SecurityControllerTest extends TestCase
$controller->clientManager->expects(self::never())->method('find');
$session = $this->createSession(null);
$session->set(BookingSessionManager::BOOKING_CREATE_KEY, $this->createBookingDto());
$response = $controller->login(...$this->arguments($controller, null, $session));
self::assertSame(200, $response->getStatusCode());
self::assertSame('booking/create/authenticate.html.twig', $controller->renderedView);
self::assertSame('/booking/create/step-1', $session->get('_security.main.target_path'));
self::assertSame('/app_booking_create_step_1', $session->get('_security.main.target_path'));
}
public function testAPendingVoucherPurchaseRendersTheVoucherLoginAndContinuesWithItsFirstStep(): void
{
$controller = $this->createController();
$controller->clientManager->expects(self::never())->method('find');
$session = $this->createSession(null);
$session->set(VoucherPurchaseSessionManager::SESSION_KEY, new VoucherPurchaseDto());
$response = $controller->login(...$this->arguments($controller, null, $session));
self::assertSame(200, $response->getStatusCode());
self::assertSame('voucher/authenticate.html.twig', $controller->renderedView);
self::assertSame('/app_voucher_step_1', $session->get('_security.main.target_path'));
self::assertNull($controller->renderedParameters['travel_title']);
}
public function testAPendingAuthorizationWinsOverAPendingVoucherPurchase(): void
{
$controller = $this->createController();
$controller->clientManager->expects(self::once())->method('find')->willReturn(new Client('EPHub', 'abc123', null));
$session = $this->createSession(self::AUTHORIZE_PATH);
$session->set(VoucherPurchaseSessionManager::SESSION_KEY, new VoucherPurchaseDto());
$controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH, $session));
self::assertSame('security/login_oauth2.html.twig', $controller->renderedView);
self::assertSame(self::AUTHORIZE_PATH, $session->get('_security.main.target_path'));
}
/**
* @return array{AuthenticationUtils, Request, BookingSessionManager, BookingSummaryAssembler, ClientManagerInterface}
* A booking and a voucher purchase can both be pending, e.g. from two tabs. The one the visitor
* started last is the one they are signing in for.
*/
public function testTheLastStartedOfTwoPendingFlowsWins(): void
{
foreach ([PurchaseFlow::Voucher, PurchaseFlow::Booking] as $lastStarted) {
$controller = $this->createController(bookingDto: $this->createBookingDto());
$controller->clientManager->expects(self::never())->method('find');
$session = $this->createSession(null);
$session->set(BookingSessionManager::BOOKING_CREATE_KEY, $this->createBookingDto());
$session->set(VoucherPurchaseSessionManager::SESSION_KEY, new VoucherPurchaseDto());
$session->set(PurchaseFlowResolver::LAST_STARTED_KEY, $lastStarted->value);
$controller->login(...$this->arguments($controller, null, $session));
self::assertSame($lastStarted->authenticateTemplate(), $controller->renderedView);
self::assertSame('/'.$lastStarted->firstStepRoute(), $session->get('_security.main.target_path'));
}
}
/**
* @return array{AuthenticationUtils, Request, BookingSessionManager, BookingSummaryAssembler, ClientManagerInterface, PurchaseFlowResolver}
*/
private function arguments(TestableSecurityController $controller, ?string $targetPath, ?Session $session = null): array
{
@@ -132,6 +187,7 @@ class SecurityControllerTest extends TestCase
$controller->bookingSessionManager,
$controller->summaryAssembler,
$controller->clientManager,
new PurchaseFlowResolver(),
];
}
@@ -203,6 +259,6 @@ final class TestableSecurityController extends SecurityController
protected function generateUrl(string $route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
return '/booking/create/step-1';
return '/'.$route;
}
}
@@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Voucher;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherSaleResponse;
use App\BusProNet\Model\VoucherTemplate;
use App\Controller\Voucher\Step3Controller;
use App\Form\Model\VoucherPurchaseDto;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherSaleRejectionMapper;
use App\Service\VoucherThumbnailResolver;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\User\UserInterface;
class Step3ControllerTest extends TestCase
{
private VoucherPurchaseSessionManager $sessionManager;
protected function setUp(): void
{
$this->sessionManager = new VoucherPurchaseSessionManager();
}
public function testAPassingDryRunOffersThePurchaseWithFreshConfirmations(): void
{
$request = $this->createRequest('GET', $dto = $this->createDto());
$dto->termsAccepted = true;
$dto->purchaseConfirmed = true;
$apiClient = $this->createMock(ApiClient::class);
$apiClient->expects(self::once())->method('createVoucherSaleInquiry')->willReturn(new VoucherSaleResponse('möglich'));
$apiClient->expects(self::never())->method('createVoucherSale');
$controller = $this->createController($apiClient);
$controller->index($request);
self::assertTrue($controller->renderParameters['purchasable']);
self::assertFalse($dto->termsAccepted);
self::assertFalse($dto->purchaseConfirmed);
}
/**
* 650 is what Notification::isError() calls a success. On this path it is a missing address field.
*/
public function testAnAddressComplaintSendsTheBuyerBackToStep2(): void
{
$request = $this->createRequest('GET', $this->createDto());
$apiClient = $this->createStub(ApiClient::class);
$apiClient->method('createVoucherSaleInquiry')->willReturn(
new Notification(650, 'Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Strasse')
);
$controller = $this->createController($apiClient);
$response = $controller->index($request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_voucher_step_2', $response->getTargetUrl());
self::assertSame(2, $this->sessionManager->find($request)?->currentStep);
self::assertSame([['error', 'Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Strasse']], $controller->flashes);
}
public function testAnAmountComplaintSendsTheBuyerBackToStep1(): void
{
$request = $this->createRequest('GET', $this->createDto());
$apiClient = $this->createStub(ApiClient::class);
$apiClient->method('createVoucherSaleInquiry')->willReturn(
new Notification(608, 'Gutscheinbetrag darf nicht größer als 5.000,00 Euro sein')
);
$response = $this->createController($apiClient)->index($request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_voucher_step_1', $response->getTargetUrl());
}
public function testAComplaintNobodyCanFixKeepsTheOverviewWithoutABuyButton(): void
{
$request = $this->createRequest('GET', $this->createDto());
$apiClient = $this->createStub(ApiClient::class);
$apiClient->method('createVoucherSaleInquiry')->willReturn(new Notification(619, 'Agentur-ID fehlt'));
$controller = $this->createController($apiClient);
$controller->index($request);
self::assertFalse($controller->renderParameters['purchasable']);
}
public function testAPurchaseClearsTheSessionAndKeepsTheRedemptionCodeOutOfTheLog(): void
{
$request = $this->createRequest('POST', $this->createDto());
$apiClient = $this->createMock(ApiClient::class);
$apiClient->expects(self::never())->method('createVoucherSaleInquiry');
$apiClient->expects(self::once())->method('createVoucherSale')->willReturn(new VoucherSaleResponse(
status: 'erfolgt',
voucherId: 4711,
voucherNumber: 'K-001234',
redemptionCode: 'ABCD1234',
price: 111.0,
invoiceNumber: 'R-998877',
email: '[email protected]',
));
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::once())->method('info')->with('Voucher successfully sold.', self::callback(
static fn (array $context) => !\in_array('ABCD1234', $context, true) && 'K-001234' === $context['voucher_number']
));
$controller = $this->createController($apiClient, $this->createSubmittedForm(), $logger);
$response = $controller->index($request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_voucher_success', $response->getTargetUrl());
self::assertNull($this->sessionManager->find($request));
self::assertContains(['voucher_number', 'K-001234'], $controller->flashes);
self::assertContains(['voucher_total', 111.0], $controller->flashes);
self::assertNotContains('ABCD1234', array_column($controller->flashes, 1));
}
/**
* The first submit cleared the session before redirecting, so the second one finds nothing to buy.
*/
public function testASecondSubmitCannotBuyTwice(): void
{
$request = Request::create('/gutschein/uebersicht', 'POST');
$request->setSession(new Session(new MockArraySessionStorage()));
$apiClient = $this->createMock(ApiClient::class);
$apiClient->expects(self::never())->method('createVoucherSale');
$response = $this->createController($apiClient, $this->createSubmittedForm())->index($request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_voucher_error', $response->getTargetUrl());
}
/**
* A timeout leaves it open whether BusPro issued the voucher, so no blind retry is offered.
*/
public function testATimeoutDuringThePurchaseOffersNoRetry(): void
{
$request = $this->createRequest('POST', $this->createDto());
$apiClient = $this->createStub(ApiClient::class);
$apiClient->method('createVoucherSale')->willThrowException(new TimeoutException('Stream timeout'));
$controller = $this->createController($apiClient, $this->createSubmittedForm());
$controller->index($request);
self::assertFalse($controller->renderParameters['purchasable']);
self::assertNotNull($this->sessionManager->find($request));
self::assertStringContainsString('Kundenservice', $controller->flashes[0][1]);
}
private function createRequest(string $method, VoucherPurchaseDto $dto): Request
{
$request = Request::create('/gutschein/uebersicht', $method);
$request->setSession(new Session(new MockArraySessionStorage()));
$this->sessionManager->save($request, $dto);
return $request;
}
private function createDto(): VoucherPurchaseDto
{
$dto = new VoucherPurchaseDto();
$dto->currentStep = 3;
$dto->template = new VoucherTemplate(3, 'K', 'Allgemein');
$dto->paymentMethod = new VoucherPaymentMethod(2, 'Überweisung', 'UEBERW');
$dto->amount = 111;
return $dto;
}
/**
* @return FormInterface<VoucherPurchaseDto>
*/
private function createSubmittedForm(): FormInterface
{
$form = $this->createStub(FormInterface::class);
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn(true);
return $form;
}
/**
* @param FormInterface<VoucherPurchaseDto>|null $form
*/
private function createController(ApiClient $apiClient, ?FormInterface $form = null, ?LoggerInterface $logger = null): TestableStep3Controller
{
return new TestableStep3Controller(
$this->sessionManager,
$this->createStub(VoucherThumbnailResolver::class),
$apiClient,
new VoucherSaleRejectionMapper(),
$logger ?? new NullLogger(),
$form ?? $this->createStub(FormInterface::class),
);
}
}
final class TestableStep3Controller extends Step3Controller
{
/**
* @var list<array{string, mixed}>
*/
public array $flashes = [];
/**
* @var array<string, mixed>
*/
public array $renderParameters = [];
/**
* @param FormInterface<VoucherPurchaseDto> $form
*/
public function __construct(
VoucherPurchaseSessionManager $sessionManager,
VoucherThumbnailResolver $thumbnailResolver,
ApiClient $apiClient,
VoucherSaleRejectionMapper $rejectionMapper,
LoggerInterface $logger,
private readonly FormInterface $form,
) {
parent::__construct($sessionManager, $thumbnailResolver, $apiClient, $rejectionMapper, $logger);
}
protected function getUser(): ?UserInterface
{
return null;
}
/**
* @return FormInterface<mixed>
*/
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
return $this->form;
}
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);
}
protected function generateUrl(string $route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
return '/'.$route;
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderParameters = $parameters;
return new Response('');
}
}