feat: provision a teamer from a received application

This commit is contained in:
fromme committed 2026-09-25 14:36:24 +02:00
1 parent d5f4b12f0a
commit b530679658
23 files changed
+2433 -148

No files matched your search

+1
View File
@@ -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
+5
View File
@@ -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
+7
View File
@@ -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:
+45
View File
@@ -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');
}
}
+82
View File
@@ -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;
}
}
+39 -1
View File
@@ -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(),
]);
}
}
+191 -13
View File
@@ -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>>
*/
+288
View File
@@ -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/');
}
}
+44 -5
View File
@@ -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 = [],
) {
+64
View File
@@ -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;
}
}
+367
View File
@@ -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 %}
+58
View File
@@ -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;
}
}