feat: provision a teamer from a received application
This commit is contained in:
1 parent
d5f4b12f0a
commit
b530679658
23 files changed
+2433
-148
No files matched your search
@@ -114,6 +114,7 @@ FEATURE_SANITIZE_UPLOADS=false
|
||||
FEATURE_STAMP_INVOICES=false
|
||||
FEATURE_DATEV_EMAIL=false
|
||||
FEATURE_MYEP_LOGIN=false
|
||||
FEATURE_APPLICATION_FORM_TEAMER_PROVISIONING=false
|
||||
|
||||
UPLOAD_REPLACEMENT_FILE=assets/pdf/chicken.pdf
|
||||
|
||||
|
||||
@@ -13,3 +13,8 @@ flagception:
|
||||
myep_login:
|
||||
default: false
|
||||
env: FEATURE_MYEP_LOGIN
|
||||
# Provisioning writes to the production CRM and cannot be rehearsed: the teamer selection
|
||||
# is only changeable there. Ships off.
|
||||
application_form_teamer_provisioning:
|
||||
default: false
|
||||
env: FEATURE_APPLICATION_FORM_TEAMER_PROVISIONING
|
||||
@@ -169,6 +169,13 @@ services:
|
||||
bpn_api_port: '%env(APP_BPN_PORT)%'
|
||||
debug: '%env(bool:APP_BPN_DEBUG)%'
|
||||
|
||||
App\Service\Teamer\TeamerProvisioner:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
# The same selection the login reads a teamer's role from - this is the one place that
|
||||
# writes it, so it must be the same id.
|
||||
$teamerSelectionId: '%bpn_crm_id_teamer%'
|
||||
|
||||
App\BusProNet\ResponseParser:
|
||||
arguments:
|
||||
$options:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* What it takes to provision a teamer from a received application: the applicant's gender, and the
|
||||
* outcome of each of the three provisioning writes
|
||||
* (.ddev/plans/application-form-teamer-provisioning.md).
|
||||
*
|
||||
* Emptying the table first is deliberate. gender is NOT NULL because BusPro refuses an address
|
||||
* without one and Teamer::$gender is not nullable either, and the existing rows predate the
|
||||
* tightened endpoint contract, so none of them carries a value. They are test submissions in dev -
|
||||
* the feature has never shipped, and nothing in production is affected. Doing it here rather than
|
||||
* by hand keeps it reviewable and keeps the migration replayable.
|
||||
*
|
||||
* Reduced to these statements by hand: the generated diff also wanted to restate the long-standing
|
||||
* datetime comment drift and another branch's tables.
|
||||
*/
|
||||
final class Version20260925120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add gender and the provisioning state to application_form_submission';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DELETE FROM application_form_submission');
|
||||
$this->addSql('ALTER TABLE application_form_submission ADD gender VARCHAR(1) NOT NULL, ADD bus_pro_address_id INT DEFAULT NULL, ADD bus_pro_person_id INT DEFAULT NULL, ADD teamer_created_at DATETIME DEFAULT NULL, ADD user_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE application_form_submission ADD CONSTRAINT FK_F47CE995A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX IDX_F47CE995A76ED395 ON application_form_submission (user_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE application_form_submission DROP FOREIGN KEY FK_F47CE995A76ED395');
|
||||
$this->addSql('DROP INDEX IDX_F47CE995A76ED395 ON application_form_submission');
|
||||
$this->addSql('ALTER TABLE application_form_submission DROP gender, DROP bus_pro_address_id, DROP bus_pro_person_id, DROP teamer_created_at, DROP user_id');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use App\BusProNet\Model\CrmAttributesResponse;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\ProfileUpdateResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Entity\User;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
@@ -83,6 +85,86 @@ class ApiClient
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the applicant's address in the CRM, which is what makes them a person BusPro knows.
|
||||
*
|
||||
* BusPro answers with the ids of an address it already held when it recognises the person
|
||||
* ("neuanlage" False), so this never produces a duplicate and needs no pre-flight lookup -
|
||||
* reading an address would require the customer's password, which provisioning deliberately
|
||||
* does not have.
|
||||
*
|
||||
* "ohnemailversand" suppresses BusPro's own mail about the new record: the applicant is asked
|
||||
* to set a password by resetPassword() at the end of provisioning instead, so that a failure
|
||||
* in between never mails somebody who has not been provisioned.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function createAddress(ApplicationFormSubmission $submission): NotificationResponse|RegistrationResponse
|
||||
{
|
||||
$data = [
|
||||
'anfrage' => [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
|
||||
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
|
||||
'art' => 'Adresse_Neu',
|
||||
'adressdaten' => $submission->toPayload(),
|
||||
'ohnemailversand' => 'True',
|
||||
],
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or clears one CRM selection on a person - the one write to the CRM this application
|
||||
* makes. Everything else about selections is read-only here: roles and hotel codes are synced
|
||||
* from BusPro on every login and an admin may approve a claim, never grant one.
|
||||
*
|
||||
* "login" switches the password check off, which only works when the request identifies the
|
||||
* person by id: both ids are required, and the person id is "idadresseperson" on the way in
|
||||
* although the response calls it "idperson". The element order and the single-selection
|
||||
* payload are what BusPro was verified to accept - the art is undocumented, see
|
||||
* .ddev/plans/buspro-crm-selection-write.md before changing the shape.
|
||||
*
|
||||
* Idempotent: setting a selection that is already set answers änderung=True and changes
|
||||
* nothing, so a caller may write unconditionally and take isUpdated() as the success check.
|
||||
*
|
||||
* The write only goes through where the selection is marked changeable in the BusPro
|
||||
* settings, and that flag is per environment - 1070 is writable in production only.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function updateCrmAttribute(
|
||||
string $email,
|
||||
int $addressId,
|
||||
int $personId,
|
||||
int $selectionId,
|
||||
bool $selected,
|
||||
): NotificationResponse|ProfileUpdateResponse {
|
||||
$data = [
|
||||
'anfrage' => [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
|
||||
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
|
||||
'art' => 'SelektionCRM_Ändern',
|
||||
'login' => 'J',
|
||||
'email' => $email,
|
||||
'idadresse' => $addressId,
|
||||
'idadresseperson' => $personId,
|
||||
'selektionsmerkmale' => [
|
||||
'selektion' => [
|
||||
// Capitalised as in the read response, which is what the write accepts.
|
||||
['@id' => $selectionId, '@auswahl' => true === $selected ? 'True' : 'False'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* The answer to "Adresse_Neu": the ids of the address the CRM now holds for this person, and
|
||||
* whether it had to create it.
|
||||
*
|
||||
* isNewRecord() is BusPro's own duplicate check ("neuanlage"): False means it matched an address
|
||||
* that already existed and answered with that one's ids, so a caller never has to look for
|
||||
* duplicates itself - which it could not anyway, as reading an address needs the customer's
|
||||
* password. It says nothing about which CRM selections that address carries.
|
||||
*/
|
||||
class RegistrationResponse
|
||||
{
|
||||
public function __construct(
|
||||
private readonly int $addressId,
|
||||
private readonly int $personId,
|
||||
private readonly bool $isNewRecord,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getAddressId(): int
|
||||
{
|
||||
return $this->addressId;
|
||||
}
|
||||
|
||||
public function getPersonId(): int
|
||||
{
|
||||
return $this->personId;
|
||||
}
|
||||
|
||||
public function isNewRecord(): bool
|
||||
{
|
||||
return $this->isNewRecord;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\ProfileUpdateResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ResponseParser
|
||||
@@ -50,8 +51,17 @@ class ResponseParser
|
||||
case 'Adressdaten_Ändern':
|
||||
return $this->createProfileUpdateResponse($xml);
|
||||
case 'SelektionCRM':
|
||||
case 'SelektionCRM_Ändern':
|
||||
return $this->createCrmAttributesResponse($xml);
|
||||
case 'SelektionCRM_Ändern':
|
||||
// Not a CRM attribute tree: the write answers idadresse + idperson +
|
||||
// änderung, exactly the shape createProfileUpdateResponse() reads. It used
|
||||
// to be routed to createCrmAttributesResponse(), which walks a
|
||||
// "selektionsmerkmale" node that is absent here and would have returned an
|
||||
// empty response with every role flag false - dead code until something
|
||||
// sent the art (.ddev/plans/buspro-crm-selection-write.md).
|
||||
return $this->createProfileUpdateResponse($xml);
|
||||
case 'Adresse_Neu':
|
||||
return $this->createRegistrationResponse($xml);
|
||||
}
|
||||
break;
|
||||
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
|
||||
@@ -166,6 +176,34 @@ class ResponseParser
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function createRegistrationResponse(\SimpleXMLElement $xml): RegistrationResponse
|
||||
{
|
||||
$addressIdXml = $xml->xpath('idadresse') ?: [];
|
||||
$personIdXml = $xml->xpath('idperson') ?: [];
|
||||
|
||||
// Without both ids there is nothing to carry on with: the CRM selection is set by id, and
|
||||
// a local account without them cannot be matched again on the next login. Better a parse
|
||||
// error naming the response than a half-provisioned applicant.
|
||||
if (0 === count($addressIdXml) || 0 === count($personIdXml)) {
|
||||
throw new ResponseParserException('Missing idadresse or idperson in the Adresse_Neu response');
|
||||
}
|
||||
|
||||
$isNewRecord = false;
|
||||
$isNewRecordXml = $xml->xpath('neuanlage') ?: [];
|
||||
if (0 < count($isNewRecordXml)) {
|
||||
$isNewRecord = 'true' === strtolower((string) $isNewRecordXml[0]);
|
||||
}
|
||||
|
||||
return new RegistrationResponse(
|
||||
(int) $addressIdXml[0],
|
||||
(int) $personIdXml[0],
|
||||
$isNewRecord,
|
||||
);
|
||||
}
|
||||
|
||||
public function createCrmAttributesResponse(\SimpleXMLElement $xml): CrmAttributesResponse
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Model\TeamerProvisioningResult;
|
||||
use App\Service\Teamer\TeamerProvisioner;
|
||||
use App\Service\Teamer\TeamerProvisioningException;
|
||||
use Flagception\Manager\FeatureManagerInterface;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Makes a teamer out of an applicant who passed the interview: one click, because the submission
|
||||
* carries everything the CRM needs and there is nothing left for the reviewer to type.
|
||||
*
|
||||
* Behind a feature flag, because provisioning writes to the production CRM and cannot be rehearsed
|
||||
* anywhere else - the teamer selection is only changeable there.
|
||||
*/
|
||||
class CreateTeamerController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TeamerProvisioner $provisioner,
|
||||
private readonly FeatureManagerInterface $featureManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/application-form-submission/create-teamer/{uuid}', name: 'app_admin_application_form_submission_create_teamer')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(ApplicationFormSubmission $submission, Request $request): Response
|
||||
{
|
||||
if (false === $this->featureManager->isActive('application_form_teamer_provisioning')) {
|
||||
throw $this->createNotFoundException('Teamer provisioning is not enabled');
|
||||
}
|
||||
|
||||
if (false === $request->isMethod(Request::METHOD_POST)) {
|
||||
return $this->render('admin/application_form_submission/modal_create_teamer.html.twig', [
|
||||
'submission' => $submission,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->provisioner->provision($submission);
|
||||
$this->addFlash('success', $this->describe($result));
|
||||
$this->log($submission, $result);
|
||||
} catch (TeamerProvisioningException $e) {
|
||||
// Deliberately the exception's own wording: it usually carries BusPro's complaint, and
|
||||
// a reviewer can act on a named mandatory field but not on "something went wrong".
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
$this->logger->error('Unable to provision a teamer from an application form submission', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'error_message' => $e->getMessage(),
|
||||
...$e->getContext(),
|
||||
]);
|
||||
}
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_application_form_submission_detail', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'r' => $request->query->get('r'),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Names what happened rather than "erledigt": whether BusPro recognised the applicant, whether
|
||||
* an account already existed and - the one thing that needs a human afterwards - whether the
|
||||
* password mail went out.
|
||||
*/
|
||||
private function describe(TeamerProvisioningResult $result): string
|
||||
{
|
||||
if (true === $result->isAlreadyProvisioned()) {
|
||||
return 'Diese Bewerbung wurde bereits übernommen, es wurde nichts erneut angelegt';
|
||||
}
|
||||
|
||||
$message = true === $result->isUserCreated()
|
||||
? sprintf('%s wurde als Teamer:in angelegt', (string) $result->getUser()->getTeamer())
|
||||
: sprintf('%s hat schon ein Konto und ist jetzt Teamer:in', $result->getUser()->getFullName());
|
||||
|
||||
if (false === $result->isAddressCreated()) {
|
||||
$message .= '. In BusPro gab es die Adresse schon, sie wurde übernommen';
|
||||
}
|
||||
|
||||
$message .= true === $result->isPasswordMailSent()
|
||||
? '. BusPro hat die Passwort-Mail versendet'
|
||||
: '. Die Passwort-Mail von BusPro ist nicht rausgegangen - bitte erneut anstoßen';
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
private function log(ApplicationFormSubmission $submission, TeamerProvisioningResult $result): void
|
||||
{
|
||||
$this->logger->info('Provision a teamer from an application form submission', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'user_id' => $result->getUser()->getId(),
|
||||
'user_email' => $result->getUser()->getEmail(),
|
||||
'bus_pro_address_id' => $submission->getBusProAddressId(),
|
||||
'bus_pro_person_id' => $submission->getBusProPersonId(),
|
||||
'address_created' => $result->isAddressCreated(),
|
||||
'user_created' => $result->isUserCreated(),
|
||||
'already_teamer' => $result->isAlreadyTeamer(),
|
||||
'already_provisioned' => $result->isAlreadyProvisioned(),
|
||||
'password_mail_sent' => $result->isPasswordMailSent(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,17 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A form submitted on one of the public websites and posted here by TYPO3 - job applications
|
||||
* first, other "collect it and let a human read it" forms later. Unrelated to
|
||||
* App\Entity\Application, which is a teamer applying for an assignment.
|
||||
* A job application submitted on one of the public websites and posted here by TYPO3. Unrelated
|
||||
* to App\Entity\Application, which is a teamer applying for an assignment.
|
||||
*
|
||||
* The endpoint only ever receives application forms, so every row carries the nine fields it takes
|
||||
* to provision a teamer from it - see ApplicationFormSubmissionDto, where that contract is enforced.
|
||||
* gender is in the constructor because Teamer::$gender is NOT NULL as well; the postal address is
|
||||
* required of a payload but its columns stay nullable, because the address it ends up in
|
||||
* (Embeddable\Address) is nullable throughout - a stricter column here than on the entity it feeds
|
||||
* would be a promise this application does not otherwise keep. What guards provisioning is
|
||||
* TeamerProvisioner's pre-flight check, which refuses a row that predates the contract before
|
||||
* anything is written.
|
||||
*
|
||||
* Deliberately neither Blameable nor SoftDeletable. Nothing here creates a row - TYPO3 does -
|
||||
* and the only thing staff write are the four follow-up dates, where the interesting fact is
|
||||
@@ -22,10 +30,10 @@ use Symfony\Component\Uid\Uuid;
|
||||
* DELETE. A purge has to cover "details" and "payload" as well - a json column is exactly what
|
||||
* a deletion job forgets.
|
||||
*
|
||||
* The typed columns are the mapped core agreed with the TYPO3 side and all nullable: a form is
|
||||
* configured without a field as often as with it. "details" is everything else the visitor
|
||||
* filled in, keyed by the German field label in the order the form asked - display only, never
|
||||
* a schema, never queried on (see .ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.3).
|
||||
* The typed columns are the mapped core agreed with the TYPO3 side. "details" is everything
|
||||
* else the visitor filled in, keyed by the German field label in the order the form asked -
|
||||
* display only, never a schema, never queried on (see
|
||||
* .ddev/plans/MYEP-APPLICATION-ENDPOINT.md §3.3).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ApplicationFormSubmissionRepository::class)]
|
||||
// What a list and a dedupe check ask for: the submissions of one form, newest first.
|
||||
@@ -51,6 +59,14 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $submittedAt;
|
||||
|
||||
/**
|
||||
* F, M or D - the application's own vocabulary, as on Teamer. Not null, because BusPro
|
||||
* refuses an address without a gender (error 887) and Teamer::$gender is not nullable
|
||||
* either: a submission that could not be provisioned is not worth storing.
|
||||
*/
|
||||
#[ORM\Column(length: 1)]
|
||||
private string $gender;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $pageUid = null;
|
||||
|
||||
@@ -73,13 +89,18 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
private ?string $mobile = null;
|
||||
|
||||
/**
|
||||
* A string, not a date: the forms use a text field with a regex rather than a date picker,
|
||||
* so this is whatever the visitor typed - usually dd.mm.yyyy. Parse it for display, store
|
||||
* what arrived.
|
||||
* A string, not a date column: the forms use a text field rather than a date picker, so the
|
||||
* value arrives as text and is stored as it arrived. The contract does pin the format -
|
||||
* YYYY-MM-DD, enforced by the DTO - so getDateOfBirth() can parse it strictly. A date column
|
||||
* would only mean migrating the rows that predate the contract, for no gain.
|
||||
*/
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $birthDate = null;
|
||||
|
||||
/**
|
||||
* Required of every payload, though nullable here: BusPro refuses to create an address without a
|
||||
* street and a postcode (errors 891 and 890), and a town is expected with them.
|
||||
*/
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $street = null;
|
||||
|
||||
@@ -123,6 +144,34 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $secondCallHeldAt = null;
|
||||
|
||||
/**
|
||||
* What provisioning got done, step by step (App\Service\Teamer\TeamerProvisioner). The three
|
||||
* writes are not a transaction and each one can fail with the earlier ones done, so a retry
|
||||
* has to resume rather than restart: with these ids set, a second run never creates a second
|
||||
* BusPro address.
|
||||
*
|
||||
* There is deliberately no stamp for the CRM selection in between. That write is idempotent -
|
||||
* setting a selection that is already set answers "änderung=True" and changes nothing - so a
|
||||
* resume simply sends it again, and a column nobody reads would only pretend to be evidence.
|
||||
*/
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $busProAddressId = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $busProPersonId = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $teamerCreatedAt = null;
|
||||
|
||||
/**
|
||||
* The account provisioning created, or the one it found for this applicant. Unowned and
|
||||
* without any cascade on purpose: this entity gets a real DELETE (above), and a purged
|
||||
* application must never take a teamer's account with it.
|
||||
*/
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $user = null;
|
||||
|
||||
/**
|
||||
* Everything the visitor filled in beyond the mapped core, keyed by the German field label.
|
||||
* Display only: the labels are staff-written and change on a reword, so nothing may query
|
||||
@@ -148,17 +197,19 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $payload = [];
|
||||
|
||||
public function __construct(string $form, string $formLabel, \DateTimeImmutable $submittedAt)
|
||||
public function __construct(string $form, string $formLabel, \DateTimeImmutable $submittedAt, string $gender)
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
$this->form = $form;
|
||||
$this->formLabel = $formLabel;
|
||||
$this->submittedAt = $submittedAt;
|
||||
$this->gender = $gender;
|
||||
}
|
||||
|
||||
/**
|
||||
* The validated payload as a row. Called with a DTO that passed validation - submittedAt is
|
||||
* parseable by then, which is why the constructor can insist on a date.
|
||||
* parseable and gender is one of F/M/D by then, which is why the constructor can insist on
|
||||
* both.
|
||||
*
|
||||
* The raw payload is passed in separately because the DTO no longer has it: the resolver
|
||||
* drops every key the DTO has no property for, and those are the mapped fields the TYPO3
|
||||
@@ -169,7 +220,7 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
*/
|
||||
public static function fromDto(ApplicationFormSubmissionDto $dto, array $payload = []): self
|
||||
{
|
||||
$submission = new self($dto->form, $dto->formLabel, $dto->getSubmittedAt());
|
||||
$submission = new self($dto->form, $dto->formLabel, $dto->getSubmittedAt(), (string) $dto->gender);
|
||||
|
||||
return $submission
|
||||
->setPageUid($dto->getPageUid())
|
||||
@@ -189,6 +240,66 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* The applicant as BusPro's "Adresse_Neu" wants them: the German keys of Teamer::toPayload(),
|
||||
* Embeddable\Address::toPayload() and Embeddable\Communication::toPayload(), which is what
|
||||
* the CRM has always been sent from this application.
|
||||
*
|
||||
* The gender mapping is Teamer::toPayload()'s: F becomes W, everything else goes out as it
|
||||
* came. What BusPro makes of "D" is unverified - it is passed through rather than guessed at.
|
||||
*
|
||||
* The street and the postcode are BusPro's own mandatory fields here, which is why the contract
|
||||
* requires the postal address as a whole. The country is not asked on the forms and is not
|
||||
* guessed either: BusPro fills its own default in, and a wrong country on the record would be
|
||||
* worse than none.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toPayload(): array
|
||||
{
|
||||
$gender = strtoupper($this->gender);
|
||||
|
||||
return [
|
||||
'geburtsdatum' => $this->getDateOfBirth()?->format('d.m.Y'),
|
||||
'geschlecht' => 'F' === $gender ? 'W' : $gender,
|
||||
'vorname' => $this->firstName,
|
||||
'name' => $this->lastName,
|
||||
'anschrift' => [
|
||||
'strasse' => $this->street,
|
||||
'plz' => $this->zipCode,
|
||||
'ort' => $this->city,
|
||||
],
|
||||
'kommunikation' => [
|
||||
'email' => $this->email,
|
||||
'telefonmobil' => $this->mobile,
|
||||
'telefonprivat' => $this->phone,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The birthday as a date, parsed strictly: the contract guarantees YYYY-MM-DD, and anything
|
||||
* else is null rather than a date \DateTimeImmutable would have invented from it - rows that
|
||||
* predate the contract are exactly that case. Zeroed time of day, so the same row never yields
|
||||
* two different dates.
|
||||
*/
|
||||
public function getDateOfBirth(): ?\DateTimeImmutable
|
||||
{
|
||||
if (null === $this->birthDate || '' === $this->birthDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dateOfBirth = \DateTimeImmutable::createFromFormat('!Y-m-d', $this->birthDate);
|
||||
|
||||
// createFromFormat() happily rolls a 31st of February over into March, which would put a
|
||||
// date of birth on the record the applicant never gave. The round trip is what catches it.
|
||||
if (false === $dateOfBirth || $dateOfBirth->format('Y-m-d') !== $this->birthDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $dateOfBirth;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -214,6 +325,11 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
return $this->submittedAt;
|
||||
}
|
||||
|
||||
public function getGender(): string
|
||||
{
|
||||
return $this->gender;
|
||||
}
|
||||
|
||||
public function getPageUid(): ?int
|
||||
{
|
||||
return $this->pageUid;
|
||||
@@ -408,6 +524,68 @@ class ApplicationFormSubmission implements TimestampableEntityInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBusProAddressId(): ?int
|
||||
{
|
||||
return $this->busProAddressId;
|
||||
}
|
||||
|
||||
public function setBusProAddressId(?int $busProAddressId): static
|
||||
{
|
||||
$this->busProAddressId = $busProAddressId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBusProPersonId(): ?int
|
||||
{
|
||||
return $this->busProPersonId;
|
||||
}
|
||||
|
||||
public function setBusProPersonId(?int $busProPersonId): static
|
||||
{
|
||||
$this->busProPersonId = $busProPersonId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTeamerCreatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->teamerCreatedAt;
|
||||
}
|
||||
|
||||
public function setTeamerCreatedAt(?\DateTimeImmutable $teamerCreatedAt): static
|
||||
{
|
||||
$this->teamerCreatedAt = $teamerCreatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?User $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the BusPro address behind this submission is known - the one thing a retry must
|
||||
* not do twice.
|
||||
*/
|
||||
public function hasBusProAddress(): bool
|
||||
{
|
||||
return null !== $this->busProAddressId && null !== $this->busProPersonId;
|
||||
}
|
||||
|
||||
public function isProvisioned(): bool
|
||||
{
|
||||
return null !== $this->teamerCreatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|bool|list<string>>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||
use Symfony\Component\Validator\Exception\ValidationFailedException;
|
||||
|
||||
/**
|
||||
* Answers every failure under the api firewall as JSON.
|
||||
*
|
||||
* Without this, a rejected payload leaves through Symfony's error renderer, which picks its format
|
||||
* from the request: the callers send "Content-Type: application/json" but no "Accept" header, so the
|
||||
* format is html and the body is an error page. The violation messages then survive only in dev,
|
||||
* inside an HTML comment and a <title>; in production the error template carries no message at all.
|
||||
* The TYPO3 finisher logs that body verbatim, which is how a missing field became "some HTML" in the
|
||||
* log of the one system that could act on it.
|
||||
*
|
||||
* So the field names are put where the caller can read them - and into this application's own log as
|
||||
* well, because "submissions stopped arriving" is diagnosed here as often as there.
|
||||
*
|
||||
* Scoped by path, like AccessDeniedListener, and deliberately the same pattern the api firewall uses
|
||||
* (security.yaml, "^/api"): the UI must keep its HTML error pages.
|
||||
*
|
||||
* **The priority is load-bearing.** ExceptionEvent::setResponse() stops propagation, so whoever
|
||||
* answers first is the only one who answers. This has to sit below security's own firewall listener
|
||||
* (priority 1), which turns an AuthenticationException or an AccessDeniedException into the 401 the
|
||||
* caller needs - at a higher priority this listener answered those as a bare 500 - and below
|
||||
* Symfony's ErrorListener::logKernelException (priority 0), so a crash is still logged with its
|
||||
* stack trace. Everything left over is ours, well before the error page is rendered at -128.
|
||||
*/
|
||||
#[AsEventListener(event: KernelEvents::EXCEPTION, method: 'onKernelException', priority: -8)]
|
||||
class ApiExceptionListener
|
||||
{
|
||||
public function __construct(private readonly LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
public function onKernelException(ExceptionEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
|
||||
if (false === $this->isApiRequest($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exception = $event->getThrowable();
|
||||
$statusCode = $exception instanceof HttpExceptionInterface
|
||||
? $exception->getStatusCode()
|
||||
: Response::HTTP_INTERNAL_SERVER_ERROR;
|
||||
|
||||
$violations = $this->collectViolations($exception, $request);
|
||||
|
||||
$event->setResponse(new JsonResponse(
|
||||
[
|
||||
'status' => 'error',
|
||||
'message' => $this->describe($exception, $statusCode, $violations),
|
||||
'errors' => $violations,
|
||||
],
|
||||
$statusCode,
|
||||
// Keeps what the status needs to mean anything, WWW-Authenticate on a 401 above all.
|
||||
$exception instanceof HttpExceptionInterface ? $exception->getHeaders() : [],
|
||||
));
|
||||
|
||||
$this->log($request, $exception, $statusCode, $violations);
|
||||
}
|
||||
|
||||
/**
|
||||
* The failed payload fields, as "field", the message the constraint produced, and the shape of
|
||||
* what actually arrived. A violation without a property path keeps the field null rather than
|
||||
* inventing one - a class-level constraint belongs to the payload as a whole.
|
||||
*
|
||||
* @return list<array{field: string|null, message: string, received: string}>
|
||||
*/
|
||||
private function collectViolations(\Throwable $exception, Request $request): array
|
||||
{
|
||||
// RequestPayloadValueResolver wraps the ValidationFailedException in an HttpException whose
|
||||
// status code the endpoint chooses (400 here, not the resolver's default 422).
|
||||
$validationFailed = $exception instanceof ValidationFailedException
|
||||
? $exception
|
||||
: $exception->getPrevious();
|
||||
|
||||
if (false === $validationFailed instanceof ValidationFailedException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = $this->decodePayload($request);
|
||||
$violations = [];
|
||||
|
||||
foreach ($validationFailed->getViolations() as $violation) {
|
||||
$field = $violation->getPropertyPath();
|
||||
$violations[] = [
|
||||
'field' => '' === $field ? null : $field,
|
||||
'message' => $this->untranslate($violation),
|
||||
'received' => $this->describeReceived($payload, $field),
|
||||
];
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* The violation message in English, rebuilt from its own template.
|
||||
*
|
||||
* ConstraintViolation::getMessage() is translated - the application's locale is German, so a
|
||||
* constraint whose message this application does not phrase itself reaches the caller as
|
||||
* "Dieser Wert sollte vom Typ null|string sein.", in a payload the contract promises is English.
|
||||
* The template and its parameters are untouched by that, and substituting them is exactly what
|
||||
* Symfony does when no translator is configured.
|
||||
*
|
||||
* "{{ value }}" is deliberately not substituted: it would put the rejected value into the
|
||||
* response and into the log, and a rejected payload is still personal data. Which field and what
|
||||
* shape it had is what a caller needs; describeReceived() covers the shape.
|
||||
*/
|
||||
private function untranslate(ConstraintViolationInterface $violation): string
|
||||
{
|
||||
$replacements = ['{{ value }}' => '(omitted)'];
|
||||
|
||||
foreach ($violation->getParameters() as $placeholder => $value) {
|
||||
// Only real placeholders: the parameters may carry extra keys (the serializer's "hint"),
|
||||
// and strtr() would replace those as plain substrings anywhere in the template.
|
||||
if (false === str_starts_with((string) $placeholder, '{{')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (true === is_scalar($value) || null === $value) {
|
||||
$replacements[(string) $placeholder] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return strtr($violation->getMessageTemplate(), $replacements);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of the value that was rejected - never the value itself.
|
||||
*
|
||||
* This is the part the framework does not tell anybody: "This value should be of type
|
||||
* null|string." names what was expected and leaves the caller to guess what they sent. A form
|
||||
* engine that hands a date element straight to json_encode sends an object, and seeing its keys
|
||||
* is the difference between a five-minute fix and a guessing game.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function describeReceived(array $payload, string $propertyPath): string
|
||||
{
|
||||
// Top-level key only: a path like "details[Nationalität]" is described by its own bucket,
|
||||
// which is as deep as this endpoint's payload goes.
|
||||
$field = explode('[', explode('.', $propertyPath)[0])[0];
|
||||
|
||||
if ('' === $field || false === array_key_exists($field, $payload)) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
return $this->describeShape($payload[$field]);
|
||||
}
|
||||
|
||||
private function describeShape(mixed $value): string
|
||||
{
|
||||
if (true === is_string($value)) {
|
||||
return '' === $value ? 'empty string' : sprintf('string (%d characters)', mb_strlen($value));
|
||||
}
|
||||
|
||||
if (false === is_array($value)) {
|
||||
return get_debug_type($value);
|
||||
}
|
||||
|
||||
if ([] === $value) {
|
||||
return 'empty array';
|
||||
}
|
||||
|
||||
if (true === array_is_list($value)) {
|
||||
return sprintf('list of %d', count($value));
|
||||
}
|
||||
|
||||
// The keys of an object are not personal data, and they are what identifies the sender's
|
||||
// shape - a serialized date arrives as date/timezone_type/timezone, for instance.
|
||||
$keys = array_map(
|
||||
static fn (string|int $key): string => mb_substr((string) $key, 0, 32),
|
||||
array_slice(array_keys($value), 0, 10),
|
||||
);
|
||||
|
||||
return sprintf('object with keys: %s', implode(', ', $keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodePayload(Request $request): array
|
||||
{
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
|
||||
return is_array($payload) ? $payload : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* One line naming what is wrong, for a caller that logs the body and reads no further.
|
||||
*
|
||||
* A server error is described generically: its message is an internal detail, and the log entry
|
||||
* below is where it belongs. Everything else may say what it says - the 4xx messages here are
|
||||
* the endpoint's own contract ("birthDate must be a date in the format YYYY-MM-DD."), written
|
||||
* for exactly this reader.
|
||||
*
|
||||
* @param list<array{field: string|null, message: string}> $violations
|
||||
*/
|
||||
private function describe(\Throwable $exception, int $statusCode, array $violations): string
|
||||
{
|
||||
if ($statusCode >= Response::HTTP_INTERNAL_SERVER_ERROR) {
|
||||
return 'Internal server error.';
|
||||
}
|
||||
|
||||
if ([] !== $violations) {
|
||||
return implode(' ', array_column($violations, 'message'));
|
||||
}
|
||||
|
||||
// Not translated either - an HttpException message from the resolver ("Request payload
|
||||
// contains invalid \"json\" data.") is English already, unlike a constraint violation.
|
||||
|
||||
$message = trim($exception->getMessage());
|
||||
|
||||
return '' === $message ? 'Request rejected.' : $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{field: string|null, message: string}> $violations
|
||||
*/
|
||||
private function log(Request $request, \Throwable $exception, int $statusCode, array $violations): void
|
||||
{
|
||||
$context = [
|
||||
'method' => $request->getMethod(),
|
||||
'uri' => $request->getRequestUri(),
|
||||
'status' => $statusCode,
|
||||
// The field names and the shape of what arrived, never the values: a rejected payload is
|
||||
// still personal data.
|
||||
'fields' => array_values(array_filter(array_column($violations, 'field'))),
|
||||
'rejected' => $this->summarise($violations),
|
||||
'error_message' => $exception->getMessage(),
|
||||
];
|
||||
|
||||
if ($statusCode >= Response::HTTP_INTERNAL_SERVER_ERROR) {
|
||||
// The trace comes from Symfony's own logging at priority 0; this entry is the one that
|
||||
// says which request it was.
|
||||
$this->logger->error('API request failed', $context);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->warning('API request rejected', $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* One readable line per violation for the log, so this application's own log answers the same
|
||||
* question the response does.
|
||||
*
|
||||
* @param list<array{field: string|null, message: string, received: string}> $violations
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function summarise(array $violations): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (array $violation): string => sprintf(
|
||||
'%s: %s (received %s)',
|
||||
$violation['field'] ?? 'payload',
|
||||
$violation['message'],
|
||||
$violation['received'],
|
||||
),
|
||||
$violations,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The api firewall's own pattern. "/api" alone is matched as well, so a request to the prefix
|
||||
* itself does not slip out as HTML.
|
||||
*/
|
||||
private function isApiRequest(Request $request): bool
|
||||
{
|
||||
$path = $request->getPathInfo();
|
||||
|
||||
return '/api' === $path || str_starts_with($path, '/api/');
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,22 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* side without a release here, so nothing may switch the serializer's ALLOW_EXTRA_ATTRIBUTES
|
||||
* off: it would turn every later mapping change into a 400, silent because the finisher only
|
||||
* logs it.
|
||||
* - **Everything but the four basics is optional.** A form is configured without a field as
|
||||
* often as with it, so an absent key means "not part of this form", never "empty" - hence the
|
||||
* defaults, which also let the serializer construct the object from a payload of four keys.
|
||||
* - **Nine fields are required of every payload**: lastName, firstName, birthDate, email, gender,
|
||||
* eu, street, zipCode and city. They are what it takes to provision a teamer from a submission,
|
||||
* and BusPro's own mandatory fields for creating an address are among them (errors 885-891: Name,
|
||||
* Vorname, Geschlecht, EMail, PLZ, Straße) - a payload without those cannot become a teamer at
|
||||
* all, and asking a reviewer to retype them from the answers would be worse than refusing the
|
||||
* payload. The endpoint only ever receives application forms, so this holds for every payload and
|
||||
* there is no form-type discriminator
|
||||
* (.ddev/plans/application-form-teamer-provisioning.md).
|
||||
*
|
||||
* Everything beyond the nine - phone, mobile, nationality, bank details - stays optional and is
|
||||
* filled in by the teamer themselves afterwards, so an absent key there still means "not part of
|
||||
* this form", never "empty".
|
||||
*
|
||||
* A 400 costs a whole application, and the visitor sees their form submit normally either way.
|
||||
* That is accepted deliberately: the same people control the TYPO3 forms and this endpoint, the
|
||||
* response body names the offending field and the finisher logs it.
|
||||
*
|
||||
* The lengths are the storage limits of ApplicationFormSubmission, not an opinion about what a
|
||||
* visitor may type: they exist so an oversized value is a 400 the TYPO3 side logs, and not a
|
||||
@@ -25,6 +38,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
*/
|
||||
class ApplicationFormSubmissionDto
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public const GENDERS = ['F', 'M', 'D'];
|
||||
|
||||
public const MAX_DETAIL_LABEL_LENGTH = 255;
|
||||
|
||||
public const MAX_DETAIL_VALUE_LENGTH = 65535;
|
||||
@@ -47,10 +65,13 @@ class ApplicationFormSubmissionDto
|
||||
public readonly int|string|null $pageUid = null,
|
||||
#[Assert\Length(max: 2048, maxMessage: 'pageUrl must not exceed {{ limit }} characters.')]
|
||||
public readonly ?string $pageUrl = null,
|
||||
#[Assert\NotBlank(message: 'firstName must not be empty.')]
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly ?string $firstName = null,
|
||||
#[Assert\NotBlank(message: 'lastName must not be empty.')]
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly ?string $lastName = null,
|
||||
#[Assert\NotBlank(message: 'email must not be empty.')]
|
||||
#[Assert\Email(message: 'email is not a valid email address.')]
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly ?string $email = null,
|
||||
@@ -58,19 +79,37 @@ class ApplicationFormSubmissionDto
|
||||
public readonly ?string $phone = null,
|
||||
#[Assert\Length(max: 64)]
|
||||
public readonly ?string $mobile = null,
|
||||
#[Assert\Length(max: 64)]
|
||||
// The one field whose format is nailed down rather than stored as typed: provisioning
|
||||
// parses it as a date, and a free-text birthday would only fail later, in front of a
|
||||
// reviewer who cannot fix it.
|
||||
#[Assert\NotBlank(message: 'birthDate must not be empty.')]
|
||||
#[Assert\Date(message: 'birthDate must be a date in the format YYYY-MM-DD.')]
|
||||
public readonly ?string $birthDate = null,
|
||||
// The application's own vocabulary, not BusPro's: ApplicationFormSubmission::toPayload()
|
||||
// maps F to W on the way out, exactly as Teamer::toPayload() does.
|
||||
#[Assert\NotBlank(message: 'gender must not be empty.')]
|
||||
#[Assert\Choice(choices: self::GENDERS, message: 'gender must be one of F, M or D.')]
|
||||
public readonly ?string $gender = null,
|
||||
// The whole postal address, required together. Street and postcode are BusPro's own
|
||||
// Pflichtfelder for a new address (errors 891 and 890), so a submission without them is one
|
||||
// no teamer can be provisioned from; the town is not on that list, but an address carrying a
|
||||
// street and a postcode without one is not an address worth writing to the CRM either.
|
||||
#[Assert\NotBlank(message: 'street must not be empty.')]
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly ?string $street = null,
|
||||
#[Assert\NotBlank(message: 'zipCode must not be empty.')]
|
||||
#[Assert\Length(max: 32)]
|
||||
public readonly ?string $zipCode = null,
|
||||
#[Assert\NotBlank(message: 'city must not be empty.')]
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly ?string $city = null,
|
||||
// Whether the applicant holds an EU (incl. Switzerland) nationality - the one payload
|
||||
// field a rule depends on, hence mapped rather than left in $details. bool|string|int
|
||||
// for the same reason as pageUid: a form posts "1" or "" as readily as a JSON boolean,
|
||||
// and losing a whole application over the spelling would be absurd. Read it back
|
||||
// through isEu().
|
||||
// through isEu(). Required, because the receipt mail depends on it - but required as a
|
||||
// value, not as a truth: NotNull rather than NotBlank, or a lawful "no" would be a 400.
|
||||
#[Assert\NotNull(message: 'eu must not be omitted.')]
|
||||
public readonly bool|string|int|null $eu = null,
|
||||
public readonly array $details = [],
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* What provisioning actually did, so the controller can phrase the flash and the log entry
|
||||
* without re-deriving any of it (App\Service\Teamer\TeamerProvisioner).
|
||||
*
|
||||
* Every flag is a fact the reviewer wants to know: whether BusPro created the address or
|
||||
* recognised the applicant, whether an account already existed, and whether the password mail
|
||||
* went out - the last is not fatal, but without it the teamer cannot log in until somebody sends
|
||||
* it again.
|
||||
*/
|
||||
class TeamerProvisioningResult
|
||||
{
|
||||
public function __construct(
|
||||
private readonly User $user,
|
||||
private readonly bool $addressCreated,
|
||||
private readonly bool $userCreated,
|
||||
private readonly bool $alreadyTeamer,
|
||||
private readonly bool $passwordMailSent,
|
||||
private readonly bool $alreadyProvisioned = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* False means BusPro matched an address it already held and answered with its ids - not a
|
||||
* failure, and not a duplicate either.
|
||||
*/
|
||||
public function isAddressCreated(): bool
|
||||
{
|
||||
return $this->addressCreated;
|
||||
}
|
||||
|
||||
public function isUserCreated(): bool
|
||||
{
|
||||
return $this->userCreated;
|
||||
}
|
||||
|
||||
public function isAlreadyTeamer(): bool
|
||||
{
|
||||
return $this->alreadyTeamer;
|
||||
}
|
||||
|
||||
public function isPasswordMailSent(): bool
|
||||
{
|
||||
return $this->passwordMailSent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The submission had been provisioned before and nothing was sent to BusPro at all.
|
||||
*/
|
||||
public function isAlreadyProvisioned(): bool
|
||||
{
|
||||
return $this->alreadyProvisioned;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Teamer;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\ApiClientException;
|
||||
use App\BusProNet\Model\Address;
|
||||
use App\BusProNet\Model\Communication;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use App\BusProNet\ResponseParserException;
|
||||
use App\BusProNet\UserDataHandler;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\Model\TeamerProvisioningResult;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Turns an applicant who passed the interview into a teamer.
|
||||
*
|
||||
* Three writes, two of them in BusPro, which is the source of truth for who is a teamer:
|
||||
*
|
||||
* 1. create the applicant's address in the CRM (or learn that it already exists),
|
||||
* 2. set the general teamer selection on it,
|
||||
* 3. create the local User and Teamer - only once 2 succeeded.
|
||||
*
|
||||
* A password mail follows as step 4, so the applicant can log in here.
|
||||
*
|
||||
* **They are not a transaction.** Each step can fail with the earlier ones done, and there is no
|
||||
* undo for an address in BusPro. What makes that survivable is that every outcome is persisted on
|
||||
* the submission as it happens, so a retry resumes instead of restarting: with the ids stored, the
|
||||
* address is never created twice, and the selection write is idempotent by design.
|
||||
*
|
||||
* Nothing here reads the applicant's profile back from BusPro. Adressdaten needs the customer's
|
||||
* password, which provisioning deliberately never has (see
|
||||
* .ddev/plans/application-form-teamer-provisioning.md), so the local records are built from the
|
||||
* data that was sent plus the ids that came back.
|
||||
*
|
||||
* This is also the one place in the application that grants a teamer their CRM marker rather than
|
||||
* reading it. Everything else stays read-only: roles and hotel codes are synced from BusPro on
|
||||
* every login, and an admin may approve a claim, never grant one.
|
||||
*/
|
||||
class TeamerProvisioner
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly UserDataHandler $userDataHandler,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly int $teamerSelectionId,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TeamerProvisioningException
|
||||
*/
|
||||
public function provision(ApplicationFormSubmission $submission): TeamerProvisioningResult
|
||||
{
|
||||
// A second click must not create a second anything. The button is gone once a submission
|
||||
// is provisioned, but a resubmitted form or a stale page would still arrive here.
|
||||
if (true === $submission->isProvisioned() && null !== $user = $submission->getUser()) {
|
||||
return new TeamerProvisioningResult(
|
||||
user: $user,
|
||||
addressCreated: false,
|
||||
userCreated: false,
|
||||
alreadyTeamer: true,
|
||||
passwordMailSent: false,
|
||||
alreadyProvisioned: true,
|
||||
);
|
||||
}
|
||||
|
||||
$this->assertProvisionable($submission);
|
||||
|
||||
$addressCreated = $this->createAddress($submission);
|
||||
$this->setTeamerSelection($submission);
|
||||
|
||||
$user = $this->createUser($submission);
|
||||
|
||||
return new TeamerProvisioningResult(
|
||||
user: $user['user'],
|
||||
addressCreated: $addressCreated,
|
||||
userCreated: $user['created'],
|
||||
alreadyTeamer: $user['alreadyTeamer'],
|
||||
// Last, so that a failure in any earlier step never asks somebody to set a password
|
||||
// for an account that does not exist.
|
||||
passwordMailSent: $this->requestPassword($user['user']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields the contract guarantees, checked once before anything is written: a payload that
|
||||
* predates the contract would otherwise fail halfway, and BusPro's own complaint about a
|
||||
* missing field arrives after the address attempt, not before it.
|
||||
*
|
||||
* @throws TeamerProvisioningException
|
||||
*/
|
||||
private function assertProvisionable(ApplicationFormSubmission $submission): void
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
if (true === empty($submission->getFirstName())) {
|
||||
$missing[] = 'Vorname';
|
||||
}
|
||||
if (true === empty($submission->getLastName())) {
|
||||
$missing[] = 'Nachname';
|
||||
}
|
||||
if (true === empty($submission->getEmail())) {
|
||||
$missing[] = 'E-Mail-Adresse';
|
||||
}
|
||||
if (null === $submission->getDateOfBirth()) {
|
||||
$missing[] = 'Geburtsdatum';
|
||||
}
|
||||
// BusPro's own Pflichtfelder for a new address, and the reason the contract requires them:
|
||||
// without a street and a postcode the request fails at step 1 (errors 891 and 890).
|
||||
if (true === empty($submission->getStreet())) {
|
||||
$missing[] = 'Straße';
|
||||
}
|
||||
if (true === empty($submission->getZipCode())) {
|
||||
$missing[] = 'Postleitzahl';
|
||||
}
|
||||
if (true === empty($submission->getCity())) {
|
||||
$missing[] = 'Ort';
|
||||
}
|
||||
|
||||
if ([] === $missing) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new TeamerProvisioningException(sprintf('Die Bewerbung ist unvollständig, es fehlt: %s', implode(', ', $missing)), ['uuid' => $submission->getUuid(), 'missing' => $missing]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1. Returns whether BusPro created the address, or recognised the person and answered
|
||||
* with the ids of the one it already held.
|
||||
*
|
||||
* @throws TeamerProvisioningException
|
||||
*/
|
||||
private function createAddress(ApplicationFormSubmission $submission): bool
|
||||
{
|
||||
// Resume: the address of this submission exists, and asking for it again would create a
|
||||
// second one.
|
||||
if (true === $submission->hasBusProAddress()) {
|
||||
$this->logger->info('Skip BusPro address creation, the submission already has one', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'bus_pro_address_id' => $submission->getBusProAddressId(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->apiClient->createAddress($submission);
|
||||
} catch (ApiClientException|ResponseParserException $e) {
|
||||
throw new TeamerProvisioningException('Die Adresse konnte in BusPro nicht angelegt werden, BusPro hat nicht geantwortet', ['uuid' => $submission->getUuid()], $e);
|
||||
}
|
||||
|
||||
if (false === $response instanceof RegistrationResponse) {
|
||||
throw new TeamerProvisioningException(sprintf('Die Adresse konnte in BusPro nicht angelegt werden: %s', $this->describe($response)), ['uuid' => $submission->getUuid(), 'bpn_code' => $response->getCode()]);
|
||||
}
|
||||
|
||||
$submission
|
||||
->setBusProAddressId($response->getAddressId())
|
||||
->setBusProPersonId($response->getPersonId())
|
||||
;
|
||||
|
||||
// Immediately, and on its own: from here on a retry must resume rather than create a
|
||||
// second address, whatever the next step does.
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Create BusPro address for application form submission', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'bus_pro_address_id' => $response->getAddressId(),
|
||||
'bus_pro_person_id' => $response->getPersonId(),
|
||||
'new_record' => $response->isNewRecord(),
|
||||
]);
|
||||
|
||||
return $response->isNewRecord();
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2. Sets the teamer selection unconditionally: the write is idempotent, and whether the
|
||||
* address already carried it is not knowable here - Adresse_Neu answers no selections, and
|
||||
* reading them would need the customer's password.
|
||||
*
|
||||
* @throws TeamerProvisioningException
|
||||
*/
|
||||
private function setTeamerSelection(ApplicationFormSubmission $submission): void
|
||||
{
|
||||
$email = (string) $submission->getEmail();
|
||||
$addressId = (int) $submission->getBusProAddressId();
|
||||
$personId = (int) $submission->getBusProPersonId();
|
||||
|
||||
try {
|
||||
$response = $this->apiClient->updateCrmAttribute(
|
||||
$email,
|
||||
$addressId,
|
||||
$personId,
|
||||
$this->teamerSelectionId,
|
||||
true,
|
||||
);
|
||||
} catch (ApiClientException|ResponseParserException $e) {
|
||||
throw new TeamerProvisioningException('Das Teamer-Merkmal konnte in BusPro nicht gesetzt werden, BusPro hat nicht geantwortet', ['uuid' => $submission->getUuid()], $e);
|
||||
}
|
||||
|
||||
if ($response instanceof NotificationResponse) {
|
||||
throw new TeamerProvisioningException(sprintf('Das Teamer-Merkmal konnte in BusPro nicht gesetzt werden: %s', $this->describe($response)), ['uuid' => $submission->getUuid(), 'bpn_code' => $response->getCode()]);
|
||||
}
|
||||
|
||||
if (false === $response->isUpdated()) {
|
||||
throw new TeamerProvisioningException('BusPro hat das Teamer-Merkmal nicht übernommen. Die Adresse wurde angelegt, der Teamer nicht.', ['uuid' => $submission->getUuid(), 'bus_pro_address_id' => $addressId]);
|
||||
}
|
||||
|
||||
$this->logger->info('Set teamer CRM selection', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'bus_pro_address_id' => $addressId,
|
||||
'bus_pro_person_id' => $personId,
|
||||
'selection_id' => $this->teamerSelectionId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3. The local account, built from what was sent plus the ids that came back.
|
||||
*
|
||||
* @return array{user: User, created: bool, alreadyTeamer: bool}
|
||||
*
|
||||
* @throws TeamerProvisioningException
|
||||
*/
|
||||
private function createUser(ApplicationFormSubmission $submission): array
|
||||
{
|
||||
$profileResponse = $this->toProfileResponse($submission);
|
||||
$user = $this->userDataHandler->findLocalUser($profileResponse);
|
||||
|
||||
if (null === $user) {
|
||||
// ROLE_TEAMER needs no approval, which is why it may be granted here at all - see
|
||||
// UserDataHandler for the policy every identity source shares.
|
||||
$user = $this->userDataHandler->createLocalUser($profileResponse, ['ROLE_TEAMER'], isTeamer: true);
|
||||
$this->markProvisioned($submission, $user);
|
||||
|
||||
return ['user' => $user, 'created' => true, 'alreadyTeamer' => false];
|
||||
}
|
||||
|
||||
// A deleted account matched by email: granting it a role would resurrect somebody who
|
||||
// asked to be gone, under their old row. That is a decision for a human, not for a
|
||||
// provisioning click.
|
||||
if (true === $user->isDeleted()) {
|
||||
throw new TeamerProvisioningException('Zu dieser E-Mail-Adresse gehört ein gelöschtes Konto. Der Teamer muss von Hand angelegt werden.', ['uuid' => $submission->getUuid(), 'user_id' => $user->getId()]);
|
||||
}
|
||||
|
||||
$alreadyTeamer = true === $user->hasRole('ROLE_TEAMER') && null !== $user->getTeamer();
|
||||
|
||||
if (false === $alreadyTeamer) {
|
||||
if (null === $user->getTeamer()) {
|
||||
$user->setTeamer(Teamer::fromApiResponse($profileResponse));
|
||||
}
|
||||
|
||||
$this->userDataHandler->grantTeamerRole($user);
|
||||
}
|
||||
|
||||
$this->markProvisioned($submission, $user);
|
||||
|
||||
$this->logger->info('Provision existing local user as teamer', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'user_id' => $user->getId(),
|
||||
'user_email' => $user->getEmail(),
|
||||
'already_teamer' => $alreadyTeamer,
|
||||
]);
|
||||
|
||||
return ['user' => $user, 'created' => false, 'alreadyTeamer' => $alreadyTeamer];
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4. BusPro mails the applicant a password, which is what lets them log in here at all -
|
||||
* this application never sets one for a CRM account.
|
||||
*
|
||||
* A failure is reported, not thrown: by now the teamer exists, and the mail can be sent again
|
||||
* from the user's own page. Undoing the three writes over an unsent mail would be worse.
|
||||
*/
|
||||
private function requestPassword(User $user): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->apiClient->resetPassword((string) $user->getEmail());
|
||||
} catch (ApiClientException|ResponseParserException $e) {
|
||||
$this->logger->error('Unable to request the BusPro password mail for a new teamer', [
|
||||
'user_id' => $user->getId(),
|
||||
'user_email' => $user->getEmail(),
|
||||
'error_message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (false === $response->isSuccessful()) {
|
||||
$this->logger->error('BusPro refused the password mail for a new teamer', [
|
||||
'user_id' => $user->getId(),
|
||||
'user_email' => $user->getEmail(),
|
||||
'bpn_code' => $response->getCode(),
|
||||
'bpn_message' => $response->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function markProvisioned(ApplicationFormSubmission $submission, User $user): void
|
||||
{
|
||||
$submission
|
||||
->setUser($user)
|
||||
->setTeamerCreatedAt(new \DateTimeImmutable())
|
||||
;
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* The submission as a profile response, which is the shape UserDataHandler and
|
||||
* Teamer::fromApiResponse() read. Not read back from BusPro on purpose: Adressdaten would
|
||||
* need the customer's password.
|
||||
*
|
||||
* The gender goes in as it is stored - F/M/D is this application's own vocabulary, and the
|
||||
* mapping to BusPro's W happens on the way out, not here.
|
||||
*/
|
||||
private function toProfileResponse(ApplicationFormSubmission $submission): ProfileResponse
|
||||
{
|
||||
$address = new Address();
|
||||
$address
|
||||
->setStreet($submission->getStreet())
|
||||
->setPostCode($submission->getZipCode())
|
||||
->setCity($submission->getCity())
|
||||
;
|
||||
|
||||
$communication = new Communication();
|
||||
$communication
|
||||
->setEmail($submission->getEmail())
|
||||
->setPhone($submission->getPhone())
|
||||
->setMobile($submission->getMobile())
|
||||
;
|
||||
|
||||
$profileResponse = new ProfileResponse();
|
||||
|
||||
return $profileResponse
|
||||
->setAddressId($submission->getBusProAddressId())
|
||||
->setPersonId($submission->getBusProPersonId())
|
||||
->setFirstName($submission->getFirstName())
|
||||
->setName($submission->getLastName())
|
||||
->setGender($submission->getGender())
|
||||
->setDateOfBirth($submission->getDateOfBirth())
|
||||
->setAddress($address)
|
||||
->setCommunication($communication)
|
||||
;
|
||||
}
|
||||
|
||||
private function describe(NotificationResponse $response): string
|
||||
{
|
||||
$message = trim((string) $response->getMessage());
|
||||
|
||||
if ('' === $message) {
|
||||
return sprintf('BusPro hat mit dem Code %s geantwortet', (string) $response->getCode());
|
||||
}
|
||||
|
||||
return sprintf('%s (%s)', $message, (string) $response->getCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Teamer;
|
||||
|
||||
/**
|
||||
* Provisioning stopped, with whatever the earlier steps had already done left in place.
|
||||
*
|
||||
* The message is shown to the reviewer as it is, which is why it is one of the few strings in this
|
||||
* application written for a human rather than a log: they are the only audience, and the useful part
|
||||
* is usually BusPro's own complaint about a mandatory field, which no rewording here would improve.
|
||||
* The technical detail goes to the log through getContext().
|
||||
*/
|
||||
class TeamerProvisioningException extends \Exception
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
string $message,
|
||||
private readonly array $context = [],
|
||||
?\Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getContext(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,8 @@
|
||||
<dl class="divide-y divide-gray-100">
|
||||
{{ _self.row('eingegangen am', submission.submittedAt|date('d.m.Y, H:i')) }}
|
||||
{{ _self.row('Name', [submission.firstName, submission.lastName]|filter(part => part is not empty)|join(' ')) }}
|
||||
{{ _self.row('Geburtsdatum', submission.birthDate) }}
|
||||
{{ _self.row('Geburtsdatum', submission.dateOfBirth ? submission.dateOfBirth|date('d.m.Y') : submission.birthDate) }}
|
||||
{{ _self.row('Geschlecht', submission.gender) }}
|
||||
{# Three states: the form said yes, said no, or never asked - and only the first one
|
||||
earns the automatic receipt mail, so "-" is not the same as "nein" here. #}
|
||||
{{ _self.row('EU (inkl. Schweiz)', submission.eu) }}
|
||||
@@ -105,6 +106,41 @@
|
||||
{{ form_rest(callsForm) }}
|
||||
{{ form_end(callsForm) }}
|
||||
</div>
|
||||
{% if feature('application_form_teamer_provisioning') or submission.teamerCreatedAt %}
|
||||
<div>
|
||||
<dl class="divide-y divide-gray-100">
|
||||
{{ _self.row('übernommen am', submission.teamerCreatedAt ? submission.teamerCreatedAt|date('d.m.Y, H:i') : null) }}
|
||||
</dl>
|
||||
<div class="pt-4">
|
||||
{% if submission.user and submission.user.teamer %}
|
||||
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': submission.user.teamer.uuid, 'r': forwarded_return_url() }) }}"
|
||||
class="btn btn--small btn--secondary">
|
||||
Teamer:innenprofil öffnen
|
||||
</a>
|
||||
{% elseif submission.email %}
|
||||
<button type="button"
|
||||
class="btn btn--small"
|
||||
hx-get="{{ path('app_admin_application_form_submission_create_teamer', { 'uuid': submission.uuid, 'r': forwarded_return_url() }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Teamer:in anlegen
|
||||
</button>
|
||||
{% if submission.busProAddressId %}
|
||||
{# Step 1 is done and the rest is not: naming it here is what makes a
|
||||
retry a resume rather than a guess. #}
|
||||
<div class="pt-2 text-sm">
|
||||
Die BusPro-Adresse {{ submission.busProAddressId }} wurde schon angelegt,
|
||||
die Übernahme wurde aber nicht abgeschlossen.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-sm">
|
||||
Ohne E-Mail-Adresse kann kein Teamer:innenkonto angelegt werden.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="font-bold text-lg pb-4">
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends 'htmx_confirmation_modal.html.twig' %}
|
||||
|
||||
{% block title %}Teamer:in anlegen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div>
|
||||
<em>{{ [submission.firstName, submission.lastName]|filter(part => part is not empty)|join(' ') }}</em>
|
||||
als Teamer:in übernehmen?
|
||||
<div class="pt-2 text-sm">
|
||||
Dabei wird in BusPro eine Adresse angelegt und das Teamer-Merkmal gesetzt. Kennt BusPro
|
||||
die Person schon, wird die vorhandene Adresse verwendet. Anschließend entsteht hier das
|
||||
Teamer:innenkonto, und BusPro schickt eine Mail zum Setzen des Passworts.
|
||||
</div>
|
||||
{# The three writes cannot be undone from here, so what goes to BusPro is shown before it
|
||||
goes, not afterwards in a log entry nobody reads. #}
|
||||
<dl class="pt-4 text-sm grid grid-cols-3 gap-y-1">
|
||||
<dt class="font-bold">Geburtsdatum</dt>
|
||||
<dd class="col-span-2">
|
||||
{% if submission.dateOfBirth %}
|
||||
{{ submission.dateOfBirth|date('d.m.Y') }}
|
||||
{% else %}
|
||||
<strong>fehlt</strong> - ohne Geburtsdatum kann keine Adresse angelegt werden
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt class="font-bold">Geschlecht</dt>
|
||||
<dd class="col-span-2">{{ submission.gender }}</dd>
|
||||
<dt class="font-bold">E-Mail</dt>
|
||||
<dd class="col-span-2">{{ submission.email ?: 'fehlt' }}</dd>
|
||||
<dt class="font-bold">Adresse</dt>
|
||||
<dd class="col-span-2">
|
||||
{# Street and postcode are mandatory in BusPro, so step 1 fails without them. Said
|
||||
here, before the write, rather than only in the error message afterwards. #}
|
||||
{% if submission.street is empty or submission.zipCode is empty or submission.city is empty %}
|
||||
<strong>unvollständig</strong> - ohne Straße, PLZ und Ort kann BusPro keine Adresse anlegen
|
||||
{% else %}
|
||||
{{ [submission.street, [submission.zipCode, submission.city]|filter(part => part is not empty)|join(' ')]|filter(part => part is not empty)|join(', ') }}
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
{% if submission.busProAddressId %}
|
||||
<div class="pt-4 text-sm">
|
||||
In BusPro besteht für diese Bewerbung schon die Adresse
|
||||
{{ submission.busProAddressId }} - sie wird nicht erneut angelegt.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block button_confirm %}Anlegen{% endblock %}
|
||||
@@ -9,6 +9,7 @@ use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\ProfileUpdateResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use App\BusProNet\ResponseParser;
|
||||
use App\BusProNet\ResponseParserException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -238,6 +239,63 @@ class ResponseParserTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The write answers idadresse + idperson + änderung, not a selection tree - the shape
|
||||
* createProfileUpdateResponse() reads. It used to be routed to the CRM attribute parser, which
|
||||
* would have returned an empty response with every role flag false
|
||||
* (.ddev/plans/buspro-crm-selection-write.md).
|
||||
*/
|
||||
public function testParseCrmSelectionWriteResponse(): void
|
||||
{
|
||||
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>SelektionCRM_Ändern</art><idadresse>141747</idadresse><idperson>224526</idperson><änderung>True</änderung></ergebnis>';
|
||||
|
||||
$response = $this->getParserInstance()->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content);
|
||||
|
||||
$this->assertInstanceOf(ProfileUpdateResponse::class, $response);
|
||||
$this->assertSame(141747, $response->getAddressId());
|
||||
$this->assertSame(224526, $response->getPersonId());
|
||||
$this->assertTrue($response->isUpdated());
|
||||
}
|
||||
|
||||
public function testParseNewAddressResponse(): void
|
||||
{
|
||||
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Adresse_Neu</art><idadresse>141747</idadresse><idperson>224526</idperson><neuanlage>True</neuanlage></ergebnis>';
|
||||
|
||||
$response = $this->getParserInstance()->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content);
|
||||
|
||||
$this->assertInstanceOf(RegistrationResponse::class, $response);
|
||||
$this->assertSame(141747, $response->getAddressId());
|
||||
$this->assertSame(224526, $response->getPersonId());
|
||||
$this->assertTrue($response->isNewRecord());
|
||||
}
|
||||
|
||||
/**
|
||||
* BusPro recognised the person and answered with the ids of the address it already held. Not a
|
||||
* failure and not a duplicate - provisioning carries on with those ids.
|
||||
*/
|
||||
public function testParseNewAddressResponseForAnExistingPerson(): void
|
||||
{
|
||||
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Adresse_Neu</art><idadresse>141747</idadresse><idperson>224526</idperson><neuanlage>False</neuanlage></ergebnis>';
|
||||
|
||||
$response = $this->getParserInstance()->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content);
|
||||
|
||||
$this->assertInstanceOf(RegistrationResponse::class, $response);
|
||||
$this->assertFalse($response->isNewRecord());
|
||||
}
|
||||
|
||||
/**
|
||||
* Without both ids there is nothing to provision with, and a silent 0 would be stored on the
|
||||
* account and never match again.
|
||||
*/
|
||||
public function testParseNewAddressResponseWithoutIdsFails(): void
|
||||
{
|
||||
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Adresse_Neu</art><neuanlage>True</neuanlage></ergebnis>';
|
||||
|
||||
$this->expectException(ResponseParserException::class);
|
||||
|
||||
$this->getParserInstance()->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content);
|
||||
}
|
||||
|
||||
private function loadFixture(string $filename): string
|
||||
{
|
||||
return file_get_contents(__DIR__.'/../Resources/'.$filename);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The feature gate, which is the only part of provisioning a test can exercise: everything behind
|
||||
* it writes to the production CRM, where the teamer selection is changeable - the test database
|
||||
* answers aenderbar="False" for it (.ddev/plans/buspro-crm-selection-write.md).
|
||||
*
|
||||
* With the flag off the route must not exist at all, not merely hide its button: the modal is
|
||||
* reachable by url, and a reviewer who bookmarked it would otherwise write to BusPro from a
|
||||
* release where the feature is meant to be dormant.
|
||||
*/
|
||||
class CreateTeamerControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = static::createClient(server: ['HTTPS' => true]);
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$this->entityManager->createQuery(sprintf('DELETE FROM %s', ApplicationFormSubmission::class))->execute();
|
||||
$this->entityManager->createQuery(sprintf('DELETE FROM %s', User::class))->execute();
|
||||
|
||||
$this->client->loginUser($this->reviewer());
|
||||
}
|
||||
|
||||
public function testTheDisabledFeatureHidesTheRouteEntirely(): void
|
||||
{
|
||||
$this->client->request('GET', $this->url());
|
||||
|
||||
$this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* The POST is the one that writes, so it is pinned separately rather than trusted to share the
|
||||
* guard with the GET.
|
||||
*/
|
||||
public function testTheDisabledFeatureRefusesThePostAsWell(): void
|
||||
{
|
||||
$this->client->request('POST', $this->url());
|
||||
|
||||
$this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertNull($this->submission()->getTeamerCreatedAt());
|
||||
}
|
||||
|
||||
public function testTheDetailPageOffersNoProvisioningWhileTheFeatureIsOff(): void
|
||||
{
|
||||
$crawler = $this->client->request(
|
||||
'GET',
|
||||
sprintf('/admin/application-form-submission/detail/%s', $this->submission()->getUuid())
|
||||
);
|
||||
|
||||
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertCount(0, $crawler->filter('button[hx-get*="create-teamer"]'));
|
||||
}
|
||||
|
||||
private function url(): string
|
||||
{
|
||||
return sprintf('/admin/application-form-submission/create-teamer/%s', $this->submission()->getUuid());
|
||||
}
|
||||
|
||||
private function submission(): ApplicationFormSubmission
|
||||
{
|
||||
$repository = $this->entityManager->getRepository(ApplicationFormSubmission::class);
|
||||
$submission = $repository->findOneBy([], ['id' => 'DESC']);
|
||||
|
||||
if (null !== $submission) {
|
||||
return $submission;
|
||||
}
|
||||
|
||||
$submission = (new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
'F'
|
||||
))
|
||||
->setFirstName('Anna')
|
||||
->setLastName('Muster')
|
||||
->setEmail('[email protected]')
|
||||
->setBirthDate('1990-01-01')
|
||||
;
|
||||
|
||||
$this->entityManager->persist($submission);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $submission;
|
||||
}
|
||||
|
||||
private function reviewer(): User
|
||||
{
|
||||
$user = (new User())
|
||||
->setFirstName('Rita')
|
||||
->setLastName('Reviewer')
|
||||
->setEmail('[email protected]')
|
||||
->setRoles(['ROLE_TEAM_ADMIN'])
|
||||
->setBusProAddressId(1)
|
||||
->setBusProPersonId(1)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,8 @@ class DetailControllerTest extends WebTestCase
|
||||
$submission = (new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00')
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
'F'
|
||||
))->setEmail('[email protected]');
|
||||
|
||||
$this->entityManager->persist($submission);
|
||||
|
||||
@@ -14,10 +14,16 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The cases here are the ones the TYPO3 side actually produces
|
||||
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §8): a payload of nothing but the basics, labels
|
||||
* with slashes and umlauts, a long textarea answer, a duplicate label, and a mapped key this
|
||||
* application does not know - which must not be a 400, or a later mapping change on the
|
||||
* website silently stops every submission of that form.
|
||||
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §8): labels with slashes and umlauts, a long textarea
|
||||
* answer, a duplicate label, and a mapped key this application does not know - which must not be a
|
||||
* 400, or a later mapping change on the website silently stops every submission of that form.
|
||||
*
|
||||
* The other half is the required nine of the contract: every payload carries lastName, firstName,
|
||||
* birthDate, email, gender, eu, street, zipCode and city, and every one of them is a 400 when it is
|
||||
* missing. That is
|
||||
* deliberately harsh - a 400 costs a whole application - and each rejection case is pinned here
|
||||
* one field at a time, because these are the fields a teamer cannot be provisioned without
|
||||
* (.ddev/plans/application-form-teamer-provisioning.md).
|
||||
*/
|
||||
class ApplicationFormControllerTest extends WebTestCase
|
||||
{
|
||||
@@ -37,19 +43,160 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
)->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint promises JSON out, and on the error path that is not decoration: the TYPO3
|
||||
* finisher logs the response body, and Symfony's error renderer would answer an HTML error page
|
||||
* here - one that names the offending field only in dev, inside an HTML comment, and not at all
|
||||
* in production. See ApiExceptionListener.
|
||||
*/
|
||||
public function testARejectionIsJsonNamingTheField(): void
|
||||
{
|
||||
$payload = $this->payload();
|
||||
unset($payload['birthDate']);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$response = $this->client->getResponse();
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
|
||||
$this->assertStringContainsString('application/json', (string) $response->headers->get('Content-Type'));
|
||||
$this->assertStringNotContainsString('<html', (string) $response->getContent());
|
||||
|
||||
$body = $this->decode();
|
||||
|
||||
$this->assertSame('error', $body['status']);
|
||||
$this->assertSame(
|
||||
[['field' => 'birthDate', 'message' => 'birthDate must not be empty.', 'received' => 'missing']],
|
||||
$body['errors']
|
||||
);
|
||||
// One line for a caller that logs the message alone.
|
||||
$this->assertSame('birthDate must not be empty.', $body['message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of what arrived, which the framework never says: "This value should be of type
|
||||
* null|string." names the expectation and leaves the sender guessing. A form engine that hands a
|
||||
* date element straight to json_encode sends an object, and its keys are the whole diagnosis.
|
||||
*
|
||||
* Never the value itself - a rejected payload is still personal data.
|
||||
*
|
||||
* @dataProvider unusableBirthDateTypes
|
||||
*/
|
||||
public function testARejectionNamesTheShapeThatArrived(mixed $birthDate, string $received): void
|
||||
{
|
||||
$this->post(['birthDate' => $birthDate] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(
|
||||
[['field' => 'birthDate', 'message' => 'This value should be of type null|string.', 'received' => $received]],
|
||||
$this->decode()['errors']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{mixed, string}>
|
||||
*/
|
||||
public static function unusableBirthDateTypes(): iterable
|
||||
{
|
||||
yield 'a serialized date object' => [
|
||||
['date' => '1990-01-01 00:00:00.000000', 'timezone_type' => 3, 'timezone' => 'Europe/Berlin'],
|
||||
'object with keys: date, timezone_type, timezone',
|
||||
];
|
||||
yield 'a list' => [['1990-01-01'], 'list of 1'];
|
||||
yield 'a number' => [19900101, 'int'];
|
||||
yield 'a boolean' => [true, 'bool'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint's errors are English (the only caller is a machine), but a constraint message this
|
||||
* application does not phrase itself is translated by the validator - and this application's
|
||||
* locale is German. The message is therefore rebuilt from its own template rather than read off
|
||||
* the violation.
|
||||
*/
|
||||
public function testAFrameworkMessageIsNotTranslated(): void
|
||||
{
|
||||
$this->post(['birthDate' => 19900101] + $this->payload());
|
||||
|
||||
$content = (string) $this->client->getResponse()->getContent();
|
||||
|
||||
$this->assertStringContainsString('This value should be of type null|string.', $content);
|
||||
$this->assertStringNotContainsString('Dieser Wert', $content);
|
||||
}
|
||||
|
||||
public function testAnEmptyValueIsDistinguishedFromAnAbsentOne(): void
|
||||
{
|
||||
$this->post(['city' => ''] + $this->payload());
|
||||
|
||||
$this->assertSame('empty string', $this->decode()['errors'][0]['received']);
|
||||
}
|
||||
|
||||
public function testEveryOffendingFieldIsNamed(): void
|
||||
{
|
||||
$payload = $this->payload();
|
||||
unset($payload['street'], $payload['zipCode'], $payload['city']);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertEqualsCanonicalizing(['street', 'zipCode', 'city'], $this->rejectedFields());
|
||||
}
|
||||
|
||||
/**
|
||||
* A malformed body has no field to name, so the resolver's own explanation is passed on rather
|
||||
* than swallowed - still JSON, still readable in a log.
|
||||
*/
|
||||
public function testABrokenBodyIsRejectedAsJson(): void
|
||||
{
|
||||
$this->client->request(
|
||||
'POST',
|
||||
self::URL,
|
||||
server: ['CONTENT_TYPE' => 'application/json', 'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY],
|
||||
content: '{"form": "applicationform",'
|
||||
);
|
||||
|
||||
$body = $this->decode();
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame([], $body['errors']);
|
||||
$this->assertNotSame('', $body['message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not only the validation path: a wrong method comes from the router and used to leave as HTML
|
||||
* too.
|
||||
*/
|
||||
public function testAWrongMethodIsJsonToo(): void
|
||||
{
|
||||
$this->client->request('GET', self::URL, server: [
|
||||
'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY,
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_METHOD_NOT_ALLOWED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame('error', $this->decode()['status']);
|
||||
}
|
||||
|
||||
public function testAKeylessRequestIsRejected(): void
|
||||
{
|
||||
$this->post(['form' => 'applicationform'], key: null);
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
$this->assertSame('error', $this->decode()['status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrong key is answered by the firewall's entry point, which stops the exception before
|
||||
* ApiExceptionListener sees it: the body stays empty. Pinned because "empty" is the thing to
|
||||
* recognise in a caller's log - it is not the HTML error page a rejected payload used to get,
|
||||
* and it is not something this application can phrase without an entry point of its own.
|
||||
*/
|
||||
public function testAWrongKeyIsRejected(): void
|
||||
{
|
||||
$this->post(['form' => 'applicationform'], key: 'not-the-key');
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
||||
$response = $this->client->getResponse();
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
$this->assertStringNotContainsString('<html', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testAFullSubmissionIsStored(): void
|
||||
@@ -65,7 +212,9 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
'email' => '[email protected]',
|
||||
'phone' => '+49 30 1234567',
|
||||
'mobile' => '+49 170 1234567',
|
||||
'birthDate' => '01.01.1990',
|
||||
'birthDate' => '1990-01-01',
|
||||
'gender' => 'F',
|
||||
'eu' => true,
|
||||
'street' => 'Musterstraße 1',
|
||||
'zipCode' => '10115',
|
||||
'city' => 'Berlin',
|
||||
@@ -84,33 +233,132 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
$this->assertSame('Bewerbungsformular', $submission->getFormLabel());
|
||||
$this->assertSame('2026-09-22T12:30:00+02:00', $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM));
|
||||
$this->assertSame(1421, $submission->getPageUid());
|
||||
$this->assertSame('01.01.1990', $submission->getBirthDate());
|
||||
$this->assertSame('1990-01-01', $submission->getBirthDate());
|
||||
$this->assertSame('1990-01-01', $submission->getDateOfBirth()?->format('Y-m-d'));
|
||||
$this->assertSame('F', $submission->getGender());
|
||||
$this->assertSame('[email protected]', $submission->getEmail(), 'the address is normalized like everywhere else');
|
||||
$this->assertSame(['Reiseleitung', 'Skilehrer'], $submission->getDetails()['Bewerbung als']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A form may map nothing but the basics, and an absent mapped key means the form has no such
|
||||
* field - never an empty value that failed validation.
|
||||
* Beyond the required nine everything stays optional: a form is configured without a mobile
|
||||
* number as often as with it, and the teamer fills those in themselves afterwards. An absent
|
||||
* optional key means the form has no such field - never an empty value that failed validation.
|
||||
*/
|
||||
public function testTheBasicsAloneAreEnough(): void
|
||||
public function testTheRequiredFieldsAloneAreEnough(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'contactform',
|
||||
'formLabel' => 'Kontaktformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => [],
|
||||
]);
|
||||
$this->post($this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
$submission = $this->lastSubmission();
|
||||
|
||||
$this->assertNull($submission->getFirstName());
|
||||
$this->assertNull($submission->getEmail());
|
||||
$this->assertNull($submission->getPhone());
|
||||
$this->assertNull($submission->getMobile());
|
||||
$this->assertSame([], $submission->getDetails());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider requiredFields
|
||||
*/
|
||||
public function testAPayloadMissingARequiredFieldIsRejected(string $field): void
|
||||
{
|
||||
$payload = $this->payload();
|
||||
unset($payload[$field]);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
// The whole point of the rejection: the caller logs this body and has to see the field.
|
||||
$this->assertContains($field, $this->rejectedFields());
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty string is the shape a form posts for a field the visitor left blank, and it is as
|
||||
* unusable as an absent key - except for eu, where "" is a lawful no and has its own case.
|
||||
*
|
||||
* @dataProvider requiredFields
|
||||
*/
|
||||
public function testAPayloadWithAnEmptyRequiredFieldIsRejected(string $field): void
|
||||
{
|
||||
if ('eu' === $field) {
|
||||
$this->markTestSkipped('An empty eu is a no, not a missing answer - see its own test');
|
||||
}
|
||||
|
||||
$this->post([$field => ''] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function requiredFields(): iterable
|
||||
{
|
||||
yield 'form' => ['form'];
|
||||
yield 'formLabel' => ['formLabel'];
|
||||
yield 'submittedAt' => ['submittedAt'];
|
||||
yield 'firstName' => ['firstName'];
|
||||
yield 'lastName' => ['lastName'];
|
||||
yield 'email' => ['email'];
|
||||
yield 'birthDate' => ['birthDate'];
|
||||
yield 'gender' => ['gender'];
|
||||
yield 'eu' => ['eu'];
|
||||
yield 'street' => ['street'];
|
||||
yield 'zipCode' => ['zipCode'];
|
||||
yield 'city' => ['city'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The one field whose format is nailed down: provisioning parses it as a date, so the day the
|
||||
* TYPO3 side switches a form back to a free-text birthday has to be a 400 here rather than a
|
||||
* failure in front of a reviewer weeks later.
|
||||
*
|
||||
* @dataProvider unusableBirthDates
|
||||
*/
|
||||
public function testAnUnusableBirthDateIsRejected(string $birthDate): void
|
||||
{
|
||||
$this->post(['birthDate' => $birthDate] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function unusableBirthDates(): iterable
|
||||
{
|
||||
yield 'the German spelling the forms used before the contract' => ['01.01.1990'];
|
||||
yield 'free text' => ['irgendwann 1990'];
|
||||
yield 'a day that does not exist' => ['1990-02-31'];
|
||||
yield 'a year alone' => ['1990'];
|
||||
}
|
||||
|
||||
public function testAnUnknownGenderIsRejected(): void
|
||||
{
|
||||
$this->post(['gender' => 'weiblich'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
/**
|
||||
* D is passed through to BusPro as it is - what BusPro makes of it is unverified, and guessing
|
||||
* a replacement here would be worse than finding out.
|
||||
*/
|
||||
public function testEveryGenderTheApplicationKnowsIsAccepted(): void
|
||||
{
|
||||
foreach (['F', 'M', 'D'] as $gender) {
|
||||
$this->post(['gender' => $gender] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame($gender, $this->lastSubmission()->getGender());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapped vocabulary grows on the website side without a release here. An unknown key is
|
||||
* ignored, and kept in the raw payload so it can be mapped later without losing the answers
|
||||
@@ -118,13 +366,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testAnUnknownMappedKeyIsIgnoredAndKept(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'nationality' => 'deutsch',
|
||||
'details' => [],
|
||||
]);
|
||||
$this->post(['nationality' => 'deutsch'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame('deutsch', $this->lastSubmission()->getPayload()['nationality']);
|
||||
@@ -145,12 +387,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
'Geburtsdatum (text-10)' => '02.02.1992',
|
||||
];
|
||||
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => $details,
|
||||
]);
|
||||
$this->post(['details' => $details] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
@@ -165,12 +402,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
{
|
||||
$answer = str_repeat('ä', 3000);
|
||||
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => ['Warum ich' => $answer],
|
||||
]);
|
||||
$this->post(['details' => ['Warum ich' => $answer]] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame($answer, $this->lastSubmission()->getDetails()['Warum ich']);
|
||||
@@ -178,12 +410,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
|
||||
public function testAPayloadWithoutPageInformationIsAccepted(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => ['Nationalität' => 'deutsch'],
|
||||
]);
|
||||
$this->post(['details' => ['Nationalität' => 'deutsch']] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertNull($this->lastSubmission()->getPageUid());
|
||||
@@ -196,13 +423,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testANumericStringPageUidIsAccepted(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'pageUid' => '1421',
|
||||
'details' => [],
|
||||
]);
|
||||
$this->post(['pageUid' => '1421'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(1421, $this->lastSubmission()->getPageUid());
|
||||
@@ -214,12 +435,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
'POST',
|
||||
self::URL,
|
||||
server: ['HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY],
|
||||
content: json_encode([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => [],
|
||||
], JSON_THROW_ON_ERROR)
|
||||
content: json_encode($this->payload(), JSON_THROW_ON_ERROR)
|
||||
);
|
||||
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
@@ -241,26 +457,9 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
public function testAMissingFormIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
public function testAnUnparseableSubmissionTimeIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => 'gestern',
|
||||
'details' => [],
|
||||
]);
|
||||
$this->post(['submittedAt' => 'gestern'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
@@ -268,25 +467,14 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
|
||||
public function testAnInvalidEmailAddressIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'email' => 'not-an-address',
|
||||
'details' => [],
|
||||
]);
|
||||
$this->post(['email' => 'not-an-address'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testANestedDetailValueIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => ['Bewerbung als' => ['erste' => ['Reiseleitung']]],
|
||||
]);
|
||||
$this->post(['details' => ['Bewerbung als' => ['erste' => ['Reiseleitung']]]] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
@@ -307,13 +495,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testTheSamePayloadTwiceIsStoredTwiceForNow(): void
|
||||
{
|
||||
$payload = [
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'email' => '[email protected]',
|
||||
'details' => [],
|
||||
];
|
||||
$payload = $this->payload();
|
||||
|
||||
$this->post($payload);
|
||||
$this->post($payload);
|
||||
@@ -329,7 +511,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testTheReceiptGoesOutForAnEuApplicant(): void
|
||||
{
|
||||
$this->post($this->eligiblePayload());
|
||||
$this->post($this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(1);
|
||||
@@ -339,7 +521,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
|
||||
public function testNoReceiptForANonEuApplicant(): void
|
||||
{
|
||||
$this->post(['eu' => false] + $this->eligiblePayload());
|
||||
$this->post(['eu' => false] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(0);
|
||||
@@ -347,26 +529,13 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
$this->assertFalse($this->lastSubmission()->isEu());
|
||||
}
|
||||
|
||||
public function testNoReceiptWhenTheFormDoesNotAsk(): void
|
||||
{
|
||||
$payload = $this->eligiblePayload();
|
||||
unset($payload['eu']);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(0);
|
||||
$this->assertNull($this->lastSubmission()->isEu());
|
||||
$this->assertNull($this->lastSubmission()->getFirstMailSentAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* A form posts "1" as readily as a JSON true, and dropping an application over the spelling
|
||||
* would be absurd - the same tolerance pageUid has.
|
||||
*/
|
||||
public function testAQuotedEuFlagStillEarnsTheReceipt(): void
|
||||
{
|
||||
$this->post(['eu' => '1'] + $this->eligiblePayload());
|
||||
$this->post(['eu' => '1'] + $this->payload());
|
||||
|
||||
$this->assertEmailCount(1);
|
||||
$this->assertTrue($this->lastSubmission()->isEu());
|
||||
@@ -374,7 +543,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
|
||||
public function testAnEmptyEuFlagIsNotAYes(): void
|
||||
{
|
||||
$this->post(['eu' => ''] + $this->eligiblePayload());
|
||||
$this->post(['eu' => ''] + $this->payload());
|
||||
|
||||
$this->assertEmailCount(0);
|
||||
$this->assertFalse($this->lastSubmission()->isEu());
|
||||
@@ -386,30 +555,21 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testAnUnparseableEuFlagIsRejected(): void
|
||||
{
|
||||
$this->post(['eu' => 'vielleicht'] + $this->eligiblePayload());
|
||||
$this->post(['eu' => 'vielleicht'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
$this->assertEmailCount(0);
|
||||
}
|
||||
|
||||
public function testAnEligibleSubmissionWithoutAnEmailAddressIsStillStored(): void
|
||||
{
|
||||
$payload = $this->eligiblePayload();
|
||||
unset($payload['email']);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(0);
|
||||
$this->assertSame(1, $this->countSubmissions());
|
||||
$this->assertNull($this->lastSubmission()->getFirstMailSentAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* A payload with nothing but the required six, which is the smallest thing the endpoint
|
||||
* accepts. Spread an override in front of it - ['gender' => 'X'] + $this->payload() - rather
|
||||
* than rebuilding it, so a field added to the contract reaches every case at once.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function eligiblePayload(): array
|
||||
private function payload(): array
|
||||
{
|
||||
return [
|
||||
'form' => 'applicationform',
|
||||
@@ -418,11 +578,40 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
'firstName' => 'Anna',
|
||||
'lastName' => 'Muster',
|
||||
'email' => '[email protected]',
|
||||
'birthDate' => '1990-01-01',
|
||||
'gender' => 'F',
|
||||
'eu' => true,
|
||||
// The postal address, required as a whole: street and postcode are BusPro's own
|
||||
// mandatory fields for a new address.
|
||||
'street' => 'Musterstraße 1',
|
||||
'zipCode' => '10115',
|
||||
'city' => 'Berlin',
|
||||
'details' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields the endpoint named as the reason for a rejection.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function rejectedFields(): array
|
||||
{
|
||||
return array_values(array_filter(array_column($this->decode()['errors'], 'field')));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decode(): array
|
||||
{
|
||||
$body = json_decode((string) $this->client->getResponse()->getContent(), true);
|
||||
|
||||
$this->assertIsArray($body, 'The endpoint has to answer JSON, on the error path as well');
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The payload a submission goes to BusPro as, asserted directly - in the spirit of
|
||||
* AssignmentTypeTest pinning a form type's choice list. It is the one part of provisioning that
|
||||
* cannot be rehearsed against the test database at all (the teamer selection is only changeable in
|
||||
* production), so the keys and the gender mapping are pinned here rather than discovered live.
|
||||
*/
|
||||
class ApplicationFormSubmissionTest extends TestCase
|
||||
{
|
||||
public function testThePayloadCarriesTheGermanKeysBusProExpects(): void
|
||||
{
|
||||
$payload = $this->submission()->toPayload();
|
||||
|
||||
$this->assertSame([
|
||||
'geburtsdatum' => '01.01.1990',
|
||||
'geschlecht' => 'W',
|
||||
'vorname' => 'Anna',
|
||||
'name' => 'Muster',
|
||||
'anschrift' => [
|
||||
'strasse' => 'Musterstraße 1',
|
||||
'plz' => '10115',
|
||||
'ort' => 'Berlin',
|
||||
],
|
||||
'kommunikation' => [
|
||||
'email' => '[email protected]',
|
||||
'telefonmobil' => '+49 170 1234567',
|
||||
'telefonprivat' => '+49 30 1234567',
|
||||
],
|
||||
], $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* F becomes W, exactly as in Teamer::toPayload(). What BusPro makes of "D" is unverified, so it
|
||||
* is passed through rather than guessed at - the mapping must not invent a value here.
|
||||
*/
|
||||
public function testTheGenderMappingIsTheOneTeamerUses(): void
|
||||
{
|
||||
$this->assertSame('W', $this->submission('F')->toPayload()['geschlecht']);
|
||||
$this->assertSame('M', $this->submission('M')->toPayload()['geschlecht']);
|
||||
$this->assertSame('D', $this->submission('D')->toPayload()['geschlecht']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a row that predates the contract gets here - street and postcode are required of every
|
||||
* payload now. The keys still go out empty rather than absent, because BusPro is told what it
|
||||
* asked for and answers with its own Pflichtfeld error rather than a parse failure.
|
||||
*/
|
||||
public function testAnAbsentAddressLeavesTheKeysEmptyRatherThanAbsent(): void
|
||||
{
|
||||
$payload = (new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
'M',
|
||||
))->toPayload();
|
||||
|
||||
$this->assertSame(['strasse' => null, 'plz' => null, 'ort' => null], $payload['anschrift']);
|
||||
}
|
||||
|
||||
public function testTheBirthDateIsParsedStrictly(): void
|
||||
{
|
||||
$this->assertSame('1990-01-01', $this->submission()->getDateOfBirth()?->format('Y-m-d'));
|
||||
// What a row from before the contract looks like: stored, displayed, but not a date.
|
||||
$this->assertNull($this->submission(birthDate: '01.01.1990')->getDateOfBirth());
|
||||
$this->assertNull($this->submission(birthDate: 'irgendwann')->getDateOfBirth());
|
||||
$this->assertNull($this->submission(birthDate: '1990-02-31')->getDateOfBirth());
|
||||
$this->assertNull($this->submission(birthDate: null)->getDateOfBirth());
|
||||
}
|
||||
|
||||
/**
|
||||
* The time of day is zeroed rather than "now", or two runs of the same submission would produce
|
||||
* two different dates of birth.
|
||||
*/
|
||||
public function testTheBirthDateHasNoTimeOfDay(): void
|
||||
{
|
||||
$this->assertSame('00:00:00', $this->submission()->getDateOfBirth()?->format('H:i:s'));
|
||||
}
|
||||
|
||||
public function testProvisioningStateStartsEmpty(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->assertFalse($submission->hasBusProAddress());
|
||||
$this->assertFalse($submission->isProvisioned());
|
||||
|
||||
$submission->setBusProAddressId(141747);
|
||||
|
||||
// Both ids or nothing: the selection write needs the person id as well.
|
||||
$this->assertFalse($submission->hasBusProAddress());
|
||||
|
||||
$submission->setBusProPersonId(224526);
|
||||
|
||||
$this->assertTrue($submission->hasBusProAddress());
|
||||
}
|
||||
|
||||
private function submission(string $gender = 'F', ?string $birthDate = '1990-01-01'): ApplicationFormSubmission
|
||||
{
|
||||
return (new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
$gender,
|
||||
))
|
||||
->setFirstName('Anna')
|
||||
->setLastName('Muster')
|
||||
->setEmail('[email protected]')
|
||||
->setPhone('+49 30 1234567')
|
||||
->setMobile('+49 170 1234567')
|
||||
->setBirthDate($birthDate)
|
||||
->setStreet('Musterstraße 1')
|
||||
->setZipCode('10115')
|
||||
->setCity('Berlin')
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,8 @@ class ApplicationFormSubmissionCallsDtoTest extends TestCase
|
||||
return new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00')
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
'F'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Teamer;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\ApiClientException;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\ProfileUpdateResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use App\BusProNet\UserDataHandler;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\Service\Teamer\TeamerProvisioner;
|
||||
use App\Service\Teamer\TeamerProvisioningException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* The three provisioning writes are not a transaction and only the first and the third can be
|
||||
* rehearsed against BusPro's test database - the teamer selection is changeable in production only
|
||||
* (.ddev/plans/buspro-crm-selection-write.md). So the orchestration is pinned here against a mocked
|
||||
* client: what runs, in which order, what is persisted before the next step, and what a retry does
|
||||
* after a failure halfway.
|
||||
*/
|
||||
class TeamerProvisionerTest extends TestCase
|
||||
{
|
||||
private const SELECTION_ID = 1070;
|
||||
|
||||
private const ADDRESS_ID = 141747;
|
||||
|
||||
private const PERSON_ID = 224526;
|
||||
|
||||
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 testANewApplicantBecomesATeamer(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->expects($this->once())
|
||||
->method('createAddress')
|
||||
->with($submission)
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient
|
||||
->expects($this->once())
|
||||
->method('resetPassword')
|
||||
->with('[email protected]')
|
||||
->willReturn(new NotificationResponse())
|
||||
;
|
||||
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn(null);
|
||||
$this->userDataHandler
|
||||
->expects($this->once())
|
||||
->method('createLocalUser')
|
||||
// ROLE_TEAMER needs no approval, which is the only reason it may be granted here.
|
||||
->with($this->isInstanceOf(ProfileResponse::class), ['ROLE_TEAMER'], true)
|
||||
->willReturn($this->user())
|
||||
;
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertTrue($result->isAddressCreated());
|
||||
$this->assertTrue($result->isUserCreated());
|
||||
$this->assertFalse($result->isAlreadyTeamer());
|
||||
$this->assertTrue($result->isPasswordMailSent());
|
||||
$this->assertSame(self::ADDRESS_ID, $submission->getBusProAddressId());
|
||||
$this->assertSame(self::PERSON_ID, $submission->getBusProPersonId());
|
||||
$this->assertTrue($submission->isProvisioned());
|
||||
}
|
||||
|
||||
/**
|
||||
* BusPro recognised the person and answered with the ids of the address it already held. The
|
||||
* selection is set on that record and the reviewer is told - it is not a failure, and a
|
||||
* duplicate is what it prevents.
|
||||
*/
|
||||
public function testAnApplicantBusProAlreadyKnowsUsesTheExistingAddress(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient->method('resetPassword')->willReturn(new NotificationResponse());
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn(null);
|
||||
$this->userDataHandler->method('createLocalUser')->willReturn($this->user());
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertFalse($result->isAddressCreated());
|
||||
$this->assertTrue($result->isUserCreated());
|
||||
$this->assertSame(self::ADDRESS_ID, $submission->getBusProAddressId());
|
||||
}
|
||||
|
||||
/**
|
||||
* The step that cannot be rehearsed outside production. When BusPro refuses it, the address is
|
||||
* already there - so the ids have to be persisted and no local teamer may appear.
|
||||
*/
|
||||
public function testARefusedSelectionLeavesTheAddressRecordedAndNoTeamer(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true))
|
||||
;
|
||||
$this->apiClient
|
||||
->method('updateCrmAttribute')
|
||||
->willReturn(new NotificationResponse(853, 'ID, EMail oder Passwort falsch'))
|
||||
;
|
||||
$this->apiClient->expects($this->never())->method('resetPassword');
|
||||
$this->userDataHandler->expects($this->never())->method('createLocalUser');
|
||||
|
||||
try {
|
||||
$this->provisioner()->provision($submission);
|
||||
$this->fail('Provisioning was expected to fail');
|
||||
} catch (TeamerProvisioningException $e) {
|
||||
$this->assertStringContainsString('853', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->assertSame(self::ADDRESS_ID, $submission->getBusProAddressId());
|
||||
$this->assertFalse($submission->isProvisioned());
|
||||
$this->assertNull($submission->getUser());
|
||||
}
|
||||
|
||||
/**
|
||||
* änderung=False is BusPro saying it did not apply the change - the selection is not
|
||||
* changeable in this environment, which is exactly the state of the test database.
|
||||
*/
|
||||
public function testAnUnappliedSelectionIsAFailure(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true))
|
||||
;
|
||||
$this->apiClient
|
||||
->method('updateCrmAttribute')
|
||||
->willReturn((new ProfileUpdateResponse())->setUpdated(false))
|
||||
;
|
||||
$this->userDataHandler->expects($this->never())->method('createLocalUser');
|
||||
|
||||
$this->expectException(TeamerProvisioningException::class);
|
||||
|
||||
$this->provisioner()->provision($submission);
|
||||
}
|
||||
|
||||
/**
|
||||
* The retry after the failure above: the address exists and must not be created a second time.
|
||||
* The selection is written again instead, which is safe because that write is idempotent.
|
||||
*/
|
||||
public function testARetryResumesInsteadOfCreatingASecondAddress(): void
|
||||
{
|
||||
$submission = $this->submission()
|
||||
->setBusProAddressId(self::ADDRESS_ID)
|
||||
->setBusProPersonId(self::PERSON_ID)
|
||||
;
|
||||
|
||||
$this->apiClient->expects($this->never())->method('createAddress');
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient->method('resetPassword')->willReturn(new NotificationResponse());
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn(null);
|
||||
$this->userDataHandler->method('createLocalUser')->willReturn($this->user());
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertFalse($result->isAddressCreated());
|
||||
$this->assertTrue($submission->isProvisioned());
|
||||
}
|
||||
|
||||
/**
|
||||
* Somebody who already has an account - an existing customer, or a teamer from an earlier
|
||||
* season. No second account, and the reviewer is told rather than left wondering why nothing
|
||||
* appeared.
|
||||
*/
|
||||
public function testAnApplicantWhoAlreadyHasATeamerAccountIsNotCreatedTwice(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
$user = $this->user(teamer: true, roles: ['ROLE_TEAMER']);
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient->method('resetPassword')->willReturn(new NotificationResponse());
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($user);
|
||||
$this->userDataHandler->expects($this->never())->method('createLocalUser');
|
||||
$this->userDataHandler->expects($this->never())->method('grantTeamerRole');
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertFalse($result->isUserCreated());
|
||||
$this->assertTrue($result->isAlreadyTeamer());
|
||||
$this->assertSame($user, $result->getUser());
|
||||
$this->assertSame($user, $submission->getUser());
|
||||
}
|
||||
|
||||
/**
|
||||
* An account without the teamer half - an office user, or a customer who once booked a trip.
|
||||
* They get the role and a teamer record, not a second account.
|
||||
*/
|
||||
public function testAnAccountWithoutATeamerGetsOne(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
$user = $this->user();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient->method('resetPassword')->willReturn(new NotificationResponse());
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($user);
|
||||
$this->userDataHandler->expects($this->once())->method('grantTeamerRole')->with($user);
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertFalse($result->isUserCreated());
|
||||
$this->assertFalse($result->isAlreadyTeamer());
|
||||
$this->assertInstanceOf(Teamer::class, $user->getTeamer());
|
||||
$this->assertSame('Anna', $user->getTeamer()->getFirstName());
|
||||
// F/M/D is this application's own vocabulary and is stored as it came; the mapping to
|
||||
// BusPro's W happens on the way out only.
|
||||
$this->assertSame('F', $user->getTeamer()->getGender());
|
||||
$this->assertSame('1990-01-01', $user->getTeamer()->getDateOfBirth()?->format('Y-m-d'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A deleted account matched by email. Granting it a role would resurrect somebody who asked to
|
||||
* be gone, under their old row - a decision for a human, not for a provisioning click.
|
||||
*/
|
||||
public function testADeletedAccountStopsProvisioning(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($this->user(deleted: true));
|
||||
$this->userDataHandler->expects($this->never())->method('createLocalUser');
|
||||
$this->apiClient->expects($this->never())->method('resetPassword');
|
||||
|
||||
$this->expectException(TeamerProvisioningException::class);
|
||||
|
||||
$this->provisioner()->provision($submission);
|
||||
}
|
||||
|
||||
/**
|
||||
* The password mail is the last step and its failure is reported, not thrown: the teamer exists
|
||||
* by then, and undoing three writes over an unsent mail would be worse than saying so.
|
||||
*/
|
||||
public function testAFailedPasswordMailStillLeavesAProvisionedTeamer(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient->method('resetPassword')->willThrowException(new ApiClientException('timeout'));
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn(null);
|
||||
$this->userDataHandler->method('createLocalUser')->willReturn($this->user());
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertFalse($result->isPasswordMailSent());
|
||||
$this->assertTrue($submission->isProvisioned());
|
||||
}
|
||||
|
||||
/**
|
||||
* A second click, or a stale page: nothing is sent to BusPro at all.
|
||||
*/
|
||||
public function testAProvisionedSubmissionIsNotProvisionedAgain(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
$submission = $this->submission()
|
||||
->setBusProAddressId(self::ADDRESS_ID)
|
||||
->setBusProPersonId(self::PERSON_ID)
|
||||
->setUser($user)
|
||||
->setTeamerCreatedAt(new \DateTimeImmutable())
|
||||
;
|
||||
|
||||
$this->apiClient->expects($this->never())->method('createAddress');
|
||||
$this->apiClient->expects($this->never())->method('updateCrmAttribute');
|
||||
$this->apiClient->expects($this->never())->method('resetPassword');
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertTrue($result->isAlreadyProvisioned());
|
||||
$this->assertSame($user, $result->getUser());
|
||||
}
|
||||
|
||||
/**
|
||||
* A row from before the tightened endpoint contract. It is refused before anything is written,
|
||||
* because BusPro's own complaint would only arrive after the address attempt.
|
||||
*/
|
||||
public function testAnIncompleteSubmissionIsRefusedBeforeAnythingIsWritten(): void
|
||||
{
|
||||
$submission = $this->submission()->setBirthDate('01.01.1990');
|
||||
|
||||
$this->apiClient->expects($this->never())->method('createAddress');
|
||||
|
||||
try {
|
||||
$this->provisioner()->provision($submission);
|
||||
$this->fail('Provisioning was expected to fail');
|
||||
} catch (TeamerProvisioningException $e) {
|
||||
$this->assertStringContainsString('Geburtsdatum', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The postal address is required of every payload - street and postcode because BusPro treats
|
||||
* them as mandatory for a new address - so a row without it is refused here rather than at step
|
||||
* 1, where the reviewer would get BusPro's complaint only after the attempt.
|
||||
*/
|
||||
public function testASubmissionWithoutAnAddressIsRefusedBeforeAnythingIsWritten(): void
|
||||
{
|
||||
$submission = $this->submission()->setStreet(null)->setZipCode(null)->setCity(null);
|
||||
|
||||
$this->apiClient->expects($this->never())->method('createAddress');
|
||||
|
||||
try {
|
||||
$this->provisioner()->provision($submission);
|
||||
$this->fail('Provisioning was expected to fail');
|
||||
} catch (TeamerProvisioningException $e) {
|
||||
$this->assertStringContainsString('Straße', $e->getMessage());
|
||||
$this->assertStringContainsString('Postleitzahl', $e->getMessage());
|
||||
$this->assertStringContainsString('Ort', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function expectSelectionWrite(): void
|
||||
{
|
||||
$this->apiClient
|
||||
->expects($this->once())
|
||||
->method('updateCrmAttribute')
|
||||
->with('[email protected]', self::ADDRESS_ID, self::PERSON_ID, self::SELECTION_ID, true)
|
||||
->willReturn((new ProfileUpdateResponse())->setUpdated(true))
|
||||
;
|
||||
}
|
||||
|
||||
private function provisioner(): TeamerProvisioner
|
||||
{
|
||||
return new TeamerProvisioner(
|
||||
$this->apiClient,
|
||||
$this->userDataHandler,
|
||||
$this->createMock(EntityManagerInterface::class),
|
||||
new NullLogger(),
|
||||
self::SELECTION_ID,
|
||||
);
|
||||
}
|
||||
|
||||
private function submission(): ApplicationFormSubmission
|
||||
{
|
||||
return (new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
'F',
|
||||
))
|
||||
->setFirstName('Anna')
|
||||
->setLastName('Muster')
|
||||
->setEmail('[email protected]')
|
||||
->setBirthDate('1990-01-01')
|
||||
->setStreet('Musterstraße 1')
|
||||
->setZipCode('10115')
|
||||
->setCity('Berlin')
|
||||
->setEu(true)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function user(bool $teamer = false, array $roles = [], bool $deleted = false): User
|
||||
{
|
||||
$user = (new User())
|
||||
->setFirstName('Anna')
|
||||
->setLastName('Muster')
|
||||
->setEmail('[email protected]')
|
||||
->setRoles($roles)
|
||||
;
|
||||
|
||||
if (true === $teamer) {
|
||||
$user->setTeamer((new Teamer())->setFirstName('Anna')->setLastName('Muster')->setGender('F'));
|
||||
}
|
||||
|
||||
if (true === $deleted) {
|
||||
$user->setDeletedAt(new \DateTimeImmutable());
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user