Compare commits

...
3 Commits
67 changed files with 2515 additions and 828 deletions

No files matched your search

+29 -26
View File
@@ -30,30 +30,33 @@ parameters:
10321389: 'Reisen-Alert Stubaital'
10554990: 'Reisen-Alert Ski & Boarderweek'
# Hausleitung hotel codes and their labels, assignable to users in /admin/user.
# Mirrors the "Hausleitung {CODE}" CRM selections published by BusProNet.
hotel_codes:
SSL: 'SSL'
SST: 'SST'
MVK: 'MVK'
ASB: 'ASB'
LPJ: 'LPJ'
DKS: 'DKS'
DGS: 'DGS'
DPW: 'DPW'
DKI: 'DKI'
DWW: 'DWW'
PMV: 'PMV'
ASG: 'ASG'
ASC: 'ASC'
SBW: 'SBW'
UCH: 'UCH'
SZO: 'SZO'
PCJ: 'PCJ'
KHH: 'KHH'
SVS: 'SVS'
SHM: 'SHM'
AGR: 'AGR'
# BusProNet "Hausleitung {CODE}" CRM selections, by selection id.
# DEPLOYMENT-CRITICAL: roles and hotel codes are synced on every login, so an id missing
# here does not merely fail to nominate a Hausleitung — it revokes the role and the hotel
# code from everyone holding it, one login at a time, with manual re-approval per user.
# Entries for houses that are not in use yet stay commented out.
bpn_crm_house_manager_ids:
1299: 'SSL'
1300: 'SST'
1301: 'MVK'
# 1302: 'ASB'
1303: 'LPJ'
1304: 'DKS'
1305: 'DGS'
1306: 'DPW'
1307: 'DKI'
1308: 'DWW'
1309: 'PMV'
# 1352: 'ASG'
1371: 'ASC'
1373: 'PCJ'
1374: 'SBW'
# 1375: 'UCH'
# 1376: 'SZO'
1377: 'KHH'
1459: 'SVS'
1461: 'SHM'
1462: 'AGR'
# MailJet contact metadata names for name synchronization
mailjet_contact_metadata_fields:
@@ -278,9 +281,9 @@ services:
arguments:
$mailjetLists: '%mailjet_lists%'
App\Form\Admin\UserType:
App\BusProNet\XmlParser\CrmAttributesResponseParser:
arguments:
$hotelCodes: '%hotel_codes%'
$houseManagerIds: '%bpn_crm_house_manager_ids%'
App\Service\DomainConfigProvider:
arguments:
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260819120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Split the ambiguous Offen status into Angefragt and Offen, and turn the booking type into an immutable origin';
}
public function up(Schema $schema): void
{
// Offen used to mean both "inquiry just arrived" and "offer is out with the customer",
// told apart only by the access link. An Offen row without a link is exactly the
// former case and becomes Angefragt; rows with a link are genuinely awaiting the
// customer's acceptance and keep their status.
$this->addSql("UPDATE accommodation_booking SET status = 'requested' WHERE status = 'open' AND access_link_issued_at IS NULL");
// The type described the lifecycle a second time and contradicted the status once an
// offer had been accepted. It now records only where the booking came from, which is
// true at every point of its life: an old `inquiry` went through an offer however far
// it got, an old `booking` was placed directly.
$this->addSql('ALTER TABLE accommodation_booking CHANGE type origin VARCHAR(20) NOT NULL');
$this->addSql("UPDATE accommodation_booking SET origin = 'offer' WHERE origin = 'inquiry'");
$this->addSql("UPDATE accommodation_booking SET origin = 'direct' WHERE origin = 'booking'");
}
public function down(Schema $schema): void
{
$this->addSql("UPDATE accommodation_booking SET origin = 'inquiry' WHERE origin = 'offer'");
$this->addSql("UPDATE accommodation_booking SET origin = 'booking' WHERE origin = 'direct'");
$this->addSql('ALTER TABLE accommodation_booking CHANGE origin type VARCHAR(20) NOT NULL');
$this->addSql("UPDATE accommodation_booking SET status = 'open' WHERE status = 'requested'");
}
}
@@ -7,8 +7,21 @@ use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\CrmSelection;
use App\BusProNet\Model\CrmSelectionGroup;
use App\BusProNet\Traits\TypeConversionTrait;
use App\Security\Role;
use Symfony\Component\DomCrawler\Crawler;
/**
* Turns the SelektionCRM response into the roles and hotel codes the CRM claims for an account.
*
* This reports what BusPro says and nothing more: no fallback role, no default hotel code. What
* is made of the claims — which are granted outright and which only nominate — is Role::sync()'s
* business.
*
* The selection ids below are deployment-critical. BusPro always returns the full attribute tree
* and expresses membership through the `auswahl` flag, so a wrong or unset id yields a perfectly
* well-formed response in which nobody holds anything, and every user logging in is demoted one
* at a time. There is no signal inside the response that tells that apart from a real revocation.
*/
class CrmAttributesResponseParser
{
use TypeConversionTrait;
@@ -18,7 +31,13 @@ class CrmAttributesResponseParser
private const BPN_CRM_ID_TEAMER = 1070;
private const BPN_CRM_ID_GROUPS_MANAGER = 1477;
private const BPN_CRM_ID_GROUPS_ADMIN = 1478;
private const BPN_DEFAULT_HOTEL_CODE = 'SSL';
/**
* @param array<int|string, string> $houseManagerIds "Hausleitung" selection id => hotel code
*/
public function __construct(private readonly array $houseManagerIds = [])
{
}
public function parse(Crawler $result): CrmAttributes
{
@@ -45,24 +64,28 @@ class CrmAttributesResponseParser
$attribute->mutable = $this->stringToBool($node->attr('aenderbar'));
$attribute->selected = $this->stringToBool($node->attr('auswahl'));
if (1 === preg_match('/^Hausleitung ([A-Z0-9]+)$/', $attribute->label, $matches) && true === $attribute->selected) {
$roles[] = 'ROLE_HOUSE_MANAGER';
$hotelCodes[] = $matches[1];
}
if (self::BPN_CRM_ID_ADMIN === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_ADMIN';
}
if (self::BPN_CRM_ID_MANAGER === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_MANAGER';
}
if (self::BPN_CRM_ID_TEAMER === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_TEAMER';
}
if (self::BPN_CRM_ID_GROUPS_MANAGER === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_GROUPS_MANAGER';
}
if (self::BPN_CRM_ID_GROUPS_ADMIN === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_GROUPS_ADMIN';
if (true === $attribute->selected) {
$hotelCode = $this->houseManagerIds[$attribute->id] ?? null;
if (null !== $hotelCode) {
$roles[] = Role::HOUSE_MANAGER;
$hotelCodes[] = $hotelCode;
}
if (self::BPN_CRM_ID_ADMIN === $attribute->id) {
$roles[] = Role::ADMIN;
}
if (self::BPN_CRM_ID_MANAGER === $attribute->id) {
$roles[] = Role::MANAGER;
}
if (self::BPN_CRM_ID_TEAMER === $attribute->id) {
$roles[] = Role::TEAMER;
}
if (self::BPN_CRM_ID_GROUPS_MANAGER === $attribute->id) {
$roles[] = Role::GROUPS_MANAGER;
}
if (self::BPN_CRM_ID_GROUPS_ADMIN === $attribute->id) {
$roles[] = Role::GROUPS_ADMIN;
}
}
$attributes[] = $attribute;
@@ -74,13 +97,6 @@ class CrmAttributesResponseParser
})
;
$roles = array_unique($roles);
// Assign default role if none could be resolved
if (0 === count($roles)) {
$roles = ['ROLE_CUSTOMER'];
}
$result
->filterXPath('//crmaktionen/crmaktion')
->each(function (Crawler $node) use (&$actions) {
@@ -96,15 +112,11 @@ class CrmAttributesResponseParser
})
;
if (true === in_array('ROLE_ADMIN', $roles, true)) {
$hotelCodes[] = self::BPN_DEFAULT_HOTEL_CODE;
}
$response = new CrmAttributes();
$response->selectionGroups = $groups;
$response->crmActions = $actions;
$response->roles = $roles;
$response->hotelCodes = $hotelCodes;
$response->roles = array_values(array_unique($roles));
$response->hotelCodes = array_values(array_unique($hotelCodes));
return $response;
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class DiscardController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/discard', name: 'app_admin_accommodationbooking_discard')]
public function index(AccommodationBooking $booking, Request $request): Response
{
// A confirmation that has gone out is binding, so a confirmed booking is not something
// the office can walk back here; an already discarded one has nothing left to do.
if ($booking->isConfirmed() || $booking->isDiscarded()) {
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
}
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('discard_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$this->bookingService->discardBooking($booking);
$this->addFlash('success', 'Die Buchung wurde abgesagt.');
$this->logger->info('Discarded accommodation booking', [
'id' => $booking->getId(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
}
return $this->render('admin/accommodation_booking/modal_discard.html.twig', [
'booking' => $booking,
'csrf_token_id' => 'discard_accommodation_booking_'.$booking->getId(),
]);
}
}
@@ -7,7 +7,6 @@ namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Admin\Groups\AccommodationBookingType;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
@@ -93,8 +92,6 @@ class EditController extends AbstractController
'current_additional_services' => $currentAdditionalServices,
'assignable_managers' => $assignableManagers,
]);
$previousStatus = $booking->getStatus();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -125,19 +122,12 @@ class EditController extends AbstractController
$this->bookingService->refreshPriceSnapshot($booking);
// Editing never moves the booking on and never mails the customer: every status
// change is its own named action (Angebot senden, bestätigen, absagen), so the
// status, its timestamp and its email can no longer drift apart.
$this->entityManager->persist($booking);
$this->entityManager->flush();
// Entering Offen is what the customer needs to hear about: the offer becomes
// viewable. Every other status stays silent here — in particular Bestätigt, which
// is only ever reached through the explicit confirm action so that the binding
// confirmation is never sent as a side effect of editing.
$notifiableStatuses = [AccommodationBookingStatus::Open];
if ($previousStatus !== $booking->getStatus() && in_array($booking->getStatus(), $notifiableStatuses, true)) {
$this->bookingService->issueAccessLink($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking);
}
$this->addFlash('success', 'Die Buchung wurde aktualisiert');
$this->logger->info('Updated accommodation booking', [
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class SendOfferController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/send-offer', name: 'app_admin_accommodationbooking_send_offer')]
public function index(AccommodationBooking $booking, Request $request): Response
{
// Only a booking still waiting for its offer can have one sent — anything further
// along has already been put in front of the customer or been dealt with.
if (!$booking->isDraft() && !$booking->isRequested()) {
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
}
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('send_offer_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
// The offer only exists for the customer as the email carrying its link, so
// publishing it without a deliverable address would strand the booking in Offen.
if (null === $booking->getEmail() || '' === trim($booking->getEmail())) {
$this->addFlash('error', 'Für diese Buchung ist keine E-Mail-Adresse hinterlegt. Bitte ergänze sie, um das Angebot zu versenden.');
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]));
}
$this->bookingService->sendOffer($booking);
$this->addFlash('success', 'Das Angebot wurde dem Kunden per E-Mail zugestellt.');
$this->logger->info('Sent accommodation booking offer', [
'id' => $booking->getId(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
}
return $this->render('admin/accommodation_booking/modal_send_offer.html.twig', [
'booking' => $booking,
'csrf_token_id' => 'send_offer_accommodation_booking_'.$booking->getId(),
]);
}
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\User;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;
/**
* Turns a nomination from the BusPro CRM into an actual role.
*
* This is deliberately its own action rather than a field on a form: it grants a privilege, so
* it is confirmed on its own, logged on its own, and cannot happen as a side effect of saving
* something unrelated. The nomination is checked again on submit, so a sync that revoked the
* claim while the dialog was open cannot be approved through. Role::SELF_APPROVAL_FORBIDDEN is
* refused for your own account.
*/
#[IsGranted('ROLE_ADMIN')]
class ApproveRoleController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly TokenStorageInterface $tokenStorage,
) {
}
#[Route('/admin/user/{id}/approve/{role}', name: 'app_admin_user_approve_role', requirements: ['role' => 'ROLE_[A-Z_]+'])]
public function index(User $user, string $role, Request $request): Response
{
if ($user === $this->getUser() && \in_array($role, Role::SELF_APPROVAL_FORBIDDEN, true)) {
throw $this->createAccessDeniedException(sprintf('The role "%s" cannot be approved for your own account.', $role));
}
$nominated = Role::nominatedFrom($user->getRoles());
if (false === \array_key_exists($role, $nominated)) {
throw $this->createNotFoundException(sprintf('The account is not nominated for "%s".', $role));
}
$csrfTokenId = 'approve_user_role_'.$user->getId().'_'.$role;
if ($request->isMethod(Request::METHOD_POST)) {
if (false === $this->isCsrfTokenValid($csrfTokenId, $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$user->setRoles(Role::approve($user->getRoles(), $role));
$this->entityManager->flush();
$this->reissueOwnSecurityToken($user);
$this->addFlash('success', sprintf('Die Rolle %s wurde freigeschaltet', $nominated[$role]));
$this->logger->info('Approved user role', [
'email' => $user->getEmail(),
'role' => $role,
'roles' => $user->getRoles(),
]);
return new HxRedirectResponse($this->getReturnUrl($request, 'app_admin_user'));
}
return $this->render('admin/user/modal_approve_role.html.twig', [
'user' => $user,
'role_label' => $nominated[$role],
'csrf_token_id' => $csrfTokenId,
]);
}
/**
* Keeps the approver signed in when they just approved a role for themselves.
*
* A token carries the role names it was issued with, and ContextListener ends the session
* as soon as the stored user no longer matches them — the safeguard that makes a revocation
* take effect at once. Here the grant is deliberate and just happened under ROLE_ADMIN, so
* the token is re-issued with the new roles instead of the session being dropped.
*/
private function reissueOwnSecurityToken(User $user): void
{
$token = $this->tokenStorage->getToken();
if (false === $token instanceof PostAuthenticationToken || $token->getUser() !== $user) {
return;
}
$this->tokenStorage->setToken(
new PostAuthenticationToken($user, $token->getFirewallName(), $user->getRoles()),
);
}
}
@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\User;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\User;
use App\Form\Admin\UserType;
use App\Htmx\HxRedirectResponse;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class EditController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/user/{id}/edit', name: 'app_admin_user_edit')]
public function index(User $user, Request $request): Response
{
$previousRoles = $user->getRoles();
$previousHotelCodes = $user->getHotelCodes();
$form = $this->createForm(UserType::class, $user, ['hx_post' => $request->getRequestUri()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($this->locksOutSelf($user)) {
$form->get('roles')->addError(new FormError('Du kannst dir die Rolle Administration nicht selbst entziehen.'));
return $this->render('admin/user/modal_edit.html.twig', [
'user' => $user,
'form' => $form,
'syncedRoles' => Role::syncedOnly($user->getRoles()),
]);
}
$this->entityManager->flush();
$this->addFlash('success', 'Der Benutzeraccount wurde aktualisiert');
$this->logger->info('Updated user permissions', [
'email' => $user->getEmail(),
'previousRoles' => $previousRoles,
'roles' => $user->getRoles(),
'previousHotelCodes' => $previousHotelCodes,
'hotelCodes' => $user->getHotelCodes(),
]);
return new HxRedirectResponse($this->getReturnUrl($request, 'app_admin_user'));
}
return $this->render('admin/user/modal_edit.html.twig', [
'user' => $user,
'form' => $form,
'syncedRoles' => Role::syncedOnly($user->getRoles()),
]);
}
private function locksOutSelf(User $user): bool
{
return $user === $this->getUser() && false === \in_array(Role::ADMIN, $user->getRoles(), true);
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\User;
use App\Entity\User;
use App\Security\Role;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Shows what an account holds and what it is nominated for.
*
* Nothing here is editable: roles and hotel codes come from the BusPro CRM and are synced on
* every login, so the only administrative act is approving a nomination — which is its own
* route, its own confirmation and its own log entry (see ApproveRoleController).
*/
#[IsGranted('ROLE_ADMIN')]
class ShowController extends AbstractController
{
#[Route('/admin/user/{id}/permissions', name: 'app_admin_user_permissions')]
public function index(User $user, Request $request): Response
{
$nominated = Role::nominatedFrom($user->getRoles());
// What ApproveRoleController would refuse for this account is not offered either, so
// nobody is sent into an access denied page.
$refused = $user === $this->getUser()
? array_intersect_key($nominated, array_flip(Role::SELF_APPROVAL_FORBIDDEN))
: [];
return $this->render('admin/user/modal_permissions.html.twig', [
'user' => $user,
'approvableRoles' => array_diff_key($nominated, $refused),
'selfRefusedRoles' => $refused,
'returnUrl' => $this->forwardedReturnUrl($request),
]);
}
/**
* Where an approval started from, still encoded the way return_url() handed it over.
*
* The approval links must forward what this modal was given rather than call return_url()
* themselves: that function answers with the *current* request URI, which here is the modal
* itself — and redirecting to it after the approval would render a bare modal as a page.
*/
private function forwardedReturnUrl(Request $request): string
{
$returnUrl = $request->query->getString('r');
return '' !== $returnUrl ? $returnUrl : rawurlencode($this->generateUrl('app_admin_user'));
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ class UserinfoController extends AbstractController
// Patch current user's roles. The implicit ROLE_USER says nothing about the
// account — every authenticated user holds it — and is not exported.
$data->roles = Role::assignedOnly($user->getRoles());
$data->roles = Role::effectiveOnly($user->getRoles());
// Patch current user's hotel codes
$data->hotelCodes = $user->getHotelCodes();
+7 -4
View File
@@ -94,7 +94,9 @@ class OfferController extends AbstractController
return $this->redirectToOfferPage($request, $uuid);
}
if (!$booking->isInquiry() || !$booking->isOpen()) {
// Offen is the one status that means an offer is out and awaiting acceptance, so it
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
if (!$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
@@ -120,12 +122,13 @@ class OfferController extends AbstractController
}
/**
* A draft is not ready to be shown, and a discarded booking must not be viewable
* any more — in both cases the link behaves as if it had expired.
* A draft and an inquiry the office has not worked through yet are not ready to be
* shown, and a discarded booking must not be viewable any more — in all three cases
* the link behaves as if it had expired.
*/
private function isCustomerVisible(AccommodationBooking $booking): bool
{
return !$booking->isDraft() && !$booking->isDiscarded();
return !$booking->isDraft() && !$booking->isRequested() && !$booking->isDiscarded();
}
/**
+1 -1
View File
@@ -153,7 +153,7 @@ class Step4Controller extends AbstractAccommodationController
{
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services);
$this->addFlash('groups_booking_result', $booking->getType()->value);
$this->addFlash('groups_booking_result', $booking->getOrigin()->value);
$this->sessionManager->clear($request);
}
}
+37 -20
View File
@@ -9,8 +9,8 @@ use App\Entity\BlameableEntityInterface;
use App\Entity\TimestampableEntity;
use App\Entity\TimestampableEntityInterface;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Enum\Groups\AdditionalServiceType;
use App\Repository\Groups\AccommodationBookingRepository;
use Doctrine\DBAL\Types\Types;
@@ -81,12 +81,12 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
private AccommodationBookingStatus $status = AccommodationBookingStatus::Draft;
/**
* What kind of record this is — an offer the customer still has to accept, or a
* directly placed, binding booking. Independent of the status: an accepted offer
* keeps its Inquiry type, which is how offer-originated bookings stay recognisable.
* How this record came about — through an offer the customer had to accept, or as a
* booking the customer placed directly. Orthogonal to the status and never changed
* after creation, so it stays truthful at every point of the lifecycle.
*/
#[ORM\Column(length: 20, enumType: AccommodationBookingType::class)]
private AccommodationBookingType $type = AccommodationBookingType::Inquiry;
#[ORM\Column(length: 20, enumType: AccommodationBookingOrigin::class)]
private AccommodationBookingOrigin $origin = AccommodationBookingOrigin::Offer;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $accessLinkIssuedAt = null;
@@ -118,14 +118,15 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
private ?string $lastName = null;
/**
* The `offer` group covers the one field a customer-facing booking cannot do without:
* the access link and every notification are addressed to it. It is enforced on
* creation as soon as the booking is not a draft, while `edit` additionally demands
* the rest of the contact data.
* The one field a customer-facing booking cannot do without — the access link and every
* notification are addressed to it. A record on its way to the customer is validated with
* `edit`, which additionally demands the rest of the contact data; the actions that
* actually reach out (SendOfferController, ConfirmController) check the address again on
* their own, since a draft may legitimately be saved without one.
*/
#[ORM\Column(length: 255, nullable: true)]
#[Assert\NotBlank(groups: ['edit', 'offer'])]
#[Assert\Email(groups: ['edit', 'offer'])]
#[Assert\NotBlank(groups: ['edit'])]
#[Assert\Email(groups: ['edit'])]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
@@ -376,6 +377,11 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return AccommodationBookingStatus::Draft === $this->status;
}
public function isRequested(): bool
{
return AccommodationBookingStatus::Requested === $this->status;
}
public function isOpen(): bool
{
return AccommodationBookingStatus::Open === $this->status;
@@ -396,26 +402,37 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return AccommodationBookingStatus::Discarded === $this->status;
}
public function getType(): AccommodationBookingType
public function getOrigin(): AccommodationBookingOrigin
{
return $this->type;
return $this->origin;
}
public function setType(AccommodationBookingType $type): self
public function setOrigin(AccommodationBookingOrigin $origin): self
{
$this->type = $type;
$this->origin = $origin;
return $this;
}
public function isInquiry(): bool
public function isFromOffer(): bool
{
return AccommodationBookingType::Inquiry === $this->type;
return AccommodationBookingOrigin::Offer === $this->origin;
}
public function isBooking(): bool
public function isDirectBooking(): bool
{
return AccommodationBookingType::Booking === $this->type;
return AccommodationBookingOrigin::Direct === $this->origin;
}
/**
* What to call this record wherever it is named next to its status. An offer becomes a
* booking the moment the customer commits, which is exactly what acceptedAt records — so
* this can never contradict the status the way a stored type could. The origin is a
* separate question ("where did this come from") and keeps its own label.
*/
public function recordLabel(): string
{
return null !== $this->acceptedAt ? 'Buchung' : 'Angebot';
}
public function getAccessLinkIssuedAt(): ?\DateTimeImmutable
@@ -0,0 +1,23 @@
<?php
namespace App\Enum\Groups;
/**
* How a booking record came about — deliberately orthogonal to the status, which
* carries the lifecycle. The origin is set once when the record is created and
* never changes afterwards, so an accepted offer stays recognisable as one without
* the origin ever contradicting the status it has reached.
*/
enum AccommodationBookingOrigin: string
{
case Offer = 'offer';
case Direct = 'direct';
public function label(): string
{
return match ($this) {
self::Offer => 'Angebot',
self::Direct => 'Direktbuchung',
};
}
}
@@ -2,9 +2,17 @@
namespace App\Enum\Groups;
/**
* The single lifecycle of a booking record. Every case names exactly one state and
* exactly one party it is waiting on: Angefragt and Entwurf wait on the office to
* prepare an offer, Offen waits on the customer to accept it, Eingegangen waits on
* the office to confirm. Where the record came from is not encoded here — that is
* what AccommodationBookingOrigin is for.
*/
enum AccommodationBookingStatus: string
{
case Draft = 'draft';
case Requested = 'requested';
case Open = 'open';
case Received = 'received';
case Confirmed = 'confirmed';
@@ -14,6 +22,7 @@ enum AccommodationBookingStatus: string
{
return match ($this) {
self::Draft => 'Entwurf',
self::Requested => 'Angefragt',
self::Open => 'Offen',
self::Received => 'Eingegangen',
self::Confirmed => 'Bestätigt',
@@ -1,17 +0,0 @@
<?php
namespace App\Enum\Groups;
enum AccommodationBookingType: string
{
case Inquiry = 'inquiry';
case Booking = 'booking';
public function label(): string
{
return match ($this) {
self::Inquiry => 'Anfrage',
self::Booking => 'Buchung',
};
}
}
@@ -6,8 +6,8 @@ namespace App\Form\Admin\Filter;
use App\Entity\Groups\Accommodation;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Form\Model\Filter\AccommodationBookingFilterDto;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -30,10 +30,10 @@ class AccommodationBookingFilterType extends AbstractListFilterType
'expanded' => true,
'required' => false,
]],
'type' => [EnumType::class, [
'label' => 'Art',
'class' => AccommodationBookingType::class,
'choice_label' => static fn (AccommodationBookingType $type) => $type->label(),
'origin' => [EnumType::class, [
'label' => 'Herkunft',
'class' => AccommodationBookingOrigin::class,
'choice_label' => static fn (AccommodationBookingOrigin $origin) => $origin->label(),
'multiple' => true,
'expanded' => true,
'required' => false,
@@ -6,15 +6,12 @@ namespace App\Form\Admin\Groups;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -50,17 +47,9 @@ class AccommodationBookingCreateType extends AbstractType
->add('childrenCount', IntegerType::class, [
'label' => 'davon Kinder',
])
->add('status', EnumType::class, [
'label' => 'Status',
'class' => AccommodationBookingStatus::class,
'choices' => [
AccommodationBookingStatus::Draft,
AccommodationBookingStatus::Open,
],
'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(),
])
// Not marked required in HTML: a draft may be saved without it, and the
// validation group below enforces it for everything else.
// Not marked required in HTML: a new record is always an Entwurf, which is a
// scratch record nobody is pointed at yet. The address is enforced when the
// offer is actually sent — see SendOfferController.
->add('email', EmailType::class, [
'label' => 'E-Mail',
'required' => false,
@@ -73,15 +62,10 @@ class AccommodationBookingCreateType extends AbstractType
{
$resolver->setDefaults([
'data_class' => AccommodationBooking::class,
// A draft is a scratch record, but anything the customer can be pointed at
// needs a reachable address — see the `offer` group on the entity. The rest of
// the contact data is collected on the edit page, which validates `edit`.
'validation_groups' => static function (FormInterface $form): array {
/** @var AccommodationBooking $booking */
$booking = $form->getData();
return $booking->isDraft() ? ['Default'] : ['Default', 'offer'];
},
// Creation always yields an Entwurf, a scratch record the office fills in over
// time, so nothing beyond the basics is required here. The contact data is
// collected on the edit page, which validates `edit`.
'validation_groups' => ['Default'],
]);
}
}
@@ -8,13 +8,11 @@ use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingStatus;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -137,11 +135,8 @@ class AccommodationBookingType extends AbstractType
}
$builder
->add('status', EnumType::class, [
'label' => 'Status',
'class' => AccommodationBookingStatus::class,
'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(),
])
// No status field: the status is only ever moved by a named action, so that it
// can never end up set without the timestamp and email that belong to it.
->add('accommodationDiscount', IntegerType::class, [
'label' => 'Rabatt Unterkunft (%)',
'required' => false,
@@ -175,9 +170,9 @@ class AccommodationBookingType extends AbstractType
$resolver->setDefaults([
'data_class' => AccommodationBooking::class,
// Entwurf is a scratch record the office fills in over time, and an Absage is
// never going anywhere — neither needs complete contact data. The groups are
// resolved after binding, so promoting a draft to Anfrage or Buchung enforces
// the contact data in the very same save.
// never going anywhere — neither needs complete contact data. Everything else,
// Angefragt included, has already been in contact with the customer and is
// expected to carry the full set.
'validation_groups' => static function (FormInterface $form): array {
/** @var AccommodationBooking $booking */
$booking = $form->getData();
-98
View File
@@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\User;
use App\Security\Role;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Lets an administrator assign the privileged roles and the hotel codes of an existing account.
*
* Only Role::PRIVILEGED is offered: the remaining roles are synced from the BusPro CRM on every
* login (see BpnAuthenticator), so editing them here would be undone at the user's next login.
* Hotel codes are seeded once at account creation, which makes this form the only way to change
* them afterwards.
*
* @extends AbstractType<User>
*/
class UserType extends AbstractType
{
/**
* @param array<string, string> $hotelCodes code => label
*/
public function __construct(private readonly array $hotelCodes = [])
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$user = $builder->getData();
$builder
->add('roles', ChoiceType::class, [
'label' => 'Rollen',
'choices' => $this->privilegedChoices(),
'multiple' => true,
'expanded' => true,
'required' => false,
'help' => 'Alle übrigen Rollen kommen bei jeder Anmeldung aus BusPro und lassen sich hier nicht ändern.',
// Only the administrator-granted half is editable; the synced half is preserved,
// as is the implicit ROLE_USER, which must never be written back.
'getter' => static fn (User $user): array => Role::privilegedOnly($user->getRoles()),
'setter' => static function (User $user, array $roles): void {
$user->setRoles(Role::combine($user->getRoles(), $roles));
},
])
->add('hotelCodes', ChoiceType::class, [
'label' => 'Häuser',
'choices' => $this->hotelCodeChoices($user),
'multiple' => true,
'expanded' => true,
'required' => false,
'setter' => static function (User $user, array $hotelCodes): void {
$user->setHotelCodes(array_values(array_unique($hotelCodes)));
},
])
;
}
/**
* @return array<string, string> label => role
*/
private function privilegedChoices(): array
{
return array_flip(array_intersect_key(Role::labels(), array_flip(Role::PRIVILEGED)));
}
/**
* Codes already stored on the account are always offered, even when they are missing from
* the configured catalog — otherwise saving the form would silently drop them.
*
* @return array<string, string> label => code
*/
private function hotelCodeChoices(?User $user): array
{
$codes = $this->hotelCodes;
foreach ($user?->getHotelCodes() ?? [] as $code) {
$codes[$code] ??= $code;
}
ksort($codes);
return array_flip($codes);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}
@@ -6,8 +6,8 @@ namespace App\Form\Model\Filter;
use App\Entity\Groups\Accommodation;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Model\ListFilterChip;
class AccommodationBookingFilterDto extends AbstractListFilterDto
@@ -15,8 +15,8 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto
/** @var AccommodationBookingStatus[] */
public array $status = [];
/** @var AccommodationBookingType[] */
public array $type = [];
/** @var AccommodationBookingOrigin[] */
public array $origin = [];
public ?User $managedBy = null;
@@ -38,15 +38,17 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto
/**
* The list as it presents itself to someone who has not filtered yet.
*
* The still-live statuses — drafts, open bookings and bookings awaiting validation, i.e.
* everything that may still need work — nothing whose stay is already over, and, for
* staff who are not group admins, only the bookings they are responsible for.
* The still-live statuses — drafts, inquiries awaiting an offer, offers out with the
* customer and bookings awaiting validation, i.e. everything that may still need work —
* nothing whose stay is already over, and, for staff who are not group admins, only the
* bookings they are responsible for.
*/
public static function defaults(bool $seesAllBookings, ?User $currentUser): self
{
$filter = new self();
$filter->status = [
AccommodationBookingStatus::Draft,
AccommodationBookingStatus::Requested,
AccommodationBookingStatus::Open,
AccommodationBookingStatus::Received,
];
@@ -72,11 +74,11 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto
);
}
if ([] !== $this->type) {
if ([] !== $this->origin) {
$chips[] = new ListFilterChip(
'Art',
implode(', ', array_map(static fn (AccommodationBookingType $t) => $t->label(), $this->type)),
['type'],
'Herkunft',
implode(', ', array_map(static fn (AccommodationBookingOrigin $o) => $o->label(), $this->origin)),
['origin'],
);
}
+25 -15
View File
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Logger;
use App\Entity\LogEntry;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\LogRecord;
@@ -13,30 +13,40 @@ use Monolog\LogRecord;
/**
* Monolog handler that persists log entries to the database.
*
* Writes log records to the LogEntry entity, replacing message placeholders
* Writes log records to the log_entry table, replacing message placeholders
* with context values. Includes channel, context, and extra data for auditing.
* Only processes INFO level and above to avoid storing debug messages.
*
* The write goes through the DBAL connection rather than the EntityManager on purpose. A log record
* can be emitted at any point, including halfway through somebody else's unit of work, and an
* EntityManager flush would write out whatever that unit of work has pending so far — which is how
* a half-built User once reached the database with a NULL password. Staying out of the UnitOfWork
* also keeps logging alive after a failed flush has closed the EntityManager, which is exactly when
* there is something worth logging.
*
* Reads still go through the LogEntry entity and its repository.
*/
class DatabaseHandler extends AbstractProcessingHandler
{
public function __construct(private readonly EntityManagerInterface $entityManager)
public function __construct(private readonly Connection $connection)
{
parent::__construct(Level::Info);
}
protected function write(LogRecord $record): void
{
$message = $this->replacePlaceHolder($record);
$logEntry = new LogEntry($record->channel, $message);
$logEntry
->setContext($record->context)
->setExtra($record->extra)
->setErrorCode($record->extra['error_code'] ?? null)
;
$this->entityManager->persist($logEntry);
$this->entityManager->flush();
$this->connection->insert('log_entry', [
'channel' => $record->channel,
'message' => $this->replacePlaceHolder($record),
'context' => $record->context,
'extra' => $record->extra,
'error_code' => $record->extra['error_code'] ?? null,
'created_at' => new \DateTimeImmutable('now'),
], [
'context' => Types::JSON,
'extra' => Types::JSON,
'created_at' => Types::DATETIME_IMMUTABLE,
]);
}
private function replacePlaceHolder(LogRecord $record): string
@@ -95,7 +95,7 @@ final readonly class AccommodationBookingApiResponse
{
$this->uuid = $booking->getUuid();
$this->status = $booking->getStatus()->value;
$this->type = $booking->getType()->value;
$this->type = $booking->getOrigin()->value;
$this->dateFrom = $booking->getDateFrom();
$this->dateTo = $booking->getDateTo();
$this->nights = $booking->getNights();
@@ -84,8 +84,8 @@ class AccommodationBookingRepository extends ServiceEntityRepository
$qb->andWhere('booking.status IN (:status)')->setParameter('status', $filter->status);
}
if ([] !== $filter->type) {
$qb->andWhere('booking.type IN (:type)')->setParameter('type', $filter->type);
if ([] !== $filter->origin) {
$qb->andWhere('booking.origin IN (:origin)')->setParameter('origin', $filter->origin);
}
if ($filter->unassigned) {
+45 -42
View File
@@ -8,6 +8,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
@@ -34,13 +35,11 @@ use Symfony\Component\Security\Http\Util\TargetPathTrait;
* Validates credentials via BPN's getPersonalData endpoint and creates or updates
* local User entities. Passwords are stored encrypted with RSA for subsequent API calls.
*
* Roles have two owners. The non-privileged ones mirror the CRM selections on every login, in
* both directions, so somebody who becomes (or stops being) a Teamer in BusPro is granted (or
* loses) ROLE_TEAMER here — the sibling app myep-team gates on it. Role::PRIVILEGED is never
* imported: BusPro backend users can edit their own CRM selections, so honouring those would
* let anybody make themselves an administrator; they are granted in /admin/user only.
*
* Hotel codes still seed a *new* account only and are managed in /admin/user afterwards.
* BusPro owns the whole role set and the hotel codes: both are synced on every login, in both
* directions, so anything the CRM no longer reports is withdrawn here. What the CRM claims is
* not automatically granted, though — Role::sync() turns an administrative claim into a
* nomination that an administrator has to approve in /admin/user, because BusPro backend users
* can edit their own CRM selections and would otherwise make themselves administrators.
*/
class BpnAuthenticator extends AbstractLoginFormAuthenticator implements AuthenticationEntryPointInterface
{
@@ -107,15 +106,9 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
$userRepository = $this->entityManager->getRepository(User::class);
if (null === $user = $userRepository->findOneBy(['email' => $email])) {
$user = new User($email);
$user->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)));
$this->entityManager->persist($user);
}
$user = $userRepository->findOneBy(['email' => $email]) ?? new User($email);
$user
->setRoles($this->syncedRoles($email, $user->getRoles(), $crmAttributes->roles))
->setPassword($encryptedPassword)
->setPersonId($personalData->personId)
->setAddressId($personalData->addressId)
@@ -125,46 +118,56 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
->setProfileComplete($this->completenessChecker->isComplete($personalData))
;
$this->syncFromCrm($user, $crmAttributes);
// Registered only once it is fully populated: syncFromCrm() logs on a channel that writes
// to the database, and an account already managed at that point would be flushed
// half-built — which is how a NULL password used to reach the user table. A no-op for an
// account that came from the repository.
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
/**
* Merges the two halves of the role set: the non-privileged roles the BusPro CRM currently
* reports, and the privileged ones an administrator granted here. Anything the CRM no
* longer reports is dropped, so revoking a selection there revokes it here too.
*
* @param string[] $storedRoles
* @param string[] $crmRoles
*
* @return string[]
* Writes back what the CRM currently claims: the roles per Role::sync() and the hotel codes
* verbatim. Both replace what is stored, which is what makes BusPro the source of truth.
*/
private function syncedRoles(string $email, array $storedRoles, array $crmRoles): array
private function syncFromCrm(User $user, CrmAttributes $crmAttributes): void
{
return Role::combine($this->importableRoles($email, $crmRoles), $storedRoles);
}
$previousRoles = $user->getRoles();
/**
* @param string[] $crmRoles
*
* @return string[]
*/
private function importableRoles(string $email, array $crmRoles): array
{
$roles = Role::filterImportable($crmRoles);
$dropped = array_values(array_intersect($crmRoles, Role::PRIVILEGED));
if ([] !== $dropped) {
// Somebody holds a privileged CRM selection in BusPro. We do not honour it, but it
// should stay visible: it either needs to be revoked there or granted in /admin/user.
$this->authLogger->warning('Ignored privileged roles from BPN CRM attributes', [
'email' => $email,
'roles' => $dropped,
if ([] === $crmAttributes->selectionGroups) {
// BusPro always answers with the full attribute tree and expresses membership through
// the `auswahl` flag, so an empty one is a degraded payload rather than a revocation.
// Syncing it would strip the roles of every user who logs in.
$this->authLogger->warning('Skipped the role sync: the BPN CRM response carries no selection groups', [
'email' => $user->getEmail(),
]);
// An existing account keeps everything it has. A brand new one still needs a role,
// and an empty claim set is exactly what Role::sync() answers with the fallback.
if ([] !== Role::assignedOnly($previousRoles)) {
return;
}
}
return $roles;
$user
->setRoles(Role::sync($previousRoles, $crmAttributes->roles))
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
;
$nominated = array_diff(Role::pendingOnly($user->getRoles()), Role::pendingOnly($previousRoles));
if ([] !== $nominated) {
// The CRM claims an administrative role for somebody who does not hold it. It grants
// nothing until an administrator approves it in /admin/user.
$this->authLogger->info('Nominated for administrative roles by the BPN CRM', [
'email' => $user->getEmail(),
'roles' => array_values($nominated),
]);
}
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
+174 -30
View File
@@ -5,11 +5,17 @@ declare(strict_types=1);
namespace App\Security;
/**
* The roles this application knows about.
* The roles this application knows about, and the policy that assigns them.
*
* Roles are stored as plain strings in the User::$roles JSON column and consumed as strings
* by #[IsGranted], the voters and security.yaml's role_hierarchy, so they are constants
* rather than an enum.
*
* The governing rule: the BusPro CRM is the source of truth. It may nominate, but never grant,
* an administrative role — a nomination is stored as a marker (ROLE_X_PENDING) that grants
* nothing until an administrator approves it in /admin/user. The CRM's word alone is enough to
* take a role away, never to hand it out, and an administrator's word alone is enough for
* neither.
*/
final class Role
{
@@ -27,6 +33,14 @@ final class Role
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
/**
* Appended to an administrative role to mark it as claimed by the CRM but not yet approved.
* Markers live in the same column as the real roles and are handed to Symfony along with
* them, but nothing references them: no access_control rule, no role_hierarchy entry and no
* voter. Always ask effectiveOnly() when the question is what an account may actually do.
*/
public const PENDING_SUFFIX = '_PENDING';
/**
* The roles that are actually assigned to accounts. ROLE_USER is left out because every
* account has it implicitly (see User::getRoles()) and it is never stored.
@@ -44,37 +58,54 @@ final class Role
];
/**
* Roles that are never taken over from BusProNet. Many people can edit CRM selections in
* the BusPro backend, so these are granted by an administrator in /admin/user only.
* Roles the CRM grants outright. ROLE_CUSTOMER is never claimed by BusPro — it is the
* fallback for an account left without any effective role, and exclusive with the others.
*
* @var string[]
*/
public const PRIVILEGED = [
public const UNCONDITIONAL = [
self::TEAMER,
self::CUSTOMER,
];
/**
* Roles the CRM only nominates for. Many people can edit CRM selections in the BusPro
* backend, so honouring these directly would let anybody make themselves an administrator.
*
* @var string[]
*/
public const ADMINISTRATIVE = [
self::ADMIN,
self::MANAGER,
self::HOUSE_MANAGER,
self::GROUPS_ADMIN,
self::GROUPS_MANAGER,
];
/**
* Reduces the roles derived from BPN CRM attributes to the ones we accept from there.
* Roles nobody may approve for their own account. ROLE_ADMIN outranks every check in this
* application, including the approval surface itself, so it always takes a second
* administrator. The remaining administrative roles grant less than what an approver
* already holds, so requiring a second pair of eyes for them would only lock out the
* single-administrator case for no gain.
*
* Falls back to ROLE_CUSTOMER the way CrmAttributesResponseParser does, so an account
* whose only selection was a privileged one does not end up without any role.
*
* @param string[] $roles
*
* @return string[]
* @var string[]
*/
public static function filterImportable(array $roles): array
{
$importable = array_values(array_unique(array_diff($roles, self::PRIVILEGED)));
public const SELF_APPROVAL_FORBIDDEN = [
self::ADMIN,
];
return [] === $importable ? [self::CUSTOMER] : $importable;
/**
* The marker standing for a role that the CRM claims but nobody has approved.
*/
public static function pending(string $role): string
{
return $role.self::PENDING_SUFFIX;
}
/**
* The roles actually assigned to an account — everything except the implicit ROLE_USER,
* which User::getRoles() prepends and which is never stored.
* The roles actually stored on an account — everything except the implicit ROLE_USER,
* which User::getRoles() prepends and which is never stored. Markers included.
*
* @param string[] $roles
*
@@ -86,41 +117,148 @@ final class Role
}
/**
* The non-privileged half of a role set: what BpnAuthenticator syncs from the BusPro CRM.
* The roles that actually grant something: no ROLE_USER, no markers.
*
* @param string[] $roles
*
* @return string[]
*/
public static function syncedOnly(array $roles): array
public static function effectiveOnly(array $roles): array
{
return array_values(array_diff(self::assignedOnly($roles), self::PRIVILEGED));
return array_values(array_filter(
self::assignedOnly($roles),
static fn (string $role): bool => false === self::isPending($role),
));
}
/**
* The privileged half: what an administrator granted in /admin/user.
* The markers on an account: administrative roles the CRM claims, awaiting approval.
*
* @param string[] $roles
*
* @return string[]
*/
public static function privilegedOnly(array $roles): array
public static function pendingOnly(array $roles): array
{
return array_values(array_intersect($roles, self::PRIVILEGED));
return array_values(array_filter($roles, static fn (string $role): bool => self::isPending($role)));
}
/**
* Reassembles a complete role set from its two owners. Both sides are filtered, so a
* privileged role can never arrive through the synced half and vice versa.
* The roles behind those markers, labelled — what an approver acts on.
*
* @param string[] $synced
* @param string[] $privileged
* @param string[] $roles
*
* @return array<string, string> role => label
*/
public static function nominatedFrom(array $roles): array
{
$labels = self::labels();
$nominated = [];
foreach (self::pendingOnly($roles) as $marker) {
$role = self::realRole($marker);
if (\in_array($role, self::ADMINISTRATIVE, true)) {
$nominated[$role] = $labels[$role];
}
}
return $nominated;
}
/**
* The whole policy, applied on every login.
*
* 1. revoke everything the CRM no longer claims — granted roles and markers alike, which is
* what makes BusPro the source of truth;
* 2. grant the unconditional roles it claims;
* 3. mark every administrative role it claims that is not granted already. This runs after
* the revocation, so a role just revoked is not immediately marked again, and an approved
* role is never marked a second time;
* 4. fall back to ROLE_CUSTOMER when nothing effective is left.
*
* Nothing here can raise a privilege: step 3 only ever produces markers.
*
* @param string[] $storedRoles
* @param string[] $claimedRoles what the CRM reports
*
* @return string[]
*/
public static function combine(array $synced, array $privileged): array
public static function sync(array $storedRoles, array $claimedRoles): array
{
return array_values(array_unique([...self::syncedOnly($synced), ...self::privilegedOnly($privileged)]));
$claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles)));
$roles = array_values(array_filter(
self::assignedOnly($storedRoles),
static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true),
));
foreach (array_intersect($claimed, self::UNCONDITIONAL) as $role) {
$roles[] = $role;
}
foreach (array_intersect($claimed, self::ADMINISTRATIVE) as $role) {
if (false === \in_array($role, $roles, true)) {
$roles[] = self::pending($role);
}
}
return self::withCustomerFallback(array_values(array_unique($roles)));
}
/**
* Turns a marker into the role it stands for. Refuses anything the account is not nominated
* for, so neither a hand-crafted request nor a claim revoked while the confirmation dialog
* was open can grant a role the CRM never reported.
*
* @param string[] $storedRoles
*
* @return string[]
*
* @throws \InvalidArgumentException when $role is not pending on this account
*/
public static function approve(array $storedRoles, string $role): array
{
$roles = self::assignedOnly($storedRoles);
if (false === \in_array(self::pending($role), $roles, true)) {
throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role));
}
$roles = array_diff($roles, [self::pending($role)]);
$roles[] = $role;
return self::withCustomerFallback(array_values(array_unique($roles)));
}
/**
* Everybody without an effective role is a customer, and nobody with one is. The fallback is
* a fallback, not a baseline — ROLE_CUSTOMER and ROLE_TEAMER are mutually exclusive on
* purpose.
*
* @param string[] $roles
*
* @return string[]
*/
private static function withCustomerFallback(array $roles): array
{
if ([] === array_diff(self::effectiveOnly($roles), [self::CUSTOMER])) {
return array_values(array_unique([...$roles, self::CUSTOMER]));
}
return array_values(array_diff($roles, [self::CUSTOMER]));
}
private static function isPending(string $role): bool
{
return str_ends_with($role, self::PENDING_SUFFIX);
}
private static function realRole(string $role): string
{
return self::isPending($role)
? substr($role, 0, -\strlen(self::PENDING_SUFFIX))
: $role;
}
/**
@@ -128,7 +266,7 @@ final class Role
*/
public static function labels(): array
{
return [
$labels = [
self::ADMIN => 'Administration',
self::MANAGER => 'Manager:in',
self::TEAMER => 'Teamer:in',
@@ -137,5 +275,11 @@ final class Role
self::GROUPS_ADMIN => 'Preisrechner Admin',
self::GROUPS_MANAGER => 'Preisrechner',
];
foreach (self::ADMINISTRATIVE as $role) {
$labels[self::pending($role)] = $labels[$role].' (nicht freigeschaltet)';
}
return $labels;
}
}
@@ -98,7 +98,8 @@ class AccommodationBookingPdfGenerator
$groupName = (new AsciiSlugger('de'))->slug((string) $booking->getGroupName())->lower()->toString();
return sprintf(
'buchung-%s%s.pdf',
'%s-%s%s.pdf',
mb_strtolower($booking->recordLabel()),
$booking->getDateFrom()?->format('Y-m-d') ?? 'ohne-datum',
'' !== $groupName ? '-'.$groupName : ''
);
+56 -17
View File
@@ -11,8 +11,8 @@ use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingQueryParams;
use App\Model\CmsHotelData;
@@ -268,12 +268,12 @@ class AccommodationBookingService
$booking->setPaxCount($dto->paxCount);
$booking->setMinorsCount($dto->minorsCount);
$booking->setChildrenCount($dto->childrenCount);
// An inquiry arrives live — the office has nothing to prepare first, so it goes
// straight to Offen. A direct booking has been committed to by the customer but
// still awaits the office's validation, hence Eingegangen rather than Bestätigt.
// An inquiry still needs an offer the office has to prepare, so it waits at Angefragt
// until that offer goes out. A direct booking has been committed to by the customer
// but still awaits the office's validation, hence Eingegangen rather than Bestätigt.
$isInquiry = $dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry;
$booking->setType($isInquiry ? AccommodationBookingType::Inquiry : AccommodationBookingType::Booking);
$booking->setStatus($isInquiry ? AccommodationBookingStatus::Open : AccommodationBookingStatus::Received);
$booking->setOrigin($isInquiry ? AccommodationBookingOrigin::Offer : AccommodationBookingOrigin::Direct);
$booking->setStatus($isInquiry ? AccommodationBookingStatus::Requested : AccommodationBookingStatus::Received);
if (!$isInquiry) {
$booking->setAcceptedAt(new \DateTimeImmutable());
}
@@ -342,7 +342,7 @@ class AccommodationBookingService
// A direct booking gets no customer mail here: it only reaches the customer once the
// office has validated it and confirmed via confirmBooking(). Acknowledging an
// inquiry is a different matter — it promises nothing, so it still goes out.
if ($booking->isInquiry()) {
if ($booking->isFromOffer()) {
$this->sendCustomerConfirmationEmail($booking);
}
@@ -375,7 +375,7 @@ class AccommodationBookingService
$this->accommodationEmail,
sprintf(
'Neue Unterkunfts%s: %s',
$booking->isInquiry() ? 'anfrage' : 'buchung',
$booking->isDirectBooking() ? 'buchung' : 'anfrage',
$booking->getAccommodation()?->getName(),
),
'email/accommodation_booking.html.twig',
@@ -457,7 +457,7 @@ class AccommodationBookingService
*/
public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void
{
if (!$booking->isBooking()) {
if (!$booking->isDirectBooking()) {
return;
}
@@ -465,13 +465,14 @@ class AccommodationBookingService
}
/**
* Always sends a confirmation email to the customer, whether or not an access link
* exists yet — the template renders differently depending on accessLink being present,
* so the subject has to follow the same distinction.
* Serves both customer-facing messages of the pre-booking phase: the acknowledgement
* that an inquiry arrived, and the offer itself. Offen is the status that means the
* offer is out, so it is what decides the subject — the template makes the same
* distinction via accessLink, which is always issued as the offer goes out.
*/
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
{
$subject = null !== $booking->getAccessLinkIssuedAt()
$subject = $booking->isOpen()
? 'Dein Angebot ist bereit'
: 'Deine Anfrage ist bei uns eingegangen';
@@ -496,9 +497,45 @@ class AccommodationBookingService
}
/**
* Accepts an open offer and notifies the office. The type stays Anfrage —
* only the status moves to Eingegangen, so an offer-originated booking remains
* distinguishable from a direct one.
* Publishes the prepared offer: the single action that puts an offer in front of the
* customer, and the only transition into Offen. Issues the access link the email links
* to, so the two can never drift apart the way they could when sending was a side
* effect of editing.
* Idempotent — a no-op (including no email) for anything that is not awaiting an offer.
*/
public function sendOffer(AccommodationBooking $booking): void
{
if (!$booking->isDraft() && !$booking->isRequested()) {
return;
}
$this->issueAccessLink($booking);
$booking->setStatus(AccommodationBookingStatus::Open);
$this->entityManager->flush();
$this->sendCustomerConfirmationEmail($booking);
}
/**
* Closes a booking that is not going to happen. Deliberately silent: the office tells
* the customer itself, so this only records the outcome.
* Idempotent — a no-op for a booking that is already confirmed or discarded, since a
* released confirmation is binding and must not be withdrawn behind the customer's back.
*/
public function discardBooking(AccommodationBooking $booking): void
{
if ($booking->isConfirmed() || $booking->isDiscarded()) {
return;
}
$booking->setStatus(AccommodationBookingStatus::Discarded);
$this->entityManager->flush();
}
/**
* Accepts an offer that is out with the customer and notifies the office. The origin
* stays untouched, so the booking remains recognisable as offer-originated for the
* rest of its life; only the status moves on to Eingegangen.
* Idempotent — a no-op (including no emails) if the offer is not open any more.
*
* The customer deliberately gets no mail here: nothing is confirmed until the office
@@ -509,7 +546,9 @@ class AccommodationBookingService
*/
public function acceptBooking(AccommodationBooking $booking, ?string $remarks = null): void
{
if (!$booking->isInquiry() || !$booking->isOpen()) {
// Offen means exactly one thing — an offer is out and awaiting this acceptance —
// so the status alone is the whole guard.
if (!$booking->isOpen()) {
return;
}
+2 -1
View File
@@ -27,7 +27,8 @@ class AppExtension extends AbstractExtension
new TwigFilter('map_status', [AppRuntime::class, 'mapStatus']),
new TwigFilter('map_country', [AppRuntime::class, 'mapCountry']),
new TwigFilter('map_nationality', [AppRuntime::class, 'mapNationality']),
new TwigFilter('map_roles', [AppRuntime::class, 'mapRoles']),
new TwigFilter('effective_roles', [AppRuntime::class, 'effectiveRoles']),
new TwigFilter('nominated_roles', [AppRuntime::class, 'nominatedRoles']),
];
}
+28 -6
View File
@@ -126,22 +126,44 @@ class AppRuntime implements RuntimeExtensionInterface
}
/**
* Turns the roles stored on a User into the labels the edit form uses.
*
* ROLE_USER is dropped because every account holds it implicitly; a role without a label
* is passed through unchanged so it stays visible instead of silently disappearing.
* The labels of the roles an account actually holds — no ROLE_USER, no nominations.
*
* @param string[] $roles
*
* @return string[]
*/
public function mapRoles(array $roles): array
public function effectiveRoles(array $roles): array
{
return $this->roleLabels(Role::effectiveOnly($roles));
}
/**
* The labels of the roles the BusPro CRM claims for an account but nobody has approved.
*
* @param string[] $roles
*
* @return string[]
*/
public function nominatedRoles(array $roles): array
{
return array_values(Role::nominatedFrom($roles));
}
/**
* A role without a label is passed through unchanged so it stays visible instead of
* silently disappearing.
*
* @param string[] $roles
*
* @return string[]
*/
private function roleLabels(array $roles): array
{
$labels = Role::labels();
$mapped = [];
foreach (Role::assignedOnly($roles) as $role) {
foreach ($roles as $role) {
$mapped[] = $labels[$role] ?? $role;
}
@@ -1,5 +1 @@
{#- Offen covers two situations that need different office action, told apart by the access
link: no link yet means the offer still has to be prepared, a link means the customer is
the one who has to act. Display only — both are the same status. -#}
{{- booking.status.label -}}
{%- if booking.open %} · {{ booking.accessLinkIssuedAt is not null ? 'Angebot versendet' : 'Angebot ausstehend' }}{% endif -%}
@@ -1,7 +1,7 @@
{% extends 'layout_admin.html.twig' %}
{% block content %}
<twig:page:heading>Buchung {{ booking.groupName }}</twig:page:heading>
<twig:page:heading>{{ booking.recordLabel }} {{ booking.groupName }}</twig:page:heading>
<p class="text-sm text-gray-500 mb-6">{{ booking.accommodation.name }}</p>
{% form_theme form 'forms_admin.html.twig' %}
@@ -39,9 +39,13 @@
{% endif %}
<h2 class="lg:col-span-2 text-base font-bold pt-2">Status & Rabatt</h2>
<div class="lg:col-span-2">
{{ form_row(form.status) }}
</div>
{# Read-only: the status only ever moves through a named action on the detail page,
so that it cannot be set without the email and timestamp that belong to it. #}
<p class="lg:col-span-2 text-sm">
<span class="font-medium">Status:</span>
{% include 'admin/accommodation_booking/_status_label.html.twig' %}
<span class="text-gray-500">· Herkunft: {{ booking.origin.label }}</span>
</p>
{% if booking.acceptedAt is not null %}
<p class="lg:col-span-2 text-xs text-gray-500">
Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }}
@@ -66,7 +66,7 @@
{% endif %}
</td>
<td>
{{ booking.type.label }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %}
{{ booking.recordLabel }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %}
</td>
<td>
{% set discounts = [] %}
@@ -3,7 +3,7 @@
{% block content %}
<div class="text-xl font-bold pb-4">
Neue Buchung
Neues Angebot
</div>
{{ form_start(form) }}
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
@@ -15,9 +15,6 @@
{{ form_row(form.paxCount) }}
{{ form_row(form.minorsCount) }}
{{ form_row(form.childrenCount) }}
<div class="lg:col-span-2">
{{ form_row(form.status) }}
</div>
<div class="lg:col-span-2">
{{ form_row(form.email) }}
</div>
@@ -0,0 +1,16 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}{{ booking.recordLabel }} absagen{% endblock %}
{% block content %}
<div>
Möchtest du {{ booking.acceptedAt is not null ? 'die Buchung' : 'das Angebot' }} für
<em>{{ booking.groupName }}</em> absagen?
{% if booking.open %}
Das versendete Angebot kann danach nicht mehr angenommen werden.
{% endif %}
Der Kunde wird nicht automatisch benachrichtigt.
</div>
{% endblock %}
{% block button_confirm %}Absagen{% endblock %}
@@ -7,7 +7,7 @@
{{ form_row(form.dateFrom) }}
{{ form_row(form.dateTo) }}
{{ form_row(form.status) }}
{{ form_row(form.type) }}
{{ form_row(form.origin) }}
<div class="lg:col-span-2">
{{ form_row(form.accommodation) }}
</div>
@@ -0,0 +1,12 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}Angebot senden{% endblock %}
{% block content %}
<div>
Möchtest du das Angebot für <em>{{ booking.groupName }}</em> an <em>{{ booking.email }}</em> senden?
Der Kunde kann es anschließend über den Zugangslink annehmen.
</div>
{% endblock %}
{% block button_confirm %}Angebot senden{% endblock %}
@@ -2,7 +2,7 @@
{% block content %}
<twig:page:heading>
{{ booking.type.label }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %}
{{ booking.recordLabel }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %}
{{ booking.groupName }}
</twig:page:heading>
@@ -53,8 +53,8 @@
<dt class="font-medium">davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} Jahre)</dt>
<dd>{{ booking.childrenCount }}</dd>
{% endif %}
<dt class="font-medium">Art</dt>
<dd>{{ booking.type.label }}</dd>
<dt class="font-medium">Herkunft</dt>
<dd>{{ booking.origin.label }}</dd>
<dt class="font-medium">Status</dt>
<dd>{% include 'admin/accommodation_booking/_status_label.html.twig' %}</dd>
{% if booking.acceptedAt is not null %}
@@ -177,6 +177,23 @@
{% endif %}
</div>
{% if booking.draft or booking.requested %}
<div class="mt-6 border-t border-gray-200 pt-6">
<h2 class="text-lg font-bold mb-2">Angebot</h2>
<p class="text-sm text-gray-500 mb-2">
Das Angebot wurde dem Kunden noch nicht zugestellt. Beim Versand wird der Zugangslink
erzeugt und die Buchung wartet anschließend auf die Annahme durch den Kunden.
</p>
<button type="button"
class="button button--primary button--small"
hx-get="{{ path('app_admin_accommodationbooking_send_offer', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Angebot per E-Mail senden
</button>
</div>
{% endif %}
{% if booking.received %}
<div class="mt-6 border-t border-gray-200 pt-6">
<h2 class="text-lg font-bold mb-2">Bestätigung</h2>
@@ -238,6 +255,15 @@
zurück
</a>
<div class="flex items-center gap-2">
{% if not booking.confirmed and not booking.discarded %}
<button type="button"
class="button button--warning button--small"
hx-get="{{ path('app_admin_accommodationbooking_discard', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Absagen
</button>
{% endif %}
{% if booking.confirmed %}
<a href="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}" class="button button--secondary button--small">
PDF herunterladen
+10 -3
View File
@@ -53,7 +53,14 @@
{{ user.personId | default('-') }}
</td>
<td>
{{ user.roles | map_roles | join(', ') | default('-') }}
{{ user.roles | effective_roles | join(', ') | default('-') }}
{% if user.roles | nominated_roles is not empty %}
<div class="flex flex-wrap gap-1 pt-1">
{% for label in user.roles | nominated_roles %}
<twig:badge variant="warning">{{ label }}</twig:badge>
{% endfor %}
</div>
{% endif %}
</td>
<td>
{{ user.hotelCodes | join(', ') | default('-') }}
@@ -63,8 +70,8 @@
</td>
<td class="text-right">
<twig:dropdown>
<twig:dropdown:hxbutton url="{{ path('app_admin_user_edit', { 'id': user.id, 'r': return_url() }) }}">
Berechtigungen bearbeiten
<twig:dropdown:hxbutton url="{{ path('app_admin_user_permissions', { 'id': user.id, 'r': return_url() }) }}">
Berechtigungen
</twig:dropdown:hxbutton>
</twig:dropdown>
</td>
@@ -0,0 +1,12 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}Rolle freischalten{% endblock %}
{% block content %}
<div>
Möchtest du <em>{{ user.email }}</em> die Rolle <em>{{ role_label }}</em> freischalten?
Die Rolle bleibt bestehen, solange BusPro sie meldet, und kann hier nicht wieder entzogen werden.
</div>
{% endblock %}
{% block button_confirm %}Freischalten{% endblock %}
-27
View File
@@ -1,27 +0,0 @@
{% extends 'htmx_modal_admin.html.twig' %}
{% form_theme form 'forms_admin.html.twig' %}
{% block title %}
Berechtigungen
{% endblock %}
{% block content %}
<div class="pb-4 text-sm text-gray-500">
{{ user.email }}
</div>
<div class="pb-4 text-sm text-gray-500">
aus BusPro: {{ syncedRoles | map_roles | join(', ') | default('–') }}
</div>
{{ form_start(form) }}
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
{{ form_row(form.roles) }}
{{ form_row(form.hotelCodes) }}
</div>
<div class="flex justify-end pt-8">
<button type="submit" class="button button--primary button--small">
speichern
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -0,0 +1,56 @@
{% extends 'htmx_modal_admin.html.twig' %}
{% block title %}
Berechtigungen
{% endblock %}
{% block content %}
<div class="pb-4 text-sm text-gray-500">
{{ user.email }}
</div>
<div class="font-bold pb-2">Aus BusPro</div>
<div class="pb-6 text-sm text-gray-500">
<dl class="grid grid-cols-[8rem_1fr] gap-y-1">
<dt>Rollen</dt>
<dd>{{ user.roles | effective_roles | join(', ') | default('–') }}</dd>
<dt>Häuser</dt>
<dd>{{ user.hotelCodes | join(', ') | default('–') }}</dd>
<dt>Letzter Login</dt>
<dd>{{ user.lastLoginAt ? user.lastLoginAt | date('d.m.Y, H:i') : '–' }}</dd>
</dl>
<p class="pt-2">
Rollen und Häuser werden bei jeder Anmeldung aus BusPro übernommen und lassen sich hier nicht ändern.
</p>
</div>
<div class="font-bold pb-2">Freischaltung</div>
{% if approvableRoles is empty and selfRefusedRoles is empty %}
<div class="text-sm text-gray-500">
Keine offenen Freischaltungen.
</div>
{% else %}
<div class="text-sm text-gray-500 pb-4">
BusPro meldet diese Rollen für den Account. Sie sind erst nach der Freischaltung wirksam.
</div>
{% if approvableRoles is not empty %}
<div class="flex flex-wrap gap-2">
{% for role, label in approvableRoles %}
<button type="button"
class="button button--primary button--small"
hx-get="{{ path('app_admin_user_approve_role', { id: user.id, role: role, r: returnUrl }) }}"
hx-target="body"
hx-swap="beforeend">
{{ label }} freischalten
</button>
{% endfor %}
</div>
{% endif %}
{% if selfRefusedRoles is not empty %}
<div class="text-sm text-gray-500 pt-4">
{{ selfRefusedRoles | join(', ') }}: diese Rolle kannst du dir nicht selbst freischalten.
Bitte wende dich an eine:n andere:n Administrator:in.
</div>
{% endif %}
{% endif %}
{% endblock %}
@@ -2,7 +2,7 @@
{% block body %}
<h1>
Neue Unterkunfts{% if booking.inquiry %}anfrage{% else %}buchung{% endif %}
Neue Unterkunfts{% if booking.directBooking %}buchung{% else %}anfrage{% endif %}
</h1>
{% if accessLink %}
+2 -2
View File
@@ -61,7 +61,7 @@
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
<div>
<h2 class="text-xl font-bold mb-2">
{{ booking.acceptedAt is not null ? 'Gebuchte' : 'Angefragte' }} Leistungen
{{ booking.acceptedAt is not null ? 'Gebuchte' : 'Angebotene' }} Leistungen
</h2>
<ul class="list-disc pl-4">
{% if booking.boardServiceLabel %}
@@ -74,7 +74,7 @@
</div>
{% endif %}
{% if booking.inquiry and booking.open %}
{% if booking.open %}
<div>
<button type="button"
class="button button--primary w-full"
+2 -2
View File
@@ -1,6 +1,6 @@
{% extends 'layout.html.twig' %}
{% block title %}{{ resultType == 'booking' ? 'Buchung erfolgreich' : 'Anfrage erfolgreich' }}{% endblock %}
{% block title %}{{ resultType == 'direct' ? 'Buchung erfolgreich' : 'Anfrage erfolgreich' }}{% endblock %}
{% block background %}bg-outer bg-outer--summer{% endblock %}
@@ -10,7 +10,7 @@
level: 'success',
title: 'Vielen Dank!',
messages: [
resultType == 'booking'
resultType == 'direct'
? 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhälst du eine Bestätigung per E-Mail.'
: 'Deine Anfrage ist bei uns eingegangen. Wir melden uns so schnell wie möglich bei dir.'
]
@@ -2,7 +2,7 @@
<html lang="de">
<head>
<meta charset="utf-8">
<title>Buchung {{ booking.id }} – {{ booking.groupName }}</title>
<title>{{ booking.recordLabel }} {{ booking.id }} – {{ booking.groupName }}</title>
<style>
@page { size: A4 portrait; margin: 16mm 15mm; }
@@ -52,7 +52,7 @@
</tr>
</table>
<h1>{{ booking.type.label }} · {{ booking.groupName }}</h1>
<h1>{{ booking.recordLabel }} · {{ booking.groupName }}</h1>
<table class="columns">
<tr>
@@ -90,7 +90,6 @@
{% if booking.childrenCount > 0 %}
<tr><th>davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} J.)</th><td>{{ booking.childrenCount }}</td></tr>
{% endif %}
<tr><th>Art</th><td>{{ booking.type.label }}</td></tr>
</table>
</td>
</tr>
@@ -11,11 +11,17 @@ use Symfony\Component\DomCrawler\Crawler;
class CrmAttributesResponseParserTest extends TestCase
{
private const HOUSE_MANAGER_IDS = [
2001 => 'DKS',
2002 => 'XYZ',
2003 => 'ASB',
];
private CrmAttributesResponseParser $parser;
protected function setUp(): void
{
$this->parser = new CrmAttributesResponseParser();
$this->parser = new CrmAttributesResponseParser(self::HOUSE_MANAGER_IDS);
}
public function testParseAssignsGroupsManagerRoleWhenSelected(): void
@@ -38,9 +44,7 @@ class CrmAttributesResponseParserTest extends TestCase
{
$roles = $this->parseRoles($this->selectionXml(1477, false));
self::assertNotContains('ROLE_GROUPS_MANAGER', $roles);
self::assertNotContains('ROLE_GROUPS_ADMIN', $roles);
self::assertSame(['ROLE_CUSTOMER'], $roles);
self::assertSame([], $roles, 'the parser reports what BusPro says and adds no fallback');
}
public function testParseStillAssignsExistingAdminManagerTeamerRoles(): void
@@ -73,11 +77,25 @@ class CrmAttributesResponseParserTest extends TestCase
self::assertSame(['DKS', 'ASB'], $attributes->hotelCodes);
}
public function testParseAddsTheDefaultHotelCodeForAdmins(): void
public function testParseIgnoresHausleitungSelectionsThatAreNotMapped(): void
{
// A house that is deliberately left out of bpn_crm_house_manager_ids claims nothing —
// matching is by id, never by label.
$parser = new CrmAttributesResponseParser([]);
$attributes = $parser->parse((new Crawler($this->hausleitungXml()))->filterXPath('//ergebnis'));
self::assertSame([], $attributes->roles);
self::assertSame([], $attributes->hotelCodes);
}
public function testParseAddsNoDefaultHotelCodeForAdmins(): void
{
// Hotel codes are synced on every login now, so a default would permanently grant a
// house to every administrator.
$attributes = $this->parse($this->selectionXml(1292, true));
self::assertSame(['SSL'], $attributes->hotelCodes);
self::assertSame(['ROLE_ADMIN'], $attributes->roles);
self::assertSame([], $attributes->hotelCodes);
}
/**
@@ -83,6 +83,7 @@ class ConfirmControllerTest extends TestCase
public static function nonReceivedStatuses(): iterable
{
yield 'draft' => [AccommodationBookingStatus::Draft];
yield 'requested' => [AccommodationBookingStatus::Requested];
yield 'open' => [AccommodationBookingStatus::Open];
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\DiscardController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* Covers the guards around discarding — the transition itself is tested in
* AccommodationBookingServiceTest.
*/
class DiscardControllerTest extends TestCase
{
public function testGetRendersTheConfirmationModal(): void
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('discardBooking');
$controller = new TestableDiscardController($bookingService, $this->createMock(LoggerInterface::class));
$response = $controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/discard'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('admin/accommodation_booking/modal_discard.html.twig', $controller->renderedView);
}
/**
* @dataProvider discardableStatuses
*/
public function testPostDiscardsTheBookingAndRedirectsTheBrowser(AccommodationBookingStatus $status): void
{
$booking = $this->openBooking();
$booking->setStatus($status);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('discardBooking')->with($booking);
$controller = new TestableDiscardController($bookingService, $this->createMock(LoggerInterface::class));
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/discard', 'POST'));
self::assertTrue($response->headers->has('HX-Redirect'));
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function discardableStatuses(): iterable
{
yield 'draft' => [AccommodationBookingStatus::Draft];
yield 'requested' => [AccommodationBookingStatus::Requested];
yield 'open' => [AccommodationBookingStatus::Open];
yield 'received' => [AccommodationBookingStatus::Received];
}
/**
* @dataProvider closedStatuses
*/
public function testAClosedBookingCannotBeDiscarded(AccommodationBookingStatus $status): void
{
// A released confirmation is binding, and an Absage is already the end of the line.
$booking = $this->openBooking();
$booking->setStatus($status);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('discardBooking');
$controller = new TestableDiscardController($bookingService, $this->createMock(LoggerInterface::class));
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/discard', 'POST'));
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function closedStatuses(): iterable
{
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
public function testPostWithAnInvalidTokenIsDenied(): void
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('discardBooking');
$controller = new TestableDiscardController($bookingService, $this->createMock(LoggerInterface::class), tokenValid: false);
$this->expectException(AccessDeniedException::class);
$controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/discard', 'POST'));
}
private function openBooking(): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setGroupName('Schulklasse 7b');
$booking->setEmail('[email protected]');
return $booking;
}
}
final class TestableDiscardController extends DiscardController
{
public ?string $renderedView = null;
/** @var list<array{type: string, message: mixed}> */
public array $flashes = [];
public function __construct(
AccommodationBookingService $bookingService,
LoggerInterface $logger,
private readonly bool $tokenValid = true,
) {
parent::__construct($bookingService, $logger);
}
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
{
return $this->tokenValid;
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
return new Response();
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = ['type' => $type, 'message' => $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
}
@@ -6,8 +6,8 @@ namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\EditController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use App\Repository\UserRepository;
@@ -20,79 +20,47 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Covers the status transition side effects only — the service and form behaviour
* they build on is tested in AccommodationBookingServiceTest.
* Covers what editing must NOT do — it never moves the booking on and never mails the
* customer — plus the Betreuer choices. The transitions themselves belong to the named
* actions and are tested in AccommodationBookingServiceTest and the action controllers.
*/
class EditControllerTest extends TestCase
{
public function testTransitionToOpenIssuesAccessLinkAndSendsCustomerEmail(): void
{
$bookingService = $this->assertNotified(AccommodationBookingStatus::Open);
$this->submitStatusChange(AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, $bookingService);
}
public function testTransitionToConfirmedSendsNothing(): void
{
// The binding confirmation only ever goes out through the explicit confirm action,
// never as a side effect of editing the status.
$bookingService = $this->assertNotNotified();
$this->submitStatusChange(AccommodationBookingStatus::Received, AccommodationBookingStatus::Confirmed, $bookingService);
}
public function testTransitionToDiscardedSendsNothing(): void
{
$bookingService = $this->assertNotNotified();
$this->submitStatusChange(AccommodationBookingStatus::Open, AccommodationBookingStatus::Discarded, $bookingService);
}
public function testSavingWithoutStatusChangeSendsNothing(): void
{
$bookingService = $this->assertNotNotified();
$this->submitStatusChange(AccommodationBookingStatus::Confirmed, AccommodationBookingStatus::Confirmed, $bookingService);
}
private function assertNotified(AccommodationBookingStatus $expected): AccommodationBookingService
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('refreshPriceSnapshot');
$bookingService
->expects(self::once())
->method('issueAccessLink')
->with(self::callback(static fn (AccommodationBooking $booking) => $expected === $booking->getStatus()));
$bookingService->expects(self::once())->method('sendCustomerConfirmationEmail');
return $bookingService;
}
private function assertNotNotified(): AccommodationBookingService
/**
* @dataProvider everyStatus
*/
public function testSavingNeverNotifiesTheCustomer(AccommodationBookingStatus $status): void
{
// Sending used to be a side effect of saving a status change, which is how an inquiry
// that arrived already Offen could never be offered at all. Editing is now inert.
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('refreshPriceSnapshot');
$bookingService->expects(self::never())->method('issueAccessLink');
$bookingService->expects(self::never())->method('sendCustomerConfirmationEmail');
$bookingService->expects(self::never())->method('sendOffer');
return $bookingService;
$booking = new AccommodationBooking();
$booking->setStatus($status);
$this->submitEdit($booking, $bookingService);
self::assertSame($status, $booking->getStatus(), 'editing leaves the status where it was');
}
private function submitStatusChange(
AccommodationBookingStatus $from,
AccommodationBookingStatus $to,
AccommodationBookingService $bookingService,
): void {
$booking = new AccommodationBooking();
$booking->setStatus($from);
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function everyStatus(): iterable
{
foreach (AccommodationBookingStatus::cases() as $status) {
yield $status->value => [$status];
}
}
// The form is what moves the entity to its new status during handleRequest().
private function submitEdit(AccommodationBooking $booking, AccommodationBookingService $bookingService): void
{
$form = $this->createMock(FormInterface::class);
$form->method('handleRequest')->willReturnCallback(static function () use ($booking, $to, $form) {
$booking->setStatus($to);
return $form;
});
$form->method('handleRequest')->willReturn($form);
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn(true);
$form->method('has')->willReturn(false);
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\SendOfferController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* Covers the guards around sending an offer — the sending itself is tested in
* AccommodationBookingServiceTest.
*/
class SendOfferControllerTest extends TestCase
{
public function testGetRendersTheConfirmationModal(): void
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('sendOffer');
$controller = new TestableSendOfferController($bookingService, $this->createMock(LoggerInterface::class));
$response = $controller->index($this->requestedBooking(), Request::create('/admin/accommodation-booking/1/send-offer'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('admin/accommodation_booking/modal_send_offer.html.twig', $controller->renderedView);
}
/**
* @dataProvider statusesAwaitingAnOffer
*/
public function testPostSendsTheOfferAndRedirectsTheBrowser(AccommodationBookingStatus $status): void
{
$booking = $this->requestedBooking();
$booking->setStatus($status);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('sendOffer')->with($booking);
$controller = new TestableSendOfferController($bookingService, $this->createMock(LoggerInterface::class));
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/send-offer', 'POST'));
self::assertTrue($response->headers->has('HX-Redirect'));
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function statusesAwaitingAnOffer(): iterable
{
// The admin-authored offer and the customer's inquiry converge on the same action.
yield 'draft' => [AccommodationBookingStatus::Draft];
yield 'requested' => [AccommodationBookingStatus::Requested];
}
/**
* @dataProvider statusesPastTheOffer
*/
public function testABookingThatIsNotAwaitingAnOfferGetsNone(AccommodationBookingStatus $status): void
{
$booking = $this->requestedBooking();
$booking->setStatus($status);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('sendOffer');
$controller = new TestableSendOfferController($bookingService, $this->createMock(LoggerInterface::class));
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/send-offer', 'POST'));
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function statusesPastTheOffer(): iterable
{
yield 'open' => [AccommodationBookingStatus::Open];
yield 'received' => [AccommodationBookingStatus::Received];
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
public function testPostWithAnInvalidTokenIsDenied(): void
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('sendOffer');
$controller = new TestableSendOfferController($bookingService, $this->createMock(LoggerInterface::class), tokenValid: false);
$this->expectException(AccessDeniedException::class);
$controller->index($this->requestedBooking(), Request::create('/admin/accommodation-booking/1/send-offer', 'POST'));
}
public function testABookingWithoutAnEmailAddressGetsNoOffer(): void
{
// The offer only exists for the customer as the mail carrying its link, so sending
// it nowhere would leave the booking waiting in Offen for an acceptance that cannot
// come — the dead end this whole flow is meant to remove.
$booking = $this->requestedBooking();
$booking->setEmail(null);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('sendOffer');
$controller = new TestableSendOfferController($bookingService, $this->createMock(LoggerInterface::class));
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/send-offer', 'POST'));
self::assertSame(['error'], array_column($controller->flashes, 'type'));
self::assertStringContainsString('app_admin_accommodationbooking_edit', (string) $response->headers->get('HX-Redirect'));
}
private function requestedBooking(): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Requested);
$booking->setGroupName('Schulklasse 7b');
$booking->setEmail('[email protected]');
return $booking;
}
}
final class TestableSendOfferController extends SendOfferController
{
public ?string $renderedView = null;
/** @var list<array{type: string, message: mixed}> */
public array $flashes = [];
public function __construct(
AccommodationBookingService $bookingService,
LoggerInterface $logger,
private readonly bool $tokenValid = true,
) {
parent::__construct($bookingService, $logger);
}
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
{
return $this->tokenValid;
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
return new Response();
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = ['type' => $type, 'message' => $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
}
@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\User;
use App\Controller\Admin\User\ApproveRoleController;
use App\Entity\User;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;
/**
* Covers the guards around approving a nomination — the only way a role is ever granted here.
*/
class ApproveRoleControllerTest extends TestCase
{
public function testGetRendersTheConfirmationModal(): void
{
$user = $this->nominatedUser();
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class));
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('admin/user/modal_approve_role.html.twig', $controller->renderedView);
self::assertSame([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)], Role::assignedOnly($user->getRoles()));
}
public function testPostGrantsTheRoleAndRedirectsTheBrowser(): void
{
$user = $this->nominatedUser();
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class));
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles()));
self::assertTrue($response->headers->has('HX-Redirect'));
self::assertSame(['success'], array_column($controller->flashes, 'type'));
}
public function testARoleTheCrmNeverClaimedCannotBeApproved(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class));
$this->expectException(NotFoundHttpException::class);
// ROLE_ADMIN is not nominated, so no hand-crafted request can grant it.
$controller->index($this->nominatedUser(), Role::ADMIN, Request::create('/admin/user/1/approve/ROLE_ADMIN', 'POST'));
}
public function testApprovingRoleAdminForYourOwnAccountIsRefused(): void
{
$user = (new User('[email protected]'))->setRoles([Role::ADMIN, Role::pending(Role::ADMIN)]);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class), currentUser: $user);
$this->expectException(AccessDeniedException::class);
$controller->index($user, Role::ADMIN, Request::create('/admin/user/1/approve/ROLE_ADMIN', 'POST'));
}
public function testApprovingALesserRoleForYourOwnAccountIsAllowed(): void
{
$user = $this->nominatedUser();
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class), currentUser: $user);
// Only ROLE_ADMIN needs a second pair of eyes — an approver already holds it, so the
// rest grant less than they could grant themselves anyway.
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles()));
}
public function testPostWithAnInvalidTokenIsDenied(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class), tokenValid: false);
$this->expectException(AccessDeniedException::class);
$controller->index($this->nominatedUser(), Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
}
public function testApprovingForYourselfReissuesTheSecurityToken(): void
{
$user = $this->nominatedUser();
$tokenStorage = new TokenStorage();
$tokenStorage->setToken(new PostAuthenticationToken($user, 'main', $user->getRoles()));
$controller = new TestableApproveRoleController(
$this->createMock(EntityManagerInterface::class),
$this->createMock(LoggerInterface::class),
currentUser: $user,
tokenStorage: $tokenStorage,
);
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
// Without this the next request would find the stored roles out of step with the token
// and end the session, logging the approver out mid-action.
self::assertContains(Role::GROUPS_ADMIN, $tokenStorage->getToken()?->getRoleNames() ?? []);
self::assertNotContains(Role::pending(Role::GROUPS_ADMIN), $tokenStorage->getToken()?->getRoleNames() ?? []);
}
public function testApprovingForSomebodyElseLeavesYourOwnTokenAlone(): void
{
$other = $this->nominatedUser();
$tokenStorage = new TokenStorage();
$admin = (new User('[email protected]'))->setRoles([Role::ADMIN]);
$tokenStorage->setToken($originalToken = new PostAuthenticationToken($admin, 'main', $admin->getRoles()));
$controller = new TestableApproveRoleController(
$this->createMock(EntityManagerInterface::class),
$this->createMock(LoggerInterface::class),
currentUser: $admin,
tokenStorage: $tokenStorage,
);
$controller->index($other, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
self::assertSame($originalToken, $tokenStorage->getToken());
}
private function nominatedUser(): User
{
return (new User('[email protected]'))->setRoles([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)]);
}
}
final class TestableApproveRoleController extends ApproveRoleController
{
public ?string $renderedView = null;
/** @var list<array{type: string, message: mixed}> */
public array $flashes = [];
public function __construct(
EntityManagerInterface $entityManager,
LoggerInterface $logger,
private readonly bool $tokenValid = true,
private readonly ?UserInterface $currentUser = null,
public readonly TokenStorageInterface $tokenStorage = new TokenStorage(),
) {
parent::__construct($entityManager, $logger, $this->tokenStorage);
}
protected function getUser(): ?UserInterface
{
return $this->currentUser;
}
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
{
return $this->tokenValid;
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
return new Response();
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = ['type' => $type, 'message' => $message];
}
/**
* @param array<string, mixed> $parameters
*/
public function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\User;
use App\Controller\Admin\User\ShowController;
use App\Entity\User;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\User\UserInterface;
class ShowControllerTest extends TestCase
{
public function testNominationsAreOfferedForApproval(): void
{
$controller = new TestableShowController();
$controller->index($this->nominatedUser(), $this->permissionsRequest());
self::assertSame(
[Role::MANAGER => 'Manager:in', Role::GROUPS_ADMIN => 'Preisrechner Admin'],
$controller->parameters['approvableRoles'],
);
self::assertSame([], $controller->parameters['selfRefusedRoles']);
}
public function testWhatCannotBeSelfApprovedIsNotOffered(): void
{
$user = (new User('[email protected]'))->setRoles([Role::ADMIN, Role::pending(Role::ADMIN), Role::pending(Role::MANAGER)]);
$controller = new TestableShowController(currentUser: $user);
$controller->index($user, $this->permissionsRequest());
// ROLE_ADMIN needs a second administrator, so no button leads into an access denied page.
self::assertSame([Role::MANAGER => 'Manager:in'], $controller->parameters['approvableRoles']);
self::assertSame([Role::ADMIN => 'Administration'], $controller->parameters['selfRefusedRoles']);
}
public function testTheReturnUrlOfTheListIsForwardedUntouched(): void
{
$controller = new TestableShowController();
$controller->index($this->nominatedUser(), $this->permissionsRequest());
// Calling return_url() in the template instead would hand the approval this very modal
// and redirect the browser onto a bare modal fragment afterwards.
self::assertSame('%2Fadmin%2Fuser%3Fpage%3D2', $controller->parameters['returnUrl']);
}
public function testWithoutAReturnUrlTheListIsUsed(): void
{
$controller = new TestableShowController();
$controller->index($this->nominatedUser(), Request::create('/admin/user/7/permissions'));
self::assertSame(rawurlencode('/app_admin_user'), $controller->parameters['returnUrl']);
}
private function nominatedUser(): User
{
return (new User('[email protected]'))
->setRoles([Role::TEAMER, Role::pending(Role::MANAGER), Role::pending(Role::GROUPS_ADMIN)]);
}
/**
* The list links the modal with r=return_url(), which rawurlencodes the URI, and path()
* encodes that again as a query value — so what arrives here is encoded exactly once.
*/
private function permissionsRequest(): Request
{
return Request::create('/admin/user/7/permissions?r='.rawurlencode(rawurlencode('/admin/user?page=2')));
}
}
final class TestableShowController extends ShowController
{
/** @var array<string, mixed> */
public array $parameters = [];
public function __construct(private readonly ?UserInterface $currentUser = null)
{
}
protected function getUser(): ?UserInterface
{
return $this->currentUser;
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->parameters = $parameters;
return new Response();
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route;
}
}
@@ -7,8 +7,8 @@ namespace App\Tests\Controller\Api;
use App\Controller\Api\AccommodationBookingController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingService;
@@ -57,7 +57,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setBoardServiceDiscount(20);
$booking->setAdditionalServicesDiscount(30);
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setType(AccommodationBookingType::Booking);
$booking->setOrigin(AccommodationBookingOrigin::Direct);
$booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00'));
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
@@ -88,7 +88,7 @@ class AccommodationBookingControllerTest extends TestCase
self::assertSame([
'uuid' => $booking->getUuid(),
'status' => 'confirmed',
'type' => 'booking',
'type' => 'direct',
'dateFrom' => '2026-07-20',
'dateTo' => '2026-07-25',
'nights' => 5,
@@ -139,7 +139,7 @@ class AccommodationBookingControllerTest extends TestCase
self::assertArrayNotHasKey('currency', $payload['accommodation']);
}
public function testSingleReturnsOpenStatusAndInquiryTypeForAnOffer(): void
public function testSingleReturnsOpenStatusAndOfferOriginForAnOffer(): void
{
$booking = new AccommodationBooking();
$booking->setDateFrom(new \DateTimeImmutable('2026-08-01'));
@@ -149,7 +149,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setLastName('Beispiel');
$booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setType(AccommodationBookingType::Inquiry);
$booking->setOrigin(AccommodationBookingOrigin::Offer);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository
@@ -166,7 +166,7 @@ class AccommodationBookingControllerTest extends TestCase
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertSame('open', $payload['status']);
self::assertSame('inquiry', $payload['type']);
self::assertSame('offer', $payload['type']);
self::assertNull($payload['priceBreakdown']);
self::assertNull($payload['acceptedAt']);
}
@@ -230,7 +230,7 @@ class AccommodationBookingControllerTest extends TestCase
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('received', $payload['status']);
self::assertSame('inquiry', $payload['type']);
self::assertSame('offer', $payload['type']);
self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
self::assertNotNull($booking->getAcceptedAt());
self::assertSame($booking->getAcceptedAt()->format(\DATE_ATOM), $payload['acceptedAt']);
@@ -94,10 +94,13 @@ class OfferControllerTest extends TestCase
self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView);
}
public function testAccessRendersUnavailableForDraft(): void
/**
* @dataProvider statusesTheCustomerMustNotSee
*/
public function testAccessRendersUnavailableForABookingThatIsNotOfferedYet(AccommodationBookingStatus $status): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Draft);
$booking->setStatus($status);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
@@ -119,6 +122,18 @@ class OfferControllerTest extends TestCase
self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView);
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function statusesTheCustomerMustNotSee(): iterable
{
// A scratch record and an inquiry the office has not worked through yet are both
// unfinished; a discarded one is over. All three behave like an expired link.
yield 'draft' => [AccommodationBookingStatus::Draft];
yield 'requested' => [AccommodationBookingStatus::Requested];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
public function testViewRendersUnavailableForDiscardedBooking(): void
{
$booking = new AccommodationBooking();
@@ -7,7 +7,7 @@ namespace App\Tests\Controller\Groups;
use App\Controller\Groups\Step4Controller;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingType;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Form\Model\AccommodationBookingDto;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
@@ -102,7 +102,7 @@ class Step4ControllerTest extends TestCase
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$booking = (new AccommodationBooking())->setType(AccommodationBookingType::Inquiry);
$booking = (new AccommodationBooking())->setOrigin(AccommodationBookingOrigin::Offer);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
@@ -275,7 +275,7 @@ class Step4ControllerTest extends TestCase
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$booking = (new AccommodationBooking())->setType(AccommodationBookingType::Booking);
$booking = (new AccommodationBooking())->setOrigin(AccommodationBookingOrigin::Direct);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity\Groups;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use PHPUnit\Framework\TestCase;
class AccommodationBookingTest extends TestCase
{
/**
* The label is what the backoffice prints next to the status, so it must never
* contradict it — "Angebot · Bestätigt" is the shape of mistake this replaces.
*
* @dataProvider statusesBeforeTheCustomerCommits
*/
public function testARecordIsCalledAnOfferUntilTheCustomerCommits(AccommodationBookingStatus $status): void
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
self::assertNull($booking->getAcceptedAt());
self::assertSame('Angebot', $booking->recordLabel());
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function statusesBeforeTheCustomerCommits(): iterable
{
yield 'draft' => [AccommodationBookingStatus::Draft];
yield 'requested' => [AccommodationBookingStatus::Requested];
yield 'open' => [AccommodationBookingStatus::Open];
}
/**
* @dataProvider statusesAfterTheCustomerCommits
*/
public function testARecordIsCalledABookingOnceTheCustomerHasCommitted(AccommodationBookingStatus $status): void
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
$booking->setAcceptedAt(new \DateTimeImmutable());
self::assertSame('Buchung', $booking->recordLabel());
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function statusesAfterTheCustomerCommits(): iterable
{
yield 'received' => [AccommodationBookingStatus::Received];
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
}
public function testADiscardedRecordKeepsWhicheverItHadBecome(): void
{
// An Absage can close either an offer nobody accepted or a booking that fell
// through, so the label follows the commitment rather than the status.
$unaccepted = new AccommodationBooking();
$unaccepted->setStatus(AccommodationBookingStatus::Discarded);
$accepted = new AccommodationBooking();
$accepted->setStatus(AccommodationBookingStatus::Discarded);
$accepted->setAcceptedAt(new \DateTimeImmutable());
self::assertSame('Angebot', $unaccepted->recordLabel());
self::assertSame('Buchung', $accepted->recordLabel());
}
public function testTheLabelIsIndependentOfWhereTheBookingCameFrom(): void
{
// Origin answers "how did this come about" and is deliberately not the same
// question — a confirmed booking that started as an offer is still a Buchung.
foreach (AccommodationBookingOrigin::cases() as $origin) {
$booking = new AccommodationBooking();
$booking->setOrigin($origin);
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setAcceptedAt(new \DateTimeImmutable());
self::assertSame('Buchung', $booking->recordLabel(), $origin->value);
}
}
}
@@ -8,7 +8,7 @@ use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Admin\Groups\AccommodationBookingCreateType;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\EmailValidator;
@@ -17,76 +17,74 @@ use Symfony\Component\Validator\Validation;
class AccommodationBookingCreateTypeTest extends TestCase
{
public function testDraftsAreValidatedWithoutTheOfferGroup(): void
public function testCreationIsAlwaysValidatedAsAScratchRecord(): void
{
self::assertSame(['Default'], $this->resolveValidationGroups(AccommodationBookingStatus::Draft));
self::assertSame(['Default'], $this->resolveValidationGroups());
}
public function testOffersAddTheOfferGroup(): void
public function testTheStatusCannotBeChosenOnCreation(): void
{
self::assertSame(['Default', 'offer'], $this->resolveValidationGroups(AccommodationBookingStatus::Open));
// A new record is always an Entwurf and only leaves that status through a named
// action, so offering the choice here would let it start out somewhere it cannot
// have arrived at legitimately.
self::assertNotContains('status', $this->builtFieldNames());
}
public function testDraftCanBeCreatedWithoutAnEmail(): void
public function testANewBookingStartsAsADraft(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Draft);
self::assertTrue((new AccommodationBooking())->isDraft());
}
$violations = $this->validate($booking, $this->resolveValidationGroups(AccommodationBookingStatus::Draft));
public function testABookingCanBeCreatedWithoutAnEmail(): void
{
// The address is enforced when the booking is actually sent somewhere — see
// SendOfferController and ConfirmController — not while it is still a scratch record.
$violations = $this->validate(new AccommodationBooking(), $this->resolveValidationGroups());
self::assertCount(0, $violations);
}
public function testOfferCannotBeCreatedWithoutAnEmail(): void
public function testAMalformedEmailIsStillRejectedOnceTheBookingIsEdited(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setStatus(AccommodationBookingStatus::Requested);
$booking->setEmail('not-an-address');
$violations = $this->validate($booking, $this->resolveValidationGroups(AccommodationBookingStatus::Open));
$violations = $this->validate($booking, ['edit']);
$properties = array_map(static fn ($violation) => $violation->getPropertyPath(), iterator_to_array($violations));
self::assertSame(['email'], $properties, 'only the address is enforced here — the rest of the contact data belongs to the edit form');
}
public function testOfferRejectsAMalformedEmail(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setEmail('not-an-address');
$violations = $this->validate($booking, $this->resolveValidationGroups(AccommodationBookingStatus::Open));
self::assertCount(1, $violations);
self::assertSame('email', $violations[0]->getPropertyPath());
}
public function testOfferWithAnEmailIsValid(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setEmail('[email protected]');
$violations = $this->validate($booking, $this->resolveValidationGroups(AccommodationBookingStatus::Open));
self::assertCount(0, $violations);
self::assertContains('email', $properties);
}
/**
* @return string[]
*/
private function resolveValidationGroups(AccommodationBookingStatus $status): array
private function resolveValidationGroups(): array
{
$resolver = new OptionsResolver();
(new AccommodationBookingCreateType())->configureOptions($resolver);
$booking = new AccommodationBooking();
$booking->setStatus($status);
return $resolver->resolve()['validation_groups'];
}
$form = $this->createMock(FormInterface::class);
$form->method('getData')->willReturn($booking);
/**
* @return string[]
*/
private function builtFieldNames(): array
{
$names = [];
return ($resolver->resolve()['validation_groups'])($form);
$builder = $this->createMock(FormBuilderInterface::class);
$builder->method('add')->willReturnCallback(static function (string $name) use (&$names, $builder) {
$names[] = $name;
return $builder;
});
(new AccommodationBookingCreateType())->buildForm($builder, ['data' => new AccommodationBooking()]);
return $names;
}
/**
-89
View File
@@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Admin;
use App\Entity\User;
use App\Form\Admin\UserType;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\Forms;
class UserTypeTest extends TestCase
{
public function testOnlyThePrivilegedRolesArePreselected(): void
{
$user = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
self::assertSame([Role::GROUPS_MANAGER], $this->createForm($user)->get('roles')->getData());
}
public function testOnlyPrivilegedRolesAreOffered(): void
{
$choices = $this->createForm(new User('[email protected]'))->get('roles')->getConfig()->getOption('choices');
// The rest is synced from BusPro on every login and would be overwritten right away.
self::assertSame(Role::PRIVILEGED, array_values($choices));
}
public function testSubmittingRolesKeepsTheSyncedOnesAndNotTheImplicitRoleUser(): void
{
$user = (new User('[email protected]'))->setRoles([Role::TEAMER]);
$form = $this->createForm($user);
$form->submit(['roles' => [Role::GROUPS_MANAGER], 'hotelCodes' => []]);
self::assertTrue($form->isSynchronized());
// getRoles() prepends ROLE_USER; it must not have been persisted a second time.
self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles());
}
public function testClearingEveryCheckboxKeepsTheSyncedRoles(): void
{
$user = (new User('[email protected]'))
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER])
->setHotelCodes(['SSL'])
;
$form = $this->createForm($user);
$form->submit([]);
self::assertTrue($form->isSynchronized());
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
self::assertSame([], $user->getHotelCodes());
}
public function testHotelCodeMissingFromTheCatalogSurvivesAnEdit(): void
{
$user = (new User('[email protected]'))->setHotelCodes(['SSL', 'XYZ']);
$form = $this->createForm($user);
$form->submit(['roles' => [], 'hotelCodes' => ['SSL', 'XYZ']]);
self::assertTrue($form->isSynchronized());
self::assertSame(['SSL', 'XYZ'], $user->getHotelCodes());
}
public function testHotelCodesAreOfferedAlphabetically(): void
{
$user = (new User('[email protected]'))->setHotelCodes(['ASB']);
$choices = $this->createForm($user)->get('hotelCodes')->getConfig()->getOption('choices');
self::assertSame(['ASB', 'DKS', 'SSL'], array_values($choices));
}
/**
* @return FormInterface<User>
*/
private function createForm(User $user): FormInterface
{
return Forms::createFormFactoryBuilder()
->addType(new UserType(['SSL' => 'SSL', 'DKS' => 'DKS']))
->getFormFactory()
->create(UserType::class, $user)
;
}
}
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Tests\Form\Model\Filter;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Form\Model\Filter\AccommodationBookingFilterDto;
use PHPUnit\Framework\TestCase;
@@ -17,9 +17,14 @@ class AccommodationBookingFilterDtoTest extends TestCase
$filter = AccommodationBookingFilterDto::defaults(true, new User('[email protected]'));
self::assertSame(
[AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, AccommodationBookingStatus::Received],
[
AccommodationBookingStatus::Draft,
AccommodationBookingStatus::Requested,
AccommodationBookingStatus::Open,
AccommodationBookingStatus::Received,
],
$filter->status,
'accepted and discarded bookings need no further work, so they stay out of the way',
'confirmed and discarded bookings need no further work, so they stay out of the way',
);
self::assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $filter->dateFrom?->format('Y-m-d'));
self::assertNull($filter->managedBy, 'a group admin sees everybody’s bookings');
@@ -83,11 +88,11 @@ class AccommodationBookingFilterDtoTest extends TestCase
$filter->q = 'meier';
$filter->dateFrom = new \DateTimeImmutable('2026-08-01');
$filter->status = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Confirmed];
$filter->type = [AccommodationBookingType::Booking];
$filter->origin = [AccommodationBookingOrigin::Direct];
self::assertSame(4, $filter->activeCount());
self::assertSame('Offen, Bestätigt', $this->chipFor($filter, 'Status')?->value);
self::assertSame('Buchung', $this->chipFor($filter, 'Art')?->value);
self::assertSame('Direktbuchung', $this->chipFor($filter, 'Herkunft')?->value);
self::assertSame('01.08.2026', $this->chipFor($filter, 'Aufenthalt ab')?->value);
}
+153
View File
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Tests\Logger;
use App\Entity\LogEntry;
use App\Logger\DatabaseHandler;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
use Doctrine\ORM\Mapping\UnderscoreNamingStrategy;
use Doctrine\Persistence\Mapping\RuntimeReflectionService;
use Doctrine\Persistence\ObjectManager;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\TestCase;
/**
* The handler writes through the DBAL connection instead of the EntityManager, so the column names
* are spelled out by hand here and can drift away from the LogEntry mapping. These tests pin them
* to the mapping, and pin the handler to the connection.
*/
class DatabaseHandlerTest extends TestCase
{
public function testTheHandlerStaysOutOfTheUnitOfWork(): void
{
// A log record can be emitted halfway through somebody else's unit of work. Flushing an
// EntityManager here would write out whatever that unit of work has pending, which is how
// a half-built User once reached the database with a NULL password.
$parameters = (new \ReflectionMethod(DatabaseHandler::class, '__construct'))->getParameters();
foreach ($parameters as $parameter) {
$type = $parameter->getType();
self::assertInstanceOf(\ReflectionNamedType::class, $type);
self::assertNotSame(ObjectManager::class, $type->getName());
self::assertFalse(
is_a($type->getName(), ObjectManager::class, true),
'the handler must not be able to flush anybody else’s entities',
);
}
}
public function testTheWrittenColumnsMatchTheLogEntryMapping(): void
{
$handler = new DatabaseHandler($this->connection($table, $data, $types));
$handler->handle($this->record('auth', 'Login', ['email' => '[email protected]']));
$metadata = $this->logEntryMetadata();
self::assertSame($metadata->getTableName(), $table);
self::assertSame([], array_diff(array_keys($data), $metadata->getColumnNames()));
// Everything the table requires has to be supplied: no default fills these in, and the id
// is the only column the database generates itself.
$required = array_diff($metadata->getColumnNames(), ['id']);
foreach ($required as $column) {
self::assertArrayHasKey($column, $data);
}
self::assertSame([], array_diff(array_keys($types), array_keys($data)));
}
public function testTheRecordIsWrittenAsIs(): void
{
$handler = new DatabaseHandler($this->connection($table, $data, $types));
$handler->handle($this->record('bpn', 'Request failed', ['request_id' => 'r-1'], ['error_code' => '853']));
self::assertSame('bpn', $data['channel']);
self::assertSame('Request failed', $data['message']);
self::assertSame(['request_id' => 'r-1'], $data['context']);
self::assertSame(['error_code' => '853'], $data['extra']);
self::assertSame('853', $data['error_code']);
self::assertInstanceOf(\DateTimeImmutable::class, $data['created_at']);
}
public function testPlaceholdersAreReplacedFromTheContext(): void
{
$handler = new DatabaseHandler($this->connection($table, $data, $types));
$handler->handle($this->record('core', 'Booking {id} confirmed', ['id' => '4711']));
self::assertSame('Booking 4711 confirmed', $data['message']);
}
public function testAnErrorCodeIsOptional(): void
{
$handler = new DatabaseHandler($this->connection($table, $data, $types));
$handler->handle($this->record('core', 'Login'));
self::assertNull($data['error_code']);
}
public function testDebugRecordsAreNotWritten(): void
{
$connection = $this->createMock(Connection::class);
$connection->expects(self::never())->method('insert');
$handler = new DatabaseHandler($connection);
$handler->handle($this->record('core', 'Noise', level: Level::Debug));
}
/**
* @param array<string, mixed>|null $data
* @param array<string, mixed>|null $types
*/
private function connection(?string &$table, ?array &$data, ?array &$types): Connection
{
$connection = $this->createMock(Connection::class);
$connection
->method('insert')
->willReturnCallback(
static function (string $insertTable, array $insertData, array $insertTypes) use (&$table, &$data, &$types): int {
$table = $insertTable;
$data = $insertData;
$types = $insertTypes;
return 1;
}
)
;
return $connection;
}
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $extra
*/
private function record(string $channel, string $message, array $context = [], array $extra = [], Level $level = Level::Info): LogRecord
{
return new LogRecord(new \DateTimeImmutable(), $channel, $level, $message, $context, $extra);
}
/**
* @return ClassMetadata<LogEntry>
*/
private function logEntryMetadata(): ClassMetadata
{
/** @var ClassMetadata<LogEntry> $metadata */
$metadata = new ClassMetadata(LogEntry::class, new UnderscoreNamingStrategy(CASE_LOWER, true));
$metadata->initializeReflection(new RuntimeReflectionService());
(new AttributeDriver([dirname(__DIR__, 2).'/src/Entity']))->loadMetadataForClass(LogEntry::class, $metadata);
return $metadata;
}
}
+121 -28
View File
@@ -6,11 +6,11 @@ namespace App\Tests\Security;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\CrmSelectionGroup;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Security\BpnAuthenticator;
use App\Security\Crypt;
use App\Security\DefaultRouteResolver;
use App\Security\Role;
use App\Service\ProfileCompletenessChecker;
use Doctrine\ORM\EntityManagerInterface;
@@ -22,16 +22,16 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
/**
* Covers who may grant roles: BusPro backend users can edit their own CRM selections, so the
* import must not be a channel for privilege escalation.
* Covers what a login does to an account: BusPro owns the roles and the hotel codes, but a
* CRM claim must never grant an administrative role on its own.
*/
class BpnAuthenticatorTest extends TestCase
{
public function testNewAccountIsSeededWithTheImportableRolesOnly(): void
public function testNewAccountIsSeededFromTheCrm(): void
{
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::ADMIN, Role::TEAMER, Role::GROUPS_ADMIN], ['SSL', 'SSL']),
$this->crmAttributes([Role::ADMIN, Role::TEAMER], ['SSL', 'SSL']),
null,
$persisted,
);
@@ -39,34 +39,60 @@ class BpnAuthenticatorTest extends TestCase
$user = $this->loadUser($authenticator);
self::assertSame($persisted, $user);
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::ADMIN)], $user->getRoles());
self::assertSame(['SSL'], $user->getHotelCodes());
}
public function testExistingAccountKeepsThePrivilegedRolesAnAdministratorAssigned(): void
public function testAdministrativeClaimIsOnlyANominationUntilItIsApproved(): void
{
$existing = (new User('[email protected]'))
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER])
->setHotelCodes(['DKS'])
;
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER]);
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::ADMIN, Role::CUSTOMER], ['SSL']),
$this->crmAttributes([Role::TEAMER, Role::GROUPS_ADMIN], []),
$existing,
$persisted,
);
$user = $this->loadUser($authenticator);
self::assertNull($persisted, 'an existing account must not be persisted again');
// ROLE_TEAMER is gone with its CRM selection, ROLE_ADMIN is still not honoured, and the
// administrator-granted ROLE_GROUPS_MANAGER survives.
self::assertSame(['ROLE_USER', Role::CUSTOMER, Role::GROUPS_MANAGER], $user->getRoles());
self::assertSame(['DKS'], $user->getHotelCodes(), 'hotel codes stay administrator-managed');
self::assertSame($existing, $persisted, 'a login must not create a second account');
self::assertSame(
['ROLE_USER', Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)],
$user->getRoles(),
);
self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced');
}
public function testApprovedRoleSurvivesTheNextLogin(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER, Role::GROUPS_MANAGER], []),
$existing,
$persisted,
);
self::assertSame(
['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER],
$this->loadUser($authenticator)->getRoles(),
);
}
public function testRoleRevokedInBusProIsWithdrawnOnLogin(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted);
// Nothing is claimed any more, so nothing is held — and an account without an effective
// role is a customer.
self::assertSame(['ROLE_USER', Role::CUSTOMER], $this->loadUser($authenticator)->getRoles());
}
public function testRoleGainedInBusProIsGrantedOnLogin(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::CUSTOMER]);
@@ -82,35 +108,100 @@ class BpnAuthenticatorTest extends TestCase
self::assertSame(['ROLE_USER', Role::TEAMER], $this->loadUser($authenticator)->getRoles());
}
public function testAccountWithoutAnyRoleIsHealedOnLogin(): void
public function testHotelCodesAreResyncedOnEveryLogin(): void
{
$existing = new User('teamer@example.org');
$existing = (new User('house@example.org'))
->setRoles([Role::HOUSE_MANAGER])
->setHotelCodes(['DKS', 'SSL'])
;
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER], []),
$this->crmAttributes([Role::HOUSE_MANAGER], ['DKS']),
$existing,
$persisted,
);
self::assertSame(['ROLE_USER', Role::TEAMER], $this->loadUser($authenticator)->getRoles());
self::assertSame(['DKS'], $this->loadUser($authenticator)->getHotelCodes());
}
public function testDegradedCrmResponseLeavesAnExistingAccountUntouched(): void
{
$existing = (new User('[email protected]'))
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER])
->setHotelCodes(['DKS'])
;
$persisted = null;
// No selection groups at all: BusPro always answers with the full attribute tree, so
// this is a degraded payload and not a revocation of everything.
$authenticator = $this->authenticator(
$this->crmAttributes([], [], selectionGroups: []),
$existing,
$persisted,
);
$user = $this->loadUser($authenticator);
self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles());
self::assertSame(['DKS'], $user->getHotelCodes());
}
public function testDegradedCrmResponseStillGivesANewAccountTheFallbackRole(): void
{
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([], [], selectionGroups: []),
null,
$persisted,
);
self::assertSame(['ROLE_USER', Role::CUSTOMER], $this->loadUser($authenticator)->getRoles());
}
public function testANewAccountIsOnlyRegisteredOnceItCarriesItsPassword(): void
{
$persisted = null;
$persistedPassword = null;
// A degraded payload is what makes syncFromCrm() log, and that log write reaches the
// database. An account registered before the profile is complete would be written out
// half-built, and the user table rejects it: password is NOT NULL.
$authenticator = $this->authenticator(
$this->crmAttributes([], [], selectionGroups: []),
null,
$persisted,
$persistedPassword,
);
$this->loadUser($authenticator);
self::assertInstanceOf(User::class, $persisted);
self::assertSame('encrypted', $persistedPassword);
}
/**
* @param string[] $roles
* @param string[] $hotelCodes
* @param string[] $roles
* @param string[] $hotelCodes
* @param CrmSelectionGroup[] $selectionGroups only their presence matters here — an empty
* set is what marks a response as degraded
*/
private function crmAttributes(array $roles, array $hotelCodes): CrmAttributes
private function crmAttributes(array $roles, array $hotelCodes, ?array $selectionGroups = null): CrmAttributes
{
$attributes = new CrmAttributes();
$attributes->roles = $roles;
$attributes->hotelCodes = $hotelCodes;
$attributes->selectionGroups = $selectionGroups ?? [new CrmSelectionGroup()];
return $attributes;
}
private function authenticator(CrmAttributes $crmAttributes, ?User $existing, ?User &$persisted): BpnAuthenticator
{
private function authenticator(
CrmAttributes $crmAttributes,
?User $existing,
?User &$persisted,
?string &$persistedPassword = null,
): BpnAuthenticator {
$personalData = new PersonalData();
$personalData->personId = 42;
$personalData->addressId = 4711;
@@ -126,8 +217,11 @@ class BpnAuthenticatorTest extends TestCase
$entityManager->method('getRepository')->willReturn($repository);
$entityManager
->method('persist')
->willReturnCallback(static function (object $entity) use (&$persisted): void {
->willReturnCallback(static function (object $entity) use (&$persisted, &$persistedPassword): void {
$persisted = $entity;
// Snapshot rather than a reference: what matters is what the account looked like
// at the moment it was registered, not what it grew into afterwards.
$persistedPassword = $entity instanceof User ? $entity->getPassword() : null;
})
;
@@ -144,7 +238,6 @@ class BpnAuthenticatorTest extends TestCase
$crypt,
$completenessChecker,
$this->createMock(LoggerInterface::class),
$this->createMock(DefaultRouteResolver::class),
);
}
+73 -33
View File
@@ -7,62 +7,102 @@ namespace App\Tests\Security;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
/**
* Covers the role policy: BusPro backend users can edit their own CRM selections, so a claim
* must never grant an administrative role on its own.
*/
class RoleTest extends TestCase
{
public function testPrivilegedRolesAreNeverImported(): void
public function testAdministrativeClaimOnlyProducesANomination(): void
{
$roles = Role::filterImportable([
Role::TEAMER,
Role::ADMIN,
Role::GROUPS_ADMIN,
Role::GROUPS_MANAGER,
Role::HOUSE_MANAGER,
]);
$roles = Role::sync([], [Role::ADMIN, Role::GROUPS_ADMIN, Role::TEAMER]);
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], $roles);
self::assertSame(
[Role::TEAMER, Role::pending(Role::ADMIN), Role::pending(Role::GROUPS_ADMIN)],
$roles,
);
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
}
public function testResultIsADedupedList(): void
public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void
{
// CrmAttributesResponseParser applies array_unique(), which preserves keys — a
// non-list would be persisted as a JSON object instead of an array.
$roles = Role::filterImportable([0 => Role::ADMIN, 2 => Role::TEAMER, 5 => Role::TEAMER]);
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]);
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles);
}
public function testRoleTheCrmNoLongerClaimsIsRevoked(): void
{
// Both halves go: BusPro is the source of truth for the granted role as much as for
// the nomination.
$roles = Role::sync([Role::TEAMER, Role::ADMIN, Role::pending(Role::MANAGER)], [Role::TEAMER]);
self::assertSame([Role::TEAMER], $roles);
self::assertSame(array_keys($roles), range(0, \count($roles) - 1));
}
public function testAccountWithOnlyPrivilegedRolesFallsBackToCustomer(): void
public function testRevokedRoleIsNotImmediatelyNominatedAgain(): void
{
self::assertSame([Role::CUSTOMER], Role::filterImportable([Role::ADMIN]));
self::assertSame([Role::CUSTOMER], Role::filterImportable([]));
self::assertSame([Role::CUSTOMER], Role::sync([Role::ADMIN], []));
}
public function testAssignedOnlyDropsTheImplicitRoleUser(): void
public function testAccountWithoutAnEffectiveRoleFallsBackToCustomer(): void
{
$roles = Role::assignedOnly([Role::USER, Role::TEAMER, Role::GROUPS_ADMIN]);
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles);
// The value is JSON-encoded into the userinfo response and must not become an object.
self::assertSame(array_keys($roles), range(0, \count($roles) - 1));
// The nomination stays visible — it is what an approver acts on — but grants nothing,
// so the account is a customer in the meantime.
self::assertSame(
[Role::pending(Role::ADMIN), Role::CUSTOMER],
Role::sync([], [Role::ADMIN]),
);
self::assertSame([Role::CUSTOMER], Role::sync([], []));
}
public function testCombineKeepsEachHalfInItsOwnLane(): void
public function testCustomerIsAFallbackAndNotABaseline(): void
{
$roles = Role::combine([Role::TEAMER, Role::ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]);
// The ADMIN from the synced half and the TEAMER from the privileged half are discarded.
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles);
self::assertSame(array_keys($roles), range(0, \count($roles) - 1));
self::assertSame([Role::TEAMER], Role::sync([Role::CUSTOMER], [Role::TEAMER]));
}
public function testCombineDropsTheImplicitRoleUser(): void
public function testUnknownClaimsAndTheImplicitRoleUserAreIgnored(): void
{
self::assertSame([Role::TEAMER], Role::combine([Role::USER, Role::TEAMER], []));
self::assertSame(
[Role::TEAMER],
Role::sync([Role::USER, Role::TEAMER], [Role::TEAMER, 'ROLE_SOMETHING_ELSE']),
);
}
public function testEveryRoleHasALabel(): void
public function testApprovalTurnsTheNominationIntoTheRole(): void
{
self::assertSame(Role::ALL, array_keys(Role::labels()));
$roles = Role::approve([Role::pending(Role::ADMIN), Role::CUSTOMER], Role::ADMIN);
// The customer fallback goes with it: the account now holds an effective role.
self::assertSame([Role::ADMIN], $roles);
}
public function testApprovingARoleWithoutANominationIsRefused(): void
{
$this->expectException(\InvalidArgumentException::class);
Role::approve([Role::TEAMER], Role::ADMIN);
}
public function testEffectiveRolesExcludeNominationsAndTheImplicitRoleUser(): void
{
$roles = [Role::USER, Role::TEAMER, Role::pending(Role::ADMIN)];
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
self::assertSame([Role::pending(Role::ADMIN)], Role::pendingOnly($roles));
self::assertSame([Role::ADMIN => 'Administration'], Role::nominatedFrom($roles));
}
public function testEveryRoleAndNominationHasALabel(): void
{
$labels = Role::labels();
foreach (Role::ALL as $role) {
self::assertArrayHasKey($role, $labels);
}
foreach (Role::ADMINISTRATIVE as $role) {
self::assertArrayHasKey(Role::pending($role), $labels);
}
}
}
@@ -36,6 +36,19 @@ class AccommodationBookingPdfGeneratorTest extends TestCase
self::assertStringContainsString('buchung-2026-07-06-schulklasse-7b.pdf', (string) $response->headers->get('Content-Disposition'));
}
public function testADocumentForAnUnacceptedOfferIsNamedAsOne(): void
{
// The document describes whatever the record is at the time it is produced, so it
// must not call an offer nobody has accepted a booking.
$booking = $this->booking();
$booking->setAcceptedAt(null);
$booking->setConfirmedAt(null);
$response = $this->createGenerator($this->breakdown())->createDownloadResponse($booking);
self::assertStringContainsString('angebot-2026-07-06-schulklasse-7b.pdf', (string) $response->headers->get('Content-Disposition'));
}
public function testABookingWithoutPricesCannotBeRendered(): void
{
$this->expectException(\RuntimeException::class);
@@ -81,6 +94,9 @@ class AccommodationBookingPdfGeneratorTest extends TestCase
$booking->setBoardServiceLabel('Vollpension');
$booking->addAdditionalServiceSnapshot('Bettwäsche', 900, 'per_person', 5);
$booking->setRemarks('Zwei Vegetarier.');
// A confirmation is only ever released for a booking the customer committed to,
// so acceptedAt is always set by the time confirmedAt is.
$booking->setAcceptedAt(new \DateTimeImmutable('2026-08-17'));
$booking->setConfirmedAt(new \DateTimeImmutable('2026-08-18'));
return $booking;
@@ -9,8 +9,9 @@ use App\Email\PdfAttachment;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingQueryParams;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
@@ -69,14 +70,14 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setType(AccommodationBookingType::Booking);
$booking->setOrigin(AccommodationBookingOrigin::Direct);
$service->issueAccessLinkForDirectBooking($booking);
self::assertNotNull($booking->getAccessLinkIssuedAt());
}
public function testIssueAccessLinkForDirectBookingNoOpsForInquiry(): void
public function testIssueAccessLinkForDirectBookingNoOpsForAnOffer(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
@@ -84,7 +85,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setType(AccommodationBookingType::Inquiry);
$booking->setOrigin(AccommodationBookingOrigin::Offer);
$service->issueAccessLinkForDirectBooking($booking);
@@ -99,7 +100,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setType(AccommodationBookingType::Booking);
$booking->setOrigin(AccommodationBookingOrigin::Direct);
$issuedAt = new \DateTimeImmutable('2026-01-01');
$booking->setAccessLinkIssuedAt($issuedAt);
@@ -267,7 +268,236 @@ class AccommodationBookingServiceTest extends TestCase
self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt());
}
public function testAcceptBookingMovesOpenInquiryToReceivedAndNotifiesTheOfficeOnly(): void
public function testAnInquiryIsPersistedAsRequestedSoTheOfficeStillOwesAnOffer(): void
{
// Angefragt, not Offen: the customer has asked, nobody has offered anything yet.
$booking = $this->persistDto(forceInquiry: true);
self::assertSame(AccommodationBookingStatus::Requested, $booking->getStatus());
self::assertSame(AccommodationBookingOrigin::Offer, $booking->getOrigin());
self::assertNull($booking->getAcceptedAt(), 'asking is not committing');
}
public function testADirectBookingIsPersistedAsReceivedAwaitingTheOffice(): void
{
$booking = $this->persistDto(forceInquiry: false);
self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
self::assertSame(AccommodationBookingOrigin::Direct, $booking->getOrigin());
self::assertNotNull($booking->getAcceptedAt(), 'booking directly is the commitment');
}
private function persistDto(bool $forceInquiry): AccommodationBooking
{
// A price covering the whole window with no minimum the booking misses, so
// computeInquiryStatus() has no reason of its own to force an inquiry.
$price = (new AccommodationPrice())
->setDateFrom(new \DateTimeImmutable('2026-07-01'))
->setDateTo(new \DateTimeImmutable('2026-09-30'))
->setIncludedPax(10)
->setMinNights(2);
$dto = new AccommodationBookingDto();
$dto->dateFrom = new \DateTimeImmutable('2026-08-01');
$dto->dateTo = new \DateTimeImmutable('2026-08-05');
$dto->paxCount = 40;
$dto->groupName = 'Schulklasse 7b';
$dto->email = '[email protected]';
$dto->forceInquiry = $forceInquiry;
$accommodation = (new Accommodation())->setName('Hotel')->setCalendarCode('HOTEL')->setMaxAdolescentAge(17);
return $this->createServiceWithAccommodation()->persist(
$dto,
$accommodation,
[$price],
['additionalServices' => [], 'boardServices' => []],
);
}
public function testSendOfferIssuesTheLinkMovesToOpenAndMailsTheCustomer(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::atLeastOnce())->method('flush');
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Requested);
$booking->setEmail('[email protected]');
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
self::anything(),
self::callback(static fn (array $options) => '[email protected]' === $options['to']
&& 'Dein Angebot ist bereit' === $options['subject']
&& 'email/accommodation_booking_customer.html.twig' === $options['template']),
);
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$service->sendOffer($booking);
self::assertSame(AccommodationBookingStatus::Open, $booking->getStatus());
self::assertNotNull($booking->getAccessLinkIssuedAt(), 'the mail links to the offer, so the link has to exist by the time it goes out');
}
public function testSendOfferPublishesAnAdminAuthoredDraftTheSameWay(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Draft);
$booking->setEmail('[email protected]');
$service = $this->createServiceWithAccommodation();
$service->sendOffer($booking);
self::assertSame(AccommodationBookingStatus::Open, $booking->getStatus());
}
public function testSendOfferKeepsAnAlreadyIssuedLinkSoEarlierLinksStayValid(): void
{
$issuedAt = new \DateTimeImmutable('2026-01-01');
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Requested);
$booking->setEmail('[email protected]');
$booking->setAccessLinkIssuedAt($issuedAt);
$service = $this->createServiceWithAccommodation();
$service->sendOffer($booking);
self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt());
}
/**
* @dataProvider statusesThatAreNotAwaitingAnOffer
*/
public function testSendOfferNoOpsOnceTheOfferIsOut(AccommodationBookingStatus $status): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::never())->method('createAndSendEmail');
$booking = new AccommodationBooking();
$booking->setStatus($status);
$booking->setEmail('[email protected]');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$service->sendOffer($booking);
self::assertSame($status, $booking->getStatus());
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function statusesThatAreNotAwaitingAnOffer(): iterable
{
yield 'open' => [AccommodationBookingStatus::Open];
yield 'received' => [AccommodationBookingStatus::Received];
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
public function testDiscardBookingClosesTheBookingSilently(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::never())->method('createAndSendEmail');
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setEmail('[email protected]');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$service->discardBooking($booking);
self::assertSame(AccommodationBookingStatus::Discarded, $booking->getStatus());
}
/**
* @dataProvider closedStatuses
*/
public function testDiscardBookingNoOpsForAClosedBooking(AccommodationBookingStatus $status): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$booking = new AccommodationBooking();
$booking->setStatus($status);
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$service->discardBooking($booking);
self::assertSame($status, $booking->getStatus());
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function closedStatuses(): iterable
{
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
/**
* The dead end this flow was rebuilt to remove: Offen used to mean both "inquiry not yet
* offered" and "offer out with the customer", and acceptBooking() additionally demanded
* the Anfrage type. A direct booking nudged into Offen therefore rendered an offer page
* whose accept button silently did nothing, forever. Offen is now reachable only through
* sendOffer(), and acceptance turns on the status alone.
*/
public function testAnOpenBookingCanAlwaysBeAcceptedWhateverItsOrigin(): void
{
foreach (AccommodationBookingOrigin::cases() as $origin) {
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setOrigin($origin);
$booking->setEmail('[email protected]');
$this->createServiceWithAccommodation()->acceptBooking($booking);
self::assertSame(
AccommodationBookingStatus::Received,
$booking->getStatus(),
sprintf('an offer that is out must be acceptable, %s origin included', $origin->value),
);
}
}
public function testTheOriginSurvivesTheWholeLifecycleUntouched(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Requested);
$booking->setOrigin(AccommodationBookingOrigin::Offer);
$booking->setEmail('[email protected]');
$service = $this->createServiceWithAccommodation();
$service->sendOffer($booking);
$service->acceptBooking($booking);
$service->confirmBooking($booking);
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
self::assertSame(
AccommodationBookingOrigin::Offer,
$booking->getOrigin(),
'where a booking came from stays true at every point of its life',
);
}
public function testAcceptBookingMovesOpenOfferToReceivedAndNotifiesTheOfficeOnly(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
@@ -293,7 +523,7 @@ class AccommodationBookingServiceTest extends TestCase
$service->acceptBooking($booking);
self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
self::assertSame(AccommodationBookingType::Inquiry, $booking->getType(), 'an accepted offer stays an Anfrage');
self::assertSame(AccommodationBookingOrigin::Offer, $booking->getOrigin(), 'accepting does not rewrite where the booking came from');
self::assertNotNull($booking->getAcceptedAt());
}
@@ -482,7 +712,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$booking = new AccommodationBooking();
$booking->setType(AccommodationBookingType::Inquiry);
$booking->setOrigin(AccommodationBookingOrigin::Offer);
$booking->setStatus(AccommodationBookingStatus::Draft);
$service->acceptBooking($booking);
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Twig;
use App\Security\Role;
use App\Twig\AppRuntime;
use PHPUnit\Framework\TestCase;
class AppRuntimeMapRolesTest extends TestCase
{
public function testStoredRolesAreLabelledLikeInTheEditForm(): void
{
self::assertSame(
['Teamer:in', 'Preisrechner'],
$this->runtime()->mapRoles([Role::TEAMER, Role::GROUPS_MANAGER]),
);
}
public function testImplicitRoleUserIsNotListed(): void
{
self::assertSame(['Administration'], $this->runtime()->mapRoles(['ROLE_USER', Role::ADMIN]));
self::assertSame([], $this->runtime()->mapRoles(['ROLE_USER']));
}
public function testUnknownRoleStaysVisible(): void
{
self::assertSame(['ROLE_LEGACY'], $this->runtime()->mapRoles(['ROLE_LEGACY']));
}
private function runtime(): AppRuntime
{
return (new \ReflectionClass(AppRuntime::class))->newInstanceWithoutConstructor();
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Tests\Twig;
use App\Security\Role;
use App\Twig\AppRuntime;
use PHPUnit\Framework\TestCase;
class AppRuntimeRoleLabelsTest extends TestCase
{
public function testEffectiveRolesAreLabelled(): void
{
self::assertSame(
['Teamer:in', 'Preisrechner'],
$this->runtime()->effectiveRoles([Role::TEAMER, Role::GROUPS_MANAGER]),
);
}
public function testImplicitRoleUserIsNotListed(): void
{
self::assertSame(['Administration'], $this->runtime()->effectiveRoles([Role::USER, Role::ADMIN]));
self::assertSame([], $this->runtime()->effectiveRoles([Role::USER]));
}
public function testNominationsAreListedApartFromTheEffectiveRoles(): void
{
$roles = [Role::TEAMER, Role::pending(Role::ADMIN)];
self::assertSame(['Teamer:in'], $this->runtime()->effectiveRoles($roles));
self::assertSame(['Administration'], $this->runtime()->nominatedRoles($roles));
}
public function testUnknownRoleStaysVisible(): void
{
self::assertSame(['ROLE_LEGACY'], $this->runtime()->effectiveRoles(['ROLE_LEGACY']));
}
private function runtime(): AppRuntime
{
return (new \ReflectionClass(AppRuntime::class))->newInstanceWithoutConstructor();
}
}