Compare commits
2
Commits
b39a78da82
...
388ecf9603
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
388ecf9603 | ||
|
|
fd5d478a5c |
No files matched your search
@@ -75,5 +75,3 @@ FEATURE_SANITIZE_UPLOADS=false
|
||||
FEATURE_STAMP_INVOICES=false
|
||||
|
||||
UPLOAD_REPLACEMENT_FILE=assets/pdf/chicken.pdf
|
||||
|
||||
PERSONAL_DATA_CHECK_DEADLINES=04-01,10-01
|
||||
+13
-1
@@ -9,6 +9,11 @@ parameters:
|
||||
|
||||
teamer_inactive_period: '-2 years'
|
||||
|
||||
# Dates (MM-DD) on which teamers must re-confirm their personal data.
|
||||
personal_data_check_deadlines:
|
||||
- '04-01'
|
||||
- '10-01'
|
||||
|
||||
# Houses, hotel code: name. The name is used both as a label and to match
|
||||
# destination.hotel, the code to match destination.hotelCode.
|
||||
houses:
|
||||
@@ -175,13 +180,20 @@ services:
|
||||
|
||||
App\RequiredTeamerCheck\RequiredTeamerCheckRegistry:
|
||||
arguments:
|
||||
# Presentation order only. Each check must point at a page that clears
|
||||
# that check alone, so a teamer can always work through them whatever
|
||||
# the order — never make correctness depend on this list.
|
||||
$checks:
|
||||
- '@App\RequiredTeamerCheck\DriverLicenseRequiredCheck'
|
||||
- '@App\RequiredTeamerCheck\FinancialDataRequiredCheck'
|
||||
- '@App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck'
|
||||
|
||||
App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck:
|
||||
arguments:
|
||||
$dataVerificationDeadlines: '%env(csv:PERSONAL_DATA_CHECK_DEADLINES)%'
|
||||
$deadlines: !service
|
||||
class: App\RequiredTeamerCheck\RecurringDeadlines
|
||||
arguments:
|
||||
$deadlines: '%personal_data_check_deadlines%'
|
||||
|
||||
app.upload_namer:
|
||||
class: App\Service\Upload\UploadNamer
|
||||
|
||||
@@ -87,12 +87,13 @@ class CrmAttributesResponse
|
||||
{
|
||||
$crmSelections = [];
|
||||
|
||||
foreach ($this->getAttributeGroups() as $group) {
|
||||
// a response the CRM sent no attributes in still has to flatten to an empty set
|
||||
foreach ($this->getAttributeGroups() ?? [] as $group) {
|
||||
/** @var CrmAttributeGroup $group */
|
||||
if (false === isset($crmSelections[$group->getLabel()])) {
|
||||
$crmSelections[$group->getLabel()] = [];
|
||||
}
|
||||
foreach ($group->getAttributes() as $attribute) {
|
||||
foreach ($group->getAttributes() ?? [] as $attribute) {
|
||||
/** @var CrmAttribute $attribute */
|
||||
if (false === $attribute->isSelected()) {
|
||||
continue;
|
||||
|
||||
@@ -165,10 +165,11 @@ class UserDataHandler
|
||||
/**
|
||||
* Updates an existing user from BusPro data.
|
||||
*
|
||||
* Roles and hotel codes are imported once on user creation only and are managed
|
||||
* manually afterwards, so they are intentionally left untouched here. The only
|
||||
* exception are the privilege-free pending markers, which keep tracking the
|
||||
* administrative roles claimed in the CRM.
|
||||
* Administrative roles and hotel codes are imported once on user creation only and are
|
||||
* managed manually afterwards, so they are intentionally left untouched here. The
|
||||
* exceptions are the privilege-free pending markers, which keep tracking the
|
||||
* administrative roles claimed in the CRM, and ROLE_TEAMER, which needs no approval
|
||||
* and is granted to whoever the CRM reports as a teamer.
|
||||
*
|
||||
* @param string[] $claimedRoles pending markers as returned by collectPendingRoles()
|
||||
*/
|
||||
@@ -188,6 +189,8 @@ class UserDataHandler
|
||||
$this->refreshPendingRoles($user, $claimedRoles);
|
||||
|
||||
if (true === $isTeamer) {
|
||||
$this->grantTeamerRole($user);
|
||||
|
||||
$address = Address::fromApiResponse($profileResponse);
|
||||
$communication = Communication::fromApiResponse($profileResponse);
|
||||
if (null === $user->getTeamer()) {
|
||||
@@ -221,15 +224,19 @@ class UserDataHandler
|
||||
/**
|
||||
* Blocks a user the CRM no longer grants anything in this application.
|
||||
*
|
||||
* The granted roles are deliberately kept: they stay visible for review and are what
|
||||
* makes the user reappear in the administrative list, where a super admin can unblock
|
||||
* them. Only the privilege-free markers are dropped, as they no longer reflect the CRM.
|
||||
* The granted roles are deliberately kept: they stay visible for review. Only the
|
||||
* privilege-free markers are dropped, as they no longer reflect the CRM. What keeps the
|
||||
* user reachable is the block itself, not the roles: a teamer stays in the teamer list,
|
||||
* everyone else is listed by getAdministrativeUsers() whatever roles are left.
|
||||
* Regaining a CRM role does not unblock the account, that is a manual decision.
|
||||
*/
|
||||
public function disableForRevokedCrmRoles(User $user): void
|
||||
{
|
||||
// an existing block may be a disciplinary one and must never be overwritten
|
||||
// an existing block may be a disciplinary one and must never be overwritten, but
|
||||
// findLocalUser() may have refreshed the BusPro ids and nothing else flushes here
|
||||
if (true === $user->isDisabled()) {
|
||||
$this->entityManager->flush();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -249,6 +256,33 @@ class UserDataHandler
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants ROLE_TEAMER to a user the CRM reports as a teamer.
|
||||
*
|
||||
* Unlike the administrative roles this one needs no approval, so it may be granted on
|
||||
* any login rather than on creation only: it carries no privileges beyond the teamer
|
||||
* area, and a teamer without it would be left with a teamer record they cannot reach,
|
||||
* or locked out entirely for holding no assignable role at all.
|
||||
*
|
||||
* It is never withdrawn here. Losing the CRM attribute while holding no other role
|
||||
* blocks the account anyway, and a role handed out manually must survive a login.
|
||||
*/
|
||||
private function grantTeamerRole(User $user): void
|
||||
{
|
||||
$grantedRoles = $user->getAssignedRoles();
|
||||
|
||||
if (true === in_array('ROLE_TEAMER', $grantedRoles, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user->setRoles([...$grantedRoles, 'ROLE_TEAMER', ...$user->getPendingRoles()]);
|
||||
|
||||
$this->logger->info('Grant teamer role', [
|
||||
'user_id' => $user->getId(),
|
||||
'user_email' => $user->getEmail(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the pending markers in sync with the administrative roles claimed in the CRM.
|
||||
* The markers grant no privileges, so tracking them on every login is safe: only a
|
||||
|
||||
@@ -29,7 +29,7 @@ class DisableUserController extends AbstractController
|
||||
$form = $this->createForm(DisableUserType::class, $user, ['hx_post' => $request->getUri()]);
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$user->setDisabledAt(new \DateTimeImmutable());
|
||||
$user->setDisabled(true);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
@@ -56,11 +56,7 @@ class DisableUserController extends AbstractController
|
||||
$user = $teamer->getUser();
|
||||
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
$user
|
||||
->setDisabledAt(null)
|
||||
->setDisabledReason(null)
|
||||
->setDisabledReasonInternal(null)
|
||||
;
|
||||
$user->setDisabled(false);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Teamer\Check;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\Form\FinancialDataType;
|
||||
use App\Model\FinancialDataDto;
|
||||
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\Http\Attribute\IsGranted;
|
||||
|
||||
class FinancialDataController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/teamer/check/financial-data', name: 'app_teamer_check_financial_data')]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$teamer = $user->getTeamer();
|
||||
|
||||
if (null === $teamer) {
|
||||
$teamer = new Teamer();
|
||||
$user->setTeamer($teamer);
|
||||
$this->entityManager->persist($teamer);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$formData = FinancialDataDto::fromTeamer($teamer);
|
||||
$form = $this->createForm(FinancialDataType::class, $formData);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if (true === $form->isSubmitted() && true === $form->isValid()) {
|
||||
$formData->applyTo($teamer);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Deine Angaben wurden gespeichert.');
|
||||
$this->logger->info('Submit teamer financial data', [
|
||||
'teamer_id' => $teamer->getId(),
|
||||
'teamer_name' => (string) $teamer,
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_teamer_index');
|
||||
}
|
||||
|
||||
return $this->render('teamer/check/financial_data.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'teamer' => $teamer,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Teamer\Check;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\Form\PersonalDataConfirmationType;
|
||||
use App\Model\PersonalDataConfirmationDto;
|
||||
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\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
class PersonalDataController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ValidatorInterface $validator,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/teamer/check/personal-data', name: 'app_teamer_check_personal_data')]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$teamer = $user->getTeamer();
|
||||
|
||||
if (null === $teamer) {
|
||||
$teamer = new Teamer();
|
||||
$user->setTeamer($teamer);
|
||||
$this->entityManager->persist($teamer);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$form = $this->createForm(PersonalDataConfirmationType::class, new PersonalDataConfirmationDto());
|
||||
$form->handleRequest($request);
|
||||
|
||||
if (true === $form->isSubmitted() && true === $form->isValid()) {
|
||||
$teamer->setDataVerifiedAt(new \DateTimeImmutable());
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Vielen Dank, deine Daten wurden bestätigt.');
|
||||
$this->logger->info('Confirm teamer personal data', [
|
||||
'teamer_id' => $teamer->getId(),
|
||||
'teamer_name' => (string) $teamer,
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_teamer_index');
|
||||
}
|
||||
|
||||
return $this->render('teamer/check/personal_data.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'teamer' => $teamer,
|
||||
// surfaced as a hint only, deliberately not blocking the confirmation
|
||||
'errors' => $this->validator->validate($teamer, null, ['profile_preflight']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
use App\Form\TeamerProfileType;
|
||||
use App\Model\UploadSessionDto;
|
||||
use App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck;
|
||||
use App\Service\Upload\UploadHandler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -28,7 +27,6 @@ class IndexController extends AbstractController
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly UploadHandler $uploadHandler,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly PersonalDataVerificationRequiredCheck $personalDataVerificationRequiredCheck,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -55,12 +53,8 @@ class IndexController extends AbstractController
|
||||
// Validate teamer data to show missing data right away
|
||||
$errors = $this->validator->validate($teamer, null, ['profile_preflight']);
|
||||
|
||||
$verificationMode = true === $this->personalDataVerificationRequiredCheck->appliesTo($user)
|
||||
&& false === $this->personalDataVerificationRequiredCheck->isSatisfied($user);
|
||||
|
||||
$form = $this->createForm(TeamerProfileType::class, $teamer, [
|
||||
'upload_session' => $uploadSession,
|
||||
'verification_mode' => $verificationMode,
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
@@ -72,9 +66,9 @@ class IndexController extends AbstractController
|
||||
} catch (ApiClientException $e) {
|
||||
}
|
||||
|
||||
if (true === $verificationMode) {
|
||||
$teamer->setDataVerifiedAt(new \DateTimeImmutable());
|
||||
}
|
||||
// Saving the full profile form is a stronger statement than ticking the
|
||||
// confirmation box, so it clears the verification check too.
|
||||
$teamer->setDataVerifiedAt(new \DateTimeImmutable());
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
@@ -91,7 +85,6 @@ class IndexController extends AbstractController
|
||||
'form' => $form->createView(),
|
||||
'teamer' => $teamer,
|
||||
'errors' => $errors,
|
||||
'verification_mode' => $verificationMode,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ class RequiredTeamerCheckSubscriber implements EventSubscriberInterface
|
||||
|
||||
if (true === in_array($route, [
|
||||
$check->getRouteName(),
|
||||
// the profile is where data gets corrected, so it has to stay reachable
|
||||
// while a check is outstanding - otherwise a teamer asked to confirm
|
||||
// wrong data has no way to fix it first
|
||||
'app_teamer_profile_index',
|
||||
'app_upload_delete',
|
||||
'app_security_logout',
|
||||
], true)) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Model\FinancialDataDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class FinancialDataType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('taxId', TextType::class, [
|
||||
'label' => 'Steuer-ID',
|
||||
])
|
||||
->add('iban', TextType::class, [
|
||||
'label' => 'IBAN',
|
||||
])
|
||||
->add('bic', TextType::class, [
|
||||
'label' => 'BIC',
|
||||
])
|
||||
->add('bank', TextType::class, [
|
||||
'label' => 'Name der Bank',
|
||||
])
|
||||
->add('holder', TextType::class, [
|
||||
'label' => 'Kontoinhaber',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => FinancialDataDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Model\PersonalDataConfirmationDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class PersonalDataConfirmationType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('confirmed', CheckboxType::class, [
|
||||
'label' => 'Ich bestätige, dass meine Daten korrekt und aktuell sind',
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => PersonalDataConfirmationDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use App\Model\UploadSessionDto;
|
||||
use App\Service\Upload\UploadHandler;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
@@ -15,7 +14,6 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\IsTrue;
|
||||
|
||||
class TeamerProfileType extends AbstractType
|
||||
{
|
||||
@@ -96,19 +94,6 @@ class TeamerProfileType extends AbstractType
|
||||
],
|
||||
])
|
||||
;
|
||||
|
||||
if (true === $options['verification_mode']) {
|
||||
$builder->add('confirmDataVerification', CheckboxType::class, [
|
||||
'label' => 'Ich bestätige, dass meine Daten korrekt und aktuell sind',
|
||||
'mapped' => false,
|
||||
'constraints' => [
|
||||
new IsTrue([
|
||||
'message' => 'Deine Bestätigung ist erforderlich',
|
||||
'groups' => ['profile'],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
@@ -128,11 +113,9 @@ class TeamerProfileType extends AbstractType
|
||||
'validation_groups' => [
|
||||
'profile',
|
||||
],
|
||||
'verification_mode' => false,
|
||||
])
|
||||
->setRequired(['upload_session'])
|
||||
->setAllowedTypes('upload_session', UploadSessionDto::class)
|
||||
->setAllowedTypes('verification_mode', 'bool')
|
||||
;
|
||||
}
|
||||
}
|
||||
+25
-2
@@ -33,8 +33,6 @@ class UserType extends AbstractType
|
||||
'required' => false,
|
||||
'help' => 'Setzt die Rolle Admin voraus.',
|
||||
])
|
||||
// must stay ahead of the reason: properties are written in field order and
|
||||
// unblocking clears the reasons
|
||||
->add('disabled', CheckboxType::class, [
|
||||
'label' => 'Account gesperrt',
|
||||
'required' => false,
|
||||
@@ -49,8 +47,33 @@ class UserType extends AbstractType
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
],
|
||||
])
|
||||
->add('disabledReasonInternal', TextareaType::class, [
|
||||
'label' => 'Begründung intern',
|
||||
'required' => false,
|
||||
'help' => 'Wird der Benutzer:in nicht angezeigt.',
|
||||
'attr' => [
|
||||
'data-controller' => 'textarea-autosize',
|
||||
'data-action' => 'textarea-autosize#resize',
|
||||
],
|
||||
])
|
||||
;
|
||||
|
||||
// the reasons only ever describe a block, so an unblocked account carries none.
|
||||
// Done after mapping instead of relying on the field order, as the submitted text
|
||||
// would otherwise be written back over the reset done by setDisabled()
|
||||
$builder->addEventListener(FormEvents::POST_SUBMIT, static function (FormEvent $event): void {
|
||||
$user = $event->getData();
|
||||
|
||||
if (false === $user instanceof User || true === $user->isDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user
|
||||
->setDisabledReason(null)
|
||||
->setDisabledReasonInternal(null)
|
||||
;
|
||||
});
|
||||
|
||||
// hotel codes already assigned to the user may predate the catalog, so they are
|
||||
// added as choices to keep them selectable instead of failing
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Embeddable\BankAccount;
|
||||
use App\Entity\Teamer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class FinancialDataDto
|
||||
{
|
||||
#[Assert\NotBlank(message: 'Bitte gib deine SteuerID an')]
|
||||
private ?string $taxId = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte gib die IBAN an')]
|
||||
#[Assert\Iban(message: 'Bitte gib eine gültige IBAN an')]
|
||||
private ?string $iban = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte gib den BIC an')]
|
||||
private ?string $bic = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte gib den Namen deiner Bank an')]
|
||||
private ?string $bank = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte gib den Kontoinhaber an')]
|
||||
private ?string $holder = null;
|
||||
|
||||
public static function fromTeamer(Teamer $teamer): self
|
||||
{
|
||||
$instance = (new self())->setTaxId($teamer->getTaxId());
|
||||
|
||||
if (null === $bankAccount = $teamer->getBankAccount()) {
|
||||
return $instance;
|
||||
}
|
||||
|
||||
return $instance
|
||||
->setIban($bankAccount->getIban())
|
||||
->setBic($bankAccount->getBic())
|
||||
->setBank($bankAccount->getBank())
|
||||
->setHolder($bankAccount->getHolder())
|
||||
;
|
||||
}
|
||||
|
||||
public function applyTo(Teamer $teamer): void
|
||||
{
|
||||
if (null === $bankAccount = $teamer->getBankAccount()) {
|
||||
$bankAccount = new BankAccount();
|
||||
$teamer->setBankAccount($bankAccount);
|
||||
}
|
||||
|
||||
$bankAccount
|
||||
->setIban($this->getIban())
|
||||
->setBic($this->getBic())
|
||||
->setBank($this->getBank())
|
||||
->setHolder($this->getHolder())
|
||||
;
|
||||
|
||||
$teamer->setTaxId($this->getTaxId());
|
||||
}
|
||||
|
||||
public function getTaxId(): ?string
|
||||
{
|
||||
return $this->taxId;
|
||||
}
|
||||
|
||||
public function setTaxId(?string $taxId): static
|
||||
{
|
||||
$this->taxId = $taxId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIban(): ?string
|
||||
{
|
||||
return $this->iban;
|
||||
}
|
||||
|
||||
public function setIban(?string $iban): static
|
||||
{
|
||||
$this->iban = $iban;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBic(): ?string
|
||||
{
|
||||
return $this->bic;
|
||||
}
|
||||
|
||||
public function setBic(?string $bic): static
|
||||
{
|
||||
$this->bic = $bic;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBank(): ?string
|
||||
{
|
||||
return $this->bank;
|
||||
}
|
||||
|
||||
public function setBank(?string $bank): static
|
||||
{
|
||||
$this->bank = $bank;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHolder(): ?string
|
||||
{
|
||||
return $this->holder;
|
||||
}
|
||||
|
||||
public function setHolder(?string $holder): static
|
||||
{
|
||||
$this->holder = $holder;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class PersonalDataConfirmationDto
|
||||
{
|
||||
#[Assert\IsTrue(message: 'Deine Bestätigung ist erforderlich')]
|
||||
private ?bool $confirmed = false;
|
||||
|
||||
public function getConfirmed(): ?bool
|
||||
{
|
||||
return $this->confirmed;
|
||||
}
|
||||
|
||||
public function setConfirmed(?bool $confirmed): static
|
||||
{
|
||||
$this->confirmed = $confirmed;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ class UserRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Users holding an administrative role or awaiting approval for one, plus blocked
|
||||
* accounts without a teamer. A user demoted by the CRM may keep no role that would list
|
||||
* them, and a block is only ever lifted from a list: teamers are unblocked from theirs,
|
||||
* everyone else has none but this one.
|
||||
*
|
||||
* @return User[]
|
||||
*/
|
||||
public function getAdministrativeUsers(): array
|
||||
@@ -41,6 +46,11 @@ class UserRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
$qb->orWhere($qb->expr()->andX(
|
||||
$qb->expr()->isNotNull('u.disabledAt'),
|
||||
$qb->expr()->isNull('u.teamer'),
|
||||
));
|
||||
|
||||
return $qb
|
||||
->orderBy('u.lastName', 'ASC')
|
||||
->getQuery()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\RequiredTeamerCheck;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
class FinancialDataRequiredCheck extends AbstractRequiredTeamerCheck
|
||||
{
|
||||
public function getCode(): string
|
||||
{
|
||||
return 'financial_data';
|
||||
}
|
||||
|
||||
public function isSatisfied(User $user): bool
|
||||
{
|
||||
$teamer = $user->getTeamer();
|
||||
|
||||
if (null === $teamer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === empty($teamer->getTaxId())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$bankAccount = $teamer->getBankAccount();
|
||||
|
||||
if (null === $bankAccount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === empty($bankAccount->getIban())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === empty($bankAccount->getBic())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === empty($bankAccount->getBank())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === empty($bankAccount->getHolder())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getRouteName(): string
|
||||
{
|
||||
return 'app_teamer_check_financial_data';
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use Carbon\CarbonImmutable;
|
||||
class PersonalDataVerificationRequiredCheck extends AbstractRequiredTeamerCheck
|
||||
{
|
||||
public function __construct(
|
||||
private readonly array $dataVerificationDeadlines = ['04-01', '10-01'],
|
||||
private readonly RecurringDeadlines $deadlines,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -26,84 +26,28 @@ class PersonalDataVerificationRequiredCheck extends AbstractRequiredTeamerCheck
|
||||
return false;
|
||||
}
|
||||
|
||||
return false === $this->isVerificationRequired($teamer);
|
||||
return false === $this->deadlines->isDue(
|
||||
$teamer->getDataVerifiedAt(),
|
||||
$this->getRegisteredAt($teamer),
|
||||
CarbonImmutable::now(),
|
||||
);
|
||||
}
|
||||
|
||||
public function getRouteName(): string
|
||||
{
|
||||
return 'app_teamer_profile_index';
|
||||
}
|
||||
|
||||
private function isVerificationRequired(Teamer $teamer): bool
|
||||
{
|
||||
$now = CarbonImmutable::now()->startOfDay();
|
||||
$deadline = $this->getCurrentDeadline($now);
|
||||
|
||||
if (null === $deadline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === $this->isRegisteredAfterAnyCurrentYearDeadline($teamer, $now)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dataVerifiedAt = $teamer->getDataVerifiedAt();
|
||||
|
||||
if (null === $dataVerifiedAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return CarbonImmutable::instance($dataVerifiedAt) < $deadline;
|
||||
}
|
||||
|
||||
private function isRegisteredAfterAnyCurrentYearDeadline(Teamer $teamer, CarbonImmutable $now): bool
|
||||
{
|
||||
try {
|
||||
$registeredAt = CarbonImmutable::instance($teamer->getCreatedAt())->startOfDay();
|
||||
} catch (\TypeError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->getDeadlinesForYear($now) as $deadline) {
|
||||
if ($registeredAt > $deadline) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return 'app_teamer_check_personal_data';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return CarbonImmutable[]
|
||||
* Teamer::getCreatedAt() is declared non-nullable but backed by a nullable
|
||||
* column, so it throws for an unpersisted teamer.
|
||||
*/
|
||||
private function getDeadlinesForYear(CarbonImmutable $now): array
|
||||
private function getRegisteredAt(Teamer $teamer): ?\DateTimeImmutable
|
||||
{
|
||||
$deadlines = [];
|
||||
|
||||
foreach (array_filter(array_map('trim', $this->dataVerificationDeadlines)) as $date) {
|
||||
$deadline = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%s-%s', $now->format('Y'), $date));
|
||||
|
||||
if (false === $deadline instanceof CarbonImmutable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deadlines[] = $deadline->startOfDay();
|
||||
try {
|
||||
return $teamer->getCreatedAt();
|
||||
} catch (\TypeError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $deadlines;
|
||||
}
|
||||
|
||||
private function getCurrentDeadline(CarbonImmutable $now): ?CarbonImmutable
|
||||
{
|
||||
$deadlines = $this->getDeadlinesForYear($now);
|
||||
rsort($deadlines);
|
||||
|
||||
foreach ($deadlines as $deadline) {
|
||||
if ($deadline <= $now) {
|
||||
return $deadline;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\RequiredTeamerCheck;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* A set of yearly recurring dates after which something must be confirmed again.
|
||||
*
|
||||
* Belongs to the individual check that composes it, never to the application as a
|
||||
* whole: a deadline expresses "this particular confirmation has expired", not
|
||||
* "portal access has expired".
|
||||
*/
|
||||
final class RecurringDeadlines
|
||||
{
|
||||
/**
|
||||
* @param string[] $deadlines dates as MM-DD, e.g. ['04-01', '10-01']; an empty
|
||||
* list is never due
|
||||
*/
|
||||
public function __construct(private readonly array $deadlines)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a confirmation is outstanding: never confirmed, or last confirmed
|
||||
* before the most recent deadline that has passed.
|
||||
*/
|
||||
public function isDue(?\DateTimeInterface $confirmedAt, ?\DateTimeInterface $registeredAt, CarbonImmutable $now): bool
|
||||
{
|
||||
$deadline = $this->getCurrentDeadline($now);
|
||||
|
||||
if (null === $deadline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === $this->isRegisteredAfterAnyDeadline($registeredAt, $now)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (null === $confirmedAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return CarbonImmutable::instance($confirmedAt) < $deadline;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent deadline that has already passed, or null when none has.
|
||||
*/
|
||||
public function getCurrentDeadline(CarbonImmutable $now): ?CarbonImmutable
|
||||
{
|
||||
$deadlines = $this->getDeadlinesForYear($now);
|
||||
rsort($deadlines);
|
||||
|
||||
foreach ($deadlines as $deadline) {
|
||||
if ($deadline <= $now) {
|
||||
return $deadline;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Someone who registered after a deadline has effectively confirmed their data
|
||||
* by registering, so they are exempt until the next one.
|
||||
*/
|
||||
private function isRegisteredAfterAnyDeadline(?\DateTimeInterface $registeredAt, CarbonImmutable $now): bool
|
||||
{
|
||||
if (null === $registeredAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$registeredAt = CarbonImmutable::instance($registeredAt)->startOfDay();
|
||||
|
||||
foreach ($this->getDeadlinesForYear($now) as $deadline) {
|
||||
if ($registeredAt > $deadline) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return CarbonImmutable[]
|
||||
*/
|
||||
private function getDeadlinesForYear(CarbonImmutable $now): array
|
||||
{
|
||||
$deadlines = [];
|
||||
|
||||
foreach (array_filter(array_map('trim', $this->deadlines)) as $date) {
|
||||
$deadline = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%s-%s', $now->format('Y'), $date));
|
||||
|
||||
if (false === $deadline instanceof CarbonImmutable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deadlines[] = $deadline->startOfDay();
|
||||
}
|
||||
|
||||
return $deadlines;
|
||||
}
|
||||
}
|
||||
@@ -112,12 +112,16 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
): ?User {
|
||||
// Fetch CRM attributes, early return in case of an API error
|
||||
try {
|
||||
/** @var CrmAttributesResponse $crmAttributes */
|
||||
$crmAttributes = $this->apiClient->getCrmAttributes($email, $password);
|
||||
} catch (ApiClientException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// BusPro answers with a notification record instead of the data on its own errors
|
||||
if (false === $crmAttributes instanceof CrmAttributesResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Flatten selected CRM attributes
|
||||
$crmSelections = $crmAttributes->toArray();
|
||||
|
||||
@@ -140,6 +144,20 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
// are no user of it: never create an account, block an existing one. Returning
|
||||
// the blocked user lets the UserChecker explain why the login was refused.
|
||||
if ([] === $claimedRoles) {
|
||||
// A response without any attribute group carries no roles either, so it looks
|
||||
// exactly like a revocation while it really means the CRM told us nothing:
|
||||
// an empty payload, a changed schema, a misconfigured attribute id. Blocking
|
||||
// on that would lock out every user logging in, so refuse this single login
|
||||
// instead and leave the account alone.
|
||||
if ([] === ($crmAttributes->getAttributeGroups() ?? [])) {
|
||||
$this->logger->warning('Skip demotion: CRM attributes response carries no attribute groups', [
|
||||
'user_id' => $user?->getId(),
|
||||
'user_email' => $email,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (null === $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class FeedbackReminderService
|
||||
|
||||
$hotelManagers = $this
|
||||
->userRepository
|
||||
->getUsersByRoleAndHotelCode('ROLE_HOTEL_MANAGER', $hotelBaseCodes)
|
||||
->getUsersByRoleAndHotelCode('ROLE_HOUSE_MANAGER', $hotelBaseCodes)
|
||||
;
|
||||
|
||||
if (0 === count($hotelManagers)) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{{ form_row(form.hotelCodes) }}
|
||||
{{ form_row(form.disabled) }}
|
||||
{{ form_row(form.disabledReason) }}
|
||||
{{ form_row(form.disabledReasonInternal) }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button type="submit" class="btn">
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends 'teamer/layout.html.twig' %}
|
||||
|
||||
{% block title %}Honorardaten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form) }}
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-2xl font-bold pb-2">
|
||||
Bankverbindung und Steuer-ID
|
||||
</h1>
|
||||
<p class="pb-6">
|
||||
Damit wir dein Honorar auszahlen können, benötigen wir einmalig deine Steuer-ID und deine
|
||||
Bankverbindung. Bitte ergänze die folgenden Angaben:
|
||||
</p>
|
||||
|
||||
{% if not form.vars.valid %}
|
||||
{% embed '_partials/_alert.html.twig' with { 'type': 'warning' } %}
|
||||
{% block message %}
|
||||
{% include '_partials/_form_errors.html.twig' with { 'form': form } %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-col space-y-4 pb-6">
|
||||
{{ form_row(form.taxId) }}
|
||||
{{ form_row(form.iban) }}
|
||||
{{ form_row(form.bic) }}
|
||||
{{ form_row(form.bank) }}
|
||||
{{ form_row(form.holder) }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends 'teamer/layout.html.twig' %}
|
||||
|
||||
{% block title %}Datenbestätigung{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form) }}
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-2xl font-bold pb-2">
|
||||
Datenbestätigung
|
||||
</h1>
|
||||
<p class="pb-6">
|
||||
Bitte prüfe, ob deine hinterlegten Daten noch aktuell sind, und bestätige sie.
|
||||
</p>
|
||||
|
||||
{% if not form.vars.valid %}
|
||||
{% embed '_partials/_alert.html.twig' with { 'type': 'warning' } %}
|
||||
{% block message %}
|
||||
{% include '_partials/_form_errors.html.twig' with { 'form': form } %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endif %}
|
||||
|
||||
<div class="border border-gray-300 rounded-md p-4 mb-6">
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div>
|
||||
<div class="font-bold">Name</div>
|
||||
<div>{{ teamer.fullName }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold">Geburtsdatum</div>
|
||||
<div>{{ teamer.dateOfBirth ? teamer.dateOfBirth|date('d.m.Y') : '-' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold">Anschrift</div>
|
||||
<div>
|
||||
{{ teamer.address.street|default('-') }}<br>
|
||||
{{ teamer.address.postCode|default('') }} {{ teamer.address.city|default('') }}<br>
|
||||
{{ teamer.address.country|default('') }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold">Kontakt</div>
|
||||
<div>
|
||||
{{ teamer.communication.email|default('-') }}<br>
|
||||
{{ teamer.communication.mobile|default('-') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if errors|length %}
|
||||
{% embed '_partials/_alert.html.twig' with { 'type': 'warning' } %}
|
||||
{% block message %}
|
||||
Einige Angaben in deinem Profil fehlen noch:
|
||||
<ul class="list-disc list-inside pt-2">
|
||||
{% for error in errors %}
|
||||
<li>{{ error.message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="{{ path('app_teamer_profile_index') }}" class="underline font-bold inline-block pt-2">
|
||||
Im Profil ergänzen
|
||||
</a>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endif %}
|
||||
|
||||
<p class="pb-4">
|
||||
Stimmt etwas nicht?
|
||||
<a href="{{ path('app_teamer_profile_index') }}" class="underline font-bold">Daten im Profil ändern</a>
|
||||
</p>
|
||||
|
||||
<div class="pb-6">
|
||||
{{ form_row(form.confirmed) }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">
|
||||
Bestätigen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -53,9 +53,6 @@
|
||||
<h1 class="text-2xl font-bold pb-8">
|
||||
Mein Profil
|
||||
</h1>
|
||||
{% if verification_mode %}
|
||||
{% include '_partials/_alert.html.twig' with { 'message': 'Bitte überprüfe deine Daten und bestätige sie am Ende des Formulars.' } %}
|
||||
{% endif %}
|
||||
{% if not form.vars.valid %}
|
||||
<div class="border border-red-500 rounded-md p-4 text-red-500 mb-8">
|
||||
{% include '_partials/_form_errors.html.twig' with { 'form': form } %}
|
||||
@@ -192,14 +189,8 @@
|
||||
} %}
|
||||
</div>
|
||||
|
||||
{% if verification_mode %}
|
||||
<div class="pb-4">
|
||||
{{ form_row(form.confirmDataVerification) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn" {{ stimulus_target('form-upload-guard', 'submit') }}>
|
||||
{{ verification_mode ? 'Daten bestätigen' : 'Aktualisieren' }}
|
||||
Aktualisieren
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,59 @@ class ResponseParserTest extends TestCase
|
||||
$this->assertTrue($response->isTeamer());
|
||||
}
|
||||
|
||||
/**
|
||||
* BusPro always returns the full attribute tree and expresses the roles a person holds
|
||||
* through the "auswahl" flag, so a revoked role arrives as a selection set to False,
|
||||
* never as a missing group. The demotion path in BpnAuthenticator relies on that: it
|
||||
* treats "no roles" as a revocation only when attribute groups are present.
|
||||
*/
|
||||
public function testParseCrmAttributesOfAUserHoldingEveryRole(): void
|
||||
{
|
||||
$parser = $this->getParserInstance();
|
||||
$response = $parser->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $this->loadFixture('crm_attributes_granted.xml'));
|
||||
|
||||
$this->assertInstanceOf(CrmAttributesResponse::class, $response);
|
||||
$this->assertTrue($response->isTeamer());
|
||||
$this->assertTrue($response->isAdmin());
|
||||
$this->assertTrue($response->isManager());
|
||||
$this->assertTrue($response->isHouseManager());
|
||||
$this->assertSame(['DKS'], $response->getHotelCodes());
|
||||
|
||||
// "Preisrechner Admin" is matched by id, never by its label
|
||||
$this->assertCount(3, $response->getAttributeGroups());
|
||||
}
|
||||
|
||||
public function testParseCrmAttributesOfAUserWhoseRolesWereRevoked(): void
|
||||
{
|
||||
$parser = $this->getParserInstance();
|
||||
$response = $parser->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $this->loadFixture('crm_attributes_revoked.xml'));
|
||||
|
||||
$this->assertInstanceOf(CrmAttributesResponse::class, $response);
|
||||
$this->assertFalse($response->isTeamer());
|
||||
$this->assertFalse($response->isAdmin());
|
||||
$this->assertFalse($response->isManager());
|
||||
$this->assertFalse($response->isHouseManager());
|
||||
$this->assertSame([], $response->getHotelCodes());
|
||||
|
||||
// the groups still arrive, so this is a revocation and not a degraded response
|
||||
$this->assertCount(3, $response->getAttributeGroups());
|
||||
}
|
||||
|
||||
public function testParseCrmAttributesOfAnEmptyResponse(): void
|
||||
{
|
||||
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>SelektionCRM</art><idadresse>141747</idadresse><idperson>224526</idperson><selektionsmerkmale></selektionsmerkmale></ergebnis>';
|
||||
|
||||
$parser = $this->getParserInstance();
|
||||
$response = $parser->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content);
|
||||
|
||||
// indistinguishable from a revocation by the roles alone, which is why the empty
|
||||
// group set is what the demotion path checks
|
||||
$this->assertInstanceOf(CrmAttributesResponse::class, $response);
|
||||
$this->assertFalse($response->isTeamer());
|
||||
$this->assertSame([], $response->getAttributeGroups());
|
||||
$this->assertSame([], $response->toArray());
|
||||
}
|
||||
|
||||
public function testParseSuccessfulProfileUpdateResponseWithoutAddressData(): void
|
||||
{
|
||||
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Adressdaten_Ändern</art><idadresse>141747</idadresse><idperson>224526</idperson><änderung>True</änderung></ergebnis>';
|
||||
@@ -106,6 +159,11 @@ class ResponseParserTest extends TestCase
|
||||
$this->assertEquals('Gaststätte', $hotel->getType());
|
||||
}
|
||||
|
||||
private function loadFixture(string $filename): string
|
||||
{
|
||||
return file_get_contents(__DIR__.'/../Resources/'.$filename);
|
||||
}
|
||||
|
||||
private function getParserInstance(): ResponseParser
|
||||
{
|
||||
return new ResponseParser([
|
||||
|
||||
@@ -213,6 +213,54 @@ class UserDataHandlerTest extends TestCase
|
||||
];
|
||||
}
|
||||
|
||||
public function testUpdateLocalUserGrantsTheTeamerRoleToAUserWhoBecameATeamer(): void
|
||||
{
|
||||
// created as a candidate for approval, made a teamer in the CRM afterwards
|
||||
$user = (new User())
|
||||
->setFirstName('First')
|
||||
->setLastName('Last')
|
||||
->setEmail('[email protected]')
|
||||
->setRoles([User::PENDING_ROLES['ROLE_ADMIN']])
|
||||
;
|
||||
|
||||
$handler = new UserDataHandler($this->entityManager, $this->logger);
|
||||
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], [User::PENDING_ROLES['ROLE_ADMIN']]);
|
||||
|
||||
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
|
||||
$this->assertSame([User::PENDING_ROLES['ROLE_ADMIN']], $user->getPendingRoles());
|
||||
}
|
||||
|
||||
public function testUpdateLocalUserKeepsTheTeamerRoleOfSomebodyTheCrmNoLongerReportsAsTeamer(): void
|
||||
{
|
||||
// the role may have been granted manually and must survive a login
|
||||
$user = (new User())
|
||||
->setFirstName('First')
|
||||
->setLastName('Last')
|
||||
->setEmail('[email protected]')
|
||||
->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER'])
|
||||
;
|
||||
|
||||
$handler = new UserDataHandler($this->entityManager, $this->logger);
|
||||
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], []);
|
||||
|
||||
$this->assertSame(['ROLE_ADMIN', 'ROLE_TEAMER'], $user->getAssignedRoles());
|
||||
}
|
||||
|
||||
public function testUpdateLocalUserGrantsTheTeamerRoleOnlyOnce(): void
|
||||
{
|
||||
$user = (new User())
|
||||
->setFirstName('First')
|
||||
->setLastName('Last')
|
||||
->setEmail('[email protected]')
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
;
|
||||
|
||||
$handler = new UserDataHandler($this->entityManager, $this->logger);
|
||||
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], []);
|
||||
|
||||
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
|
||||
}
|
||||
|
||||
public function testDisableForRevokedCrmRolesBlocksTheUserAndDropsThePendingMarkers(): void
|
||||
{
|
||||
$user = (new User())
|
||||
@@ -239,7 +287,7 @@ class UserDataHandlerTest extends TestCase
|
||||
$this->assertSame([], $user->getPendingRoles());
|
||||
}
|
||||
|
||||
public function testDisableForRevokedCrmRolesLeavesAnExistingBlockUntouched(): void
|
||||
public function testDisableForRevokedCrmRolesLeavesAnExistingBlockUntouchedButStillFlushes(): void
|
||||
{
|
||||
$disabledAt = new \DateTimeImmutable('2026-01-01 08:00:00');
|
||||
|
||||
@@ -253,8 +301,9 @@ class UserDataHandlerTest extends TestCase
|
||||
->setDisabledReasonInternal('Siehe Vorgang 4711.')
|
||||
;
|
||||
|
||||
// findLocalUser() may have refreshed the BusPro ids on the way here
|
||||
$this->entityManager
|
||||
->expects($this->never())
|
||||
->expects($this->once())
|
||||
->method('flush');
|
||||
|
||||
$handler = new UserDataHandler($this->entityManager, $this->logger);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form;
|
||||
|
||||
use App\Entity\Embeddable\BankAccount;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\Form\FinancialDataType;
|
||||
use App\Model\FinancialDataDto;
|
||||
use App\RequiredTeamerCheck\FinancialDataRequiredCheck;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Form\FormFactoryInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
|
||||
class FinancialDataTypeTest extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* The whole point of the dedicated check page: a teamer who is missing photo,
|
||||
* language, health insurance and address fails the entity's 'profile' group,
|
||||
* but must still be able to save the five fields that open the gate. The DTO
|
||||
* carries its constraints in the default group, so nothing from the entity
|
||||
* bleeds in.
|
||||
*/
|
||||
public function testSubmitSucceedsForATeamerWhoFailsTheProfileGroup(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$formData = FinancialDataDto::fromTeamer($teamer);
|
||||
|
||||
$form = $this->createForm($formData);
|
||||
$form->submit($this->validSubmission());
|
||||
|
||||
$this->assertTrue($form->isValid());
|
||||
|
||||
$formData->applyTo($teamer);
|
||||
|
||||
$this->assertNull($teamer->getPhoto());
|
||||
$this->assertNull($teamer->getLanguage());
|
||||
$this->assertNull($teamer->getHealthInsuranceCompany());
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards against the form and the check drifting apart: what the form accepts
|
||||
* as complete must be exactly what unlocks the portal.
|
||||
*/
|
||||
public function testAValidSubmissionSatisfiesTheCheck(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
->setTeamer($teamer)
|
||||
;
|
||||
|
||||
$check = new FinancialDataRequiredCheck();
|
||||
$this->assertFalse($check->isSatisfied($user));
|
||||
|
||||
$formData = FinancialDataDto::fromTeamer($teamer);
|
||||
$form = $this->createForm($formData);
|
||||
$form->submit($this->validSubmission());
|
||||
$formData->applyTo($teamer);
|
||||
|
||||
$this->assertTrue($check->isSatisfied($user));
|
||||
}
|
||||
|
||||
public function testEmptySubmitIsRejectedOnEveryField(): void
|
||||
{
|
||||
$form = $this->createForm(new FinancialDataDto());
|
||||
$form->submit([
|
||||
'taxId' => '',
|
||||
'iban' => '',
|
||||
'bic' => '',
|
||||
'bank' => '',
|
||||
'holder' => '',
|
||||
]);
|
||||
|
||||
$this->assertFalse($form->isValid());
|
||||
|
||||
foreach (['taxId', 'iban', 'bic', 'bank', 'holder'] as $field) {
|
||||
$this->assertCount(1, $form->get($field)->getErrors(), sprintf('Expected one error on "%s"', $field));
|
||||
}
|
||||
}
|
||||
|
||||
public function testMalformedIbanIsRejected(): void
|
||||
{
|
||||
$form = $this->createForm(new FinancialDataDto());
|
||||
$form->submit(array_merge($this->validSubmission(), ['iban' => 'DE00 not an iban']));
|
||||
|
||||
$this->assertFalse($form->isValid());
|
||||
$this->assertCount(1, $form->get('iban')->getErrors());
|
||||
$this->assertCount(0, $form->get('taxId')->getErrors());
|
||||
}
|
||||
|
||||
public function testFromTeamerToleratesAMissingBankAccount(): void
|
||||
{
|
||||
$teamer = (new Teamer())->setTaxId('12345678901');
|
||||
|
||||
$formData = FinancialDataDto::fromTeamer($teamer);
|
||||
|
||||
$this->assertSame('12345678901', $formData->getTaxId());
|
||||
$this->assertNull($formData->getIban());
|
||||
}
|
||||
|
||||
public function testApplyToCreatesABankAccountWhenTheTeamerHasNone(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$this->assertNull($teamer->getBankAccount());
|
||||
|
||||
(new FinancialDataDto())
|
||||
->setTaxId('12345678901')
|
||||
->setIban('DE02120300000000202051')
|
||||
->setBic('BYLADEM1001')
|
||||
->setBank('Deutsche Kreditbank')
|
||||
->setHolder('Erika Mustermann')
|
||||
->applyTo($teamer)
|
||||
;
|
||||
|
||||
$this->assertInstanceOf(BankAccount::class, $teamer->getBankAccount());
|
||||
$this->assertSame('DE02120300000000202051', $teamer->getBankAccount()->getIban());
|
||||
$this->assertSame('12345678901', $teamer->getTaxId());
|
||||
}
|
||||
|
||||
public function testFromTeamerRoundTripsAnExistingBankAccount(): void
|
||||
{
|
||||
$teamer = (new Teamer())
|
||||
->setTaxId('12345678901')
|
||||
->setBankAccount(
|
||||
(new BankAccount())
|
||||
->setIban('DE02120300000000202051')
|
||||
->setBic('BYLADEM1001')
|
||||
->setBank('Deutsche Kreditbank')
|
||||
->setHolder('Erika Mustermann')
|
||||
)
|
||||
;
|
||||
|
||||
$formData = FinancialDataDto::fromTeamer($teamer);
|
||||
|
||||
$this->assertSame('DE02120300000000202051', $formData->getIban());
|
||||
$this->assertSame('BYLADEM1001', $formData->getBic());
|
||||
$this->assertSame('Deutsche Kreditbank', $formData->getBank());
|
||||
$this->assertSame('Erika Mustermann', $formData->getHolder());
|
||||
}
|
||||
|
||||
private function validSubmission(): array
|
||||
{
|
||||
return [
|
||||
'taxId' => '12345678901',
|
||||
'iban' => 'DE02120300000000202051',
|
||||
'bic' => 'BYLADEM1001',
|
||||
'bank' => 'Deutsche Kreditbank',
|
||||
'holder' => 'Erika Mustermann',
|
||||
];
|
||||
}
|
||||
|
||||
private function createForm(FinancialDataDto $formData): FormInterface
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
/** @var FormFactoryInterface $formFactory */
|
||||
$formFactory = self::getContainer()->get(FormFactoryInterface::class);
|
||||
|
||||
return $formFactory->create(FinancialDataType::class, $formData, [
|
||||
'csrf_protection' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\Form\PersonalDataConfirmationType;
|
||||
use App\Model\PersonalDataConfirmationDto;
|
||||
use App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck;
|
||||
use App\RequiredTeamerCheck\RecurringDeadlines;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Form\FormFactoryInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
|
||||
class PersonalDataConfirmationTypeTest extends KernelTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* The invariant that keeps registry order out of the critical path: this check's
|
||||
* own page must clear this check, no matter what else the teamer is missing.
|
||||
* Previously verification lived on the profile form, whose 'profile' group also
|
||||
* demands photo, language, health insurance, address and DOB — so a teamer in
|
||||
* this state could never get past it.
|
||||
*/
|
||||
public function testConfirmingClearsTheCheckForAnOtherwiseIncompleteTeamer(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow('2026-08-10 12:00:00');
|
||||
|
||||
$teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2023-10-11 16:29:04'));
|
||||
$user = (new User())
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
->setTeamer($teamer)
|
||||
;
|
||||
|
||||
$check = new PersonalDataVerificationRequiredCheck(new RecurringDeadlines(['04-01', '10-01']));
|
||||
$this->assertFalse($check->isSatisfied($user));
|
||||
|
||||
$form = $this->createForm();
|
||||
$form->submit(['confirmed' => '1']);
|
||||
|
||||
$this->assertTrue($form->isValid());
|
||||
|
||||
// what PersonalDataController does on a valid submit
|
||||
$teamer->setDataVerifiedAt(new \DateTimeImmutable('2026-08-10 12:00:00'));
|
||||
|
||||
$this->assertTrue($check->isSatisfied($user));
|
||||
$this->assertNull($teamer->getPhoto());
|
||||
$this->assertNull($teamer->getLanguage());
|
||||
$this->assertNull($teamer->getHealthInsuranceCompany());
|
||||
}
|
||||
|
||||
public function testAnUntickedBoxIsRejected(): void
|
||||
{
|
||||
$form = $this->createForm();
|
||||
$form->submit(['confirmed' => null]);
|
||||
|
||||
$this->assertFalse($form->isValid());
|
||||
$this->assertCount(1, $form->get('confirmed')->getErrors());
|
||||
}
|
||||
|
||||
private function createForm(): FormInterface
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
/** @var FormFactoryInterface $formFactory */
|
||||
$formFactory = self::getContainer()->get(FormFactoryInterface::class);
|
||||
|
||||
return $formFactory->create(PersonalDataConfirmationType::class, new PersonalDataConfirmationDto(), [
|
||||
'csrf_protection' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -95,11 +95,13 @@ class UserTypeTest extends KernelTestCase
|
||||
'hotelCodes' => [],
|
||||
'disabled' => '1',
|
||||
'disabledReason' => 'Wegen Fehlverhaltens gesperrt.',
|
||||
'disabledReasonInternal' => 'Siehe Vorgang 4711.',
|
||||
]);
|
||||
|
||||
$this->assertTrue($form->isValid());
|
||||
$this->assertTrue($user->isDisabled());
|
||||
$this->assertSame('Wegen Fehlverhaltens gesperrt.', $user->getDisabledReason());
|
||||
$this->assertSame('Siehe Vorgang 4711.', $user->getDisabledReasonInternal());
|
||||
}
|
||||
|
||||
public function testSubmitUnblocksTheAccountAndClearsTheReasons(): void
|
||||
@@ -118,6 +120,7 @@ class UserTypeTest extends KernelTestCase
|
||||
'hotelCodes' => [],
|
||||
'disabled' => null,
|
||||
'disabledReason' => null,
|
||||
'disabledReasonInternal' => null,
|
||||
]);
|
||||
|
||||
$this->assertTrue($form->isValid());
|
||||
@@ -127,6 +130,33 @@ class UserTypeTest extends KernelTestCase
|
||||
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
|
||||
}
|
||||
|
||||
public function testSubmitUnblockingClearsTheReasonsEvenWhenTheirFieldsAreStillFilled(): void
|
||||
{
|
||||
$user = (new User())
|
||||
->setRoles(['ROLE_ADMIN'])
|
||||
->setDisabledAt(new \DateTimeImmutable('2026-01-01 08:00:00'))
|
||||
->setDisabledReason('Für deinen Account liegt in BusPro keine Berechtigung mehr vor.')
|
||||
->setDisabledReasonInternal('Automatisch gesperrt: keine Rollen in BusPro.')
|
||||
;
|
||||
|
||||
$form = $this->createForm($user);
|
||||
|
||||
// the textareas are prefilled, so unchecking the box alone submits the old reasons
|
||||
$form->submit([
|
||||
'roles' => ['ROLE_ADMIN'],
|
||||
'superAdmin' => null,
|
||||
'hotelCodes' => [],
|
||||
'disabled' => null,
|
||||
'disabledReason' => 'Für deinen Account liegt in BusPro keine Berechtigung mehr vor.',
|
||||
'disabledReasonInternal' => 'Automatisch gesperrt: keine Rollen in BusPro.',
|
||||
]);
|
||||
|
||||
$this->assertTrue($form->isValid());
|
||||
$this->assertFalse($user->isDisabled());
|
||||
$this->assertNull($user->getDisabledReason());
|
||||
$this->assertNull($user->getDisabledReasonInternal());
|
||||
}
|
||||
|
||||
private function createForm(User $user): \Symfony\Component\Form\FormInterface
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<satz typ="KUNDENKONTO"></satz>
|
||||
<art>SelektionCRM</art>
|
||||
<idadresse>141747</idadresse>
|
||||
<idperson>224526</idperson>
|
||||
<selektionsmerkmale>
|
||||
<selektionsgruppe id="0" bezeichnung="<ohne Gruppe>">
|
||||
<selektion id="1156" bezeichnung="Gruppen-Buchungsportal" aenderbar="False" auswahl="True"></selektion>
|
||||
</selektionsgruppe>
|
||||
<selektionsgruppe id="10" bezeichnung="TEAM">
|
||||
<selektion id="1070" bezeichnung="E&P Teamer - allg. Merkmal" aenderbar="False" auswahl="True"></selektion>
|
||||
<selektion id="1292" bezeichnung="Admin" aenderbar="False" auswahl="True"></selektion>
|
||||
<selektion id="1293" bezeichnung="Manager" aenderbar="False" auswahl="True"></selektion>
|
||||
<selektion id="1299" bezeichnung="Hausleitung SSL" aenderbar="False" auswahl="False"></selektion>
|
||||
<selektion id="1304" bezeichnung="Hausleitung DKS" aenderbar="False" auswahl="True"></selektion>
|
||||
<selektion id="1305" bezeichnung="Hausleitung DGS" aenderbar="False" auswahl="False"></selektion>
|
||||
<selektion id="1477" bezeichnung="Preisrechner" aenderbar="False" auswahl="False"></selektion>
|
||||
<selektion id="1478" bezeichnung="Preisrechner Admin" aenderbar="False" auswahl="False"></selektion>
|
||||
</selektionsgruppe>
|
||||
<selektionsgruppe id="83" bezeichnung="Interessen">
|
||||
<selektion id="1067" bezeichnung="Eventreisen" aenderbar="True" auswahl="False"></selektion>
|
||||
<selektion id="1064" bezeichnung="Sportclub-Reisen" aenderbar="True" auswahl="False"></selektion>
|
||||
</selektionsgruppe>
|
||||
</selektionsmerkmale>
|
||||
<crmaktionen>
|
||||
<crmaktion id="276" code="NOMAIL" bezeichnung="Ich möchte keine Werbung per Mail erhalten" aenderbar="True" auswahl="True"></crmaktion>
|
||||
</crmaktionen>
|
||||
</ergebnis>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<satz typ="KUNDENKONTO"></satz>
|
||||
<art>SelektionCRM</art>
|
||||
<idadresse>141747</idadresse>
|
||||
<idperson>224526</idperson>
|
||||
<selektionsmerkmale>
|
||||
<selektionsgruppe id="0" bezeichnung="<ohne Gruppe>">
|
||||
<selektion id="1156" bezeichnung="Gruppen-Buchungsportal" aenderbar="False" auswahl="True"></selektion>
|
||||
</selektionsgruppe>
|
||||
<selektionsgruppe id="10" bezeichnung="TEAM">
|
||||
<selektion id="1070" bezeichnung="E&P Teamer - allg. Merkmal" aenderbar="False" auswahl="False"></selektion>
|
||||
<selektion id="1292" bezeichnung="Admin" aenderbar="False" auswahl="False"></selektion>
|
||||
<selektion id="1293" bezeichnung="Manager" aenderbar="False" auswahl="False"></selektion>
|
||||
<selektion id="1299" bezeichnung="Hausleitung SSL" aenderbar="False" auswahl="False"></selektion>
|
||||
<selektion id="1304" bezeichnung="Hausleitung DKS" aenderbar="False" auswahl="False"></selektion>
|
||||
<selektion id="1305" bezeichnung="Hausleitung DGS" aenderbar="False" auswahl="False"></selektion>
|
||||
<selektion id="1477" bezeichnung="Preisrechner" aenderbar="False" auswahl="False"></selektion>
|
||||
<selektion id="1478" bezeichnung="Preisrechner Admin" aenderbar="False" auswahl="False"></selektion>
|
||||
</selektionsgruppe>
|
||||
<selektionsgruppe id="83" bezeichnung="Interessen">
|
||||
<selektion id="1067" bezeichnung="Eventreisen" aenderbar="True" auswahl="False"></selektion>
|
||||
<selektion id="1064" bezeichnung="Sportclub-Reisen" aenderbar="True" auswahl="False"></selektion>
|
||||
</selektionsgruppe>
|
||||
</selektionsmerkmale>
|
||||
<crmaktionen>
|
||||
<crmaktion id="276" code="NOMAIL" bezeichnung="Ich möchte keine Werbung per Mail erhalten" aenderbar="True" auswahl="True"></crmaktion>
|
||||
</crmaktionen>
|
||||
</ergebnis>
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\CrmAttributeGroup;
|
||||
use App\BusProNet\Model\CrmAttributesResponse;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\UserDataHandler;
|
||||
use App\Entity\User;
|
||||
use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry;
|
||||
use App\Security\BpnAuthenticator;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
class BpnAuthenticatorTest extends TestCase
|
||||
{
|
||||
private ApiClient&MockObject $apiClient;
|
||||
private UserDataHandler&MockObject $userDataHandler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiClient = $this->createMock(ApiClient::class);
|
||||
$this->userDataHandler = $this->createMock(UserDataHandler::class);
|
||||
}
|
||||
|
||||
public function testUnknownUserWithoutClaimedRolesIsNeverCreated(): void
|
||||
{
|
||||
$this->stubApiClient($this->createCrmAttributes());
|
||||
|
||||
$this->userDataHandler->method('collectRoles')->willReturn([]);
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn(null);
|
||||
|
||||
$this->userDataHandler->expects($this->never())->method('createLocalUser');
|
||||
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
|
||||
|
||||
$this->expectException(UserNotFoundException::class);
|
||||
|
||||
$this->loadUser();
|
||||
}
|
||||
|
||||
public function testExistingUserWithoutClaimedRolesIsBlockedAndReturned(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_ADMIN']);
|
||||
|
||||
$this->stubApiClient($this->createCrmAttributes());
|
||||
|
||||
$this->userDataHandler->method('collectRoles')->willReturn([]);
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($user);
|
||||
|
||||
$this->userDataHandler->expects($this->never())->method('updateLocalUser');
|
||||
$this->userDataHandler
|
||||
->expects($this->once())
|
||||
->method('disableForRevokedCrmRoles')
|
||||
->with($user)
|
||||
;
|
||||
|
||||
// returned rather than refused, so the UserChecker can explain the block
|
||||
$this->assertSame($user, $this->loadUser());
|
||||
}
|
||||
|
||||
public function testResponseWithoutAttributeGroupsRefusesTheLoginWithoutBlocking(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_ADMIN']);
|
||||
|
||||
// an empty payload carries no roles either and must not read as a revocation
|
||||
$this->stubApiClient(new CrmAttributesResponse());
|
||||
|
||||
$this->userDataHandler->method('collectRoles')->willReturn([]);
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($user);
|
||||
|
||||
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
|
||||
|
||||
$this->expectException(UserNotFoundException::class);
|
||||
|
||||
$this->loadUser();
|
||||
}
|
||||
|
||||
private function createCrmAttributes(): CrmAttributesResponse
|
||||
{
|
||||
return (new CrmAttributesResponse())->setAttributeGroups([new CrmAttributeGroup()]);
|
||||
}
|
||||
|
||||
private function stubApiClient(CrmAttributesResponse $crmAttributes): void
|
||||
{
|
||||
$this->apiClient->method('getProfile')->willReturn(new ProfileResponse());
|
||||
$this->apiClient->method('getCrmAttributes')->willReturn($crmAttributes);
|
||||
}
|
||||
|
||||
private function loadUser(): ?User
|
||||
{
|
||||
$authenticator = new BpnAuthenticator(
|
||||
$this->createMock(UrlGeneratorInterface::class),
|
||||
$this->createMock(EntityManagerInterface::class),
|
||||
$this->apiClient,
|
||||
$this->createMock(LoggerInterface::class),
|
||||
$this->userDataHandler,
|
||||
$this->createMock(RequiredTeamerCheckRegistry::class),
|
||||
);
|
||||
|
||||
$request = new Request(request: [
|
||||
'_username' => '[email protected]',
|
||||
'_password' => 'secret',
|
||||
'_csrf_token' => 'token',
|
||||
]);
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
|
||||
$passport = $authenticator->authenticate($request);
|
||||
|
||||
/** @var UserBadge $badge */
|
||||
$badge = $passport->getBadge(UserBadge::class);
|
||||
|
||||
/* @var User $user */
|
||||
return $badge->getUser();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security\RequiredCheck;
|
||||
|
||||
use App\Entity\Embeddable\BankAccount;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\RequiredTeamerCheck\FinancialDataRequiredCheck;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FinancialDataRequiredCheckTest extends TestCase
|
||||
{
|
||||
private FinancialDataRequiredCheck $check;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->check = new FinancialDataRequiredCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider mixedRoleProvider
|
||||
*/
|
||||
public function testDoesNotApplyToExcludedMixedRoleUsersWithTeamerRole(string $excludedRole): void
|
||||
{
|
||||
$user = $this->createTeamerUser();
|
||||
$user->setRoles([$excludedRole, 'ROLE_TEAMER']);
|
||||
|
||||
$this->assertFalse($this->check->appliesTo($user));
|
||||
}
|
||||
|
||||
public function mixedRoleProvider(): array
|
||||
{
|
||||
return [
|
||||
['ROLE_ADMIN'],
|
||||
['ROLE_MANAGER'],
|
||||
['ROLE_HOUSE_MANAGER'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testRedirectsToItsOwnCheckPage(): void
|
||||
{
|
||||
// must not be the full profile form, which would also demand photo,
|
||||
// language and health insurance and could lock the teamer out
|
||||
$this->assertSame('app_teamer_check_financial_data', $this->check->getRouteName());
|
||||
}
|
||||
|
||||
public function testIsNotSatisfiedWithoutTeamer(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
|
||||
$this->assertFalse($this->check->isSatisfied($user));
|
||||
}
|
||||
|
||||
public function testIsNotSatisfiedWhenNothingIsSet(): void
|
||||
{
|
||||
$user = $this->createTeamerUser();
|
||||
|
||||
$this->assertFalse($this->check->isSatisfied($user));
|
||||
}
|
||||
|
||||
public function testIsNotSatisfiedWhenTaxIdIsMissing(): void
|
||||
{
|
||||
$user = $this->createTeamerUser();
|
||||
$user->getTeamer()?->setBankAccount($this->createBankAccount());
|
||||
|
||||
$this->assertFalse($this->check->isSatisfied($user));
|
||||
}
|
||||
|
||||
public function testIsNotSatisfiedWhenBankAccountIsMissing(): void
|
||||
{
|
||||
$user = $this->createTeamerUser();
|
||||
$user->getTeamer()?->setTaxId('12345678901');
|
||||
|
||||
$this->assertFalse($this->check->isSatisfied($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider missingBankAccountFieldProvider
|
||||
*/
|
||||
public function testIsNotSatisfiedWhenBankAccountFieldIsMissing(string $setter): void
|
||||
{
|
||||
$bankAccount = $this->createBankAccount();
|
||||
$bankAccount->{$setter}(null);
|
||||
|
||||
$user = $this->createTeamerUser();
|
||||
$user->getTeamer()
|
||||
?->setTaxId('12345678901')
|
||||
->setBankAccount($bankAccount)
|
||||
;
|
||||
|
||||
$this->assertFalse($this->check->isSatisfied($user));
|
||||
}
|
||||
|
||||
public function missingBankAccountFieldProvider(): array
|
||||
{
|
||||
return [
|
||||
['setIban'],
|
||||
['setBic'],
|
||||
['setBank'],
|
||||
['setHolder'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testIsSatisfiedWhenTaxIdAndBankAccountAreComplete(): void
|
||||
{
|
||||
$user = $this->createTeamerUser();
|
||||
$user->getTeamer()
|
||||
?->setTaxId('12345678901')
|
||||
->setBankAccount($this->createBankAccount())
|
||||
;
|
||||
|
||||
$this->assertTrue($this->check->isSatisfied($user));
|
||||
}
|
||||
|
||||
private function createBankAccount(): BankAccount
|
||||
{
|
||||
return (new BankAccount())
|
||||
->setIban('DE02120300000000202051')
|
||||
->setBic('BYLADEM1001')
|
||||
->setBank('Deutsche Kreditbank')
|
||||
->setHolder('Erika Mustermann')
|
||||
;
|
||||
}
|
||||
|
||||
private function createTeamerUser(): User
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
|
||||
return (new User())
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
->setTeamer($teamer)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Tests\Security\RequiredCheck;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck;
|
||||
use App\RequiredTeamerCheck\RecurringDeadlines;
|
||||
use Carbon\CarbonImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
@@ -24,10 +25,9 @@ class PersonalDataVerificationRequiredCheckTest extends TestCase
|
||||
|
||||
public function testAppliesToTeamerUsers(): void
|
||||
{
|
||||
$check = new PersonalDataVerificationRequiredCheck();
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
|
||||
$this->assertTrue($check->appliesTo($user));
|
||||
$this->assertTrue($this->createCheck()->appliesTo($user));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,10 +35,9 @@ class PersonalDataVerificationRequiredCheckTest extends TestCase
|
||||
*/
|
||||
public function testDoesNotApplyToExcludedMixedRoleUsersWithTeamerRole(string $excludedRole): void
|
||||
{
|
||||
$check = new PersonalDataVerificationRequiredCheck();
|
||||
$user = (new User())->setRoles([$excludedRole, 'ROLE_TEAMER']);
|
||||
|
||||
$this->assertFalse($check->appliesTo($user));
|
||||
$this->assertFalse($this->createCheck()->appliesTo($user));
|
||||
}
|
||||
|
||||
public function mixedRoleProvider(): array
|
||||
@@ -50,115 +49,63 @@ class PersonalDataVerificationRequiredCheckTest extends TestCase
|
||||
];
|
||||
}
|
||||
|
||||
public function testRedirectsToItsOwnConfirmationPage(): void
|
||||
{
|
||||
// not the profile form, which demands the whole 'profile' group and can
|
||||
// therefore be impossible to submit
|
||||
$this->assertSame('app_teamer_check_personal_data', $this->createCheck()->getRouteName());
|
||||
}
|
||||
|
||||
public function testIsNotSatisfiedWhenTeamerIsMissing(): void
|
||||
{
|
||||
$check = new PersonalDataVerificationRequiredCheck();
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
|
||||
$this->assertFalse($check->isSatisfied($user));
|
||||
$this->assertFalse($this->createCheck()->isSatisfied($user));
|
||||
}
|
||||
|
||||
public function testIsSatisfiedBeforeFirstDeadlineEvenWithoutVerification(): void
|
||||
public function testToleratesAnUnpersistedTeamerWithoutACreationDate(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow('2026-01-10 12:00:00');
|
||||
$check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']);
|
||||
CarbonImmutable::setTestNow('2026-01-20 12:00:00');
|
||||
|
||||
$teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2025-11-05 12:00:00'));
|
||||
$user = (new User())
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
->setTeamer($teamer)
|
||||
;
|
||||
|
||||
$this->assertTrue($check->isSatisfied($user));
|
||||
// Teamer::getCreatedAt() is declared non-nullable but throws until flushed
|
||||
$this->assertFalse($this->createCheck()->isSatisfied($this->createUser(new Teamer())));
|
||||
}
|
||||
|
||||
public function testIsNotSatisfiedAfterFirstDeadlineWhenNeverVerified(): void
|
||||
public function testDelegatesTheDueDecisionToItsSchedule(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow('2026-01-20 12:00:00');
|
||||
$check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']);
|
||||
|
||||
$teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2025-11-05 12:00:00'));
|
||||
$user = (new User())
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
->setTeamer($teamer)
|
||||
;
|
||||
$user = $this->createUser($teamer);
|
||||
|
||||
$this->assertFalse($check->isSatisfied($user));
|
||||
$this->assertFalse($this->createCheck()->isSatisfied($user));
|
||||
|
||||
$teamer->setDataVerifiedAt(new \DateTimeImmutable('2026-01-16 10:00:00'));
|
||||
|
||||
$this->assertTrue($this->createCheck()->isSatisfied($user));
|
||||
}
|
||||
|
||||
public function testIsSatisfiedWhenVerifiedAfterCurrentDeadline(): void
|
||||
public function testAnEmptyScheduleNeverForcesVerification(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow('2026-05-20 12:00:00');
|
||||
$check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']);
|
||||
CarbonImmutable::setTestNow('2026-01-20 12:00:00');
|
||||
|
||||
$teamer = (new Teamer())
|
||||
->setCreatedAt(new \DateTimeImmutable('2025-11-05 12:00:00'))
|
||||
->setDataVerifiedAt(new \DateTimeImmutable('2026-01-16 10:00:00'))
|
||||
;
|
||||
$user = (new User())
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
->setTeamer($teamer)
|
||||
;
|
||||
$teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2025-11-05 12:00:00'));
|
||||
|
||||
$this->assertTrue($check->isSatisfied($user));
|
||||
$check = new PersonalDataVerificationRequiredCheck(new RecurringDeadlines([]));
|
||||
|
||||
$this->assertTrue($check->isSatisfied($this->createUser($teamer)));
|
||||
}
|
||||
|
||||
public function testIsNotSatisfiedWhenVerificationIsOutdated(): void
|
||||
private function createCheck(): PersonalDataVerificationRequiredCheck
|
||||
{
|
||||
CarbonImmutable::setTestNow('2026-07-20 12:00:00');
|
||||
$check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']);
|
||||
|
||||
$teamer = (new Teamer())
|
||||
->setCreatedAt(new \DateTimeImmutable('2025-11-05 12:00:00'))
|
||||
->setDataVerifiedAt(new \DateTimeImmutable('2026-02-01 10:00:00'))
|
||||
;
|
||||
$user = (new User())
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
->setTeamer($teamer)
|
||||
;
|
||||
|
||||
$this->assertFalse($check->isSatisfied($user));
|
||||
return new PersonalDataVerificationRequiredCheck(new RecurringDeadlines(['01-15', '07-15']));
|
||||
}
|
||||
|
||||
public function testIsSatisfiedAfterFirstDeadlineWhenRegisteredAfterThatDeadline(): void
|
||||
private function createUser(Teamer $teamer): User
|
||||
{
|
||||
CarbonImmutable::setTestNow('2026-05-20 12:00:00');
|
||||
$check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']);
|
||||
|
||||
$teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2026-03-01 10:00:00'));
|
||||
$user = (new User())
|
||||
return (new User())
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
->setTeamer($teamer)
|
||||
;
|
||||
|
||||
$this->assertTrue($check->isSatisfied($user));
|
||||
}
|
||||
|
||||
public function testIsSatisfiedAfterSecondDeadlineWhenRegisteredAfterThatDeadline(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow('2026-11-20 12:00:00');
|
||||
$check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']);
|
||||
|
||||
$teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2026-10-01 10:00:00'));
|
||||
$user = (new User())
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
->setTeamer($teamer)
|
||||
;
|
||||
|
||||
$this->assertTrue($check->isSatisfied($user));
|
||||
}
|
||||
|
||||
public function testIsNotSatisfiedAfterSecondDeadlineWhenRegisteredBeforeCurrentYearDeadlines(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow('2026-11-20 12:00:00');
|
||||
$check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']);
|
||||
|
||||
$teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2025-10-01 10:00:00'));
|
||||
$user = (new User())
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
->setTeamer($teamer)
|
||||
;
|
||||
|
||||
$this->assertFalse($check->isSatisfied($user));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security\RequiredCheck;
|
||||
|
||||
use App\RequiredTeamerCheck\RecurringDeadlines;
|
||||
use Carbon\CarbonImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RecurringDeadlinesTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow();
|
||||
}
|
||||
|
||||
public function testAnEmptyScheduleIsNeverDue(): void
|
||||
{
|
||||
$deadlines = new RecurringDeadlines([]);
|
||||
|
||||
$this->assertFalse($deadlines->isDue(null, new \DateTimeImmutable('2020-01-01'), CarbonImmutable::parse('2026-08-10')));
|
||||
$this->assertNull($deadlines->getCurrentDeadline(CarbonImmutable::parse('2026-08-10')));
|
||||
}
|
||||
|
||||
public function testIsNotDueBeforeTheFirstDeadlineEvenWhenNeverConfirmed(): void
|
||||
{
|
||||
$deadlines = new RecurringDeadlines(['01-15', '07-15']);
|
||||
|
||||
$this->assertFalse($deadlines->isDue(
|
||||
null,
|
||||
new \DateTimeImmutable('2025-11-05 12:00:00'),
|
||||
CarbonImmutable::parse('2026-01-10 12:00:00'),
|
||||
));
|
||||
}
|
||||
|
||||
public function testIsDueAfterTheFirstDeadlineWhenNeverConfirmed(): void
|
||||
{
|
||||
$deadlines = new RecurringDeadlines(['01-15', '07-15']);
|
||||
|
||||
$this->assertTrue($deadlines->isDue(
|
||||
null,
|
||||
new \DateTimeImmutable('2025-11-05 12:00:00'),
|
||||
CarbonImmutable::parse('2026-01-20 12:00:00'),
|
||||
));
|
||||
}
|
||||
|
||||
public function testIsNotDueWhenConfirmedAfterTheCurrentDeadline(): void
|
||||
{
|
||||
$deadlines = new RecurringDeadlines(['01-15', '07-15']);
|
||||
|
||||
$this->assertFalse($deadlines->isDue(
|
||||
new \DateTimeImmutable('2026-01-16 10:00:00'),
|
||||
new \DateTimeImmutable('2025-11-05 12:00:00'),
|
||||
CarbonImmutable::parse('2026-05-20 12:00:00'),
|
||||
));
|
||||
}
|
||||
|
||||
public function testIsDueWhenTheConfirmationPredatesTheCurrentDeadline(): void
|
||||
{
|
||||
$deadlines = new RecurringDeadlines(['01-15', '07-15']);
|
||||
|
||||
$this->assertTrue($deadlines->isDue(
|
||||
new \DateTimeImmutable('2026-02-01 10:00:00'),
|
||||
new \DateTimeImmutable('2025-11-05 12:00:00'),
|
||||
CarbonImmutable::parse('2026-07-20 12:00:00'),
|
||||
));
|
||||
}
|
||||
|
||||
public function testIsNotDueWhenRegisteredAfterTheFirstDeadline(): void
|
||||
{
|
||||
$deadlines = new RecurringDeadlines(['01-15', '07-15']);
|
||||
|
||||
$this->assertFalse($deadlines->isDue(
|
||||
null,
|
||||
new \DateTimeImmutable('2026-03-01 10:00:00'),
|
||||
CarbonImmutable::parse('2026-05-20 12:00:00'),
|
||||
));
|
||||
}
|
||||
|
||||
public function testIsNotDueWhenRegisteredAfterTheSecondDeadline(): void
|
||||
{
|
||||
$deadlines = new RecurringDeadlines(['01-15', '07-15']);
|
||||
|
||||
$this->assertFalse($deadlines->isDue(
|
||||
null,
|
||||
new \DateTimeImmutable('2026-10-01 10:00:00'),
|
||||
CarbonImmutable::parse('2026-11-20 12:00:00'),
|
||||
));
|
||||
}
|
||||
|
||||
public function testIsDueWhenRegisteredBeforeAllCurrentYearDeadlines(): void
|
||||
{
|
||||
$deadlines = new RecurringDeadlines(['01-15', '07-15']);
|
||||
|
||||
$this->assertTrue($deadlines->isDue(
|
||||
null,
|
||||
new \DateTimeImmutable('2025-10-01 10:00:00'),
|
||||
CarbonImmutable::parse('2026-11-20 12:00:00'),
|
||||
));
|
||||
}
|
||||
|
||||
public function testAMissingRegistrationDateDoesNotExempt(): void
|
||||
{
|
||||
$deadlines = new RecurringDeadlines(['01-15', '07-15']);
|
||||
|
||||
$this->assertTrue($deadlines->isDue(null, null, CarbonImmutable::parse('2026-01-20 12:00:00')));
|
||||
}
|
||||
|
||||
public function testGetCurrentDeadlineReturnsTheMostRecentlyPassedOne(): void
|
||||
{
|
||||
$deadlines = new RecurringDeadlines(['01-15', '07-15']);
|
||||
|
||||
$this->assertSame(
|
||||
'2026-07-15',
|
||||
$deadlines->getCurrentDeadline(CarbonImmutable::parse('2026-08-10'))?->format('Y-m-d'),
|
||||
);
|
||||
$this->assertNull($deadlines->getCurrentDeadline(CarbonImmutable::parse('2026-01-10')));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user