feat: provision a teamer from a received application
This commit is contained in:
1 parent
d5f4b12f0a
commit
b530679658
23 files changed
+2433
-148
No files matched your search
@@ -9,6 +9,7 @@ use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\ProfileUpdateResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use App\BusProNet\ResponseParser;
|
||||
use App\BusProNet\ResponseParserException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -238,6 +239,63 @@ class ResponseParserTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The write answers idadresse + idperson + änderung, not a selection tree - the shape
|
||||
* createProfileUpdateResponse() reads. It used to be routed to the CRM attribute parser, which
|
||||
* would have returned an empty response with every role flag false
|
||||
* (.ddev/plans/buspro-crm-selection-write.md).
|
||||
*/
|
||||
public function testParseCrmSelectionWriteResponse(): void
|
||||
{
|
||||
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>SelektionCRM_Ändern</art><idadresse>141747</idadresse><idperson>224526</idperson><änderung>True</änderung></ergebnis>';
|
||||
|
||||
$response = $this->getParserInstance()->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content);
|
||||
|
||||
$this->assertInstanceOf(ProfileUpdateResponse::class, $response);
|
||||
$this->assertSame(141747, $response->getAddressId());
|
||||
$this->assertSame(224526, $response->getPersonId());
|
||||
$this->assertTrue($response->isUpdated());
|
||||
}
|
||||
|
||||
public function testParseNewAddressResponse(): void
|
||||
{
|
||||
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Adresse_Neu</art><idadresse>141747</idadresse><idperson>224526</idperson><neuanlage>True</neuanlage></ergebnis>';
|
||||
|
||||
$response = $this->getParserInstance()->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content);
|
||||
|
||||
$this->assertInstanceOf(RegistrationResponse::class, $response);
|
||||
$this->assertSame(141747, $response->getAddressId());
|
||||
$this->assertSame(224526, $response->getPersonId());
|
||||
$this->assertTrue($response->isNewRecord());
|
||||
}
|
||||
|
||||
/**
|
||||
* BusPro recognised the person and answered with the ids of the address it already held. Not a
|
||||
* failure and not a duplicate - provisioning carries on with those ids.
|
||||
*/
|
||||
public function testParseNewAddressResponseForAnExistingPerson(): void
|
||||
{
|
||||
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Adresse_Neu</art><idadresse>141747</idadresse><idperson>224526</idperson><neuanlage>False</neuanlage></ergebnis>';
|
||||
|
||||
$response = $this->getParserInstance()->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content);
|
||||
|
||||
$this->assertInstanceOf(RegistrationResponse::class, $response);
|
||||
$this->assertFalse($response->isNewRecord());
|
||||
}
|
||||
|
||||
/**
|
||||
* Without both ids there is nothing to provision with, and a silent 0 would be stored on the
|
||||
* account and never match again.
|
||||
*/
|
||||
public function testParseNewAddressResponseWithoutIdsFails(): void
|
||||
{
|
||||
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Adresse_Neu</art><neuanlage>True</neuanlage></ergebnis>';
|
||||
|
||||
$this->expectException(ResponseParserException::class);
|
||||
|
||||
$this->getParserInstance()->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content);
|
||||
}
|
||||
|
||||
private function loadFixture(string $filename): string
|
||||
{
|
||||
return file_get_contents(__DIR__.'/../Resources/'.$filename);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The feature gate, which is the only part of provisioning a test can exercise: everything behind
|
||||
* it writes to the production CRM, where the teamer selection is changeable - the test database
|
||||
* answers aenderbar="False" for it (.ddev/plans/buspro-crm-selection-write.md).
|
||||
*
|
||||
* With the flag off the route must not exist at all, not merely hide its button: the modal is
|
||||
* reachable by url, and a reviewer who bookmarked it would otherwise write to BusPro from a
|
||||
* release where the feature is meant to be dormant.
|
||||
*/
|
||||
class CreateTeamerControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = static::createClient(server: ['HTTPS' => true]);
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$this->entityManager->createQuery(sprintf('DELETE FROM %s', ApplicationFormSubmission::class))->execute();
|
||||
$this->entityManager->createQuery(sprintf('DELETE FROM %s', User::class))->execute();
|
||||
|
||||
$this->client->loginUser($this->reviewer());
|
||||
}
|
||||
|
||||
public function testTheDisabledFeatureHidesTheRouteEntirely(): void
|
||||
{
|
||||
$this->client->request('GET', $this->url());
|
||||
|
||||
$this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* The POST is the one that writes, so it is pinned separately rather than trusted to share the
|
||||
* guard with the GET.
|
||||
*/
|
||||
public function testTheDisabledFeatureRefusesThePostAsWell(): void
|
||||
{
|
||||
$this->client->request('POST', $this->url());
|
||||
|
||||
$this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertNull($this->submission()->getTeamerCreatedAt());
|
||||
}
|
||||
|
||||
public function testTheDetailPageOffersNoProvisioningWhileTheFeatureIsOff(): void
|
||||
{
|
||||
$crawler = $this->client->request(
|
||||
'GET',
|
||||
sprintf('/admin/application-form-submission/detail/%s', $this->submission()->getUuid())
|
||||
);
|
||||
|
||||
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertCount(0, $crawler->filter('button[hx-get*="create-teamer"]'));
|
||||
}
|
||||
|
||||
private function url(): string
|
||||
{
|
||||
return sprintf('/admin/application-form-submission/create-teamer/%s', $this->submission()->getUuid());
|
||||
}
|
||||
|
||||
private function submission(): ApplicationFormSubmission
|
||||
{
|
||||
$repository = $this->entityManager->getRepository(ApplicationFormSubmission::class);
|
||||
$submission = $repository->findOneBy([], ['id' => 'DESC']);
|
||||
|
||||
if (null !== $submission) {
|
||||
return $submission;
|
||||
}
|
||||
|
||||
$submission = (new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
'F'
|
||||
))
|
||||
->setFirstName('Anna')
|
||||
->setLastName('Muster')
|
||||
->setEmail('[email protected]')
|
||||
->setBirthDate('1990-01-01')
|
||||
;
|
||||
|
||||
$this->entityManager->persist($submission);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $submission;
|
||||
}
|
||||
|
||||
private function reviewer(): User
|
||||
{
|
||||
$user = (new User())
|
||||
->setFirstName('Rita')
|
||||
->setLastName('Reviewer')
|
||||
->setEmail('[email protected]')
|
||||
->setRoles(['ROLE_TEAM_ADMIN'])
|
||||
->setBusProAddressId(1)
|
||||
->setBusProPersonId(1)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,8 @@ class DetailControllerTest extends WebTestCase
|
||||
$submission = (new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00')
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
'F'
|
||||
))->setEmail('[email protected]');
|
||||
|
||||
$this->entityManager->persist($submission);
|
||||
|
||||
@@ -14,10 +14,16 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The cases here are the ones the TYPO3 side actually produces
|
||||
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §8): a payload of nothing but the basics, labels
|
||||
* with slashes and umlauts, a long textarea answer, a duplicate label, and a mapped key this
|
||||
* application does not know - which must not be a 400, or a later mapping change on the
|
||||
* website silently stops every submission of that form.
|
||||
* (.ddev/plans/MYEP-APPLICATION-ENDPOINT.md §8): labels with slashes and umlauts, a long textarea
|
||||
* answer, a duplicate label, and a mapped key this application does not know - which must not be a
|
||||
* 400, or a later mapping change on the website silently stops every submission of that form.
|
||||
*
|
||||
* The other half is the required nine of the contract: every payload carries lastName, firstName,
|
||||
* birthDate, email, gender, eu, street, zipCode and city, and every one of them is a 400 when it is
|
||||
* missing. That is
|
||||
* deliberately harsh - a 400 costs a whole application - and each rejection case is pinned here
|
||||
* one field at a time, because these are the fields a teamer cannot be provisioned without
|
||||
* (.ddev/plans/application-form-teamer-provisioning.md).
|
||||
*/
|
||||
class ApplicationFormControllerTest extends WebTestCase
|
||||
{
|
||||
@@ -37,19 +43,160 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
)->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint promises JSON out, and on the error path that is not decoration: the TYPO3
|
||||
* finisher logs the response body, and Symfony's error renderer would answer an HTML error page
|
||||
* here - one that names the offending field only in dev, inside an HTML comment, and not at all
|
||||
* in production. See ApiExceptionListener.
|
||||
*/
|
||||
public function testARejectionIsJsonNamingTheField(): void
|
||||
{
|
||||
$payload = $this->payload();
|
||||
unset($payload['birthDate']);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$response = $this->client->getResponse();
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
|
||||
$this->assertStringContainsString('application/json', (string) $response->headers->get('Content-Type'));
|
||||
$this->assertStringNotContainsString('<html', (string) $response->getContent());
|
||||
|
||||
$body = $this->decode();
|
||||
|
||||
$this->assertSame('error', $body['status']);
|
||||
$this->assertSame(
|
||||
[['field' => 'birthDate', 'message' => 'birthDate must not be empty.', 'received' => 'missing']],
|
||||
$body['errors']
|
||||
);
|
||||
// One line for a caller that logs the message alone.
|
||||
$this->assertSame('birthDate must not be empty.', $body['message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of what arrived, which the framework never says: "This value should be of type
|
||||
* null|string." names the expectation and leaves the sender guessing. A form engine that hands a
|
||||
* date element straight to json_encode sends an object, and its keys are the whole diagnosis.
|
||||
*
|
||||
* Never the value itself - a rejected payload is still personal data.
|
||||
*
|
||||
* @dataProvider unusableBirthDateTypes
|
||||
*/
|
||||
public function testARejectionNamesTheShapeThatArrived(mixed $birthDate, string $received): void
|
||||
{
|
||||
$this->post(['birthDate' => $birthDate] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(
|
||||
[['field' => 'birthDate', 'message' => 'This value should be of type null|string.', 'received' => $received]],
|
||||
$this->decode()['errors']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{mixed, string}>
|
||||
*/
|
||||
public static function unusableBirthDateTypes(): iterable
|
||||
{
|
||||
yield 'a serialized date object' => [
|
||||
['date' => '1990-01-01 00:00:00.000000', 'timezone_type' => 3, 'timezone' => 'Europe/Berlin'],
|
||||
'object with keys: date, timezone_type, timezone',
|
||||
];
|
||||
yield 'a list' => [['1990-01-01'], 'list of 1'];
|
||||
yield 'a number' => [19900101, 'int'];
|
||||
yield 'a boolean' => [true, 'bool'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint's errors are English (the only caller is a machine), but a constraint message this
|
||||
* application does not phrase itself is translated by the validator - and this application's
|
||||
* locale is German. The message is therefore rebuilt from its own template rather than read off
|
||||
* the violation.
|
||||
*/
|
||||
public function testAFrameworkMessageIsNotTranslated(): void
|
||||
{
|
||||
$this->post(['birthDate' => 19900101] + $this->payload());
|
||||
|
||||
$content = (string) $this->client->getResponse()->getContent();
|
||||
|
||||
$this->assertStringContainsString('This value should be of type null|string.', $content);
|
||||
$this->assertStringNotContainsString('Dieser Wert', $content);
|
||||
}
|
||||
|
||||
public function testAnEmptyValueIsDistinguishedFromAnAbsentOne(): void
|
||||
{
|
||||
$this->post(['city' => ''] + $this->payload());
|
||||
|
||||
$this->assertSame('empty string', $this->decode()['errors'][0]['received']);
|
||||
}
|
||||
|
||||
public function testEveryOffendingFieldIsNamed(): void
|
||||
{
|
||||
$payload = $this->payload();
|
||||
unset($payload['street'], $payload['zipCode'], $payload['city']);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertEqualsCanonicalizing(['street', 'zipCode', 'city'], $this->rejectedFields());
|
||||
}
|
||||
|
||||
/**
|
||||
* A malformed body has no field to name, so the resolver's own explanation is passed on rather
|
||||
* than swallowed - still JSON, still readable in a log.
|
||||
*/
|
||||
public function testABrokenBodyIsRejectedAsJson(): void
|
||||
{
|
||||
$this->client->request(
|
||||
'POST',
|
||||
self::URL,
|
||||
server: ['CONTENT_TYPE' => 'application/json', 'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY],
|
||||
content: '{"form": "applicationform",'
|
||||
);
|
||||
|
||||
$body = $this->decode();
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame([], $body['errors']);
|
||||
$this->assertNotSame('', $body['message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not only the validation path: a wrong method comes from the router and used to leave as HTML
|
||||
* too.
|
||||
*/
|
||||
public function testAWrongMethodIsJsonToo(): void
|
||||
{
|
||||
$this->client->request('GET', self::URL, server: [
|
||||
'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY,
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_METHOD_NOT_ALLOWED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame('error', $this->decode()['status']);
|
||||
}
|
||||
|
||||
public function testAKeylessRequestIsRejected(): void
|
||||
{
|
||||
$this->post(['form' => 'applicationform'], key: null);
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
$this->assertSame('error', $this->decode()['status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrong key is answered by the firewall's entry point, which stops the exception before
|
||||
* ApiExceptionListener sees it: the body stays empty. Pinned because "empty" is the thing to
|
||||
* recognise in a caller's log - it is not the HTML error page a rejected payload used to get,
|
||||
* and it is not something this application can phrase without an entry point of its own.
|
||||
*/
|
||||
public function testAWrongKeyIsRejected(): void
|
||||
{
|
||||
$this->post(['form' => 'applicationform'], key: 'not-the-key');
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
||||
$response = $this->client->getResponse();
|
||||
|
||||
$this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
$this->assertStringNotContainsString('<html', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testAFullSubmissionIsStored(): void
|
||||
@@ -65,7 +212,9 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
'email' => '[email protected]',
|
||||
'phone' => '+49 30 1234567',
|
||||
'mobile' => '+49 170 1234567',
|
||||
'birthDate' => '01.01.1990',
|
||||
'birthDate' => '1990-01-01',
|
||||
'gender' => 'F',
|
||||
'eu' => true,
|
||||
'street' => 'Musterstraße 1',
|
||||
'zipCode' => '10115',
|
||||
'city' => 'Berlin',
|
||||
@@ -84,33 +233,132 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
$this->assertSame('Bewerbungsformular', $submission->getFormLabel());
|
||||
$this->assertSame('2026-09-22T12:30:00+02:00', $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM));
|
||||
$this->assertSame(1421, $submission->getPageUid());
|
||||
$this->assertSame('01.01.1990', $submission->getBirthDate());
|
||||
$this->assertSame('1990-01-01', $submission->getBirthDate());
|
||||
$this->assertSame('1990-01-01', $submission->getDateOfBirth()?->format('Y-m-d'));
|
||||
$this->assertSame('F', $submission->getGender());
|
||||
$this->assertSame('[email protected]', $submission->getEmail(), 'the address is normalized like everywhere else');
|
||||
$this->assertSame(['Reiseleitung', 'Skilehrer'], $submission->getDetails()['Bewerbung als']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A form may map nothing but the basics, and an absent mapped key means the form has no such
|
||||
* field - never an empty value that failed validation.
|
||||
* Beyond the required nine everything stays optional: a form is configured without a mobile
|
||||
* number as often as with it, and the teamer fills those in themselves afterwards. An absent
|
||||
* optional key means the form has no such field - never an empty value that failed validation.
|
||||
*/
|
||||
public function testTheBasicsAloneAreEnough(): void
|
||||
public function testTheRequiredFieldsAloneAreEnough(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'contactform',
|
||||
'formLabel' => 'Kontaktformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => [],
|
||||
]);
|
||||
$this->post($this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
$submission = $this->lastSubmission();
|
||||
|
||||
$this->assertNull($submission->getFirstName());
|
||||
$this->assertNull($submission->getEmail());
|
||||
$this->assertNull($submission->getPhone());
|
||||
$this->assertNull($submission->getMobile());
|
||||
$this->assertSame([], $submission->getDetails());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider requiredFields
|
||||
*/
|
||||
public function testAPayloadMissingARequiredFieldIsRejected(string $field): void
|
||||
{
|
||||
$payload = $this->payload();
|
||||
unset($payload[$field]);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
// The whole point of the rejection: the caller logs this body and has to see the field.
|
||||
$this->assertContains($field, $this->rejectedFields());
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty string is the shape a form posts for a field the visitor left blank, and it is as
|
||||
* unusable as an absent key - except for eu, where "" is a lawful no and has its own case.
|
||||
*
|
||||
* @dataProvider requiredFields
|
||||
*/
|
||||
public function testAPayloadWithAnEmptyRequiredFieldIsRejected(string $field): void
|
||||
{
|
||||
if ('eu' === $field) {
|
||||
$this->markTestSkipped('An empty eu is a no, not a missing answer - see its own test');
|
||||
}
|
||||
|
||||
$this->post([$field => ''] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function requiredFields(): iterable
|
||||
{
|
||||
yield 'form' => ['form'];
|
||||
yield 'formLabel' => ['formLabel'];
|
||||
yield 'submittedAt' => ['submittedAt'];
|
||||
yield 'firstName' => ['firstName'];
|
||||
yield 'lastName' => ['lastName'];
|
||||
yield 'email' => ['email'];
|
||||
yield 'birthDate' => ['birthDate'];
|
||||
yield 'gender' => ['gender'];
|
||||
yield 'eu' => ['eu'];
|
||||
yield 'street' => ['street'];
|
||||
yield 'zipCode' => ['zipCode'];
|
||||
yield 'city' => ['city'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The one field whose format is nailed down: provisioning parses it as a date, so the day the
|
||||
* TYPO3 side switches a form back to a free-text birthday has to be a 400 here rather than a
|
||||
* failure in front of a reviewer weeks later.
|
||||
*
|
||||
* @dataProvider unusableBirthDates
|
||||
*/
|
||||
public function testAnUnusableBirthDateIsRejected(string $birthDate): void
|
||||
{
|
||||
$this->post(['birthDate' => $birthDate] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function unusableBirthDates(): iterable
|
||||
{
|
||||
yield 'the German spelling the forms used before the contract' => ['01.01.1990'];
|
||||
yield 'free text' => ['irgendwann 1990'];
|
||||
yield 'a day that does not exist' => ['1990-02-31'];
|
||||
yield 'a year alone' => ['1990'];
|
||||
}
|
||||
|
||||
public function testAnUnknownGenderIsRejected(): void
|
||||
{
|
||||
$this->post(['gender' => 'weiblich'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
/**
|
||||
* D is passed through to BusPro as it is - what BusPro makes of it is unverified, and guessing
|
||||
* a replacement here would be worse than finding out.
|
||||
*/
|
||||
public function testEveryGenderTheApplicationKnowsIsAccepted(): void
|
||||
{
|
||||
foreach (['F', 'M', 'D'] as $gender) {
|
||||
$this->post(['gender' => $gender] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame($gender, $this->lastSubmission()->getGender());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapped vocabulary grows on the website side without a release here. An unknown key is
|
||||
* ignored, and kept in the raw payload so it can be mapped later without losing the answers
|
||||
@@ -118,13 +366,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testAnUnknownMappedKeyIsIgnoredAndKept(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'nationality' => 'deutsch',
|
||||
'details' => [],
|
||||
]);
|
||||
$this->post(['nationality' => 'deutsch'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame('deutsch', $this->lastSubmission()->getPayload()['nationality']);
|
||||
@@ -145,12 +387,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
'Geburtsdatum (text-10)' => '02.02.1992',
|
||||
];
|
||||
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => $details,
|
||||
]);
|
||||
$this->post(['details' => $details] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
@@ -165,12 +402,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
{
|
||||
$answer = str_repeat('ä', 3000);
|
||||
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => ['Warum ich' => $answer],
|
||||
]);
|
||||
$this->post(['details' => ['Warum ich' => $answer]] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame($answer, $this->lastSubmission()->getDetails()['Warum ich']);
|
||||
@@ -178,12 +410,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
|
||||
public function testAPayloadWithoutPageInformationIsAccepted(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => ['Nationalität' => 'deutsch'],
|
||||
]);
|
||||
$this->post(['details' => ['Nationalität' => 'deutsch']] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertNull($this->lastSubmission()->getPageUid());
|
||||
@@ -196,13 +423,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testANumericStringPageUidIsAccepted(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'pageUid' => '1421',
|
||||
'details' => [],
|
||||
]);
|
||||
$this->post(['pageUid' => '1421'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(1421, $this->lastSubmission()->getPageUid());
|
||||
@@ -214,12 +435,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
'POST',
|
||||
self::URL,
|
||||
server: ['HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => self::KEY],
|
||||
content: json_encode([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => [],
|
||||
], JSON_THROW_ON_ERROR)
|
||||
content: json_encode($this->payload(), JSON_THROW_ON_ERROR)
|
||||
);
|
||||
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
@@ -241,26 +457,9 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
public function testAMissingFormIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
}
|
||||
|
||||
public function testAnUnparseableSubmissionTimeIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => 'gestern',
|
||||
'details' => [],
|
||||
]);
|
||||
$this->post(['submittedAt' => 'gestern'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
@@ -268,25 +467,14 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
|
||||
public function testAnInvalidEmailAddressIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'email' => 'not-an-address',
|
||||
'details' => [],
|
||||
]);
|
||||
$this->post(['email' => 'not-an-address'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testANestedDetailValueIsRejected(): void
|
||||
{
|
||||
$this->post([
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'details' => ['Bewerbung als' => ['erste' => ['Reiseleitung']]],
|
||||
]);
|
||||
$this->post(['details' => ['Bewerbung als' => ['erste' => ['Reiseleitung']]]] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
@@ -307,13 +495,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testTheSamePayloadTwiceIsStoredTwiceForNow(): void
|
||||
{
|
||||
$payload = [
|
||||
'form' => 'applicationform',
|
||||
'formLabel' => 'Bewerbungsformular',
|
||||
'submittedAt' => '2026-09-22T12:30:00+02:00',
|
||||
'email' => '[email protected]',
|
||||
'details' => [],
|
||||
];
|
||||
$payload = $this->payload();
|
||||
|
||||
$this->post($payload);
|
||||
$this->post($payload);
|
||||
@@ -329,7 +511,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testTheReceiptGoesOutForAnEuApplicant(): void
|
||||
{
|
||||
$this->post($this->eligiblePayload());
|
||||
$this->post($this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(1);
|
||||
@@ -339,7 +521,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
|
||||
public function testNoReceiptForANonEuApplicant(): void
|
||||
{
|
||||
$this->post(['eu' => false] + $this->eligiblePayload());
|
||||
$this->post(['eu' => false] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(0);
|
||||
@@ -347,26 +529,13 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
$this->assertFalse($this->lastSubmission()->isEu());
|
||||
}
|
||||
|
||||
public function testNoReceiptWhenTheFormDoesNotAsk(): void
|
||||
{
|
||||
$payload = $this->eligiblePayload();
|
||||
unset($payload['eu']);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(0);
|
||||
$this->assertNull($this->lastSubmission()->isEu());
|
||||
$this->assertNull($this->lastSubmission()->getFirstMailSentAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* A form posts "1" as readily as a JSON true, and dropping an application over the spelling
|
||||
* would be absurd - the same tolerance pageUid has.
|
||||
*/
|
||||
public function testAQuotedEuFlagStillEarnsTheReceipt(): void
|
||||
{
|
||||
$this->post(['eu' => '1'] + $this->eligiblePayload());
|
||||
$this->post(['eu' => '1'] + $this->payload());
|
||||
|
||||
$this->assertEmailCount(1);
|
||||
$this->assertTrue($this->lastSubmission()->isEu());
|
||||
@@ -374,7 +543,7 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
|
||||
public function testAnEmptyEuFlagIsNotAYes(): void
|
||||
{
|
||||
$this->post(['eu' => ''] + $this->eligiblePayload());
|
||||
$this->post(['eu' => ''] + $this->payload());
|
||||
|
||||
$this->assertEmailCount(0);
|
||||
$this->assertFalse($this->lastSubmission()->isEu());
|
||||
@@ -386,30 +555,21 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
*/
|
||||
public function testAnUnparseableEuFlagIsRejected(): void
|
||||
{
|
||||
$this->post(['eu' => 'vielleicht'] + $this->eligiblePayload());
|
||||
$this->post(['eu' => 'vielleicht'] + $this->payload());
|
||||
|
||||
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertSame(0, $this->countSubmissions());
|
||||
$this->assertEmailCount(0);
|
||||
}
|
||||
|
||||
public function testAnEligibleSubmissionWithoutAnEmailAddressIsStillStored(): void
|
||||
{
|
||||
$payload = $this->eligiblePayload();
|
||||
unset($payload['email']);
|
||||
|
||||
$this->post($payload);
|
||||
|
||||
$this->assertSame(Response::HTTP_ACCEPTED, $this->client->getResponse()->getStatusCode());
|
||||
$this->assertEmailCount(0);
|
||||
$this->assertSame(1, $this->countSubmissions());
|
||||
$this->assertNull($this->lastSubmission()->getFirstMailSentAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* A payload with nothing but the required six, which is the smallest thing the endpoint
|
||||
* accepts. Spread an override in front of it - ['gender' => 'X'] + $this->payload() - rather
|
||||
* than rebuilding it, so a field added to the contract reaches every case at once.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function eligiblePayload(): array
|
||||
private function payload(): array
|
||||
{
|
||||
return [
|
||||
'form' => 'applicationform',
|
||||
@@ -418,11 +578,40 @@ class ApplicationFormControllerTest extends WebTestCase
|
||||
'firstName' => 'Anna',
|
||||
'lastName' => 'Muster',
|
||||
'email' => '[email protected]',
|
||||
'birthDate' => '1990-01-01',
|
||||
'gender' => 'F',
|
||||
'eu' => true,
|
||||
// The postal address, required as a whole: street and postcode are BusPro's own
|
||||
// mandatory fields for a new address.
|
||||
'street' => 'Musterstraße 1',
|
||||
'zipCode' => '10115',
|
||||
'city' => 'Berlin',
|
||||
'details' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields the endpoint named as the reason for a rejection.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function rejectedFields(): array
|
||||
{
|
||||
return array_values(array_filter(array_column($this->decode()['errors'], 'field')));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decode(): array
|
||||
{
|
||||
$body = json_decode((string) $this->client->getResponse()->getContent(), true);
|
||||
|
||||
$this->assertIsArray($body, 'The endpoint has to answer JSON, on the error path as well');
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The payload a submission goes to BusPro as, asserted directly - in the spirit of
|
||||
* AssignmentTypeTest pinning a form type's choice list. It is the one part of provisioning that
|
||||
* cannot be rehearsed against the test database at all (the teamer selection is only changeable in
|
||||
* production), so the keys and the gender mapping are pinned here rather than discovered live.
|
||||
*/
|
||||
class ApplicationFormSubmissionTest extends TestCase
|
||||
{
|
||||
public function testThePayloadCarriesTheGermanKeysBusProExpects(): void
|
||||
{
|
||||
$payload = $this->submission()->toPayload();
|
||||
|
||||
$this->assertSame([
|
||||
'geburtsdatum' => '01.01.1990',
|
||||
'geschlecht' => 'W',
|
||||
'vorname' => 'Anna',
|
||||
'name' => 'Muster',
|
||||
'anschrift' => [
|
||||
'strasse' => 'Musterstraße 1',
|
||||
'plz' => '10115',
|
||||
'ort' => 'Berlin',
|
||||
],
|
||||
'kommunikation' => [
|
||||
'email' => '[email protected]',
|
||||
'telefonmobil' => '+49 170 1234567',
|
||||
'telefonprivat' => '+49 30 1234567',
|
||||
],
|
||||
], $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* F becomes W, exactly as in Teamer::toPayload(). What BusPro makes of "D" is unverified, so it
|
||||
* is passed through rather than guessed at - the mapping must not invent a value here.
|
||||
*/
|
||||
public function testTheGenderMappingIsTheOneTeamerUses(): void
|
||||
{
|
||||
$this->assertSame('W', $this->submission('F')->toPayload()['geschlecht']);
|
||||
$this->assertSame('M', $this->submission('M')->toPayload()['geschlecht']);
|
||||
$this->assertSame('D', $this->submission('D')->toPayload()['geschlecht']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a row that predates the contract gets here - street and postcode are required of every
|
||||
* payload now. The keys still go out empty rather than absent, because BusPro is told what it
|
||||
* asked for and answers with its own Pflichtfeld error rather than a parse failure.
|
||||
*/
|
||||
public function testAnAbsentAddressLeavesTheKeysEmptyRatherThanAbsent(): void
|
||||
{
|
||||
$payload = (new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
'M',
|
||||
))->toPayload();
|
||||
|
||||
$this->assertSame(['strasse' => null, 'plz' => null, 'ort' => null], $payload['anschrift']);
|
||||
}
|
||||
|
||||
public function testTheBirthDateIsParsedStrictly(): void
|
||||
{
|
||||
$this->assertSame('1990-01-01', $this->submission()->getDateOfBirth()?->format('Y-m-d'));
|
||||
// What a row from before the contract looks like: stored, displayed, but not a date.
|
||||
$this->assertNull($this->submission(birthDate: '01.01.1990')->getDateOfBirth());
|
||||
$this->assertNull($this->submission(birthDate: 'irgendwann')->getDateOfBirth());
|
||||
$this->assertNull($this->submission(birthDate: '1990-02-31')->getDateOfBirth());
|
||||
$this->assertNull($this->submission(birthDate: null)->getDateOfBirth());
|
||||
}
|
||||
|
||||
/**
|
||||
* The time of day is zeroed rather than "now", or two runs of the same submission would produce
|
||||
* two different dates of birth.
|
||||
*/
|
||||
public function testTheBirthDateHasNoTimeOfDay(): void
|
||||
{
|
||||
$this->assertSame('00:00:00', $this->submission()->getDateOfBirth()?->format('H:i:s'));
|
||||
}
|
||||
|
||||
public function testProvisioningStateStartsEmpty(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->assertFalse($submission->hasBusProAddress());
|
||||
$this->assertFalse($submission->isProvisioned());
|
||||
|
||||
$submission->setBusProAddressId(141747);
|
||||
|
||||
// Both ids or nothing: the selection write needs the person id as well.
|
||||
$this->assertFalse($submission->hasBusProAddress());
|
||||
|
||||
$submission->setBusProPersonId(224526);
|
||||
|
||||
$this->assertTrue($submission->hasBusProAddress());
|
||||
}
|
||||
|
||||
private function submission(string $gender = 'F', ?string $birthDate = '1990-01-01'): ApplicationFormSubmission
|
||||
{
|
||||
return (new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
$gender,
|
||||
))
|
||||
->setFirstName('Anna')
|
||||
->setLastName('Muster')
|
||||
->setEmail('[email protected]')
|
||||
->setPhone('+49 30 1234567')
|
||||
->setMobile('+49 170 1234567')
|
||||
->setBirthDate($birthDate)
|
||||
->setStreet('Musterstraße 1')
|
||||
->setZipCode('10115')
|
||||
->setCity('Berlin')
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,8 @@ class ApplicationFormSubmissionCallsDtoTest extends TestCase
|
||||
return new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00')
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
'F'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Teamer;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\ApiClientException;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\ProfileUpdateResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use App\BusProNet\UserDataHandler;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\Service\Teamer\TeamerProvisioner;
|
||||
use App\Service\Teamer\TeamerProvisioningException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* The three provisioning writes are not a transaction and only the first and the third can be
|
||||
* rehearsed against BusPro's test database - the teamer selection is changeable in production only
|
||||
* (.ddev/plans/buspro-crm-selection-write.md). So the orchestration is pinned here against a mocked
|
||||
* client: what runs, in which order, what is persisted before the next step, and what a retry does
|
||||
* after a failure halfway.
|
||||
*/
|
||||
class TeamerProvisionerTest extends TestCase
|
||||
{
|
||||
private const SELECTION_ID = 1070;
|
||||
|
||||
private const ADDRESS_ID = 141747;
|
||||
|
||||
private const PERSON_ID = 224526;
|
||||
|
||||
private ApiClient&MockObject $apiClient;
|
||||
|
||||
private UserDataHandler&MockObject $userDataHandler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiClient = $this->createMock(ApiClient::class);
|
||||
$this->userDataHandler = $this->createMock(UserDataHandler::class);
|
||||
}
|
||||
|
||||
public function testANewApplicantBecomesATeamer(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->expects($this->once())
|
||||
->method('createAddress')
|
||||
->with($submission)
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient
|
||||
->expects($this->once())
|
||||
->method('resetPassword')
|
||||
->with('[email protected]')
|
||||
->willReturn(new NotificationResponse())
|
||||
;
|
||||
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn(null);
|
||||
$this->userDataHandler
|
||||
->expects($this->once())
|
||||
->method('createLocalUser')
|
||||
// ROLE_TEAMER needs no approval, which is the only reason it may be granted here.
|
||||
->with($this->isInstanceOf(ProfileResponse::class), ['ROLE_TEAMER'], true)
|
||||
->willReturn($this->user())
|
||||
;
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertTrue($result->isAddressCreated());
|
||||
$this->assertTrue($result->isUserCreated());
|
||||
$this->assertFalse($result->isAlreadyTeamer());
|
||||
$this->assertTrue($result->isPasswordMailSent());
|
||||
$this->assertSame(self::ADDRESS_ID, $submission->getBusProAddressId());
|
||||
$this->assertSame(self::PERSON_ID, $submission->getBusProPersonId());
|
||||
$this->assertTrue($submission->isProvisioned());
|
||||
}
|
||||
|
||||
/**
|
||||
* BusPro recognised the person and answered with the ids of the address it already held. The
|
||||
* selection is set on that record and the reviewer is told - it is not a failure, and a
|
||||
* duplicate is what it prevents.
|
||||
*/
|
||||
public function testAnApplicantBusProAlreadyKnowsUsesTheExistingAddress(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient->method('resetPassword')->willReturn(new NotificationResponse());
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn(null);
|
||||
$this->userDataHandler->method('createLocalUser')->willReturn($this->user());
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertFalse($result->isAddressCreated());
|
||||
$this->assertTrue($result->isUserCreated());
|
||||
$this->assertSame(self::ADDRESS_ID, $submission->getBusProAddressId());
|
||||
}
|
||||
|
||||
/**
|
||||
* The step that cannot be rehearsed outside production. When BusPro refuses it, the address is
|
||||
* already there - so the ids have to be persisted and no local teamer may appear.
|
||||
*/
|
||||
public function testARefusedSelectionLeavesTheAddressRecordedAndNoTeamer(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true))
|
||||
;
|
||||
$this->apiClient
|
||||
->method('updateCrmAttribute')
|
||||
->willReturn(new NotificationResponse(853, 'ID, EMail oder Passwort falsch'))
|
||||
;
|
||||
$this->apiClient->expects($this->never())->method('resetPassword');
|
||||
$this->userDataHandler->expects($this->never())->method('createLocalUser');
|
||||
|
||||
try {
|
||||
$this->provisioner()->provision($submission);
|
||||
$this->fail('Provisioning was expected to fail');
|
||||
} catch (TeamerProvisioningException $e) {
|
||||
$this->assertStringContainsString('853', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->assertSame(self::ADDRESS_ID, $submission->getBusProAddressId());
|
||||
$this->assertFalse($submission->isProvisioned());
|
||||
$this->assertNull($submission->getUser());
|
||||
}
|
||||
|
||||
/**
|
||||
* änderung=False is BusPro saying it did not apply the change - the selection is not
|
||||
* changeable in this environment, which is exactly the state of the test database.
|
||||
*/
|
||||
public function testAnUnappliedSelectionIsAFailure(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true))
|
||||
;
|
||||
$this->apiClient
|
||||
->method('updateCrmAttribute')
|
||||
->willReturn((new ProfileUpdateResponse())->setUpdated(false))
|
||||
;
|
||||
$this->userDataHandler->expects($this->never())->method('createLocalUser');
|
||||
|
||||
$this->expectException(TeamerProvisioningException::class);
|
||||
|
||||
$this->provisioner()->provision($submission);
|
||||
}
|
||||
|
||||
/**
|
||||
* The retry after the failure above: the address exists and must not be created a second time.
|
||||
* The selection is written again instead, which is safe because that write is idempotent.
|
||||
*/
|
||||
public function testARetryResumesInsteadOfCreatingASecondAddress(): void
|
||||
{
|
||||
$submission = $this->submission()
|
||||
->setBusProAddressId(self::ADDRESS_ID)
|
||||
->setBusProPersonId(self::PERSON_ID)
|
||||
;
|
||||
|
||||
$this->apiClient->expects($this->never())->method('createAddress');
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient->method('resetPassword')->willReturn(new NotificationResponse());
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn(null);
|
||||
$this->userDataHandler->method('createLocalUser')->willReturn($this->user());
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertFalse($result->isAddressCreated());
|
||||
$this->assertTrue($submission->isProvisioned());
|
||||
}
|
||||
|
||||
/**
|
||||
* Somebody who already has an account - an existing customer, or a teamer from an earlier
|
||||
* season. No second account, and the reviewer is told rather than left wondering why nothing
|
||||
* appeared.
|
||||
*/
|
||||
public function testAnApplicantWhoAlreadyHasATeamerAccountIsNotCreatedTwice(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
$user = $this->user(teamer: true, roles: ['ROLE_TEAMER']);
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient->method('resetPassword')->willReturn(new NotificationResponse());
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($user);
|
||||
$this->userDataHandler->expects($this->never())->method('createLocalUser');
|
||||
$this->userDataHandler->expects($this->never())->method('grantTeamerRole');
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertFalse($result->isUserCreated());
|
||||
$this->assertTrue($result->isAlreadyTeamer());
|
||||
$this->assertSame($user, $result->getUser());
|
||||
$this->assertSame($user, $submission->getUser());
|
||||
}
|
||||
|
||||
/**
|
||||
* An account without the teamer half - an office user, or a customer who once booked a trip.
|
||||
* They get the role and a teamer record, not a second account.
|
||||
*/
|
||||
public function testAnAccountWithoutATeamerGetsOne(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
$user = $this->user();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient->method('resetPassword')->willReturn(new NotificationResponse());
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($user);
|
||||
$this->userDataHandler->expects($this->once())->method('grantTeamerRole')->with($user);
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertFalse($result->isUserCreated());
|
||||
$this->assertFalse($result->isAlreadyTeamer());
|
||||
$this->assertInstanceOf(Teamer::class, $user->getTeamer());
|
||||
$this->assertSame('Anna', $user->getTeamer()->getFirstName());
|
||||
// F/M/D is this application's own vocabulary and is stored as it came; the mapping to
|
||||
// BusPro's W happens on the way out only.
|
||||
$this->assertSame('F', $user->getTeamer()->getGender());
|
||||
$this->assertSame('1990-01-01', $user->getTeamer()->getDateOfBirth()?->format('Y-m-d'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A deleted account matched by email. Granting it a role would resurrect somebody who asked to
|
||||
* be gone, under their old row - a decision for a human, not for a provisioning click.
|
||||
*/
|
||||
public function testADeletedAccountStopsProvisioning(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($this->user(deleted: true));
|
||||
$this->userDataHandler->expects($this->never())->method('createLocalUser');
|
||||
$this->apiClient->expects($this->never())->method('resetPassword');
|
||||
|
||||
$this->expectException(TeamerProvisioningException::class);
|
||||
|
||||
$this->provisioner()->provision($submission);
|
||||
}
|
||||
|
||||
/**
|
||||
* The password mail is the last step and its failure is reported, not thrown: the teamer exists
|
||||
* by then, and undoing three writes over an unsent mail would be worse than saying so.
|
||||
*/
|
||||
public function testAFailedPasswordMailStillLeavesAProvisionedTeamer(): void
|
||||
{
|
||||
$submission = $this->submission();
|
||||
|
||||
$this->apiClient
|
||||
->method('createAddress')
|
||||
->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true))
|
||||
;
|
||||
$this->expectSelectionWrite();
|
||||
$this->apiClient->method('resetPassword')->willThrowException(new ApiClientException('timeout'));
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn(null);
|
||||
$this->userDataHandler->method('createLocalUser')->willReturn($this->user());
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertFalse($result->isPasswordMailSent());
|
||||
$this->assertTrue($submission->isProvisioned());
|
||||
}
|
||||
|
||||
/**
|
||||
* A second click, or a stale page: nothing is sent to BusPro at all.
|
||||
*/
|
||||
public function testAProvisionedSubmissionIsNotProvisionedAgain(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
$submission = $this->submission()
|
||||
->setBusProAddressId(self::ADDRESS_ID)
|
||||
->setBusProPersonId(self::PERSON_ID)
|
||||
->setUser($user)
|
||||
->setTeamerCreatedAt(new \DateTimeImmutable())
|
||||
;
|
||||
|
||||
$this->apiClient->expects($this->never())->method('createAddress');
|
||||
$this->apiClient->expects($this->never())->method('updateCrmAttribute');
|
||||
$this->apiClient->expects($this->never())->method('resetPassword');
|
||||
|
||||
$result = $this->provisioner()->provision($submission);
|
||||
|
||||
$this->assertTrue($result->isAlreadyProvisioned());
|
||||
$this->assertSame($user, $result->getUser());
|
||||
}
|
||||
|
||||
/**
|
||||
* A row from before the tightened endpoint contract. It is refused before anything is written,
|
||||
* because BusPro's own complaint would only arrive after the address attempt.
|
||||
*/
|
||||
public function testAnIncompleteSubmissionIsRefusedBeforeAnythingIsWritten(): void
|
||||
{
|
||||
$submission = $this->submission()->setBirthDate('01.01.1990');
|
||||
|
||||
$this->apiClient->expects($this->never())->method('createAddress');
|
||||
|
||||
try {
|
||||
$this->provisioner()->provision($submission);
|
||||
$this->fail('Provisioning was expected to fail');
|
||||
} catch (TeamerProvisioningException $e) {
|
||||
$this->assertStringContainsString('Geburtsdatum', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The postal address is required of every payload - street and postcode because BusPro treats
|
||||
* them as mandatory for a new address - so a row without it is refused here rather than at step
|
||||
* 1, where the reviewer would get BusPro's complaint only after the attempt.
|
||||
*/
|
||||
public function testASubmissionWithoutAnAddressIsRefusedBeforeAnythingIsWritten(): void
|
||||
{
|
||||
$submission = $this->submission()->setStreet(null)->setZipCode(null)->setCity(null);
|
||||
|
||||
$this->apiClient->expects($this->never())->method('createAddress');
|
||||
|
||||
try {
|
||||
$this->provisioner()->provision($submission);
|
||||
$this->fail('Provisioning was expected to fail');
|
||||
} catch (TeamerProvisioningException $e) {
|
||||
$this->assertStringContainsString('Straße', $e->getMessage());
|
||||
$this->assertStringContainsString('Postleitzahl', $e->getMessage());
|
||||
$this->assertStringContainsString('Ort', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function expectSelectionWrite(): void
|
||||
{
|
||||
$this->apiClient
|
||||
->expects($this->once())
|
||||
->method('updateCrmAttribute')
|
||||
->with('[email protected]', self::ADDRESS_ID, self::PERSON_ID, self::SELECTION_ID, true)
|
||||
->willReturn((new ProfileUpdateResponse())->setUpdated(true))
|
||||
;
|
||||
}
|
||||
|
||||
private function provisioner(): TeamerProvisioner
|
||||
{
|
||||
return new TeamerProvisioner(
|
||||
$this->apiClient,
|
||||
$this->userDataHandler,
|
||||
$this->createMock(EntityManagerInterface::class),
|
||||
new NullLogger(),
|
||||
self::SELECTION_ID,
|
||||
);
|
||||
}
|
||||
|
||||
private function submission(): ApplicationFormSubmission
|
||||
{
|
||||
return (new ApplicationFormSubmission(
|
||||
'applicationform',
|
||||
'Bewerbungsformular',
|
||||
new \DateTimeImmutable('2026-09-22T12:30:00+02:00'),
|
||||
'F',
|
||||
))
|
||||
->setFirstName('Anna')
|
||||
->setLastName('Muster')
|
||||
->setEmail('[email protected]')
|
||||
->setBirthDate('1990-01-01')
|
||||
->setStreet('Musterstraße 1')
|
||||
->setZipCode('10115')
|
||||
->setCity('Berlin')
|
||||
->setEu(true)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function user(bool $teamer = false, array $roles = [], bool $deleted = false): User
|
||||
{
|
||||
$user = (new User())
|
||||
->setFirstName('Anna')
|
||||
->setLastName('Muster')
|
||||
->setEmail('[email protected]')
|
||||
->setRoles($roles)
|
||||
;
|
||||
|
||||
if (true === $teamer) {
|
||||
$user->setTeamer((new Teamer())->setFirstName('Anna')->setLastName('Muster')->setGender('F'));
|
||||
}
|
||||
|
||||
if (true === $deleted) {
|
||||
$user->setDeletedAt(new \DateTimeImmutable());
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user