Compare commits

...
10 Commits
18 changed files with 625 additions and 149 deletions

No files matched your search

Generated
+26 -26
View File
@@ -3684,16 +3684,16 @@
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.8.0",
"version": "5.9.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "01964d92536edf1a3a874b9580a52824bebf6fbb"
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/01964d92536edf1a3a874b9580a52824bebf6fbb",
"reference": "01964d92536edf1a3a874b9580a52824bebf6fbb",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339",
"shasum": ""
},
"require": {
@@ -3715,7 +3715,7 @@
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"php": "^8.2",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
@@ -3729,7 +3729,7 @@
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"phpunit/phpunit": "^10.5 || ^11.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
@@ -3787,9 +3787,9 @@
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.8.0"
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0"
},
"time": "2026-06-07T03:51:10+00:00"
"time": "2026-07-12T19:17:39+00:00"
},
{
"name": "phpseclib/phpseclib",
@@ -11533,16 +11533,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v3.95.12",
"version": "v3.95.13",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "b1b9055997a98dce3c2338e884626e718a25a923"
"reference": "ea941114a002eb5e5876f190223deec1066c482c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923",
"reference": "b1b9055997a98dce3c2338e884626e718a25a923",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/ea941114a002eb5e5876f190223deec1066c482c",
"reference": "ea941114a002eb5e5876f190223deec1066c482c",
"shasum": ""
},
"require": {
@@ -11626,7 +11626,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.12"
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.13"
},
"funding": [
{
@@ -11634,7 +11634,7 @@
"type": "github"
}
],
"time": "2026-07-07T13:29:36+00:00"
"time": "2026-07-10T09:23:21+00:00"
},
{
"name": "myclabs/deep-copy",
@@ -11937,16 +11937,16 @@
},
{
"name": "phpstan/phpstan-doctrine",
"version": "2.0.27",
"version": "2.0.28",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-doctrine.git",
"reference": "39b4ca45a07cdd6366eeefa2f7a993cddf3b9f9f"
"reference": "b4623954d5ffee6311e4ddbaef65c074ae0f781a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/39b4ca45a07cdd6366eeefa2f7a993cddf3b9f9f",
"reference": "39b4ca45a07cdd6366eeefa2f7a993cddf3b9f9f",
"url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/b4623954d5ffee6311e4ddbaef65c074ae0f781a",
"reference": "b4623954d5ffee6311e4ddbaef65c074ae0f781a",
"shasum": ""
},
"require": {
@@ -12008,9 +12008,9 @@
],
"support": {
"issues": "https://github.com/phpstan/phpstan-doctrine/issues",
"source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.27"
"source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.28"
},
"time": "2026-06-10T10:39:35+00:00"
"time": "2026-07-13T08:43:43+00:00"
},
{
"name": "phpstan/phpstan-symfony",
@@ -13028,16 +13028,16 @@
},
{
"name": "rector/rector",
"version": "2.5.5",
"version": "2.5.7",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
"reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e"
"reference": "ba22f8c087848278fed6b4910d4cf1108096d8d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/9718a72e7f1aacacbdcb6eeed07a47147bce802e",
"reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/ba22f8c087848278fed6b4910d4cf1108096d8d3",
"reference": "ba22f8c087848278fed6b4910d4cf1108096d8d3",
"shasum": ""
},
"require": {
@@ -13076,7 +13076,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.5.5"
"source": "https://github.com/rectorphp/rector/tree/2.5.7"
},
"funding": [
{
@@ -13084,7 +13084,7 @@
"type": "github"
}
],
"time": "2026-07-09T09:48:44+00:00"
"time": "2026-07-13T15:24:18+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -105,7 +105,7 @@ class PersonalDataController extends AbstractController
return $this->redirectToRoute('app_personal_data');
}
if ($updateResult instanceof PersonalDataUpdateResponse && false === $updateResult->changed) {
if (false === $updateResult->changed) {
$this->logger->error('BPN did not apply personal data update', [
'email' => $user->getEmail(),
'addressId' => $updateResult->addressId,
@@ -12,6 +12,7 @@ use App\Form\Model\BookingDto;
use App\Htmx\HxTrait;
use App\Model\BookingEditSubmissionResult;
use App\Service\BookingChangeTracker;
use App\Service\BookingConfigurator;
use App\Service\BookingEditContextFactory;
use App\Service\BookingEditDataLoader;
use App\Service\BookingEditDraftManager;
@@ -42,6 +43,7 @@ class IndexController extends AbstractController
private readonly BookingEditDraftManager $draftService,
private readonly BookingSessionManager $bookingSessionService,
private readonly BookingChangeTracker $fingerprintService,
private readonly BookingConfigurator $bookingConfigurator,
private readonly BookingEditContextFactory $editContextFactory,
private readonly BookingEditPreFlightChecker $preFlightChecker,
private readonly BookingEditSubmitter $formSubmitter,
@@ -92,6 +94,15 @@ class IndexController extends AbstractController
return $this->redirectToRoute('app_bookings');
}
$wasDirtyBeforePreselection = $this->fingerprintService->isDirty($bookingDto);
$this->bookingConfigurator->preselectDefaultServices($bookingDto);
if (false === $wasDirtyBeforePreselection) {
$bookingDto->originalFingerprint = $this->fingerprintService->generateFingerprint($bookingDto);
}
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
// Show flash message if draft was restored
if (true === $this->dataLoader->isDraftRestored()) {
$this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.');
@@ -137,10 +148,13 @@ class IndexController extends AbstractController
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
$isDirty = $wasDirtyBeforePreselection
|| (true === $isOverviewSubmitted && $this->fingerprintService->isDirty($bookingDto));
$bookingEditContext = $this->editContextFactory->createOverviewContext(
$bookingDto,
$bookingData,
$this->fingerprintService->isDirty($bookingDto),
$isDirty,
$isOverviewSubmitted,
$hasBlockingValidationErrors,
$missingValueLabelsByParticipantIndex,
@@ -210,6 +224,7 @@ class IndexController extends AbstractController
/** @var User $user */
$user = $this->getUser();
$this->draftService->deleteDraft($user, $bookingId);
$this->dataLoader->invalidateBookingCache($bookingId, $user);
$this->addFlash('success', 'Änderungen verworfen, Daten neu geladen');
@@ -238,9 +253,14 @@ class IndexController extends AbstractController
// Clear session state
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
// Check if a draft exists to show appropriate message
/** @var User $user */
$user = $this->getUser();
// Invalidate booking cache so a direct re-entry to /edit (bypassing /start)
// still loads live data rather than the 5-minute cached response.
$this->dataLoader->invalidateBookingCache($bookingId, $user);
// Check if a draft exists to show appropriate message
$draft = $this->draftService->findDraft($user, $bookingId);
if (null !== $draft) {
@@ -13,6 +13,7 @@ use App\Htmx\HxTrait;
use App\Service\BookingEditContextFactory;
use App\Service\BookingEditDataLoader;
use App\Service\BookingEditDraftManager;
use App\Service\BookingConfigurator;
use App\Service\ParticipantDataPrefiller;
use App\Service\BookingSessionManager;
use App\Service\ParticipantFormSupport;
@@ -34,6 +35,7 @@ class ParticipantController extends AbstractController
private readonly BookingEditDataLoader $dataLoader,
private readonly BookingEditDraftManager $draftService,
private readonly BookingEditContextFactory $editContextFactory,
private readonly BookingConfigurator $bookingConfigurator,
private readonly BookingSessionManager $bookingSessionService,
private readonly ParticipantDataPrefiller $prepopulationService,
private readonly ParticipantFormSupport $participantFormSupportService,
@@ -148,6 +150,15 @@ class ParticipantController extends AbstractController
$form->handleRequest($request);
$this->bookingConfigurator->preselectDefaultServices($bookingDto);
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
$form = $this->createForm(
BookingParticipantType::class,
$this->participantFormSupportService->createParticipantEditDto($bookingDto, $index),
$this->participantFormSupportService->getParticipantFormOptions($bookingDto, true)
);
$notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto);
$context = $this->editContextFactory->createParticipantContext(
$bookingDto,
+3
View File
@@ -6,6 +6,9 @@ use Symfony\Component\HttpFoundation\Request;
trait ReturnUrlTrait
{
/**
* @param array<string, mixed> $parameters
*/
public function getReturnUrl(Request $request, string $defaultRoute, array $parameters = []): string
{
$defaultUrl = $this->generateUrl($defaultRoute, $parameters);
+2
View File
@@ -52,11 +52,13 @@ class PersonalDataType extends AbstractType
])
->add('country', CountryType::class, [
'label' => 'Land',
'placeholder' => 'Bitte auswählen...',
'property_path' => 'address.country',
])
->add('nationality', CountryType::class, [
'label' => 'Nationalität',
'property' => 'nationality',
'placeholder' => 'Bitte auswählen...',
'preferred_choices' => ['D', 'A', 'CH'],
])
->add('email', EmailType::class, [
+3
View File
@@ -24,6 +24,9 @@ abstract class AbstractMenuBuilder
return $this->factory->createItem('root');
}
/**
* @return array<string, mixed>
*/
protected function getDefaultRouteParameters(string $parameter = 'id', string $default = '0'): array
{
$request = $this->requestStack->getMainRequest();
+3
View File
@@ -6,6 +6,9 @@ use Knp\Menu\ItemInterface;
class AdminMenuBuilder extends AbstractMenuBuilder
{
/**
* @param array<string, mixed> $options
*/
public function createMainMenu(array $options): ItemInterface
{
$menu = $this->createRootElement();
@@ -38,4 +38,31 @@ class NewsletterConsentRepository extends ServiceEntityRepository
'mailjetListId' => $mailjetListId,
]);
}
/**
* @param list<int> $mailjetListIds
*
* @return array<int, NewsletterConsent> existing consents indexed by mailjetListId
*/
public function findByEmailAndListIds(string $email, array $mailjetListIds): array
{
if ([] === $mailjetListIds) {
return [];
}
$consents = $this->createQueryBuilder('c')
->where('c.email = :email')
->andWhere('c.mailjetListId IN (:listIds)')
->setParameter('email', mb_strtolower(trim($email)))
->setParameter('listIds', $mailjetListIds)
->getQuery()
->getResult();
$byListId = [];
foreach ($consents as $consent) {
$byListId[$consent->getMailjetListId()] = $consent;
}
return $byListId;
}
}
+11
View File
@@ -85,6 +85,17 @@ class BookingEditDataLoader
return $this->initializeFromApi($request, $bookingId, $user);
}
// No user edits pending — discard the session and reload live from API so that
// BusPro-side changes (participant data, status, surcharges) are immediately
// visible. isDirty() also returns false when originalFingerprint is null
// (inconsistent session state), which is equally safe to reload.
if (!$this->fingerprintService->isDirty($formData)) {
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
$this->invalidateBookingCache($bookingId, $user);
return $this->initializeFromApi($request, $bookingId, $user);
}
// Refresh availability and mutability data to ensure current state.
// Availability is refreshed on every load; mutability is patched from cache.
$this->travelDataService->enrichWithFreshAvailabilities($formData->travel);
+34 -110
View File
@@ -10,41 +10,11 @@ use Faker\Generator;
class BpnXmlAnonymizer
{
private const SAFE_EMAIL_DOMAIN = 'example.com';
private const SALUTATIONS = ['Herr', 'Frau', 'Divers'];
private const GENDERS = ['M', 'W', 'D'];
private const COUNTRY_CODES = ['AT', 'BE', 'CH', 'DE', 'DK', 'ES', 'FR', 'IT', 'NL', 'NO', 'PL', 'SE'];
private Generator $faker;
/**
* @var array<string, array{
* firstName: string,
* lastName: string,
* salutation: string,
* title: string,
* gender: string,
* nationality: string,
* birthDate: string,
* street: string,
* postalCode: string,
* city: string,
* district: string,
* country: string,
* email: string,
* mobilePhone: string,
* phone: string,
* newsletter: string,
* iban: string,
* userName: string,
* requestKey: string,
* requestPassword: string,
* personId: string,
* addressId: string,
* customerId: string,
* height: string,
* weight: string,
* shoeSize: string
* }>
* @var array<string, array<string, string>>
*/
private array $identities = [];
@@ -141,24 +111,9 @@ class BpnXmlAnonymizer
}
}
$currentNode = $personNode;
while ($currentNode instanceof \DOMElement) {
foreach (['personid', 'idperson', 'idadresseperson'] as $tagName) {
$child = $this->getDirectChild($currentNode, $tagName);
if (null !== $child) {
$value = trim($child->textContent);
if ('' !== $value) {
return $value;
}
}
}
$parentNode = $currentNode->parentNode;
if (false === $parentNode instanceof \DOMElement) {
break;
}
$currentNode = $parentNode;
$personIdValue = $this->findPersonIdInAncestors($personNode);
if (null !== $personIdValue) {
return $personIdValue;
}
$fingerprintKey = $this->buildFingerprintKey($personNode);
@@ -232,34 +187,7 @@ class BpnXmlAnonymizer
}
/**
* @return array{
* firstName: string,
* lastName: string,
* salutation: string,
* title: string,
* gender: string,
* nationality: string,
* birthDate: string,
* street: string,
* postalCode: string,
* city: string,
* district: string,
* country: string,
* email: string,
* mobilePhone: string,
* phone: string,
* newsletter: string,
* iban: string,
* userName: string,
* requestKey: string,
* requestPassword: string,
* personId: string,
* addressId: string,
* customerId: string,
* height: string,
* weight: string,
* shoeSize: string
* }
* @return array<string, string>
*/
private function getOrCreateIdentity(string $personId): array
{
@@ -330,7 +258,7 @@ class BpnXmlAnonymizer
}
/**
* @param array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string} $identity
* @param array<string, string> $identity
*/
private function replaceNameFields(\DOMElement $personNode, array $identity): void
{
@@ -358,7 +286,7 @@ class BpnXmlAnonymizer
}
/**
* @param array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string} $identity
* @param array<string, string> $identity
*/
private function replaceEmailFields(\DOMElement $personNode, array $identity): void
{
@@ -396,7 +324,7 @@ class BpnXmlAnonymizer
}
/**
* @param array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string} $identity
* @param array<string, string> $identity
*/
private function replaceAddressFields(\DOMElement $personNode, array $identity): void
{
@@ -436,6 +364,9 @@ class BpnXmlAnonymizer
}
}
/**
* @param array<string, string> $identity
*/
private function replaceDemographicFields(\DOMElement $personNode, array $identity): void
{
$salutationNode = $this->getDirectChild($personNode, 'anrede');
@@ -479,6 +410,9 @@ class BpnXmlAnonymizer
}
}
/**
* @param array<string, string> $identity
*/
private function replaceFinancialFields(\DOMElement $personNode, array $identity): void
{
$bankNode = $this->getDirectChild($personNode, 'bankverbindung');
@@ -492,6 +426,9 @@ class BpnXmlAnonymizer
}
}
/**
* @param array<string, string> $identity
*/
private function replaceMetadataFields(\DOMElement $personNode, array $identity): void
{
if (true === $personNode->hasAttribute('id')) {
@@ -503,6 +440,9 @@ class BpnXmlAnonymizer
}
}
/**
* @param array<string, string> $identity
*/
private function replaceRequestFields(\DOMElement $personNode, array $identity): void
{
$userNode = $this->getDirectChild($personNode, 'user');
@@ -544,23 +484,24 @@ class BpnXmlAnonymizer
$node->setAttribute($attributeName, $replacement);
}
private function findAncestorDirectChild(\DOMElement $personNode, string $ancestorTagName, string $childTagName): ?\DOMElement
private function findPersonIdInAncestors(\DOMElement $node): ?string
{
$currentNode = $personNode;
while ($currentNode instanceof \DOMElement) {
if ($ancestorTagName === $currentNode->tagName) {
return $this->getDirectChild($currentNode, $childTagName);
foreach (['personid', 'idperson', 'idadresseperson'] as $tagName) {
$child = $this->getDirectChild($node, $tagName);
if (null !== $child) {
$value = trim($child->textContent);
if ('' !== $value) {
return $value;
}
}
$parentNode = $currentNode->parentNode;
if (false === $parentNode instanceof \DOMElement) {
break;
}
$currentNode = $parentNode;
}
return null;
$parent = $node->parentNode;
if (false === $parent instanceof \DOMElement) {
return null;
}
return $this->findPersonIdInAncestors($parent);
}
private function getDirectChild(\DOMElement $parent, string $name): ?\DOMElement
@@ -578,21 +519,4 @@ class BpnXmlAnonymizer
return null;
}
private function slug(string $value): string
{
$normalizedValue = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
if (false === $normalizedValue) {
$normalizedValue = $value;
}
$normalizedValue = strtolower($normalizedValue);
$normalizedValue = preg_replace('/[^a-z0-9]+/', '.', $normalizedValue);
$normalizedValue = trim((string) $normalizedValue, '.');
if ('' === $normalizedValue) {
return 'user';
}
return $normalizedValue;
}
}
+6
View File
@@ -48,6 +48,9 @@ class DatabaseAnonymizer
$this->identitySequence = 0;
}
/**
* @return array{users: int, newsletterConsents: int, newsletterOptInRequests: int, bookingEditDrafts: int}
*/
public function anonymizeAll(int $batchSize = self::DEFAULT_BATCH_SIZE): array
{
if ($batchSize < 1) {
@@ -405,6 +408,9 @@ class DatabaseAnonymizer
return null !== $this->stringOrNull($value);
}
/**
* @param array<string, string> $identity
*/
private function placeholderText(array $identity, string $suffix): string
{
return sprintf('%s-%s', $identity['lastName'], $suffix);
+4 -3
View File
@@ -327,8 +327,7 @@ class NewsletterManager
$this->newsletterService->ensureSubscribed($confirmation->getEmail(), $mailjetListId);
}
$confirmation->markConfirmed();
$this->upsertConfirmedConsents($confirmation->getEmail(), $mailjetListIds, $confirmation->getFirstName(), $confirmation->getLastName(), false, false);
$this->upsertConfirmedConsents($confirmation->getEmail(), $mailjetListIds, $confirmation->getFirstName(), $confirmation->getLastName(), flush: false, deletePending: false);
$this->entityManager->remove($confirmation);
$this->entityManager->flush();
@@ -468,8 +467,10 @@ class NewsletterManager
*/
private function upsertConfirmedConsents(string $email, array $mailjetListIds, ?string $firstName, ?string $lastName, bool $flush = true, bool $deletePending = true): void
{
$existingByListId = $this->consentRepository->findByEmailAndListIds($email, $mailjetListIds);
foreach ($mailjetListIds as $mailjetListId) {
$consent = $this->consentRepository->findOneByEmailAndListId($email, $mailjetListId);
$consent = $existingByListId[$mailjetListId] ?? null;
if (null === $consent) {
$consent = new NewsletterConsent($email, $mailjetListId, $firstName, $lastName);
$this->entityManager->persist($consent);
@@ -1,4 +1,4 @@
<div id="loading-indicator"
class="invisible fixed top-0 left-0 inset-0 z-50 backdrop-blur-sm flex items-center justify-center transition-all duration-100">
class="invisible fixed top-0 left-0 inset-0 z-[60] backdrop-blur-sm flex items-center justify-center transition-all duration-100">
{% include '_partials/_spinner.html.twig' %}
</div>
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Controller\Booking\Edit;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\AgencyLoader;
use App\Controller\Booking\Edit\IndexController;
@@ -16,6 +17,7 @@ use App\Form\Model\ParticipantCardDataDto;
use App\Form\Model\ParticipantCardPriceDto;
use App\Model\BookingEditSubmissionResult;
use App\Service\BookingChangeTracker;
use App\Service\BookingConfigurator;
use App\Service\BookingEditContextFactory;
use App\Service\BookingEditDataLoader;
use App\Service\BookingEditDraftManager;
@@ -67,6 +69,8 @@ class IndexControllerTest extends TestCase
->method('isDirty')
->with($bookingDto)
->willReturn(true);
$fingerprintService->expects($this->never())
->method('generateFingerprint');
$dataLoader = $this->createMock(BookingEditDataLoader::class);
$dataLoader->expects($this->once())
@@ -81,6 +85,16 @@ class IndexControllerTest extends TestCase
->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')
@@ -96,8 +110,9 @@ class IndexControllerTest extends TestCase
$controller = new TestableIndexController(
$dataLoader,
$this->createMock(BookingEditDraftManager::class),
$this->createMock(BookingSessionManager::class),
$bookingSessionService,
$fingerprintService,
$bookingConfigurator,
$contextFactory,
$preflightChecker,
$this->createMock(BookingEditSubmitter::class),
@@ -139,6 +154,10 @@ class IndexControllerTest extends TestCase
->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())
@@ -170,6 +189,7 @@ class IndexControllerTest extends TestCase
$this->createMock(BookingEditDraftManager::class),
$this->createMock(BookingSessionManager::class),
$fingerprintService,
$this->createMock(BookingConfigurator::class),
$contextFactory,
$preflightChecker,
$this->createMock(BookingEditSubmitter::class),
@@ -212,8 +232,12 @@ class IndexControllerTest extends TestCase
->willReturn(false);
$fingerprintService = $this->createMock(BookingChangeTracker::class);
$fingerprintService->expects($this->once())
->method('isDirty')
->with($bookingDto)
->willReturn(true);
$fingerprintService->expects($this->never())
->method('isDirty');
->method('generateFingerprint');
$dataLoader = $this->createMock(BookingEditDataLoader::class);
$dataLoader->expects($this->once())
@@ -253,6 +277,7 @@ class IndexControllerTest extends TestCase
$this->createMock(BookingEditDraftManager::class),
$this->createMock(BookingSessionManager::class),
$fingerprintService,
$this->createMock(BookingConfigurator::class),
$contextFactory,
$preflightChecker,
$submitter,
@@ -296,10 +321,14 @@ class IndexControllerTest extends TestCase
->willReturn(false);
$fingerprintService = $this->createMock(BookingChangeTracker::class);
$fingerprintService->expects($this->once())
$fingerprintService->expects($this->exactly(2))
->method('isDirty')
->with($bookingDto)
->willReturn(false);
->willReturnOnConsecutiveCalls(false, false);
$fingerprintService->expects($this->once())
->method('generateFingerprint')
->with($bookingDto)
->willReturn('normalized-fingerprint');
$dataLoader = $this->createMock(BookingEditDataLoader::class);
$dataLoader->expects($this->once())
@@ -335,6 +364,7 @@ class IndexControllerTest extends TestCase
$this->createMock(BookingEditDraftManager::class),
$this->createMock(BookingSessionManager::class),
$fingerprintService,
$this->createMock(BookingConfigurator::class),
$contextFactory,
$preflightChecker,
$submitter,
@@ -350,6 +380,184 @@ class IndexControllerTest extends TestCase
$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
*
@@ -433,6 +641,25 @@ class IndexControllerTest extends TestCase
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,
@@ -484,8 +711,12 @@ class IndexControllerTest extends TestCase
$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('isDirty');
->method('generateFingerprint');
$form->expects($this->once())
->method('handleRequest')
@@ -526,6 +757,7 @@ class IndexControllerTest extends TestCase
$this->createMock(BookingEditDraftManager::class),
$this->createMock(BookingSessionManager::class),
$fingerprintService,
$this->createMock(BookingConfigurator::class),
$this->createMock(BookingEditContextFactory::class),
$preflightChecker,
$submitter,
@@ -566,6 +798,7 @@ final class TestableIndexController extends IndexController
BookingEditDraftManager $draftService,
BookingSessionManager $bookingSessionService,
BookingChangeTracker $fingerprintService,
BookingConfigurator $bookingService,
BookingEditContextFactory $editContextFactory,
BookingEditPreFlightChecker $preFlightChecker,
BookingEditSubmitter $formSubmitter,
@@ -579,6 +812,7 @@ final class TestableIndexController extends IndexController
$draftService,
$bookingSessionService,
$fingerprintService,
$bookingService,
$editContextFactory,
$preFlightChecker,
$formSubmitter,
@@ -15,6 +15,7 @@ use App\Form\Model\BookingEditContext;
use App\Form\Model\BookingSummaryDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\ParticipantEditDto;
use App\Service\BookingConfigurator;
use App\Service\BookingEditContextFactory;
use App\Service\BookingEditDataLoader;
use App\Service\BookingEditDraftManager;
@@ -26,6 +27,7 @@ use Carbon\CarbonImmutable;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -126,6 +128,7 @@ class ParticipantControllerTest extends TestCase
$dataLoader,
$draftService,
$contextFactory,
$this->createMock(BookingConfigurator::class),
$bookingSessionService,
$prepopulationService,
$participantFormSupportService,
@@ -145,6 +148,109 @@ class ParticipantControllerTest extends TestCase
$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->createMock(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->createMock(ApiClient::class),
$this->createMock(Crypt::class),
$this->createMock(LoggerInterface::class),
);
$controller = new TestableParticipantController(
$dataLoader,
$this->createMock(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);
}
private function createUser(): User
{
return new User('[email protected]');
@@ -184,6 +290,11 @@ final class TestableParticipantController extends ParticipantController
public ?string $renderTemplate = null;
/**
* @var list<string>
*/
public array $renderedBlocks = [];
/**
* @var array<string, mixed>
*/
@@ -201,6 +312,7 @@ final class TestableParticipantController extends ParticipantController
BookingEditDataLoader $dataLoader,
BookingEditDraftManager $draftService,
BookingEditContextFactory $editContextFactory,
BookingConfigurator $bookingService,
BookingSessionManager $bookingSessionService,
ParticipantDataPrefiller $prepopulationService,
ParticipantFormSupport $participantFormSupportService,
@@ -213,6 +325,7 @@ final class TestableParticipantController extends ParticipantController
$dataLoader,
$draftService,
$editContextFactory,
$bookingService,
$bookingSessionService,
$prepopulationService,
$participantFormSupportService,
@@ -256,4 +369,14 @@ final class TestableParticipantController extends ParticipantController
return new Response('');
}
/**
* @param array<string, mixed> $parameters
*/
protected function renderBlockView(string $view, string $block, array $parameters = []): string
{
$this->renderedBlocks[] = $block;
return '<div></div>';
}
}
@@ -317,6 +317,107 @@ class BookingEditPreFlightCheckerTest extends TestCase
return $bookingDto;
}
public function testScanAttentionReturnsEmptyArrayForCreateMode(): void
{
$travel = new Travel();
$travel->dateFrom = CarbonImmutable::now()->addMonth()->toDateTimeImmutable();
$bookingDto = new BookingDto($travel, 1);
// No $bookingDto->booking set → getMode() returns MODE_CREATE
$participant = $this->createCompleteParticipant(0);
$participant->firstName = null;
$participant->lastName = null;
$bookingDto->participants = [$participant];
$checker = new BookingEditPreFlightChecker($this->createValidator());
$this->assertSame([], $checker->findMissingValueLabelsByParticipantIndex($bookingDto));
}
public function testScanAttentionSkipsCanceledParticipants(): void
{
$bookingDto = $this->createBookingDto(true);
$canceledParticipant = $this->createCompleteParticipant(1);
$canceledParticipant->status = 'S';
$canceledParticipant->firstName = null;
$canceledParticipant->lastName = null;
$canceledParticipant->email = null;
$bookingDto->participants = [
$this->createCompleteParticipant(0),
$canceledParticipant,
];
$checker = new BookingEditPreFlightChecker($this->createValidator());
$this->assertSame([], $checker->findMissingValueLabelsByParticipantIndex($bookingDto));
}
public function testScanAttentionFlagsEmailUniquenessViolationForDuplicateAdultEmail(): void
{
$bookingDto = $this->createBookingDto(false);
// All three participants share the same email address (from createCompleteParticipant).
// Index 0 (applicant) is exempt; indices 1 and 2 each see a duplicate and are flagged.
$bookingDto->participants = [
$this->createCompleteParticipant(0),
$this->createCompleteParticipant(1),
$this->createCompleteParticipant(2),
];
$checker = new BookingEditPreFlightChecker($this->createValidator());
$this->assertSame(
[
1 => ['E-Mail'],
2 => ['E-Mail'],
],
$checker->findMissingValueLabelsByParticipantIndex($bookingDto)
);
}
public function testScanAttentionCoversRemainingPropertyLabels(): void
{
$bookingDto = $this->createBookingDto(true);
$participant = $this->createCompleteParticipant(0);
$participant->gender = null;
$participant->nationality = null;
$participant->dateOfBirth = null;
$participant->assignedRoomId = null;
$participant->transportationOutbound = null;
$participant->transportationInbound = null;
$bookingDto->participants = [$participant];
$checker = new BookingEditPreFlightChecker($this->createValidator());
$this->assertSame(
[0 => ['Anrede', 'Nationalität', 'Geburtsdatum', 'Zimmer', 'Anreise', 'Abreise']],
$checker->findMissingValueLabelsByParticipantIndex($bookingDto)
);
}
public function testScanAttentionFlagsAddressFieldLabelsForApplicantInNonInternalAgencyBooking(): void
{
$bookingDto = $this->createBookingDto(false);
$participant = $this->createCompleteParticipant(0);
$participant->address = new Address();
$bookingDto->participants = [$participant];
$checker = new BookingEditPreFlightChecker($this->createValidator());
$this->assertSame(
[0 => ['Straße', 'PLZ', 'Ort', 'Land']],
$checker->findMissingValueLabelsByParticipantIndex($bookingDto)
);
}
/**
* @param list<int> $selectionIds
*/
+8 -1
View File
@@ -166,6 +166,10 @@ class NewsletterManagerTest extends TestCase
default => null,
};
});
$consents
->method('findByEmailAndListIds')
->with('[email protected]', [1, 2])
->willReturn([1 => $existingConsent, 2 => $existingOtherConsent]);
$repository->expects(self::once())->method('deletePendingByEmail')->with('[email protected]')->willReturn(0);
$entityManager->expects(self::never())->method('persist');
$entityManager->expects(self::once())->method('flush');
@@ -223,6 +227,7 @@ class NewsletterManagerTest extends TestCase
$mailjet->expects(self::once())->method('ensureSubscribed')->with('[email protected]', 2);
$consents->method('findActiveByEmail')->with('[email protected]')->willReturn($existingConsent);
$consents->method('findOneByEmailAndListId')->willReturn(null);
$consents->method('findByEmailAndListIds')->willReturn([]);
$repository->expects(self::once())->method('deletePendingByEmail')->with('[email protected]')->willReturn(0);
$repository->expects(self::never())->method('findPendingByEmail');
$entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(NewsletterConsent::class));
@@ -254,6 +259,7 @@ class NewsletterManagerTest extends TestCase
$mailjet->expects(self::once())->method('ensureSubscribed')->with('[email protected]', 2);
$consents->method('findActiveByEmail')->with('[email protected]')->willReturn($existingConsent);
$consents->method('findOneByEmailAndListId')->with('[email protected]', 2)->willReturn(null);
$consents->method('findByEmailAndListIds')->willReturn([]);
$repository->expects(self::once())->method('deletePendingByEmail')->with('[email protected]')->willReturn(0);
$entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(NewsletterConsent::class));
$entityManager->expects(self::once())->method('flush');
@@ -338,7 +344,7 @@ class NewsletterManagerTest extends TestCase
$repository->expects(self::once())->method('findByTokenHash')->with(hash('sha256', $token))->willReturn($confirmation);
$repository->expects(self::never())->method('deletePendingByEmail');
$consents->expects(self::once())->method('findOneByEmailAndListId')->with('[email protected]', 10321569)->willReturn(null);
$consents->expects(self::once())->method('findByEmailAndListIds')->with('[email protected]', [10321569])->willReturn([]);
$mailjet->expects(self::once())->method('upsertContact')->with('[email protected]', 'Mia', 'Muster');
$mailjet->expects(self::once())->method('ensureSubscribed')->with('[email protected]', 10321569);
$entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(NewsletterConsent::class));
@@ -370,6 +376,7 @@ class NewsletterManagerTest extends TestCase
$repository->expects(self::once())->method('findByTokenHash')->with(hash('sha256', $token))->willReturn($confirmation);
$repository->expects(self::never())->method('deletePendingByEmail');
$consents->method('findOneByEmailAndListId')->willReturn(null);
$consents->method('findByEmailAndListIds')->with('[email protected]', [1, 2])->willReturn([]);
$mailjet
->expects(self::exactly(2))
->method('ensureSubscribed')