From b530679658f83eb1a2f4a8dca38f8be0439b1c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 25 Sep 2026 14:34:39 +0200 Subject: [PATCH] feat: provision a teamer from a received application --- .env | 1 + config/packages/flagception.yaml | 5 + config/services.yaml | 7 + migrations/Version20260925120000.php | 45 ++ src/BusProNet/ApiClient.php | 82 ++++ src/BusProNet/Model/RegistrationResponse.php | 37 ++ src/BusProNet/ResponseParser.php | 40 +- .../CreateTeamerController.php | 110 +++++ src/Entity/ApplicationFormSubmission.php | 204 +++++++- src/EventListener/ApiExceptionListener.php | 288 ++++++++++++ src/Model/ApplicationFormSubmissionDto.php | 49 +- src/Model/TeamerProvisioningResult.php | 64 +++ src/Service/Teamer/TeamerProvisioner.php | 367 +++++++++++++++ .../Teamer/TeamerProvisioningException.php | 33 ++ .../detail.html.twig | 38 +- .../modal_create_teamer.html.twig | 49 ++ tests/BusProNet/ResponseParserTest.php | 58 +++ .../CreateTeamerControllerTest.php | 118 +++++ .../DetailControllerTest.php | 3 +- .../Api/ApplicationFormControllerTest.php | 441 +++++++++++++----- .../Entity/ApplicationFormSubmissionTest.php | 123 +++++ .../ApplicationFormSubmissionCallsDtoTest.php | 3 +- .../Service/Teamer/TeamerProvisionerTest.php | 416 +++++++++++++++++ 23 files changed, 2433 insertions(+), 148 deletions(-) create mode 100644 migrations/Version20260925120000.php create mode 100644 src/BusProNet/Model/RegistrationResponse.php create mode 100644 src/Controller/Admin/ApplicationFormSubmission/CreateTeamerController.php create mode 100644 src/EventListener/ApiExceptionListener.php create mode 100644 src/Model/TeamerProvisioningResult.php create mode 100644 src/Service/Teamer/TeamerProvisioner.php create mode 100644 src/Service/Teamer/TeamerProvisioningException.php create mode 100644 templates/admin/application_form_submission/modal_create_teamer.html.twig create mode 100644 tests/Controller/Admin/ApplicationFormSubmission/CreateTeamerControllerTest.php create mode 100644 tests/Entity/ApplicationFormSubmissionTest.php create mode 100644 tests/Service/Teamer/TeamerProvisionerTest.php diff --git a/.env b/.env index 643c347..69ee53c 100644 --- a/.env +++ b/.env @@ -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 diff --git a/config/packages/flagception.yaml b/config/packages/flagception.yaml index 4d658e4..4c28445 100644 --- a/config/packages/flagception.yaml +++ b/config/packages/flagception.yaml @@ -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 diff --git a/config/services.yaml b/config/services.yaml index e213ae3..e2c30aa 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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: diff --git a/migrations/Version20260925120000.php b/migrations/Version20260925120000.php new file mode 100644 index 0000000..5796869 --- /dev/null +++ b/migrations/Version20260925120000.php @@ -0,0 +1,45 @@ +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'); + } +} diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index e9068ff..d9811ac 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -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 diff --git a/src/BusProNet/Model/RegistrationResponse.php b/src/BusProNet/Model/RegistrationResponse.php new file mode 100644 index 0000000..ad4d25e --- /dev/null +++ b/src/BusProNet/Model/RegistrationResponse.php @@ -0,0 +1,37 @@ +addressId; + } + + public function getPersonId(): int + { + return $this->personId; + } + + public function isNewRecord(): bool + { + return $this->isNewRecord; + } +} diff --git a/src/BusProNet/ResponseParser.php b/src/BusProNet/ResponseParser.php index 2c5cf0f..c3ced18 100644 --- a/src/BusProNet/ResponseParser.php +++ b/src/BusProNet/ResponseParser.php @@ -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 = []; diff --git a/src/Controller/Admin/ApplicationFormSubmission/CreateTeamerController.php b/src/Controller/Admin/ApplicationFormSubmission/CreateTeamerController.php new file mode 100644 index 0000000..6f72eeb --- /dev/null +++ b/src/Controller/Admin/ApplicationFormSubmission/CreateTeamerController.php @@ -0,0 +1,110 @@ +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(), + ]); + } +} diff --git a/src/Entity/ApplicationFormSubmission.php b/src/Entity/ApplicationFormSubmission.php index b6b1768..3975c13 100644 --- a/src/Entity/ApplicationFormSubmission.php +++ b/src/Entity/ApplicationFormSubmission.php @@ -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 + */ + 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> */ diff --git a/src/EventListener/ApiExceptionListener.php b/src/EventListener/ApiExceptionListener.php new file mode 100644 index 0000000..a71bc57 --- /dev/null +++ b/src/EventListener/ApiExceptionListener.php @@ -0,0 +1,288 @@ +; 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 + */ + 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 $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 + */ + 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 $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 $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 $violations + * + * @return list + */ + 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/'); + } +} diff --git a/src/Model/ApplicationFormSubmissionDto.php b/src/Model/ApplicationFormSubmissionDto.php index e790d70..b6163bc 100644 --- a/src/Model/ApplicationFormSubmissionDto.php +++ b/src/Model/ApplicationFormSubmissionDto.php @@ -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 + */ + 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 = [], ) { diff --git a/src/Model/TeamerProvisioningResult.php b/src/Model/TeamerProvisioningResult.php new file mode 100644 index 0000000..a7a747a --- /dev/null +++ b/src/Model/TeamerProvisioningResult.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/src/Service/Teamer/TeamerProvisioner.php b/src/Service/Teamer/TeamerProvisioner.php new file mode 100644 index 0000000..5394e1c --- /dev/null +++ b/src/Service/Teamer/TeamerProvisioner.php @@ -0,0 +1,367 @@ +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()); + } +} diff --git a/src/Service/Teamer/TeamerProvisioningException.php b/src/Service/Teamer/TeamerProvisioningException.php new file mode 100644 index 0000000..0d5fb81 --- /dev/null +++ b/src/Service/Teamer/TeamerProvisioningException.php @@ -0,0 +1,33 @@ + $context + */ + public function __construct( + string $message, + private readonly array $context = [], + ?\Throwable $previous = null, + ) { + parent::__construct($message, 0, $previous); + } + + /** + * @return array + */ + public function getContext(): array + { + return $this->context; + } +} diff --git a/templates/admin/application_form_submission/detail.html.twig b/templates/admin/application_form_submission/detail.html.twig index bb0c34c..36945c4 100644 --- a/templates/admin/application_form_submission/detail.html.twig +++ b/templates/admin/application_form_submission/detail.html.twig @@ -38,7 +38,8 @@
{{ _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) }} + {% if feature('application_form_teamer_provisioning') or submission.teamerCreatedAt %} +
+
+ {{ _self.row('übernommen am', submission.teamerCreatedAt ? submission.teamerCreatedAt|date('d.m.Y, H:i') : null) }} +
+
+ {% if submission.user and submission.user.teamer %} + + Teamer:innenprofil öffnen + + {% elseif submission.email %} + + {% 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. #} +
+ Die BusPro-Adresse {{ submission.busProAddressId }} wurde schon angelegt, + die Übernahme wurde aber nicht abgeschlossen. +
+ {% endif %} + {% else %} +
+ Ohne E-Mail-Adresse kann kein Teamer:innenkonto angelegt werden. +
+ {% endif %} +
+
+ {% endif %}

diff --git a/templates/admin/application_form_submission/modal_create_teamer.html.twig b/templates/admin/application_form_submission/modal_create_teamer.html.twig new file mode 100644 index 0000000..3d69b6f --- /dev/null +++ b/templates/admin/application_form_submission/modal_create_teamer.html.twig @@ -0,0 +1,49 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block title %}Teamer:in anlegen{% endblock %} + +{% block content %} +
+ {{ [submission.firstName, submission.lastName]|filter(part => part is not empty)|join(' ') }} + als Teamer:in übernehmen? +
+ 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. +
+ {# 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. #} +
+
Geburtsdatum
+
+ {% if submission.dateOfBirth %} + {{ submission.dateOfBirth|date('d.m.Y') }} + {% else %} + fehlt - ohne Geburtsdatum kann keine Adresse angelegt werden + {% endif %} +
+
Geschlecht
+
{{ submission.gender }}
+
E-Mail
+
{{ submission.email ?: 'fehlt' }}
+
Adresse
+
+ {# 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 %} + unvollständig - 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 %} +
+
+ {% if submission.busProAddressId %} +
+ In BusPro besteht für diese Bewerbung schon die Adresse + {{ submission.busProAddressId }} - sie wird nicht erneut angelegt. +
+ {% endif %} +
+{% endblock %} + +{% block button_confirm %}Anlegen{% endblock %} diff --git a/tests/BusProNet/ResponseParserTest.php b/tests/BusProNet/ResponseParserTest.php index c06911f..8764f59 100644 --- a/tests/BusProNet/ResponseParserTest.php +++ b/tests/BusProNet/ResponseParserTest.php @@ -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 = 'SelektionCRM_Ändern141747224526<änderung>True'; + + $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 = 'Adresse_Neu141747224526True'; + + $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 = 'Adresse_Neu141747224526False'; + + $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 = 'Adresse_NeuTrue'; + + $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); diff --git a/tests/Controller/Admin/ApplicationFormSubmission/CreateTeamerControllerTest.php b/tests/Controller/Admin/ApplicationFormSubmission/CreateTeamerControllerTest.php new file mode 100644 index 0000000..a5b0198 --- /dev/null +++ b/tests/Controller/Admin/ApplicationFormSubmission/CreateTeamerControllerTest.php @@ -0,0 +1,118 @@ +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('anna@example.com') + ->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('rita.reviewer@example.com') + ->setRoles(['ROLE_TEAM_ADMIN']) + ->setBusProAddressId(1) + ->setBusProPersonId(1) + ; + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } +} diff --git a/tests/Controller/Admin/ApplicationFormSubmission/DetailControllerTest.php b/tests/Controller/Admin/ApplicationFormSubmission/DetailControllerTest.php index 817c482..64b00a1 100644 --- a/tests/Controller/Admin/ApplicationFormSubmission/DetailControllerTest.php +++ b/tests/Controller/Admin/ApplicationFormSubmission/DetailControllerTest.php @@ -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('anna@example.com'); $this->entityManager->persist($submission); diff --git a/tests/Controller/Api/ApplicationFormControllerTest.php b/tests/Controller/Api/ApplicationFormControllerTest.php index e9025e4..17c83d8 100644 --- a/tests/Controller/Api/ApplicationFormControllerTest.php +++ b/tests/Controller/Api/ApplicationFormControllerTest.php @@ -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('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 + */ + 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('getContent()); } public function testAFullSubmissionIsStored(): void @@ -65,7 +212,9 @@ class ApplicationFormControllerTest extends WebTestCase 'email' => 'Anna@Example.com', '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('anna@example.com', $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 + */ + 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 + */ + 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' => 'anna@example.com', - '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 */ - private function eligiblePayload(): array + private function payload(): array { return [ 'form' => 'applicationform', @@ -418,11 +578,40 @@ class ApplicationFormControllerTest extends WebTestCase 'firstName' => 'Anna', 'lastName' => 'Muster', 'email' => 'anna@example.com', + '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 + */ + private function rejectedFields(): array + { + return array_values(array_filter(array_column($this->decode()['errors'], 'field'))); + } + + /** + * @return array + */ + 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 $payload */ diff --git a/tests/Entity/ApplicationFormSubmissionTest.php b/tests/Entity/ApplicationFormSubmissionTest.php new file mode 100644 index 0000000..b576abb --- /dev/null +++ b/tests/Entity/ApplicationFormSubmissionTest.php @@ -0,0 +1,123 @@ +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' => 'anna@example.com', + '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('anna@example.com') + ->setPhone('+49 30 1234567') + ->setMobile('+49 170 1234567') + ->setBirthDate($birthDate) + ->setStreet('Musterstraße 1') + ->setZipCode('10115') + ->setCity('Berlin') + ; + } +} diff --git a/tests/Model/ApplicationFormSubmissionCallsDtoTest.php b/tests/Model/ApplicationFormSubmissionCallsDtoTest.php index 031cf3c..72097f6 100644 --- a/tests/Model/ApplicationFormSubmissionCallsDtoTest.php +++ b/tests/Model/ApplicationFormSubmissionCallsDtoTest.php @@ -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' ); } } diff --git a/tests/Service/Teamer/TeamerProvisionerTest.php b/tests/Service/Teamer/TeamerProvisionerTest.php new file mode 100644 index 0000000..d25aa51 --- /dev/null +++ b/tests/Service/Teamer/TeamerProvisionerTest.php @@ -0,0 +1,416 @@ +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('anna@example.com') + ->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('anna@example.com', 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('anna@example.com') + ->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('anna@example.com') + ->setRoles($roles) + ; + + if (true === $teamer) { + $user->setTeamer((new Teamer())->setFirstName('Anna')->setLastName('Muster')->setGender('F')); + } + + if (true === $deleted) { + $user->setDeletedAt(new \DateTimeImmutable()); + } + + return $user; + } +}