assertSubmissionResultFlashes( new BookingEditSubmissionResult( BookingEditSubmissionResult::STATUS_SUCCESS, null, true ), [ ['info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.'], ['success', 'Buchung erfolgreich aktualisiert'], ['booking_edit_notice', 'Bitte ladet euch eine aktualisierte Rechnung herunter, damit ihr stets den aktuellen Stand eurer Buchung vorliegen habt.'], ], ); } public function testIndexShowsAttentionWarningWithoutBlockingInternalAgencyOverview(): void { $request = Request::create('/bookings/42/edit', 'GET'); $user = $this->createUser(); $bookingDto = $this->createBookingDto(); $bookingDto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE; $bookingDto->participants = [ $this->createParticipant(), $this->createParticipant(), ]; $bookingData = $this->createBooking(); $form = $this->createMock(FormInterface::class); $form->method('isSubmitted')->willReturn(false); $fingerprintService = $this->createMock(BookingChangeTracker::class); $fingerprintService->expects($this->once()) ->method('isDirty') ->with($bookingDto) ->willReturn(true); $fingerprintService->expects($this->never()) ->method('generateFingerprint'); $dataLoader = $this->createMock(BookingEditDataLoader::class); $dataLoader->expects($this->once()) ->method('loadFormData') ->with($request, 42, $user) ->willReturn($bookingDto); $dataLoader->expects($this->once()) ->method('fetchBookingData') ->with(42, $user) ->willReturn($bookingData); $dataLoader->expects($this->once()) ->method('isDraftRestored') ->willReturn(false); $bookingSessionService = $this->createMock(BookingSessionManager::class); $bookingSessionService->expects($this->once()) ->method('saveBookingDto') ->with($request, $bookingDto, BookingDto::MODE_EDIT); $bookingConfigurator = $this->createMock(BookingConfigurator::class); $bookingConfigurator->expects($this->once()) ->method('preselectDefaultServices') ->with($bookingDto); $preflightChecker = $this->createMock(BookingEditPreFlightChecker::class); $preflightChecker->expects($this->once()) ->method('findMissingValueLabelsByParticipantIndex') ->with($bookingDto) ->willReturn([1 => ['E-Mail', 'Straße']]); $contextFactory = $this->createMock(BookingEditContextFactory::class); $contextFactory->expects($this->once()) ->method('createOverviewContext') ->with($bookingDto, $bookingData, true, false, false, [1 => ['E-Mail', 'Straße']]) ->willReturn($this->createOverviewContext($bookingDto, $bookingData, true, false, false, [1 => ['E-Mail', 'Straße']])); $controller = new TestableIndexController( $dataLoader, $this->createMock(BookingEditDraftManager::class), $bookingSessionService, $fingerprintService, $bookingConfigurator, $contextFactory, $preflightChecker, $this->createMock(BookingEditSubmitter::class), $user, $form, ); $response = $controller->index(42, $request); $this->assertInstanceOf(Response::class, $response); $this->assertSame([], $controller->flashes); $this->assertTrue($controller->renderParameters['bookingEditContext']->isDirty); $this->assertFalse($controller->renderParameters['bookingEditContext']->hasBlockingValidationErrors); $this->assertSame( [1 => ['E-Mail', 'Straße']], $controller->renderParameters['bookingEditContext']->missingValueLabelsByParticipantIndex ); $this->assertTrue($controller->renderParameters['bookingEditContext']->cardsData[0]->isValid); $this->assertTrue($controller->renderParameters['bookingEditContext']->cardsData[1]->isValid); } public function testIndexShowsAttentionWarningWithoutBlockingNonAgencyOverview(): void { $request = Request::create('/bookings/42/edit', 'GET'); $user = $this->createUser(); $bookingDto = $this->createBookingDto(); $bookingDto->agencyCode = 'OTHER'; $bookingDto->participants = [ $this->createParticipant(), $this->createParticipant(), ]; $bookingData = $this->createBooking(); $form = $this->createMock(FormInterface::class); $form->method('isSubmitted')->willReturn(false); $fingerprintService = $this->createMock(BookingChangeTracker::class); $fingerprintService->expects($this->once()) ->method('isDirty') ->with($bookingDto) ->willReturn(false); $fingerprintService->expects($this->once()) ->method('generateFingerprint') ->with($bookingDto) ->willReturn('normalized-fingerprint'); $dataLoader = $this->createMock(BookingEditDataLoader::class); $dataLoader->expects($this->once()) ->method('loadFormData') ->with($request, 42, $user) ->willReturn($bookingDto); $dataLoader->expects($this->once()) ->method('fetchBookingData') ->with(42, $user) ->willReturn($bookingData); $dataLoader->expects($this->once()) ->method('isDraftRestored') ->willReturn(false); $preflightChecker = $this->createMock(BookingEditPreFlightChecker::class); $preflightChecker->expects($this->once()) ->method('findMissingValueLabelsByParticipantIndex') ->with($bookingDto) ->willReturn([0 => ['E-Mail']]); $contextFactory = $this->createMock(BookingEditContextFactory::class); $contextFactory->expects($this->once()) ->method('createOverviewContext') ->with($bookingDto, $bookingData, false, false, false, [0 => ['E-Mail']]) ->willReturn($this->createOverviewContext($bookingDto, $bookingData, false, false, false, [0 => ['E-Mail']])); $controller = new TestableIndexController( $dataLoader, $this->createMock(BookingEditDraftManager::class), $this->createMock(BookingSessionManager::class), $fingerprintService, $this->createMock(BookingConfigurator::class), $contextFactory, $preflightChecker, $this->createMock(BookingEditSubmitter::class), $user, $form, ); $response = $controller->index(42, $request); $this->assertInstanceOf(Response::class, $response); $this->assertSame([], $controller->flashes); $this->assertFalse($controller->renderParameters['bookingEditContext']->isDirty); $this->assertFalse($controller->renderParameters['bookingEditContext']->hasBlockingValidationErrors); $this->assertSame( [0 => ['E-Mail']], $controller->renderParameters['bookingEditContext']->missingValueLabelsByParticipantIndex ); } public function testIndexStillSubmitsInternalAgencyBookingsWhenOverviewIsInvalid(): void { $request = Request::create('/bookings/42/edit', 'POST'); $user = $this->createUser(); $bookingDto = $this->createBookingDto(); $bookingDto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE; $bookingDto->participants = [ $this->createParticipant(), $this->createParticipant(), ]; $bookingData = $this->createBooking(); $form = $this->createMock(FormInterface::class); $form->expects($this->once()) ->method('handleRequest') ->with($request) ->willReturnSelf(); $form->method('isSubmitted')->willReturn(true); $form->expects($this->once()) ->method('isValid') ->willReturn(false); $fingerprintService = $this->createMock(BookingChangeTracker::class); $fingerprintService->expects($this->once()) ->method('isDirty') ->with($bookingDto) ->willReturn(true); $fingerprintService->expects($this->never()) ->method('generateFingerprint'); $dataLoader = $this->createMock(BookingEditDataLoader::class); $dataLoader->expects($this->once()) ->method('loadFormData') ->with($request, 42, $user) ->willReturn($bookingDto); $dataLoader->expects($this->once()) ->method('fetchBookingData') ->with(42, $user) ->willReturn($bookingData); $dataLoader->expects($this->once()) ->method('isDraftRestored') ->willReturn(false); $preflightChecker = $this->createMock(BookingEditPreFlightChecker::class); $preflightChecker->expects($this->once()) ->method('findMissingValueLabelsByParticipantIndex') ->with($bookingDto) ->willReturn([1 => ['E-Mail', 'Straße']]); $submitter = $this->createMock(BookingEditSubmitter::class); $submitter->expects($this->once()) ->method('handleSubmission') ->with($request, $bookingDto, 42, $user) ->willReturn(new BookingEditSubmissionResult( BookingEditSubmissionResult::STATUS_SUCCESS, null, false )); $contextFactory = $this->createMock(BookingEditContextFactory::class); $contextFactory->expects($this->never()) ->method('createOverviewContext'); $controller = new TestableIndexController( $dataLoader, $this->createMock(BookingEditDraftManager::class), $this->createMock(BookingSessionManager::class), $fingerprintService, $this->createMock(BookingConfigurator::class), $contextFactory, $preflightChecker, $submitter, $user, $form, ); $response = $controller->index(42, $request); $this->assertInstanceOf(RedirectResponse::class, $response); $this->assertSame('/bookings/42/edit', $response->headers->get('Location')); $this->assertSame( [ ['success', 'Buchung erfolgreich aktualisiert'], ['booking_edit_notice', 'Bitte ladet euch eine aktualisierte Rechnung herunter, damit ihr stets den aktuellen Stand eurer Buchung vorliegen habt.'], ], $controller->flashes ); } public function testIndexBlocksNonInternalAgencyBookingsWhenOverviewIsInvalid(): void { $request = Request::create('/bookings/42/edit', 'POST'); $user = $this->createUser(); $bookingDto = $this->createBookingDto(); $bookingDto->agencyCode = 'OTHER'; $bookingDto->participants = [ $this->createParticipant(), $this->createParticipant(), ]; $bookingData = $this->createBooking(); $form = $this->createMock(FormInterface::class); $form->expects($this->once()) ->method('handleRequest') ->with($request) ->willReturnSelf(); $form->method('isSubmitted')->willReturn(true); $form->expects($this->once()) ->method('isValid') ->willReturn(false); $fingerprintService = $this->createMock(BookingChangeTracker::class); $fingerprintService->expects($this->exactly(2)) ->method('isDirty') ->with($bookingDto) ->willReturnOnConsecutiveCalls(false, false); $fingerprintService->expects($this->once()) ->method('generateFingerprint') ->with($bookingDto) ->willReturn('normalized-fingerprint'); $dataLoader = $this->createMock(BookingEditDataLoader::class); $dataLoader->expects($this->once()) ->method('loadFormData') ->with($request, 42, $user) ->willReturn($bookingDto); $dataLoader->expects($this->once()) ->method('fetchBookingData') ->with(42, $user) ->willReturn($bookingData); $dataLoader->expects($this->once()) ->method('isDraftRestored') ->willReturn(false); $preflightChecker = $this->createMock(BookingEditPreFlightChecker::class); $preflightChecker->expects($this->once()) ->method('findMissingValueLabelsByParticipantIndex') ->with($bookingDto) ->willReturn([0 => ['E-Mail']]); $submitter = $this->createMock(BookingEditSubmitter::class); $submitter->expects($this->never()) ->method('handleSubmission'); $contextFactory = $this->createMock(BookingEditContextFactory::class); $contextFactory->expects($this->once()) ->method('createOverviewContext') ->with($bookingDto, $bookingData, false, true, true, [0 => ['E-Mail']]) ->willReturn($this->createOverviewContext($bookingDto, $bookingData, false, true, true, [0 => ['E-Mail']])); $controller = new TestableIndexController( $dataLoader, $this->createMock(BookingEditDraftManager::class), $this->createMock(BookingSessionManager::class), $fingerprintService, $this->createMock(BookingConfigurator::class), $contextFactory, $preflightChecker, $submitter, $user, $form, ); $response = $controller->index(42, $request); $this->assertInstanceOf(Response::class, $response); $this->assertSame([], $controller->flashes); $this->assertTrue($controller->renderParameters['bookingEditContext']->hasBlockingValidationErrors); $this->assertSame([0 => ['E-Mail']], $controller->renderParameters['bookingEditContext']->missingValueLabelsByParticipantIndex); } public function testIndexTreatsDefaultPreselectionOnCleanDtoAsBaselineState(): void { $request = Request::create('/bookings/42/edit', 'GET'); $user = $this->createUser(); $bookingDto = $this->createBookingDto(); $bookingDto->originalFingerprint = 'api-fingerprint'; $bookingDto->participants = [$this->createParticipant()]; $bookingData = $this->createBooking(); $form = $this->createMock(FormInterface::class); $form->method('isSubmitted')->willReturn(false); $fingerprintService = $this->createMock(BookingChangeTracker::class); $fingerprintService->expects($this->once()) ->method('isDirty') ->with($bookingDto) ->willReturn(false); $fingerprintService->expects($this->once()) ->method('generateFingerprint') ->with($bookingDto) ->willReturn('normalized-fingerprint'); $dataLoader = $this->createMock(BookingEditDataLoader::class); $dataLoader->expects($this->once()) ->method('loadFormData') ->with($request, 42, $user) ->willReturn($bookingDto); $dataLoader->expects($this->once()) ->method('fetchBookingData') ->with(42, $user) ->willReturn($bookingData); $dataLoader->expects($this->once()) ->method('isDraftRestored') ->willReturn(false); $preselectedService = $this->createService(1001); $bookingConfigurator = $this->createMock(BookingConfigurator::class); $bookingConfigurator->expects($this->once()) ->method('preselectDefaultServices') ->with($bookingDto) ->willReturnCallback(static function (BookingDto $dto) use ($preselectedService): void { $dto->participants[0]->additionalServices[] = $preselectedService; }); $contextFactory = $this->createMock(BookingEditContextFactory::class); $contextFactory->expects($this->once()) ->method('createOverviewContext') ->with($bookingDto, $bookingData, false, false, false, []) ->willReturn($this->createOverviewContext($bookingDto, $bookingData, false, false, false)); $controller = new TestableIndexController( $dataLoader, $this->createMock(BookingEditDraftManager::class), $this->createMock(BookingSessionManager::class), $fingerprintService, $bookingConfigurator, $contextFactory, $this->createPreflightChecker($bookingDto), $this->createMock(BookingEditSubmitter::class), $user, $form, ); $response = $controller->index(42, $request); $this->assertInstanceOf(Response::class, $response); $this->assertSame('normalized-fingerprint', $bookingDto->originalFingerprint); $this->assertFalse($controller->renderParameters['bookingEditContext']->isDirty); } public function testIndexKeepsDirtyStateWhenDtoWasDirtyBeforeDefaultPreselection(): void { $request = Request::create('/bookings/42/edit', 'GET'); $user = $this->createUser(); $bookingDto = $this->createBookingDto(); $bookingDto->originalFingerprint = 'api-fingerprint'; $bookingDto->participants = [$this->createParticipant()]; $bookingData = $this->createBooking(); $form = $this->createMock(FormInterface::class); $form->method('isSubmitted')->willReturn(false); $fingerprintService = $this->createMock(BookingChangeTracker::class); $fingerprintService->expects($this->once()) ->method('isDirty') ->with($bookingDto) ->willReturn(true); $fingerprintService->expects($this->never()) ->method('generateFingerprint'); $dataLoader = $this->createMock(BookingEditDataLoader::class); $dataLoader->expects($this->once()) ->method('loadFormData') ->with($request, 42, $user) ->willReturn($bookingDto); $dataLoader->expects($this->once()) ->method('fetchBookingData') ->with(42, $user) ->willReturn($bookingData); $dataLoader->expects($this->once()) ->method('isDraftRestored') ->willReturn(false); $preselectedService = $this->createService(1001); $bookingConfigurator = $this->createMock(BookingConfigurator::class); $bookingConfigurator->expects($this->once()) ->method('preselectDefaultServices') ->with($bookingDto) ->willReturnCallback(static function (BookingDto $dto) use ($preselectedService): void { $dto->participants[0]->additionalServices[] = $preselectedService; }); $contextFactory = $this->createMock(BookingEditContextFactory::class); $contextFactory->expects($this->once()) ->method('createOverviewContext') ->with($bookingDto, $bookingData, true, false, false, []) ->willReturn($this->createOverviewContext($bookingDto, $bookingData, true, false, false)); $controller = new TestableIndexController( $dataLoader, $this->createMock(BookingEditDraftManager::class), $this->createMock(BookingSessionManager::class), $fingerprintService, $bookingConfigurator, $contextFactory, $this->createPreflightChecker($bookingDto), $this->createMock(BookingEditSubmitter::class), $user, $form, ); $response = $controller->index(42, $request); $this->assertInstanceOf(Response::class, $response); $this->assertSame('api-fingerprint', $bookingDto->originalFingerprint); $this->assertTrue($controller->renderParameters['bookingEditContext']->isDirty); } public function testReloadFromApiClearsSessionDeletesDraftInvalidatesCacheAndRedirectsToEdit(): void { $request = Request::create('/bookings/42/edit/reload', 'POST'); $user = $this->createUser(); $bookingSessionService = $this->createMock(BookingSessionManager::class); $bookingSessionService->expects($this->once()) ->method('clearBookingDto') ->with($request, BookingDto::MODE_EDIT); $draftService = $this->createMock(BookingEditDraftManager::class); $draftService->expects($this->once()) ->method('deleteDraft') ->with($user, 42); $dataLoader = $this->createMock(BookingEditDataLoader::class); $dataLoader->expects($this->once()) ->method('invalidateBookingCache') ->with(42, $user); $controller = new TestableIndexController( $dataLoader, $draftService, $bookingSessionService, $this->createMock(BookingChangeTracker::class), $this->createMock(BookingConfigurator::class), $this->createMock(BookingEditContextFactory::class), $this->createMock(BookingEditPreFlightChecker::class), $this->createMock(BookingEditSubmitter::class), $user, $this->createMock(FormInterface::class), ); $response = $controller->reloadFromApi(42, $request); $this->assertInstanceOf(RedirectResponse::class, $response); $this->assertSame('/bookings/42/edit', $response->headers->get('Location')); $this->assertSame([['success', 'Änderungen verworfen, Daten neu geladen']], $controller->flashes); } /** * @dataProvider submissionResultProvider * * @param array $expectedFlashes * @phpstan-param array $expectedFlashes */ public function testIndexAppliesErrorSubmissionResultFlashesAndRedirectsBackToEditPage( BookingEditSubmissionResult $submissionResult, array $expectedFlashes, ): void { $this->assertSubmissionResultFlashes($submissionResult, $expectedFlashes); } /** * @return array}> */ public static function submissionResultProvider(): array { return [ 'notification error' => [ new BookingEditSubmissionResult( BookingEditSubmissionResult::STATUS_NOTIFICATION_ERROR, 'BPN-FAIL', false ), [ ['error', 'BPN-FAIL'], ], ], 'timeout' => [ new BookingEditSubmissionResult( BookingEditSubmissionResult::STATUS_TIMEOUT, null, false ), [ ['error', 'Die Anfrage hat zu lange gedauert. Bitte versuche es erneut.'], ], ], 'reload failed' => [ new BookingEditSubmissionResult( BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED, null, false ), [ ['error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden'], ], ], ]; } private function createUser(): User { return new User('tester@example.com'); } private function createBookingDto(): BookingDto { $travel = new Travel(); $travel->id = 1234; $bookingDto = new BookingDto($travel, 77); $bookingDto->booking = new Booking(); $bookingDto->booking->id = 42; return $bookingDto; } private function createBooking(): Booking { $booking = new Booking(); $booking->id = 42; $booking->dateId = 1234; return $booking; } private function createParticipant(): \App\Form\Model\ParticipantDto { return new \App\Form\Model\ParticipantDto(); } private function createService(int $id): Service { $service = new Service(); $service->id = $id; return $service; } private function createPreflightChecker(BookingDto $bookingDto): BookingEditPreFlightChecker { $preflightChecker = $this->createMock(BookingEditPreFlightChecker::class); $preflightChecker->expects($this->once()) ->method('findMissingValueLabelsByParticipantIndex') ->with($bookingDto) ->willReturn([]); return $preflightChecker; } private function createOverviewContext( BookingDto $bookingDto, Booking $bookingData, bool $isDirty, bool $isSubmitted, bool $hasBlockingValidationErrors, array $missingValueLabelsByParticipantIndex = [], ): BookingEditContext { return new BookingEditContext( bookingDto: $bookingDto, bookingData: $bookingData, mutableData: null, summaryData: $this->createMock(BookingSummaryDto::class), cardsData: [ 0 => new ParticipantCardDataDto( 'One', 'one@example.com', 'Room A', new ParticipantCardPriceDto(10.0, false), false, true ), 1 => new ParticipantCardDataDto( 'Two', 'two@example.com', 'Room B', new ParticipantCardPriceDto(20.0, false), false, true ), ], missingValueLabelsByParticipantIndex: $missingValueLabelsByParticipantIndex, isDirty: $isDirty, isSubmitted: $isSubmitted, hasBlockingValidationErrors: $hasBlockingValidationErrors, ); } /** * @param array $expectedFlashes */ private function assertSubmissionResultFlashes( BookingEditSubmissionResult $submissionResult, array $expectedFlashes, ): void { $request = Request::create('/bookings/42/edit', 'POST'); $user = $this->createUser(); $bookingDto = $this->createBookingDto(); $bookingData = $this->createBooking(); $form = $this->createMock(FormInterface::class); $fingerprintService = $this->createMock(BookingChangeTracker::class); $fingerprintService->expects($this->once()) ->method('isDirty') ->with($bookingDto) ->willReturn(true); $fingerprintService->expects($this->never()) ->method('generateFingerprint'); $form->expects($this->once()) ->method('handleRequest') ->with($request) ->willReturnSelf(); $form->method('isSubmitted')->willReturn(true); $form->expects($this->once()) ->method('isValid') ->willReturn(true); $dataLoader = $this->createMock(BookingEditDataLoader::class); $dataLoader->expects($this->once()) ->method('loadFormData') ->with($request, 42, $user) ->willReturn($bookingDto); $dataLoader->expects($this->once()) ->method('fetchBookingData') ->with(42, $user) ->willReturn($bookingData); $dataLoader->expects($this->once()) ->method('isDraftRestored') ->willReturn(false); $preflightChecker = $this->createMock(BookingEditPreFlightChecker::class); $preflightChecker->expects($this->once()) ->method('findMissingValueLabelsByParticipantIndex') ->with($bookingDto) ->willReturn([]); $submitter = $this->createMock(BookingEditSubmitter::class); $submitter->expects($this->once()) ->method('handleSubmission') ->with($request, $bookingDto, 42, $user) ->willReturn($submissionResult); $controller = new TestableIndexController( $dataLoader, $this->createMock(BookingEditDraftManager::class), $this->createMock(BookingSessionManager::class), $fingerprintService, $this->createMock(BookingConfigurator::class), $this->createMock(BookingEditContextFactory::class), $preflightChecker, $submitter, $user, $form, ); $response = $controller->index(42, $request); $this->assertInstanceOf(RedirectResponse::class, $response); $this->assertSame('/bookings/42/edit', $response->headers->get('Location')); $this->assertSame($expectedFlashes, $controller->flashes); } } final class TestableIndexController extends IndexController { /** * @var array */ public array $flashes = []; /** * @var array */ public array $renderParameters = []; /** * @var FormInterface */ private readonly FormInterface $form; /** * @param FormInterface $form */ public function __construct( BookingEditDataLoader $dataLoader, BookingEditDraftManager $draftService, BookingSessionManager $bookingSessionService, BookingChangeTracker $fingerprintService, BookingConfigurator $bookingService, BookingEditContextFactory $editContextFactory, BookingEditPreFlightChecker $preFlightChecker, BookingEditSubmitter $formSubmitter, private readonly User $user, FormInterface $form, ) { $this->form = $form; parent::__construct( $dataLoader, $draftService, $bookingSessionService, $fingerprintService, $bookingService, $editContextFactory, $preFlightChecker, $formSubmitter, new NullLogger(), ); } protected function getUser(): UserInterface { return $this->user; } /** * @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 { $location = match ($route) { 'app_booking_edit' => '/bookings/'.$parameters['bookingId'].'/edit', default => '/'.$route, }; return new RedirectResponse($location, $status); } /** * @param array $parameters */ protected function render(string $view, array $parameters = [], ?Response $response = null): Response { $this->renderParameters = $parameters; return new Response(''); } }