createUser(); $bookingDto = $this->createBookingDto(); $bookingData = $this->createBooking(); $participant = $bookingDto->participants[0]; $bookingDto->originalFingerprint = (new BookingChangeTracker())->generateFingerprint($bookingDto); $participant->lastName = ParticipantDataPrefiller::TOKEN; $form = $this->createMock(FormInterface::class); $form->expects($this->once()) ->method('handleRequest') ->with($request) ->willReturnSelf(); $form->expects($this->once()) ->method('isSubmitted') ->willReturn(true); $form->expects($this->never()) ->method('isValid'); $dataLoader = $this->createMock(BookingEditDataLoader::class); $dataLoader->expects($this->once()) ->method('fetchBookingData') ->with(42, $user) ->willReturn($bookingData); $bookingSessionService = $this->createMock(BookingSessionManager::class); $bookingSessionService->expects($this->once()) ->method('getBookingDto') ->with($request, BookingDto::MODE_EDIT) ->willReturn($bookingDto); $bookingSessionService->expects($this->once()) ->method('saveBookingDto') ->with($request, $bookingDto, BookingDto::MODE_EDIT); $draftService = $this->createMock(BookingEditDraftManager::class); $draftService->expects($this->once()) ->method('saveDraft') ->with($user, 42, $bookingDto); $prepopulationService = new ParticipantDataPrefiller( $this->createStub(ApiClient::class), $this->createStub(Crypt::class), $this->createStub(LoggerInterface::class), ); $participantFormSupportService = $this->createMock(ParticipantFormSupport::class); $participantFormSupportService->expects($this->once()) ->method('ensureParticipantExists') ->with($bookingDto, 0) ->willReturn($participant); $participantFormSupportService->expects($this->exactly(2)) ->method('createParticipantEditDto') ->with($bookingDto, 0) ->willReturn(new ParticipantEditDto($participant, $bookingDto)); $participantFormSupportService->expects($this->exactly(2)) ->method('getParticipantFormOptions') ->with($bookingDto) ->willReturn([ 'booking_context' => $bookingDto, 'body_dimension_ranges' => [ 'height_min' => 100, 'height_max' => 250, 'weight_min' => 20, 'weight_max' => 200, 'shoe_size_min' => 20, 'shoe_size_max' => 55, ], ]); $summaryData = $this->createStub(BookingSummaryDto::class); $context = new BookingEditContext($bookingDto, $bookingData, null, $summaryData); $contextFactory = $this->createMock(BookingEditContextFactory::class); $contextFactory->expects($this->once()) ->method('prepareBookingDto') ->with($bookingDto); $contextFactory->expects($this->once()) ->method('createParticipantContext') ->with($bookingDto, $bookingData) ->willReturn($context); $controller = new TestableParticipantController( $dataLoader, $draftService, $contextFactory, $this->createStub(BookingConfigurator::class), $bookingSessionService, $prepopulationService, $participantFormSupportService, $user, $form, ); $response = $controller->editParticipant(42, 0, $request); $this->assertInstanceOf(Response::class, $response); $this->assertSame('booking/edit/participant.html.twig', $controller->renderTemplate); $this->assertSame('Vorname 1', $participant->firstName); $this->assertSame('Muster 1 14:30', $participant->lastName); $this->assertSame('teilnehmer.in1@example.com', $participant->email); $this->assertSame('2006-01-15', $participant->dateOfBirth?->format('Y-m-d')); $this->assertSame(BookingParticipantType::class, $controller->createdFormTypes[0]); $this->assertSame(BookingParticipantType::class, $controller->createdFormTypes[1]); } public function testRefreshPreselectsDefaultServicesAndRerendersParticipantForm(): void { $request = Request::create('/bookings/42/edit/participants/0/refresh', 'POST'); $user = $this->createUser(); $bookingDto = $this->createBookingDto(); $bookingData = $this->createBooking(); $participant = $bookingDto->participants[0]; $form = $this->createMock(FormInterface::class); $form->expects($this->once()) ->method('handleRequest') ->with($request) ->willReturnSelf(); $form->expects($this->once()) ->method('createView') ->willReturn(new FormView()); $dataLoader = $this->createMock(BookingEditDataLoader::class); $dataLoader->expects($this->once()) ->method('fetchBookingData') ->with(42, $user) ->willReturn($bookingData); $bookingSessionService = $this->createMock(BookingSessionManager::class); $bookingSessionService->expects($this->once()) ->method('getBookingDto') ->with($request, BookingDto::MODE_EDIT) ->willReturn($bookingDto); $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); $participantFormSupportService = $this->createMock(ParticipantFormSupport::class); $participantFormSupportService->expects($this->once()) ->method('ensureParticipantExists') ->with($bookingDto, 0) ->willReturn($participant); $participantFormSupportService->expects($this->exactly(2)) ->method('createParticipantEditDto') ->with($bookingDto, 0) ->willReturn(new ParticipantEditDto($participant, $bookingDto)); $participantFormSupportService->expects($this->exactly(2)) ->method('getParticipantFormOptions') ->with($bookingDto, true) ->willReturn([ 'booking_context' => $bookingDto, 'body_dimension_ranges' => [ 'height_min' => 100, 'height_max' => 250, 'weight_min' => 20, 'weight_max' => 200, 'shoe_size_min' => 20, 'shoe_size_max' => 55, ], 'validation_groups' => false, ]); $participantFormSupportService->expects($this->once()) ->method('collectAndClearNotifications') ->with($bookingDto) ->willReturn([]); $summaryData = $this->createStub(BookingSummaryDto::class); $context = new BookingEditContext($bookingDto, $bookingData, null, $summaryData); $contextFactory = $this->createMock(BookingEditContextFactory::class); $contextFactory->expects($this->once()) ->method('prepareBookingDto') ->with($bookingDto); $contextFactory->expects($this->once()) ->method('createParticipantContext') ->with($bookingDto, $bookingData) ->willReturn($context); $prepopulationService = new ParticipantDataPrefiller( $this->createStub(ApiClient::class), $this->createStub(Crypt::class), $this->createStub(LoggerInterface::class), ); $controller = new TestableParticipantController( $dataLoader, $this->createStub(BookingEditDraftManager::class), $contextFactory, $bookingConfigurator, $bookingSessionService, $prepopulationService, $participantFormSupportService, $user, $form, ); $response = $controller->refreshParticipantForm(42, 0, $request); $this->assertSame(200, $response->getStatusCode()); $this->assertSame([BookingParticipantType::class, BookingParticipantType::class], $controller->createdFormTypes); $this->assertSame(['participant_form', 'booking_summary'], $controller->renderedBlocks); } /** * Regression: saving the participant form without touching anything used to write a * draft identical to the API state. The overview then reloaded from the API, found * that draft, applied it and told the user their draft had been restored — for edits * they never made. */ public function testSubmitWithoutChangesDeletesDraftInsteadOfSavingIt(): void { $draftService = $this->createMock(BookingEditDraftManager::class); $draftService->expects($this->never())->method('saveDraft'); $draftService->expects($this->once())->method('deleteDraft')->with($this->isInstanceOf(User::class), 42); $this->runValidSubmit($draftService, dirty: false); } public function testSubmitWithChangesPersistsDraft(): void { $draftService = $this->createMock(BookingEditDraftManager::class); $draftService->expects($this->once())->method('saveDraft'); $draftService->expects($this->never())->method('deleteDraft'); $this->runValidSubmit($draftService, dirty: true); } /** * Drives editParticipant() through a valid, non-dummy submit. */ private function runValidSubmit(BookingEditDraftManager $draftService, bool $dirty): void { $request = Request::create('/bookings/42/edit/participants/0', 'POST'); $user = $this->createUser(); $bookingDto = $this->createBookingDto(); $bookingData = $this->createBooking(); $participant = $bookingDto->participants[0]; $participant->firstName = 'Anna'; $bookingDto->originalFingerprint = (new BookingChangeTracker())->generateFingerprint($bookingDto); if (true === $dirty) { $participant->firstName = 'Berta'; } $form = $this->createStub(FormInterface::class); $form->method('handleRequest')->willReturnSelf(); $form->method('isSubmitted')->willReturn(true); $form->method('isValid')->willReturn(true); $dataLoader = $this->createStub(BookingEditDataLoader::class); $dataLoader->method('fetchBookingData')->willReturn($bookingData); $bookingSessionService = $this->createMock(BookingSessionManager::class); $bookingSessionService->method('getBookingDto')->willReturn($bookingDto); $bookingSessionService->expects($this->once()) ->method('saveBookingDto') ->with($request, $bookingDto, BookingDto::MODE_EDIT); $participantFormSupportService = $this->createStub(ParticipantFormSupport::class); $participantFormSupportService->method('ensureParticipantExists')->willReturn($participant); $participantFormSupportService->method('createParticipantEditDto') ->willReturn(new ParticipantEditDto($participant, $bookingDto)); $participantFormSupportService->method('getParticipantFormOptions') ->willReturn(['booking_context' => $bookingDto]); $participantFormSupportService->method('collectAndClearNotifications')->willReturn([]); $contextFactory = $this->createStub(BookingEditContextFactory::class); $contextFactory->method('createParticipantContext')->willReturn( new BookingEditContext($bookingDto, $bookingData, null, $this->createStub(BookingSummaryDto::class)) ); $controller = new TestableParticipantController( $dataLoader, $draftService, $contextFactory, $this->createStub(BookingConfigurator::class), $bookingSessionService, new ParticipantDataPrefiller( $this->createStub(ApiClient::class), $this->createStub(Crypt::class), $this->createStub(LoggerInterface::class), ), $participantFormSupportService, $user, $form, ); $response = $controller->editParticipant(42, 0, $request); $this->assertInstanceOf(RedirectResponse::class, $response); } private function createUser(): User { return new User('tester@example.com'); } /** * Regression: the edit flow must not mutate the BookingDto on a page view. * * BookingChangeTracker hashes each participant's insurance into the fingerprint that * drives unsaved-changes detection, and the baseline is taken from un-cascaded API data * in BookingEditDataLoader before the user reaches this route. An earlier revision called * ApplicantInsuranceCascade here, which cleared dependents' BPN-loaded insurance and * re-tiered the applicant's. That made the booking permanently "dirty": the overview only * re-baselines while still clean, and the reload-from-BusPro path is gated on the same * flag, so BusPro-side changes stopped surfacing for the rest of the session. */ public function testRefreshDoesNotDirtyFingerprintForFamilyInsuranceBooking(): void { $request = Request::create('/bookings/42/edit/participants/0/refresh', 'POST'); $user = $this->createUser(); $bookingDto = $this->createFamilyInsuranceBookingDto(); $bookingData = $this->createBooking(); $bookingData->participantsStatus = ['F', 'F']; $participant = $bookingDto->participants[0]; $applicantInsurance = $bookingDto->participants[0]->insurance; $dependentInsurance = $bookingDto->participants[1]->insurance; $changeTracker = new BookingChangeTracker(); $bookingDto->originalFingerprint = $changeTracker->generateFingerprint($bookingDto); $this->assertFalse($changeTracker->isDirty($bookingDto), 'Baseline must start clean'); $form = $this->createStub(FormInterface::class); $form->method('handleRequest')->willReturnSelf(); $form->method('createView')->willReturn(new FormView()); $dataLoader = $this->createStub(BookingEditDataLoader::class); $dataLoader->method('fetchBookingData')->willReturn($bookingData); $bookingSessionService = $this->createStub(BookingSessionManager::class); $bookingSessionService->method('getBookingDto')->willReturn($bookingDto); $participantFormSupportService = $this->createStub(ParticipantFormSupport::class); $participantFormSupportService->method('ensureParticipantExists')->willReturn($participant); $participantFormSupportService->method('createParticipantEditDto') ->willReturn(new ParticipantEditDto($participant, $bookingDto)); $participantFormSupportService->method('getParticipantFormOptions')->willReturn([ 'booking_context' => $bookingDto, 'validation_groups' => false, ]); $participantFormSupportService->method('collectAndClearNotifications')->willReturn([]); $summaryData = $this->createStub(BookingSummaryDto::class); $contextFactory = $this->createStub(BookingEditContextFactory::class); $contextFactory->method('createParticipantContext') ->willReturn(new BookingEditContext($bookingDto, $bookingData, null, $summaryData)); $controller = new TestableParticipantController( $dataLoader, $this->createStub(BookingEditDraftManager::class), $contextFactory, $this->createStub(BookingConfigurator::class), $bookingSessionService, new ParticipantDataPrefiller( $this->createStub(ApiClient::class), $this->createStub(Crypt::class), $this->createStub(LoggerInterface::class), ), $participantFormSupportService, $user, $form, ); $controller->refreshParticipantForm(42, 0, $request); $this->assertFalse( $changeTracker->isDirty($bookingDto), 'Viewing a participant must not mark an unchanged booking as dirty' ); $this->assertSame( $applicantInsurance, $bookingDto->participants[0]->insurance, "Applicant's insurance must not be re-tiered on a page view" ); $this->assertSame( $dependentInsurance, $bookingDto->participants[1]->insurance, "Dependent's BusPro-loaded insurance must survive a page view" ); } private function createBookingDto(): BookingDto { $travel = new Travel(); $travel->id = 1234; $bookingDto = new BookingDto($travel, 77); $bookingDto->booking = new Booking(); $bookingDto->booking->id = 42; $bookingDto->participants = [new ParticipantDto()]; $bookingDto->participants[0]->index = 0; return $bookingDto; } private function createFamilyInsuranceBookingDto(): BookingDto { $bookingDto = $this->createBookingDto(); $familyInsurance = new Insurance(); $familyInsurance->id = 'family-1'; $familyInsurance->label = 'Familienversicherung'; $familyInsurance->price = 120.0; $familyInsurance->subType = 'RRV'; $familyInsurance->familyInsurance = true; // BusPro can map one policy to several participants via , so a dependent // may legitimately load holding it too. $dependentInsurance = new Insurance(); $dependentInsurance->id = 'dependent-own'; $dependentInsurance->label = 'Reise-Rucktritt'; $dependentInsurance->price = 50.0; $dependentInsurance->subType = 'RRV'; $dependentInsurance->familyInsurance = false; $bookingDto->participants[0]->insurance = $familyInsurance; $dependent = new ParticipantDto(); $dependent->index = 1; $dependent->insurance = $dependentInsurance; $bookingDto->participants[] = $dependent; return $bookingDto; } private function createBooking(): Booking { $booking = new Booking(); $booking->id = 42; $booking->dateId = 1234; $booking->participantsStatus = ['F']; return $booking; } } final class TestableParticipantController extends ParticipantController { /** * @var array */ public array $createdFormTypes = []; public ?string $renderTemplate = null; /** * @var list */ public array $renderedBlocks = []; /** * @var array */ public array $renderParameters = []; /** * @var FormInterface */ private readonly FormInterface $form; /** * @param FormInterface $form */ public function __construct( BookingEditDataLoader $dataLoader, BookingEditDraftManager $draftService, BookingEditContextFactory $editContextFactory, BookingConfigurator $bookingService, BookingSessionManager $bookingSessionService, ParticipantDataPrefiller $prepopulationService, ParticipantFormSupport $participantFormSupportService, private readonly User $user, FormInterface $form, ) { $this->form = $form; parent::__construct( $dataLoader, $draftService, $editContextFactory, $bookingService, new BookingChangeTracker(), $bookingSessionService, $prepopulationService, $participantFormSupportService, new NullLogger(), ); } protected function getUser(): UserInterface { return $this->user; } /** * @return FormInterface */ protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface { $this->createdFormTypes[] = $type; return $this->form; } protected function addFlash(string $type, mixed $message): void { } /** * @param array $parameters */ protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse { return new RedirectResponse('/'.$route, $status); } /** * @param array $parameters */ protected function render(string $view, array $parameters = [], ?Response $response = null): Response { $this->renderTemplate = $view; $this->renderParameters = $parameters; return new Response(''); } /** * @param array $parameters */ protected function renderBlockView(string $view, string $block, array $parameters = []): string { $this->renderedBlocks[] = $block; return '
'; } }