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: 'erika@example.invalid', )); $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 */ private function createSubmittedForm(): FormInterface { $form = $this->createStub(FormInterface::class); $form->method('isSubmitted')->willReturn(true); $form->method('isValid')->willReturn(true); return $form; } /** * @param FormInterface|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 */ public array $flashes = []; /** * @var array */ public array $renderParameters = []; /** * @param FormInterface $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 */ 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 $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 $parameters */ protected function render(string $view, array $parameters = [], ?Response $response = null): Response { $this->renderParameters = $parameters; return new Response(''); } }